diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS new file mode 100644 index 0000000000..5ebb71f913 --- /dev/null +++ b/.github/CODEOWNERS @@ -0,0 +1,25 @@ +# Owners must have write access to this repository. + +* @soramitsu/fearless + +/.github/ @soramitsu/devops-team +/AGENTS.md @soramitsu/fearless @soramitsu/android-developers +/CONTRIBUTING.md @soramitsu/fearless @soramitsu/android-developers +/README.md @soramitsu/fearless @soramitsu/android-developers +/docs/release-checklist.md @soramitsu/fearless @soramitsu/devops-team +/docs/rollback-checklist.md @soramitsu/fearless @soramitsu/devops-team + +/app/ @soramitsu/fearless @soramitsu/android-developers +/common/ @soramitsu/fearless @soramitsu/android-developers +/runtime/ @soramitsu/fearless @soramitsu/android-developers +/feature-wallet-api/ @soramitsu/fearless @soramitsu/android-developers +/feature-wallet-impl/ @soramitsu/fearless @soramitsu/android-developers +/feature-walletconnect-api/ @soramitsu/fearless @soramitsu/android-developers +/feature-walletconnect-impl/ @soramitsu/fearless @soramitsu/android-developers + +/key/ @soramitsu/fearless @soramitsu/android-developers @soramitsu/security +/common/src/main/java/jp/co/soramitsu/common/model/ @soramitsu/fearless @soramitsu/android-developers @soramitsu/security +/common/src/main/java/jp/co/soramitsu/common/wallet/ @soramitsu/fearless @soramitsu/android-developers @soramitsu/security +/**/*Key* @soramitsu/fearless @soramitsu/android-developers @soramitsu/security +/**/*Keystore* @soramitsu/fearless @soramitsu/android-developers @soramitsu/security +/**/*Signer* @soramitsu/fearless @soramitsu/android-developers @soramitsu/security diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md index be8211e09b..7ed77c18ea 100644 --- a/.github/pull_request_template.md +++ b/.github/pull_request_template.md @@ -6,6 +6,11 @@ Describe the change and why it’s needed. Closes # (or) Relates to # +## Target Branch + +- [ ] This PR targets `develop` +- [ ] This PR targets `master` and is a release or hotfix PR + ## Type of Change - [ ] feat (new feature) @@ -26,6 +31,7 @@ Commands run locally: ./gradlew detektAll ./gradlew runTest ./gradlew :app:lint +./scripts/audit-public-artifacts.sh ``` Additional checks and scenarios covered: @@ -41,5 +47,5 @@ Potential impact, migrations, or config/secrets required. - [ ] Added/updated tests for changed code (where applicable) - [ ] Updated docs (README/AGENTS) when behavior or commands changed - [ ] Ran detektAll, runTest, and :app:lint locally (or via CI) -- [ ] No secrets or local.properties committed - +- [ ] Public artifact audit passed; no secrets or local.properties committed +- [ ] No direct-to-`master` workflow is introduced diff --git a/.github/workflows/android-ci.yml b/.github/workflows/android-ci.yml index 77b3901911..273722f550 100644 --- a/.github/workflows/android-ci.yml +++ b/.github/workflows/android-ci.yml @@ -18,7 +18,11 @@ jobs: timeout-minutes: 45 env: CI: true - USE_REMOTE_UTILS: "true" + FEARLESS_UTILS_COMMIT: 7500809f33243ee47ecb2ec8563fc284ac4de0d6 + FEARLESS_UTILS_PATH: ${{ github.workspace }}/fearless-utils-Android + FORCE_LOCAL_UTILS: "true" + FEARLESS_UTILS_LIBRARY_ONLY: "true" + USE_REMOTE_UTILS: "false" SHARED_FEATURES_VERSION_OVERRIDE: "" # Provide safe stubs to avoid repository/config failures during resolution PAY_WINGS_REPOSITORY_URL: https://maven.google.com @@ -41,6 +45,80 @@ jobs: with: fetch-depth: 0 + - name: Checkout fearless-utils-Android + uses: actions/checkout@v4 + with: + repository: soramitsu/fearless-utils-Android + ref: ${{ env.FEARLESS_UTILS_COMMIT }} + path: fearless-utils-Android + fetch-depth: 0 + + - name: Verify fearless-utils source + run: ./scripts/ensure-fearless-utils.sh + + - name: Public dependency upstream handoff + run: | + bash ./scripts/test-public-dependency-upstream-delta-export.sh + bash ./scripts/export-public-dependency-upstream-delta.sh --output build/reports/public-dependency-upstream-delta + + - name: Branch flow audit + run: | + bash ./scripts/audit-branch-flow.sh + bash ./scripts/test-branch-flow-audit.sh + + - name: Public artifact audit + run: ./scripts/audit-public-artifacts.sh + + - name: TODO debt audit + run: | + bash ./scripts/test-todo-debt-audit.sh + bash ./scripts/audit-todo-debt.sh + + - name: XCM registry metadata audit + run: | + bash ./scripts/test-xcm-registry-metadata-audit.sh + bash ./scripts/audit-xcm-registry-metadata.sh --require-executable \ + --write-gap-report build/reports/xcm-registry-gap-report.json \ + --require-route-file scripts/xcm-required-routes.tsv \ + --require-gap-file scripts/xcm-discovery-only-routes.tsv + bash ./scripts/test-xcm-production-evidence-template.sh + bash ./scripts/test-xcm-production-evidence-audit.sh + bash ./scripts/audit-xcm-production-evidence.sh + + - name: Upload XCM registry gap report + if: always() + uses: actions/upload-artifact@v4 + with: + name: xcm-registry-gap-report + path: build/reports/xcm-registry-gap-report.json + if-no-files-found: ignore + retention-days: 14 + + - name: Upload public dependency handoff + if: always() + uses: actions/upload-artifact@v4 + with: + name: public-dependency-upstream-delta + path: build/reports/public-dependency-upstream-delta + if-no-files-found: ignore + retention-days: 14 + + - name: Iroha mobile SDK release asset contract + env: + GH_TOKEN: ${{ github.token }} + IROHA_MOBILE_SDK_RELEASE_TAG: ${{ vars.IROHA_MOBILE_SDK_RELEASE_TAG }} + IROHA_MOBILE_SDK_RELEASE_REPO: ${{ vars.IROHA_MOBILE_SDK_RELEASE_REPO }} + run: | + bash ./scripts/check-iroha-mobile-sdk-release-assets.sh --self-test + if [[ -n "${IROHA_MOBILE_SDK_RELEASE_TAG:-}" ]]; then + bash ./scripts/check-iroha-mobile-sdk-release-assets.sh --download --tag "$IROHA_MOBILE_SDK_RELEASE_TAG" + else + echo "IROHA_MOBILE_SDK_RELEASE_TAG is not configured; real release asset validation skipped." + fi + + - name: Private overlay audit self-test + run: bash ./scripts/test-private-overlay-boundary.sh + - name: Setup Java 21 (Temurin) uses: actions/setup-java@v4 with: @@ -48,21 +126,44 @@ jobs: java-version: '21' - name: Setup Android SDK - uses: android-actions/setup-android@v3 - with: - packages: | - platforms;android-36 - build-tools;36.0.0 - platform-tools - ndk;28.0.12674087 - ndk;25.2.9519653 + run: | + set -euo pipefail + export ANDROID_SDK_ROOT="${ANDROID_SDK_ROOT:-/usr/local/lib/android/sdk}" + export ANDROID_HOME="$ANDROID_SDK_ROOT" + echo "ANDROID_SDK_ROOT=$ANDROID_SDK_ROOT" >> "$GITHUB_ENV" + echo "ANDROID_HOME=$ANDROID_HOME" >> "$GITHUB_ENV" + + sdkmanager_bin="$(command -v sdkmanager || true)" + if [[ -z "$sdkmanager_bin" ]]; then + for candidate in \ + "$ANDROID_SDK_ROOT/cmdline-tools/latest/bin/sdkmanager" \ + "$ANDROID_SDK_ROOT/cmdline-tools/bin/sdkmanager" \ + "$ANDROID_SDK_ROOT/tools/bin/sdkmanager"; do + if [[ -x "$candidate" ]]; then + sdkmanager_bin="$candidate" + break + fi + done + fi + if [[ -z "$sdkmanager_bin" ]]; then + echo "sdkmanager not found under ANDROID_SDK_ROOT=$ANDROID_SDK_ROOT" >&2 + exit 1 + fi + + yes | "$sdkmanager_bin" --licenses >/dev/null || true + "$sdkmanager_bin" --install \ + "platforms;android-36" \ + "build-tools;36.0.0" \ + "platform-tools" \ + "ndk;28.0.12674087" \ + "ndk;25.2.9519653" - name: Export NDK environment run: | echo "ANDROID_NDK_HOME=$ANDROID_SDK_ROOT/ndk/28.0.12674087" >> $GITHUB_ENV echo "ANDROID_NDK_ROOT=$ANDROID_SDK_ROOT/ndk/28.0.12674087" >> $GITHUB_ENV echo "NDK_HOME=$ANDROID_SDK_ROOT/ndk/28.0.12674087" >> $GITHUB_ENV - + - name: NDK revision run: | echo "== NDK (HOME) ==" @@ -71,15 +172,14 @@ jobs: ls -1 "$ANDROID_SDK_ROOT/ndk" || true - name: Setup Rust toolchain - uses: dtolnay/rust-toolchain@stable - with: - targets: aarch64-linux-android,armv7-linux-androideabi,i686-linux-android,x86_64-linux-android - - - name: Cache cargo registry and builds - uses: Swatinem/rust-cache@v2 - - - name: Setup Gradle - uses: gradle/actions/setup-gradle@v3 + run: | + rustup toolchain install stable --profile minimal + rustup default stable + rustup target add \ + aarch64-linux-android \ + armv7-linux-androideabi \ + i686-linux-android \ + x86_64-linux-android - name: Gradle version run: ./gradlew --version --no-daemon --console=plain --stacktrace diff --git a/.github/workflows/android-release.yml b/.github/workflows/android-release.yml new file mode 100644 index 0000000000..6ee00a7e80 --- /dev/null +++ b/.github/workflows/android-release.yml @@ -0,0 +1,150 @@ +name: Android Release + +on: + workflow_dispatch: + inputs: + build_task: + description: Gradle task to run after release overlays are restored. + required: true + default: :app:bundleRelease + strict_provenance: + description: Fail if any tracked binary still lacks source/reproducible provenance. + required: true + type: boolean + default: true + +permissions: + contents: read + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: false + +jobs: + release-build: + runs-on: ubuntu-latest + timeout-minutes: 60 + env: + CI: true + FEARLESS_UTILS_COMMIT: 7500809f33243ee47ecb2ec8563fc284ac4de0d6 + FEARLESS_UTILS_PATH: ${{ github.workspace }}/fearless-utils-Android + FORCE_LOCAL_UTILS: "true" + FEARLESS_UTILS_LIBRARY_ONLY: "true" + USE_REMOTE_UTILS: "false" + SHARED_FEATURES_VERSION_OVERRIDE: "" + + GOOGLE_SERVICES_RELEASE_JSON_B64: ${{ secrets.GOOGLE_SERVICES_RELEASE_JSON_B64 }} + ANDROID_RELEASE_KEYSTORE_B64: ${{ secrets.ANDROID_RELEASE_KEYSTORE_B64 }} + PLAY_SERVICE_ACCOUNT_JSON_B64: ${{ secrets.PLAY_SERVICE_ACCOUNT_JSON_B64 }} + CI_KEYSTORE_PASS: ${{ secrets.ANDROID_RELEASE_KEYSTORE_PASSWORD }} + CI_KEYSTORE_KEY_ALIAS: ${{ secrets.ANDROID_RELEASE_KEY_ALIAS }} + CI_KEYSTORE_KEY_PASS: ${{ secrets.ANDROID_RELEASE_KEY_PASSWORD }} + + RAMP_TOKEN_RELEASE: ${{ secrets.RAMP_TOKEN_RELEASE }} + COINBASE_APP_ID: ${{ secrets.COINBASE_APP_ID }} + MOONPAY_PRODUCTION_SECRET: ${{ secrets.MOONPAY_PRODUCTION_SECRET }} + MOONPAY_PRODUCTION_PUBLIC_KEY: ${{ secrets.MOONPAY_PRODUCTION_PUBLIC_KEY }} + X1_ENDPOINT_URL_RELEASE: ${{ secrets.X1_ENDPOINT_URL_RELEASE }} + X1_WIDGET_ID_RELEASE: ${{ secrets.X1_WIDGET_ID_RELEASE }} + WEB_CLIENT_ID_RELEASE: ${{ secrets.WEB_CLIENT_ID_RELEASE }} + FL_BLAST_API_ETHEREUM_KEY: ${{ secrets.FL_BLAST_API_ETHEREUM_KEY }} + FL_BLAST_API_BSC_KEY: ${{ secrets.FL_BLAST_API_BSC_KEY }} + FL_BLAST_API_SEPOLIA_KEY: ${{ secrets.FL_BLAST_API_SEPOLIA_KEY }} + FL_BLAST_API_GOERLI_KEY: ${{ secrets.FL_BLAST_API_GOERLI_KEY }} + FL_BLAST_API_POLYGON_KEY: ${{ secrets.FL_BLAST_API_POLYGON_KEY }} + FL_ANDROID_ETHERSCAN_API_KEY: ${{ secrets.FL_ANDROID_ETHERSCAN_API_KEY }} + FL_ANDROID_BSCSCAN_API_KEY: ${{ secrets.FL_ANDROID_BSCSCAN_API_KEY }} + FL_ANDROID_POLYGONSCAN_API_KEY: ${{ secrets.FL_ANDROID_POLYGONSCAN_API_KEY }} + FL_ANDROID_OKLINK_API_KEY: ${{ secrets.FL_ANDROID_OKLINK_API_KEY }} + FL_ANDROID_OPMAINNET_API_KEY: ${{ secrets.FL_ANDROID_OPMAINNET_API_KEY }} + FL_WALLET_CONNECT_PROJECT_ID: ${{ secrets.FL_WALLET_CONNECT_PROJECT_ID }} + WALLET_CONNECT_PROJECT_ID: ${{ secrets.FL_WALLET_CONNECT_PROJECT_ID }} + FL_DWELLIR_API_KEY: ${{ secrets.FL_DWELLIR_API_KEY }} + FL_ANDROID_TON_API_KEY: ${{ secrets.FL_ANDROID_TON_API_KEY }} + FL_ANDROID_TON_INDEXER_URL: ${{ secrets.FL_ANDROID_TON_INDEXER_URL }} + + steps: + - name: Checkout + uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Checkout fearless-utils-Android + uses: actions/checkout@v4 + with: + repository: soramitsu/fearless-utils-Android + ref: ${{ env.FEARLESS_UTILS_COMMIT }} + path: fearless-utils-Android + fetch-depth: 0 + + - name: Verify fearless-utils source + run: ./scripts/ensure-fearless-utils.sh + + - name: Setup Java 21 (Temurin) + uses: actions/setup-java@v4 + with: + distribution: temurin + java-version: '21' + + - name: Setup Android SDK + run: | + set -euo pipefail + export ANDROID_SDK_ROOT="${ANDROID_SDK_ROOT:-/usr/local/lib/android/sdk}" + export ANDROID_HOME="$ANDROID_SDK_ROOT" + echo "ANDROID_SDK_ROOT=$ANDROID_SDK_ROOT" >> "$GITHUB_ENV" + echo "ANDROID_HOME=$ANDROID_HOME" >> "$GITHUB_ENV" + + sdkmanager_bin="$(command -v sdkmanager || true)" + if [[ -z "$sdkmanager_bin" ]]; then + for candidate in \ + "$ANDROID_SDK_ROOT/cmdline-tools/latest/bin/sdkmanager" \ + "$ANDROID_SDK_ROOT/cmdline-tools/bin/sdkmanager" \ + "$ANDROID_SDK_ROOT/tools/bin/sdkmanager"; do + if [[ -x "$candidate" ]]; then + sdkmanager_bin="$candidate" + break + fi + done + fi + if [[ -z "$sdkmanager_bin" ]]; then + echo "sdkmanager not found under ANDROID_SDK_ROOT=$ANDROID_SDK_ROOT" >&2 + exit 1 + fi + + yes | "$sdkmanager_bin" --licenses >/dev/null || true + "$sdkmanager_bin" --install \ + "platforms;android-36" \ + "build-tools;36.0.0" \ + "platform-tools" \ + "ndk;28.0.12674087" \ + "ndk;25.2.9519653" + + - name: Export NDK environment + run: | + echo "ANDROID_NDK_HOME=$ANDROID_SDK_ROOT/ndk/28.0.12674087" >> $GITHUB_ENV + echo "ANDROID_NDK_ROOT=$ANDROID_SDK_ROOT/ndk/28.0.12674087" >> $GITHUB_ENV + echo "NDK_HOME=$ANDROID_SDK_ROOT/ndk/28.0.12674087" >> $GITHUB_ENV + + - name: Setup Rust toolchain + run: | + rustup toolchain install stable --profile minimal + rustup default stable + rustup target add \ + aarch64-linux-android \ + armv7-linux-androideabi \ + i686-linux-android \ + x86_64-linux-android + + - name: Restore release overlays + run: ./scripts/restore-release-overlays.sh + + - name: Release artifact audit + run: | + flags=(--release) + if [ "${{ inputs.strict_provenance }}" = "true" ]; then + flags+=(--strict-provenance) + fi + ./scripts/audit-public-artifacts.sh "${flags[@]}" + + - name: Build release artifact + run: ./gradlew "${{ inputs.build_task }}" --no-daemon --console=plain --stacktrace diff --git a/.github/workflows/branch-flow.yml b/.github/workflows/branch-flow.yml new file mode 100644 index 0000000000..ccfb351e13 --- /dev/null +++ b/.github/workflows/branch-flow.yml @@ -0,0 +1,51 @@ +name: Branch Flow + +on: + pull_request: + +permissions: + contents: read + +jobs: + validate: + runs-on: ubuntu-latest + timeout-minutes: 5 + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Branch flow audit self-test + run: bash scripts/test-branch-flow-audit.sh + + - name: Branch flow drift audit + run: bash scripts/audit-branch-flow.sh + + - name: Validate PR target + env: + BASE_BRANCH: ${{ github.base_ref }} + HEAD_BRANCH: ${{ github.head_ref }} + run: | + set -euo pipefail + + echo "PR branch flow: ${HEAD_BRANCH} -> ${BASE_BRANCH}" + + if [[ "$BASE_BRANCH" == "develop" ]]; then + if [[ "$HEAD_BRANCH" == "master" ]]; then + echo "::error::Do not open normal work from master to develop. Use a feature/fix/chore/refactor, release, or hotfix branch." + exit 1 + fi + + exit 0 + fi + + if [[ "$BASE_BRANCH" == "master" ]]; then + if [[ "$HEAD_BRANCH" == "develop" || "$HEAD_BRANCH" == release/* || "$HEAD_BRANCH" == hotfix/* ]]; then + exit 0 + fi + + echo "::error::master is releasable. Only develop, release/*, or hotfix/* branches may target master." + exit 1 + fi + + echo "::error::Pull requests must target develop for normal work or master for release/hotfix promotion." + exit 1 diff --git a/.gitignore b/.gitignore index 02fc28ff96..8284e6e35a 100644 --- a/.gitignore +++ b/.gitignore @@ -1,8 +1,10 @@ *.iml .gradle +.kotlin/ /local.properties .DS_Store /build +**/build/ /captures .externalNativeBuild app/src/main/aidl/ @@ -17,4 +19,10 @@ app/*.apk fearless.jks fearless-upload.jks +debug-keystore.jks +*.keystore +*.p12 +*.p8 +*.pem +/.release-overlays/ /buildSrc/build diff --git a/.kotlin/sessions/kotlin-compiler-16587347577044867778.salive b/.kotlin/sessions/kotlin-compiler-16587347577044867778.salive deleted file mode 100644 index e69de29bb2..0000000000 diff --git a/AGENTS.md b/AGENTS.md index f5d96913a8..8d2c80c06e 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -55,7 +55,7 @@ ## Security & Configuration - Secrets are read via `scripts/secrets.gradle`; set in env vars or `local.properties` (see `README.md`). -- Do not commit keys or `local.properties`. Use the provided debug keystore only for local builds. +- Do not commit keys, keystores, provisioning files, or `local.properties`. - Polkadot runtime sources: to align with a specific Polkadot SDK release (e.g., `polkadot-stable2503`), you can override chain/type registries without code changes: - `TYPES_URL_OVERRIDE`, `DEFAULT_V13_TYPES_URL_OVERRIDE`, `CHAINS_URL_OVERRIDE` - Example in `local.properties`: diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index c409ea18e5..baef79a9d1 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -29,7 +29,25 @@ If you think you miss some functionality on our application, please open issue o * (OPTIONAL) If possible, provide complete description of functionality you suggest * (OPTIONAL) Provide your telegram username, and be sure to join [Our Community Channel](https://t.me/fearlesshappiness) on Telegram, so our team can get in touch with you to retrieve more information about your vision -Even if you provide mockups, please understand that our design team will review it and might implement this in very different way. +Even if you provide mockups, please understand that our design team will review it and might implement this in very different way. + +### Git flow + +All normal work starts from `develop` and is submitted back to `develop`. +Use short-lived branches named `feature/-`, `fix/-`, +`chore/`, or `refactor/`. + +`master` is the releasable branch. Do not target `master` except for release +pull requests from `develop` or urgent `hotfix/` branches. +Every commit on `master` must be safe to release, and release tags must point at +commits already merged to `master`. + +Feature PRs are squash-merged after review and green CI. Release PRs to +`master` use merge commits so the release boundary remains visible. + +The `Branch Flow` GitHub Actions workflow validates PR targets automatically: +normal work must target `develop`, while `master` accepts only `develop`, +`release/*`, or `hotfix/*` branches. ### 💻 Opening Pull Requests @@ -38,10 +56,10 @@ If you would like to help us by contributing writing the code, please follow nex * Always create and issue prior to opening Pull Request (hereinafter the PR), or your PR less likely to be reviewed * Remember, that in that case you are required to provide full description of your feature in created issue, otherwise it would be hard for us to understand what you're trying to add to our codebase * Follow the coding standards guidelines (TO BE PROVIDED LATER), or you will be asked to make changes to follow them -* Please avoid huge PRs, and if your contribution really requires lots of files, please make a base branch with series of small PRs on your fork, and then provide link to those PRs in your big one PR in our repository +* Please avoid huge PRs, and if your contribution really requires lots of files, please make a base branch with series of small PRs on your fork, and then provide link to those PRs in your big one PR in our repository * Provide steps for QA engineer to test your functionality (they should cover requirements from your issue) -Even if you provide mockups, please understand that our design team will review it and might implement this in very different way. +Even if you provide mockups, please understand that our design team will review it and might implement this in very different way. ### Telegram links diff --git a/Jenkinsfile b/Jenkinsfile index 6b2d50683b..2342e6f28d 100644 --- a/Jenkinsfile +++ b/Jenkinsfile @@ -3,7 +3,7 @@ // Job properties def jobParams = [ booleanParam(defaultValue: false, description: 'push to the dev profile', name: 'prDeployment'), - booleanParam(defaultValue: false, description: 'Upload builds to nexus(master,develop and staging branches upload always)', name: 'upload_to_nexus'), + booleanParam(defaultValue: false, description: 'Upload builds to nexus(master and develop branches upload always)', name: 'upload_to_nexus'), ] // Silence optional SDK pin warnings in CI @@ -24,6 +24,6 @@ def pipeline = new org.android.AppPipeline( jobParams: jobParams, appPushNoti: true, dojoProductType: 'android', - uploadToNexusFor: ['master','develop','staging'] + uploadToNexusFor: ['master','develop'] ) pipeline.runPipeline('fearless') diff --git a/README.md b/README.md index bf120423fe..8da6fcf36e 100644 --- a/README.md +++ b/README.md @@ -30,10 +30,29 @@ To build Fearless Wallet Android project, you need to provide several keys eithe ``` MOONPAY_TEST_SECRET=stub MOONPAY_PRODUCTION_SECRET=stub +MOONPAY_TEST_PUBLIC_KEY=stub +MOONPAY_PRODUCTION_PUBLIC_KEY=stub ``` Note, that with stub keys buy via moonpay will not work correctly. However, other parts of the application will not be affected. +### Buy provider partner properties +``` +RAMP_TOKEN_DEBUG=stub +RAMP_TOKEN_RELEASE=stub +COINBASE_APP_ID=stub +``` + +Partner identifiers are loaded from `local.properties` or environment variables +so public builds do not require committed release config. + +### Firebase properties + +The checked-in `app/src/*/google-services.json` files are public placeholders +with real package names but no live Firebase project IDs, API keys, OAuth +clients, or certificate hashes. Release and distribution builds must replace +them from the private CI overlay before publishing. + ### X1 plugin X1 is a plugin which is embedded into webView. It requires url and id for launching. @@ -80,19 +99,23 @@ Manual equivalents if you prefer: ./gradlew :app:lint ``` -Prerequisites: JDK 21 (Temurin/Adoptium) and Android SDK with API 35 + build-tools 35.0.0. The script will try to locate `ANDROID_SDK_ROOT` and install missing packages if `sdkmanager` is available. +Prerequisites: JDK 21 (Temurin/Adoptium) and Android SDK with API 36 + build-tools 36.0.0. The script will try to locate `ANDROID_SDK_ROOT` and install missing packages if `sdkmanager` is available. -### Use fearless-utils-Android (local or remote source dependency) +### Use fearless-utils-Android -The build now prefers a local checkout of `fearless-utils-Android`. By default it looks for `../fearless-utils-Android`, or you can override it with `FEARLESS_UTILS_PATH`: +Public builds use a checked-out copy of `fearless-utils-Android` as a composite build. CI pins that checkout to `7500809f33243ee47ecb2ec8563fc284ac4de0d6`. Locally, clone the repo next to this checkout or set `FEARLESS_UTILS_PATH`: ``` +git clone https://github.com/soramitsu/fearless-utils-Android.git ../fearless-utils-Android +git -C ../fearless-utils-Android checkout 7500809f33243ee47ecb2ec8563fc284ac4de0d6 export FEARLESS_UTILS_PATH=/absolute/path/to/fearless-utils-Android +export FEARLESS_UTILS_LIBRARY_ONLY=true +./scripts/ensure-fearless-utils.sh ./gradlew :app:assembleDebug ``` -Gradle includes the local project via a composite build and substitutes `jp.co.soramitsu.fearless-utils:fearless-utils` automatically. -If no local checkout is found and you need to build from source, set `USE_REMOTE_UTILS=true` (env var or `-PUSE_REMOTE_UTILS=true`) and Gradle will fetch `https://github.com/soramitsu/fearless-utils-Android` instead. +Gradle includes the local project via a composite build through Gradle 9 and substitutes `jp.co.soramitsu.fearless-utils:fearless-utils` automatically. +Run `./scripts/ensure-fearless-utils.sh` to verify the checkout, pinned commit, and library-only overlay before building. The Gradle `USE_REMOTE_UTILS=true` source-control fallback remains experimental and is not the public CI contract. Prereqs for building the utils from source: NDK r28 (android-ndk-r28 / 28.0.x) and a Rust toolchain on `PATH` (`rustup`, `cargo`). ### Rebuild libsodium with 16 KB alignment @@ -107,6 +130,11 @@ ANDROID_NDK_HOME=/Users//Library/Android/sdk/ndk/28.0.12674087 \ The script rebuilds `libsodium.so` for arm64-v8a, armeabi-v7a, x86, and x86_64 with the Google Play-required `-Wl,-z,common-page-size=4096 -Wl,-z,max-page-size=16384` flags and copies them into `app/src/main/jniLibs`. +Tracked native/vendor binary provenance is documented in +`docs/binary-provenance.md`. +Public dependency provenance and current Soramitsu artifact blockers are tracked +in `docs/public-dependency-audit.md`. + ## Contributing - Contributor Guide: see [AGENTS.md](AGENTS.md) for project layout, commands, and conventions. diff --git a/app/AGENTS.md b/app/AGENTS.md index c1480e273b..40bb74f251 100644 --- a/app/AGENTS.md +++ b/app/AGENTS.md @@ -16,7 +16,11 @@ Build Types Configs & Secrets (see README for details) - WalletConnect: `WALLET_CONNECT_PROJECT_ID` in `BuildConfig`. -- Moonpay: `MOONPAY_TEST_SECRET`, `MOONPAY_PRODUCTION_SECRET`. +- Moonpay: `MOONPAY_TEST_SECRET`, `MOONPAY_PRODUCTION_SECRET`, + `MOONPAY_TEST_PUBLIC_KEY`, `MOONPAY_PRODUCTION_PUBLIC_KEY`. +- Buy providers: `RAMP_TOKEN_DEBUG`, `RAMP_TOKEN_RELEASE`, `COINBASE_APP_ID`. +- Firebase: checked-in `google-services.json` files are placeholders; real + Firebase project files must come from private CI or local overlay config. - EVM/API keys: `FL_BLAST_API_*`, `FL_ANDROID_*SCAN_API_KEY`. Common Tasks diff --git a/app/build.gradle b/app/build.gradle index 297b30085e..6cfd89793e 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -44,7 +44,7 @@ android { ci { def keystorePath = envOrLocalProp("CI_KEYSTORE_PATH", "ANDROID_RELEASE_KEYSTORE_PATH") def releaseKeystore = rootProject.file("fearless-upload.jks") - def debugKeystore = rootProject.file("debug-keystore.jks") + def debugSigningConfig = signingConfigs.getByName("debug") def resolvedStoreFile = null if (keystorePath) { @@ -59,20 +59,14 @@ android { resolvedStoreFile = releaseKeystore } if (resolvedStoreFile == null) { - resolvedStoreFile = debugKeystore + resolvedStoreFile = debugSigningConfig.storeFile } storeFile resolvedStoreFile - storePassword envOrLocalProp("CI_KEYSTORE_PASS", "ANDROID_RELEASE_KEYSTORE_PASSWORD") ?: "00000000" - keyAlias envOrLocalProp("CI_KEYSTORE_KEY_ALIAS", "ANDROID_RELEASE_KEYSTORE_ALIAS") ?: "fearless" - keyPassword envOrLocalProp("CI_KEYSTORE_KEY_PASS", "ANDROID_RELEASE_KEYSTORE_KEY_PASSWORD") ?: "00000000" - } - debug { - storeFile file("../debug-keystore.jks") - storePassword "00000000" - keyAlias "fearless" - keyPassword "00000000" + storePassword envOrLocalProp("CI_KEYSTORE_PASS", "ANDROID_RELEASE_KEYSTORE_PASSWORD") ?: debugSigningConfig.storePassword + keyAlias envOrLocalProp("CI_KEYSTORE_KEY_ALIAS", "ANDROID_RELEASE_KEYSTORE_ALIAS") ?: debugSigningConfig.keyAlias + keyPassword envOrLocalProp("CI_KEYSTORE_KEY_PASS", "ANDROID_RELEASE_KEYSTORE_KEY_PASSWORD") ?: debugSigningConfig.keyPassword } } diff --git a/app/proguard-rules.pro b/app/proguard-rules.pro index bbcbf95918..aa751af506 100644 --- a/app/proguard-rules.pro +++ b/app/proguard-rules.pro @@ -7,7 +7,7 @@ #-keep class ** { *; } #-dontobfuscate --keep class jp.co.soramitsu.shared_utils.** { *; } +-keep class jp.co.soramitsu.fearless_utils.** { *; } -keep class jp.co.soramitsu.runtime.** { *; } -keep class jp.co.soramitsu.wallet.impl.data.** { *; } diff --git a/app/src/debug/google-services.json b/app/src/debug/google-services.json index 07dcd67f4f..973459eea7 100644 --- a/app/src/debug/google-services.json +++ b/app/src/debug/google-services.json @@ -1,70 +1,66 @@ { "project_info": { - "project_number": "621183184145", - "firebase_url": "https://manticora-37173.firebaseio.com", - "project_id": "manticora-37173", - "storage_bucket": "manticora-37173.appspot.com" + "project_number": "000000000000", + "firebase_url": "https://fearless-public.firebaseio.com", + "project_id": "fearless-public", + "storage_bucket": "fearless-public.appspot.com" }, "client": [ { "client_info": { - "mobilesdk_app_id": "1:621183184145:android:0f3c905e5882bbf50730b0", + "mobilesdk_app_id": "1:000000000000:android:0000000000000000000001", "android_client_info": { "package_name": "jp.co.soramitsu.fearless.debug" } }, "oauth_client": [ { - "client_id": "621183184145-605h6edvqe598tdeb8u102uo4rgq6v48.apps.googleusercontent.com", + "client_id": "000000000000-public-0-0.apps.googleusercontent.com", "client_type": 1, "android_info": { - "package_name": "jp.co.soramitsu.fearless.debug", - "certificate_hash": "a6cb8486963f79002d4bafdf0bf1b84c0288eba8" + "package_name": "jp.co.soramitsu.fearless.debug" } }, { - "client_id": "621183184145-eboev5kfhfmqhlh20u09kbfugi4rs9k7.apps.googleusercontent.com", + "client_id": "000000000000-public-0-1.apps.googleusercontent.com", "client_type": 1, "android_info": { - "package_name": "jp.co.soramitsu.fearless.debug", - "certificate_hash": "a65f469b8a488fa8cb5629c79121e9bec759f1ed" + "package_name": "jp.co.soramitsu.fearless.debug" } }, { - "client_id": "621183184145-g3i9om82fat5hrit1urm94g946iipac4.apps.googleusercontent.com", + "client_id": "000000000000-public-0-2.apps.googleusercontent.com", "client_type": 1, "android_info": { - "package_name": "jp.co.soramitsu.fearless.debug", - "certificate_hash": "f107164171ec2d32d37cbc61574dbf09a4d567ca" + "package_name": "jp.co.soramitsu.fearless.debug" } }, { - "client_id": "621183184145-jriggsgmap823nt1e9ctlbrjgevonla0.apps.googleusercontent.com", + "client_id": "000000000000-public-0-3.apps.googleusercontent.com", "client_type": 1, "android_info": { - "package_name": "jp.co.soramitsu.fearless.debug", - "certificate_hash": "613acdffd5bcf696d2a19cec0c0394c3183cb642" + "package_name": "jp.co.soramitsu.fearless.debug" } }, { - "client_id": "621183184145-9f45pucnai4o553imtpnep1mdl909i5b.apps.googleusercontent.com", + "client_id": "000000000000-public-0-4.apps.googleusercontent.com", "client_type": 3 } ], "api_key": [ { - "current_key": "AIzaSyAdN45IMOhSas1GG1wuJCbXG1VlnrW_ZxE" + "current_key": "" } ], "services": { "appinvite_service": { "other_platform_oauth_client": [ { - "client_id": "621183184145-3hd3h5ioiejqu2bs1ngamlpd616mmcio.apps.googleusercontent.com", + "client_id": "000000000000-public-other-0-0.apps.googleusercontent.com", "client_type": 3 }, { - "client_id": "621183184145-3epcp830ue13bh54eshg09mfku2me1qv.apps.googleusercontent.com", + "client_id": "000000000000-public-other-0-1.apps.googleusercontent.com", "client_type": 2, "ios_info": { "bundle_id": "co.jp.soramitsu.soraui-example" @@ -76,47 +72,45 @@ }, { "client_info": { - "mobilesdk_app_id": "1:621183184145:android:2b0172d9e8fe674b0730b0", + "mobilesdk_app_id": "1:000000000000:android:0000000000000000000002", "android_client_info": { "package_name": "jp.co.soramitsu.fearless.dev" } }, "oauth_client": [ { - "client_id": "621183184145-11lu1jtvfcjmsvm09di6eqtsln5qe6fi.apps.googleusercontent.com", + "client_id": "000000000000-public-1-0.apps.googleusercontent.com", "client_type": 1, "android_info": { - "package_name": "jp.co.soramitsu.fearless.dev", - "certificate_hash": "f107164171ec2d32d37cbc61574dbf09a4d567ca" + "package_name": "jp.co.soramitsu.fearless.dev" } }, { - "client_id": "621183184145-j7hcbp3p9cl99nuuea3d9hs88l1k4k46.apps.googleusercontent.com", + "client_id": "000000000000-public-1-1.apps.googleusercontent.com", "client_type": 1, "android_info": { - "package_name": "jp.co.soramitsu.fearless.dev", - "certificate_hash": "a6cb8486963f79002d4bafdf0bf1b84c0288eba8" + "package_name": "jp.co.soramitsu.fearless.dev" } }, { - "client_id": "621183184145-9f45pucnai4o553imtpnep1mdl909i5b.apps.googleusercontent.com", + "client_id": "000000000000-public-1-2.apps.googleusercontent.com", "client_type": 3 } ], "api_key": [ { - "current_key": "AIzaSyAdN45IMOhSas1GG1wuJCbXG1VlnrW_ZxE" + "current_key": "" } ], "services": { "appinvite_service": { "other_platform_oauth_client": [ { - "client_id": "621183184145-3hd3h5ioiejqu2bs1ngamlpd616mmcio.apps.googleusercontent.com", + "client_id": "000000000000-public-other-1-0.apps.googleusercontent.com", "client_type": 3 }, { - "client_id": "621183184145-3epcp830ue13bh54eshg09mfku2me1qv.apps.googleusercontent.com", + "client_id": "000000000000-public-other-1-1.apps.googleusercontent.com", "client_type": 2, "ios_info": { "bundle_id": "co.jp.soramitsu.soraui-example" @@ -128,31 +122,31 @@ }, { "client_info": { - "mobilesdk_app_id": "1:621183184145:android:fc33975da180db940730b0", + "mobilesdk_app_id": "1:000000000000:android:0000000000000000000003", "android_client_info": { "package_name": "jp.co.soramitsu.fearless.staging" } }, "oauth_client": [ { - "client_id": "621183184145-9f45pucnai4o553imtpnep1mdl909i5b.apps.googleusercontent.com", + "client_id": "000000000000-public-2-0.apps.googleusercontent.com", "client_type": 3 } ], "api_key": [ { - "current_key": "AIzaSyAdN45IMOhSas1GG1wuJCbXG1VlnrW_ZxE" + "current_key": "" } ], "services": { "appinvite_service": { "other_platform_oauth_client": [ { - "client_id": "621183184145-3hd3h5ioiejqu2bs1ngamlpd616mmcio.apps.googleusercontent.com", + "client_id": "000000000000-public-other-2-0.apps.googleusercontent.com", "client_type": 3 }, { - "client_id": "621183184145-3epcp830ue13bh54eshg09mfku2me1qv.apps.googleusercontent.com", + "client_id": "000000000000-public-other-2-1.apps.googleusercontent.com", "client_type": 2, "ios_info": { "bundle_id": "co.jp.soramitsu.soraui-example" @@ -164,4 +158,4 @@ } ], "configuration_version": "1" -} \ No newline at end of file +} diff --git a/app/src/develop/google-services.json b/app/src/develop/google-services.json index 07dcd67f4f..973459eea7 100644 --- a/app/src/develop/google-services.json +++ b/app/src/develop/google-services.json @@ -1,70 +1,66 @@ { "project_info": { - "project_number": "621183184145", - "firebase_url": "https://manticora-37173.firebaseio.com", - "project_id": "manticora-37173", - "storage_bucket": "manticora-37173.appspot.com" + "project_number": "000000000000", + "firebase_url": "https://fearless-public.firebaseio.com", + "project_id": "fearless-public", + "storage_bucket": "fearless-public.appspot.com" }, "client": [ { "client_info": { - "mobilesdk_app_id": "1:621183184145:android:0f3c905e5882bbf50730b0", + "mobilesdk_app_id": "1:000000000000:android:0000000000000000000001", "android_client_info": { "package_name": "jp.co.soramitsu.fearless.debug" } }, "oauth_client": [ { - "client_id": "621183184145-605h6edvqe598tdeb8u102uo4rgq6v48.apps.googleusercontent.com", + "client_id": "000000000000-public-0-0.apps.googleusercontent.com", "client_type": 1, "android_info": { - "package_name": "jp.co.soramitsu.fearless.debug", - "certificate_hash": "a6cb8486963f79002d4bafdf0bf1b84c0288eba8" + "package_name": "jp.co.soramitsu.fearless.debug" } }, { - "client_id": "621183184145-eboev5kfhfmqhlh20u09kbfugi4rs9k7.apps.googleusercontent.com", + "client_id": "000000000000-public-0-1.apps.googleusercontent.com", "client_type": 1, "android_info": { - "package_name": "jp.co.soramitsu.fearless.debug", - "certificate_hash": "a65f469b8a488fa8cb5629c79121e9bec759f1ed" + "package_name": "jp.co.soramitsu.fearless.debug" } }, { - "client_id": "621183184145-g3i9om82fat5hrit1urm94g946iipac4.apps.googleusercontent.com", + "client_id": "000000000000-public-0-2.apps.googleusercontent.com", "client_type": 1, "android_info": { - "package_name": "jp.co.soramitsu.fearless.debug", - "certificate_hash": "f107164171ec2d32d37cbc61574dbf09a4d567ca" + "package_name": "jp.co.soramitsu.fearless.debug" } }, { - "client_id": "621183184145-jriggsgmap823nt1e9ctlbrjgevonla0.apps.googleusercontent.com", + "client_id": "000000000000-public-0-3.apps.googleusercontent.com", "client_type": 1, "android_info": { - "package_name": "jp.co.soramitsu.fearless.debug", - "certificate_hash": "613acdffd5bcf696d2a19cec0c0394c3183cb642" + "package_name": "jp.co.soramitsu.fearless.debug" } }, { - "client_id": "621183184145-9f45pucnai4o553imtpnep1mdl909i5b.apps.googleusercontent.com", + "client_id": "000000000000-public-0-4.apps.googleusercontent.com", "client_type": 3 } ], "api_key": [ { - "current_key": "AIzaSyAdN45IMOhSas1GG1wuJCbXG1VlnrW_ZxE" + "current_key": "" } ], "services": { "appinvite_service": { "other_platform_oauth_client": [ { - "client_id": "621183184145-3hd3h5ioiejqu2bs1ngamlpd616mmcio.apps.googleusercontent.com", + "client_id": "000000000000-public-other-0-0.apps.googleusercontent.com", "client_type": 3 }, { - "client_id": "621183184145-3epcp830ue13bh54eshg09mfku2me1qv.apps.googleusercontent.com", + "client_id": "000000000000-public-other-0-1.apps.googleusercontent.com", "client_type": 2, "ios_info": { "bundle_id": "co.jp.soramitsu.soraui-example" @@ -76,47 +72,45 @@ }, { "client_info": { - "mobilesdk_app_id": "1:621183184145:android:2b0172d9e8fe674b0730b0", + "mobilesdk_app_id": "1:000000000000:android:0000000000000000000002", "android_client_info": { "package_name": "jp.co.soramitsu.fearless.dev" } }, "oauth_client": [ { - "client_id": "621183184145-11lu1jtvfcjmsvm09di6eqtsln5qe6fi.apps.googleusercontent.com", + "client_id": "000000000000-public-1-0.apps.googleusercontent.com", "client_type": 1, "android_info": { - "package_name": "jp.co.soramitsu.fearless.dev", - "certificate_hash": "f107164171ec2d32d37cbc61574dbf09a4d567ca" + "package_name": "jp.co.soramitsu.fearless.dev" } }, { - "client_id": "621183184145-j7hcbp3p9cl99nuuea3d9hs88l1k4k46.apps.googleusercontent.com", + "client_id": "000000000000-public-1-1.apps.googleusercontent.com", "client_type": 1, "android_info": { - "package_name": "jp.co.soramitsu.fearless.dev", - "certificate_hash": "a6cb8486963f79002d4bafdf0bf1b84c0288eba8" + "package_name": "jp.co.soramitsu.fearless.dev" } }, { - "client_id": "621183184145-9f45pucnai4o553imtpnep1mdl909i5b.apps.googleusercontent.com", + "client_id": "000000000000-public-1-2.apps.googleusercontent.com", "client_type": 3 } ], "api_key": [ { - "current_key": "AIzaSyAdN45IMOhSas1GG1wuJCbXG1VlnrW_ZxE" + "current_key": "" } ], "services": { "appinvite_service": { "other_platform_oauth_client": [ { - "client_id": "621183184145-3hd3h5ioiejqu2bs1ngamlpd616mmcio.apps.googleusercontent.com", + "client_id": "000000000000-public-other-1-0.apps.googleusercontent.com", "client_type": 3 }, { - "client_id": "621183184145-3epcp830ue13bh54eshg09mfku2me1qv.apps.googleusercontent.com", + "client_id": "000000000000-public-other-1-1.apps.googleusercontent.com", "client_type": 2, "ios_info": { "bundle_id": "co.jp.soramitsu.soraui-example" @@ -128,31 +122,31 @@ }, { "client_info": { - "mobilesdk_app_id": "1:621183184145:android:fc33975da180db940730b0", + "mobilesdk_app_id": "1:000000000000:android:0000000000000000000003", "android_client_info": { "package_name": "jp.co.soramitsu.fearless.staging" } }, "oauth_client": [ { - "client_id": "621183184145-9f45pucnai4o553imtpnep1mdl909i5b.apps.googleusercontent.com", + "client_id": "000000000000-public-2-0.apps.googleusercontent.com", "client_type": 3 } ], "api_key": [ { - "current_key": "AIzaSyAdN45IMOhSas1GG1wuJCbXG1VlnrW_ZxE" + "current_key": "" } ], "services": { "appinvite_service": { "other_platform_oauth_client": [ { - "client_id": "621183184145-3hd3h5ioiejqu2bs1ngamlpd616mmcio.apps.googleusercontent.com", + "client_id": "000000000000-public-other-2-0.apps.googleusercontent.com", "client_type": 3 }, { - "client_id": "621183184145-3epcp830ue13bh54eshg09mfku2me1qv.apps.googleusercontent.com", + "client_id": "000000000000-public-other-2-1.apps.googleusercontent.com", "client_type": 2, "ios_info": { "bundle_id": "co.jp.soramitsu.soraui-example" @@ -164,4 +158,4 @@ } ], "configuration_version": "1" -} \ No newline at end of file +} diff --git a/app/src/pr/google-services.json b/app/src/pr/google-services.json index 07dcd67f4f..973459eea7 100644 --- a/app/src/pr/google-services.json +++ b/app/src/pr/google-services.json @@ -1,70 +1,66 @@ { "project_info": { - "project_number": "621183184145", - "firebase_url": "https://manticora-37173.firebaseio.com", - "project_id": "manticora-37173", - "storage_bucket": "manticora-37173.appspot.com" + "project_number": "000000000000", + "firebase_url": "https://fearless-public.firebaseio.com", + "project_id": "fearless-public", + "storage_bucket": "fearless-public.appspot.com" }, "client": [ { "client_info": { - "mobilesdk_app_id": "1:621183184145:android:0f3c905e5882bbf50730b0", + "mobilesdk_app_id": "1:000000000000:android:0000000000000000000001", "android_client_info": { "package_name": "jp.co.soramitsu.fearless.debug" } }, "oauth_client": [ { - "client_id": "621183184145-605h6edvqe598tdeb8u102uo4rgq6v48.apps.googleusercontent.com", + "client_id": "000000000000-public-0-0.apps.googleusercontent.com", "client_type": 1, "android_info": { - "package_name": "jp.co.soramitsu.fearless.debug", - "certificate_hash": "a6cb8486963f79002d4bafdf0bf1b84c0288eba8" + "package_name": "jp.co.soramitsu.fearless.debug" } }, { - "client_id": "621183184145-eboev5kfhfmqhlh20u09kbfugi4rs9k7.apps.googleusercontent.com", + "client_id": "000000000000-public-0-1.apps.googleusercontent.com", "client_type": 1, "android_info": { - "package_name": "jp.co.soramitsu.fearless.debug", - "certificate_hash": "a65f469b8a488fa8cb5629c79121e9bec759f1ed" + "package_name": "jp.co.soramitsu.fearless.debug" } }, { - "client_id": "621183184145-g3i9om82fat5hrit1urm94g946iipac4.apps.googleusercontent.com", + "client_id": "000000000000-public-0-2.apps.googleusercontent.com", "client_type": 1, "android_info": { - "package_name": "jp.co.soramitsu.fearless.debug", - "certificate_hash": "f107164171ec2d32d37cbc61574dbf09a4d567ca" + "package_name": "jp.co.soramitsu.fearless.debug" } }, { - "client_id": "621183184145-jriggsgmap823nt1e9ctlbrjgevonla0.apps.googleusercontent.com", + "client_id": "000000000000-public-0-3.apps.googleusercontent.com", "client_type": 1, "android_info": { - "package_name": "jp.co.soramitsu.fearless.debug", - "certificate_hash": "613acdffd5bcf696d2a19cec0c0394c3183cb642" + "package_name": "jp.co.soramitsu.fearless.debug" } }, { - "client_id": "621183184145-9f45pucnai4o553imtpnep1mdl909i5b.apps.googleusercontent.com", + "client_id": "000000000000-public-0-4.apps.googleusercontent.com", "client_type": 3 } ], "api_key": [ { - "current_key": "AIzaSyAdN45IMOhSas1GG1wuJCbXG1VlnrW_ZxE" + "current_key": "" } ], "services": { "appinvite_service": { "other_platform_oauth_client": [ { - "client_id": "621183184145-3hd3h5ioiejqu2bs1ngamlpd616mmcio.apps.googleusercontent.com", + "client_id": "000000000000-public-other-0-0.apps.googleusercontent.com", "client_type": 3 }, { - "client_id": "621183184145-3epcp830ue13bh54eshg09mfku2me1qv.apps.googleusercontent.com", + "client_id": "000000000000-public-other-0-1.apps.googleusercontent.com", "client_type": 2, "ios_info": { "bundle_id": "co.jp.soramitsu.soraui-example" @@ -76,47 +72,45 @@ }, { "client_info": { - "mobilesdk_app_id": "1:621183184145:android:2b0172d9e8fe674b0730b0", + "mobilesdk_app_id": "1:000000000000:android:0000000000000000000002", "android_client_info": { "package_name": "jp.co.soramitsu.fearless.dev" } }, "oauth_client": [ { - "client_id": "621183184145-11lu1jtvfcjmsvm09di6eqtsln5qe6fi.apps.googleusercontent.com", + "client_id": "000000000000-public-1-0.apps.googleusercontent.com", "client_type": 1, "android_info": { - "package_name": "jp.co.soramitsu.fearless.dev", - "certificate_hash": "f107164171ec2d32d37cbc61574dbf09a4d567ca" + "package_name": "jp.co.soramitsu.fearless.dev" } }, { - "client_id": "621183184145-j7hcbp3p9cl99nuuea3d9hs88l1k4k46.apps.googleusercontent.com", + "client_id": "000000000000-public-1-1.apps.googleusercontent.com", "client_type": 1, "android_info": { - "package_name": "jp.co.soramitsu.fearless.dev", - "certificate_hash": "a6cb8486963f79002d4bafdf0bf1b84c0288eba8" + "package_name": "jp.co.soramitsu.fearless.dev" } }, { - "client_id": "621183184145-9f45pucnai4o553imtpnep1mdl909i5b.apps.googleusercontent.com", + "client_id": "000000000000-public-1-2.apps.googleusercontent.com", "client_type": 3 } ], "api_key": [ { - "current_key": "AIzaSyAdN45IMOhSas1GG1wuJCbXG1VlnrW_ZxE" + "current_key": "" } ], "services": { "appinvite_service": { "other_platform_oauth_client": [ { - "client_id": "621183184145-3hd3h5ioiejqu2bs1ngamlpd616mmcio.apps.googleusercontent.com", + "client_id": "000000000000-public-other-1-0.apps.googleusercontent.com", "client_type": 3 }, { - "client_id": "621183184145-3epcp830ue13bh54eshg09mfku2me1qv.apps.googleusercontent.com", + "client_id": "000000000000-public-other-1-1.apps.googleusercontent.com", "client_type": 2, "ios_info": { "bundle_id": "co.jp.soramitsu.soraui-example" @@ -128,31 +122,31 @@ }, { "client_info": { - "mobilesdk_app_id": "1:621183184145:android:fc33975da180db940730b0", + "mobilesdk_app_id": "1:000000000000:android:0000000000000000000003", "android_client_info": { "package_name": "jp.co.soramitsu.fearless.staging" } }, "oauth_client": [ { - "client_id": "621183184145-9f45pucnai4o553imtpnep1mdl909i5b.apps.googleusercontent.com", + "client_id": "000000000000-public-2-0.apps.googleusercontent.com", "client_type": 3 } ], "api_key": [ { - "current_key": "AIzaSyAdN45IMOhSas1GG1wuJCbXG1VlnrW_ZxE" + "current_key": "" } ], "services": { "appinvite_service": { "other_platform_oauth_client": [ { - "client_id": "621183184145-3hd3h5ioiejqu2bs1ngamlpd616mmcio.apps.googleusercontent.com", + "client_id": "000000000000-public-other-2-0.apps.googleusercontent.com", "client_type": 3 }, { - "client_id": "621183184145-3epcp830ue13bh54eshg09mfku2me1qv.apps.googleusercontent.com", + "client_id": "000000000000-public-other-2-1.apps.googleusercontent.com", "client_type": 2, "ios_info": { "bundle_id": "co.jp.soramitsu.soraui-example" @@ -164,4 +158,4 @@ } ], "configuration_version": "1" -} \ No newline at end of file +} diff --git a/app/src/release/google-services.json b/app/src/release/google-services.json index edba0d59e7..79998c7047 100644 --- a/app/src/release/google-services.json +++ b/app/src/release/google-services.json @@ -1,38 +1,38 @@ { "project_info": { - "project_number": "621183184145", - "firebase_url": "https://manticora-37173.firebaseio.com", - "project_id": "manticora-37173", - "storage_bucket": "manticora-37173.appspot.com" + "project_number": "000000000000", + "firebase_url": "https://fearless-public.firebaseio.com", + "project_id": "fearless-public", + "storage_bucket": "fearless-public.appspot.com" }, "client": [ { "client_info": { - "mobilesdk_app_id": "1:621183184145:android:98678d680ec2e9060730b0", + "mobilesdk_app_id": "1:000000000000:android:0000000000000000000001", "android_client_info": { "package_name": "jp.co.soramitsu.adar.staging" } }, "oauth_client": [ { - "client_id": "621183184145-9f45pucnai4o553imtpnep1mdl909i5b.apps.googleusercontent.com", + "client_id": "000000000000-public-0-0.apps.googleusercontent.com", "client_type": 3 } ], "api_key": [ { - "current_key": "AIzaSyAdN45IMOhSas1GG1wuJCbXG1VlnrW_ZxE" + "current_key": "" } ], "services": { "appinvite_service": { "other_platform_oauth_client": [ { - "client_id": "621183184145-3hd3h5ioiejqu2bs1ngamlpd616mmcio.apps.googleusercontent.com", + "client_id": "000000000000-public-other-0-0.apps.googleusercontent.com", "client_type": 3 }, { - "client_id": "621183184145-3epcp830ue13bh54eshg09mfku2me1qv.apps.googleusercontent.com", + "client_id": "000000000000-public-other-0-1.apps.googleusercontent.com", "client_type": 2, "ios_info": { "bundle_id": "co.jp.soramitsu.soraui-example" @@ -44,39 +44,38 @@ }, { "client_info": { - "mobilesdk_app_id": "1:621183184145:android:526e6a583906c7390730b0", + "mobilesdk_app_id": "1:000000000000:android:0000000000000000000002", "android_client_info": { "package_name": "jp.co.soramitsu.fearless" } }, "oauth_client": [ { - "client_id": "621183184145-gs8frrcgo4pcpdbt9h9k4poec3plstvp.apps.googleusercontent.com", + "client_id": "000000000000-public-1-0.apps.googleusercontent.com", "client_type": 1, "android_info": { - "package_name": "jp.co.soramitsu.fearless", - "certificate_hash": "b6ee8d19cbce5f030a1467293da64358a77f8855" + "package_name": "jp.co.soramitsu.fearless" } }, { - "client_id": "621183184145-9f45pucnai4o553imtpnep1mdl909i5b.apps.googleusercontent.com", + "client_id": "000000000000-public-1-1.apps.googleusercontent.com", "client_type": 3 } ], "api_key": [ { - "current_key": "AIzaSyAdN45IMOhSas1GG1wuJCbXG1VlnrW_ZxE" + "current_key": "" } ], "services": { "appinvite_service": { "other_platform_oauth_client": [ { - "client_id": "621183184145-3hd3h5ioiejqu2bs1ngamlpd616mmcio.apps.googleusercontent.com", + "client_id": "000000000000-public-other-1-0.apps.googleusercontent.com", "client_type": 3 }, { - "client_id": "621183184145-3epcp830ue13bh54eshg09mfku2me1qv.apps.googleusercontent.com", + "client_id": "000000000000-public-other-1-1.apps.googleusercontent.com", "client_type": 2, "ios_info": { "bundle_id": "co.jp.soramitsu.soraui-example" @@ -88,71 +87,66 @@ }, { "client_info": { - "mobilesdk_app_id": "1:621183184145:android:0f3c905e5882bbf50730b0", + "mobilesdk_app_id": "1:000000000000:android:0000000000000000000003", "android_client_info": { "package_name": "jp.co.soramitsu.fearless.debug" } }, "oauth_client": [ { - "client_id": "621183184145-605h6edvqe598tdeb8u102uo4rgq6v48.apps.googleusercontent.com", + "client_id": "000000000000-public-2-0.apps.googleusercontent.com", "client_type": 1, "android_info": { - "package_name": "jp.co.soramitsu.fearless.debug", - "certificate_hash": "a6cb8486963f79002d4bafdf0bf1b84c0288eba8" + "package_name": "jp.co.soramitsu.fearless.debug" } }, { - "client_id": "621183184145-eboev5kfhfmqhlh20u09kbfugi4rs9k7.apps.googleusercontent.com", + "client_id": "000000000000-public-2-1.apps.googleusercontent.com", "client_type": 1, "android_info": { - "package_name": "jp.co.soramitsu.fearless.debug", - "certificate_hash": "a65f469b8a488fa8cb5629c79121e9bec759f1ed" + "package_name": "jp.co.soramitsu.fearless.debug" } }, { - "client_id": "621183184145-g3i9om82fat5hrit1urm94g946iipac4.apps.googleusercontent.com", + "client_id": "000000000000-public-2-2.apps.googleusercontent.com", "client_type": 1, "android_info": { - "package_name": "jp.co.soramitsu.fearless.debug", - "certificate_hash": "f107164171ec2d32d37cbc61574dbf09a4d567ca" + "package_name": "jp.co.soramitsu.fearless.debug" } }, { - "client_id": "621183184145-jriggsgmap823nt1e9ctlbrjgevonla0.apps.googleusercontent.com", + "client_id": "000000000000-public-2-3.apps.googleusercontent.com", "client_type": 1, "android_info": { - "package_name": "jp.co.soramitsu.fearless.debug", - "certificate_hash": "613acdffd5bcf696d2a19cec0c0394c3183cb642" + "package_name": "jp.co.soramitsu.fearless.debug" } }, { - "client_id": "621183184145-mdlt5831s26lbus6ti942ah9714k865o.apps.googleusercontent.com", + "client_id": "000000000000-public-2-4.apps.googleusercontent.com", "client_type": 1, "android_info": { - "package_name": "jp.co.soramitsu.fearless.debug", - "certificate_hash": "23ac06fbe7574ebd167e85cc189eb2f2aa02d68c" + "package_name": "jp.co.soramitsu.fearless.debug" } }, { - "client_id": "621183184145-9f45pucnai4o553imtpnep1mdl909i5b.apps.googleusercontent.com", + "client_id": "000000000000-public-2-5.apps.googleusercontent.com", "client_type": 3 } ], "api_key": [ { - "current_key": "AIzaSyAdN45IMOhSas1GG1wuJCbXG1VlnrW_ZxE" + "current_key": "" } ], "services": { "appinvite_service": { "other_platform_oauth_client": [ { - "client_id": "621183184145-3hd3h5ioiejqu2bs1ngamlpd616mmcio.apps.googleusercontent.com", + "client_id": "000000000000-public-other-2-0.apps.googleusercontent.com", "client_type": 3 }, { - "client_id": "621183184145-3epcp830ue13bh54eshg09mfku2me1qv.apps.googleusercontent.com", + "client_id": "000000000000-public-other-2-1.apps.googleusercontent.com", "client_type": 2, "ios_info": { "bundle_id": "co.jp.soramitsu.soraui-example" @@ -164,55 +158,52 @@ }, { "client_info": { - "mobilesdk_app_id": "1:621183184145:android:2b0172d9e8fe674b0730b0", + "mobilesdk_app_id": "1:000000000000:android:0000000000000000000004", "android_client_info": { "package_name": "jp.co.soramitsu.fearless.dev" } }, "oauth_client": [ { - "client_id": "621183184145-11lu1jtvfcjmsvm09di6eqtsln5qe6fi.apps.googleusercontent.com", + "client_id": "000000000000-public-3-0.apps.googleusercontent.com", "client_type": 1, "android_info": { - "package_name": "jp.co.soramitsu.fearless.dev", - "certificate_hash": "f107164171ec2d32d37cbc61574dbf09a4d567ca" + "package_name": "jp.co.soramitsu.fearless.dev" } }, { - "client_id": "621183184145-j7hcbp3p9cl99nuuea3d9hs88l1k4k46.apps.googleusercontent.com", + "client_id": "000000000000-public-3-1.apps.googleusercontent.com", "client_type": 1, "android_info": { - "package_name": "jp.co.soramitsu.fearless.dev", - "certificate_hash": "a6cb8486963f79002d4bafdf0bf1b84c0288eba8" + "package_name": "jp.co.soramitsu.fearless.dev" } }, { - "client_id": "621183184145-mmlj96ufa3rnc8qhljd6p7hj5uv6um2a.apps.googleusercontent.com", + "client_id": "000000000000-public-3-2.apps.googleusercontent.com", "client_type": 1, "android_info": { - "package_name": "jp.co.soramitsu.fearless.dev", - "certificate_hash": "23ac06fbe7574ebd167e85cc189eb2f2aa02d68c" + "package_name": "jp.co.soramitsu.fearless.dev" } }, { - "client_id": "621183184145-9f45pucnai4o553imtpnep1mdl909i5b.apps.googleusercontent.com", + "client_id": "000000000000-public-3-3.apps.googleusercontent.com", "client_type": 3 } ], "api_key": [ { - "current_key": "AIzaSyAdN45IMOhSas1GG1wuJCbXG1VlnrW_ZxE" + "current_key": "" } ], "services": { "appinvite_service": { "other_platform_oauth_client": [ { - "client_id": "621183184145-3hd3h5ioiejqu2bs1ngamlpd616mmcio.apps.googleusercontent.com", + "client_id": "000000000000-public-other-3-0.apps.googleusercontent.com", "client_type": 3 }, { - "client_id": "621183184145-3epcp830ue13bh54eshg09mfku2me1qv.apps.googleusercontent.com", + "client_id": "000000000000-public-other-3-1.apps.googleusercontent.com", "client_type": 2, "ios_info": { "bundle_id": "co.jp.soramitsu.soraui-example" @@ -224,31 +215,31 @@ }, { "client_info": { - "mobilesdk_app_id": "1:621183184145:android:fc33975da180db940730b0", + "mobilesdk_app_id": "1:000000000000:android:0000000000000000000005", "android_client_info": { "package_name": "jp.co.soramitsu.fearless.staging" } }, "oauth_client": [ { - "client_id": "621183184145-9f45pucnai4o553imtpnep1mdl909i5b.apps.googleusercontent.com", + "client_id": "000000000000-public-4-0.apps.googleusercontent.com", "client_type": 3 } ], "api_key": [ { - "current_key": "AIzaSyAdN45IMOhSas1GG1wuJCbXG1VlnrW_ZxE" + "current_key": "" } ], "services": { "appinvite_service": { "other_platform_oauth_client": [ { - "client_id": "621183184145-3hd3h5ioiejqu2bs1ngamlpd616mmcio.apps.googleusercontent.com", + "client_id": "000000000000-public-other-4-0.apps.googleusercontent.com", "client_type": 3 }, { - "client_id": "621183184145-3epcp830ue13bh54eshg09mfku2me1qv.apps.googleusercontent.com", + "client_id": "000000000000-public-other-4-1.apps.googleusercontent.com", "client_type": 2, "ios_info": { "bundle_id": "co.jp.soramitsu.soraui-example" @@ -260,31 +251,31 @@ }, { "client_info": { - "mobilesdk_app_id": "1:621183184145:android:77c7ba52dc549fe50730b0", + "mobilesdk_app_id": "1:000000000000:android:0000000000000000000006", "android_client_info": { "package_name": "jp.co.soramitsu.manticora" } }, "oauth_client": [ { - "client_id": "621183184145-9f45pucnai4o553imtpnep1mdl909i5b.apps.googleusercontent.com", + "client_id": "000000000000-public-5-0.apps.googleusercontent.com", "client_type": 3 } ], "api_key": [ { - "current_key": "AIzaSyAdN45IMOhSas1GG1wuJCbXG1VlnrW_ZxE" + "current_key": "" } ], "services": { "appinvite_service": { "other_platform_oauth_client": [ { - "client_id": "621183184145-3hd3h5ioiejqu2bs1ngamlpd616mmcio.apps.googleusercontent.com", + "client_id": "000000000000-public-other-5-0.apps.googleusercontent.com", "client_type": 3 }, { - "client_id": "621183184145-3epcp830ue13bh54eshg09mfku2me1qv.apps.googleusercontent.com", + "client_id": "000000000000-public-other-5-1.apps.googleusercontent.com", "client_type": 2, "ios_info": { "bundle_id": "co.jp.soramitsu.soraui-example" @@ -296,31 +287,31 @@ }, { "client_info": { - "mobilesdk_app_id": "1:621183184145:android:16d74f53a9d0df090730b0", + "mobilesdk_app_id": "1:000000000000:android:0000000000000000000007", "android_client_info": { "package_name": "jp.co.soramitsu.manticora.debug" } }, "oauth_client": [ { - "client_id": "621183184145-9f45pucnai4o553imtpnep1mdl909i5b.apps.googleusercontent.com", + "client_id": "000000000000-public-6-0.apps.googleusercontent.com", "client_type": 3 } ], "api_key": [ { - "current_key": "AIzaSyAdN45IMOhSas1GG1wuJCbXG1VlnrW_ZxE" + "current_key": "" } ], "services": { "appinvite_service": { "other_platform_oauth_client": [ { - "client_id": "621183184145-3hd3h5ioiejqu2bs1ngamlpd616mmcio.apps.googleusercontent.com", + "client_id": "000000000000-public-other-6-0.apps.googleusercontent.com", "client_type": 3 }, { - "client_id": "621183184145-3epcp830ue13bh54eshg09mfku2me1qv.apps.googleusercontent.com", + "client_id": "000000000000-public-other-6-1.apps.googleusercontent.com", "client_type": 2, "ios_info": { "bundle_id": "co.jp.soramitsu.soraui-example" @@ -332,31 +323,31 @@ }, { "client_info": { - "mobilesdk_app_id": "1:621183184145:android:4cd848e984cf539c0730b0", + "mobilesdk_app_id": "1:000000000000:android:0000000000000000000008", "android_client_info": { "package_name": "jp.co.soramitsu.manticora.demo" } }, "oauth_client": [ { - "client_id": "621183184145-9f45pucnai4o553imtpnep1mdl909i5b.apps.googleusercontent.com", + "client_id": "000000000000-public-7-0.apps.googleusercontent.com", "client_type": 3 } ], "api_key": [ { - "current_key": "AIzaSyAdN45IMOhSas1GG1wuJCbXG1VlnrW_ZxE" + "current_key": "" } ], "services": { "appinvite_service": { "other_platform_oauth_client": [ { - "client_id": "621183184145-3hd3h5ioiejqu2bs1ngamlpd616mmcio.apps.googleusercontent.com", + "client_id": "000000000000-public-other-7-0.apps.googleusercontent.com", "client_type": 3 }, { - "client_id": "621183184145-3epcp830ue13bh54eshg09mfku2me1qv.apps.googleusercontent.com", + "client_id": "000000000000-public-other-7-1.apps.googleusercontent.com", "client_type": 2, "ios_info": { "bundle_id": "co.jp.soramitsu.soraui-example" @@ -368,31 +359,31 @@ }, { "client_info": { - "mobilesdk_app_id": "1:621183184145:android:966f9975216628600730b0", + "mobilesdk_app_id": "1:000000000000:android:0000000000000000000009", "android_client_info": { "package_name": "jp.co.soramitsu.unkai.terminal" } }, "oauth_client": [ { - "client_id": "621183184145-9f45pucnai4o553imtpnep1mdl909i5b.apps.googleusercontent.com", + "client_id": "000000000000-public-8-0.apps.googleusercontent.com", "client_type": 3 } ], "api_key": [ { - "current_key": "AIzaSyAdN45IMOhSas1GG1wuJCbXG1VlnrW_ZxE" + "current_key": "" } ], "services": { "appinvite_service": { "other_platform_oauth_client": [ { - "client_id": "621183184145-3hd3h5ioiejqu2bs1ngamlpd616mmcio.apps.googleusercontent.com", + "client_id": "000000000000-public-other-8-0.apps.googleusercontent.com", "client_type": 3 }, { - "client_id": "621183184145-3epcp830ue13bh54eshg09mfku2me1qv.apps.googleusercontent.com", + "client_id": "000000000000-public-other-8-1.apps.googleusercontent.com", "client_type": 2, "ios_info": { "bundle_id": "co.jp.soramitsu.soraui-example" @@ -404,31 +395,31 @@ }, { "client_info": { - "mobilesdk_app_id": "1:621183184145:android:b3de34997fb76ecd0730b0", + "mobilesdk_app_id": "1:000000000000:android:0000000000000000000010", "android_client_info": { "package_name": "jp.co.soramitsu.unkai.terminal.debug" } }, "oauth_client": [ { - "client_id": "621183184145-9f45pucnai4o553imtpnep1mdl909i5b.apps.googleusercontent.com", + "client_id": "000000000000-public-9-0.apps.googleusercontent.com", "client_type": 3 } ], "api_key": [ { - "current_key": "AIzaSyAdN45IMOhSas1GG1wuJCbXG1VlnrW_ZxE" + "current_key": "" } ], "services": { "appinvite_service": { "other_platform_oauth_client": [ { - "client_id": "621183184145-3hd3h5ioiejqu2bs1ngamlpd616mmcio.apps.googleusercontent.com", + "client_id": "000000000000-public-other-9-0.apps.googleusercontent.com", "client_type": 3 }, { - "client_id": "621183184145-3epcp830ue13bh54eshg09mfku2me1qv.apps.googleusercontent.com", + "client_id": "000000000000-public-other-9-1.apps.googleusercontent.com", "client_type": 2, "ios_info": { "bundle_id": "co.jp.soramitsu.soraui-example" @@ -440,4 +431,4 @@ } ], "configuration_version": "1" -} \ No newline at end of file +} diff --git a/app/src/staging/google-services.json b/app/src/staging/google-services.json index 270978ae77..51becea406 100644 --- a/app/src/staging/google-services.json +++ b/app/src/staging/google-services.json @@ -1,38 +1,38 @@ { "project_info": { - "project_number": "621183184145", - "firebase_url": "https://manticora-37173.firebaseio.com", - "project_id": "manticora-37173", - "storage_bucket": "manticora-37173.appspot.com" + "project_number": "000000000000", + "firebase_url": "https://fearless-public.firebaseio.com", + "project_id": "fearless-public", + "storage_bucket": "fearless-public.appspot.com" }, "client": [ { "client_info": { - "mobilesdk_app_id": "1:621183184145:android:1da1ae032a08288f0730b0", + "mobilesdk_app_id": "1:000000000000:android:0000000000000000000001", "android_client_info": { "package_name": "jp.co.soramitsu.adar.testnet" } }, "oauth_client": [ { - "client_id": "621183184145-9f45pucnai4o553imtpnep1mdl909i5b.apps.googleusercontent.com", + "client_id": "000000000000-public-0-0.apps.googleusercontent.com", "client_type": 3 } ], "api_key": [ { - "current_key": "AIzaSyAdN45IMOhSas1GG1wuJCbXG1VlnrW_ZxE" + "current_key": "" } ], "services": { "appinvite_service": { "other_platform_oauth_client": [ { - "client_id": "621183184145-9f45pucnai4o553imtpnep1mdl909i5b.apps.googleusercontent.com", + "client_id": "000000000000-public-other-0-0.apps.googleusercontent.com", "client_type": 3 }, { - "client_id": "621183184145-3epcp830ue13bh54eshg09mfku2me1qv.apps.googleusercontent.com", + "client_id": "000000000000-public-other-0-1.apps.googleusercontent.com", "client_type": 2, "ios_info": { "bundle_id": "co.jp.soramitsu.soraui-example" @@ -44,31 +44,31 @@ }, { "client_info": { - "mobilesdk_app_id": "1:621183184145:android:0f3c905e5882bbf50730b0", + "mobilesdk_app_id": "1:000000000000:android:0000000000000000000002", "android_client_info": { "package_name": "jp.co.soramitsu.fearless.debug" } }, "oauth_client": [ { - "client_id": "621183184145-9f45pucnai4o553imtpnep1mdl909i5b.apps.googleusercontent.com", + "client_id": "000000000000-public-1-0.apps.googleusercontent.com", "client_type": 3 } ], "api_key": [ { - "current_key": "AIzaSyAdN45IMOhSas1GG1wuJCbXG1VlnrW_ZxE" + "current_key": "" } ], "services": { "appinvite_service": { "other_platform_oauth_client": [ { - "client_id": "621183184145-9f45pucnai4o553imtpnep1mdl909i5b.apps.googleusercontent.com", + "client_id": "000000000000-public-other-1-0.apps.googleusercontent.com", "client_type": 3 }, { - "client_id": "621183184145-3epcp830ue13bh54eshg09mfku2me1qv.apps.googleusercontent.com", + "client_id": "000000000000-public-other-1-1.apps.googleusercontent.com", "client_type": 2, "ios_info": { "bundle_id": "co.jp.soramitsu.soraui-example" @@ -80,31 +80,31 @@ }, { "client_info": { - "mobilesdk_app_id": "1:621183184145:android:2b0172d9e8fe674b0730b0", + "mobilesdk_app_id": "1:000000000000:android:0000000000000000000003", "android_client_info": { "package_name": "jp.co.soramitsu.fearless.dev" } }, "oauth_client": [ { - "client_id": "621183184145-9f45pucnai4o553imtpnep1mdl909i5b.apps.googleusercontent.com", + "client_id": "000000000000-public-2-0.apps.googleusercontent.com", "client_type": 3 } ], "api_key": [ { - "current_key": "AIzaSyAdN45IMOhSas1GG1wuJCbXG1VlnrW_ZxE" + "current_key": "" } ], "services": { "appinvite_service": { "other_platform_oauth_client": [ { - "client_id": "621183184145-9f45pucnai4o553imtpnep1mdl909i5b.apps.googleusercontent.com", + "client_id": "000000000000-public-other-2-0.apps.googleusercontent.com", "client_type": 3 }, { - "client_id": "621183184145-3epcp830ue13bh54eshg09mfku2me1qv.apps.googleusercontent.com", + "client_id": "000000000000-public-other-2-1.apps.googleusercontent.com", "client_type": 2, "ios_info": { "bundle_id": "co.jp.soramitsu.soraui-example" @@ -116,31 +116,31 @@ }, { "client_info": { - "mobilesdk_app_id": "1:621183184145:android:fc33975da180db940730b0", + "mobilesdk_app_id": "1:000000000000:android:0000000000000000000004", "android_client_info": { "package_name": "jp.co.soramitsu.fearless.staging" } }, "oauth_client": [ { - "client_id": "621183184145-9f45pucnai4o553imtpnep1mdl909i5b.apps.googleusercontent.com", + "client_id": "000000000000-public-3-0.apps.googleusercontent.com", "client_type": 3 } ], "api_key": [ { - "current_key": "AIzaSyAdN45IMOhSas1GG1wuJCbXG1VlnrW_ZxE" + "current_key": "" } ], "services": { "appinvite_service": { "other_platform_oauth_client": [ { - "client_id": "621183184145-9f45pucnai4o553imtpnep1mdl909i5b.apps.googleusercontent.com", + "client_id": "000000000000-public-other-3-0.apps.googleusercontent.com", "client_type": 3 }, { - "client_id": "621183184145-3epcp830ue13bh54eshg09mfku2me1qv.apps.googleusercontent.com", + "client_id": "000000000000-public-other-3-1.apps.googleusercontent.com", "client_type": 2, "ios_info": { "bundle_id": "co.jp.soramitsu.soraui-example" @@ -152,31 +152,31 @@ }, { "client_info": { - "mobilesdk_app_id": "1:621183184145:android:77c7ba52dc549fe50730b0", + "mobilesdk_app_id": "1:000000000000:android:0000000000000000000005", "android_client_info": { "package_name": "jp.co.soramitsu.manticora" } }, "oauth_client": [ { - "client_id": "621183184145-9f45pucnai4o553imtpnep1mdl909i5b.apps.googleusercontent.com", + "client_id": "000000000000-public-4-0.apps.googleusercontent.com", "client_type": 3 } ], "api_key": [ { - "current_key": "AIzaSyAdN45IMOhSas1GG1wuJCbXG1VlnrW_ZxE" + "current_key": "" } ], "services": { "appinvite_service": { "other_platform_oauth_client": [ { - "client_id": "621183184145-9f45pucnai4o553imtpnep1mdl909i5b.apps.googleusercontent.com", + "client_id": "000000000000-public-other-4-0.apps.googleusercontent.com", "client_type": 3 }, { - "client_id": "621183184145-3epcp830ue13bh54eshg09mfku2me1qv.apps.googleusercontent.com", + "client_id": "000000000000-public-other-4-1.apps.googleusercontent.com", "client_type": 2, "ios_info": { "bundle_id": "co.jp.soramitsu.soraui-example" @@ -188,31 +188,31 @@ }, { "client_info": { - "mobilesdk_app_id": "1:621183184145:android:16d74f53a9d0df090730b0", + "mobilesdk_app_id": "1:000000000000:android:0000000000000000000006", "android_client_info": { "package_name": "jp.co.soramitsu.manticora.debug" } }, "oauth_client": [ { - "client_id": "621183184145-9f45pucnai4o553imtpnep1mdl909i5b.apps.googleusercontent.com", + "client_id": "000000000000-public-5-0.apps.googleusercontent.com", "client_type": 3 } ], "api_key": [ { - "current_key": "AIzaSyAdN45IMOhSas1GG1wuJCbXG1VlnrW_ZxE" + "current_key": "" } ], "services": { "appinvite_service": { "other_platform_oauth_client": [ { - "client_id": "621183184145-9f45pucnai4o553imtpnep1mdl909i5b.apps.googleusercontent.com", + "client_id": "000000000000-public-other-5-0.apps.googleusercontent.com", "client_type": 3 }, { - "client_id": "621183184145-3epcp830ue13bh54eshg09mfku2me1qv.apps.googleusercontent.com", + "client_id": "000000000000-public-other-5-1.apps.googleusercontent.com", "client_type": 2, "ios_info": { "bundle_id": "co.jp.soramitsu.soraui-example" @@ -224,31 +224,31 @@ }, { "client_info": { - "mobilesdk_app_id": "1:621183184145:android:4cd848e984cf539c0730b0", + "mobilesdk_app_id": "1:000000000000:android:0000000000000000000007", "android_client_info": { "package_name": "jp.co.soramitsu.manticora.demo" } }, "oauth_client": [ { - "client_id": "621183184145-9f45pucnai4o553imtpnep1mdl909i5b.apps.googleusercontent.com", + "client_id": "000000000000-public-6-0.apps.googleusercontent.com", "client_type": 3 } ], "api_key": [ { - "current_key": "AIzaSyAdN45IMOhSas1GG1wuJCbXG1VlnrW_ZxE" + "current_key": "" } ], "services": { "appinvite_service": { "other_platform_oauth_client": [ { - "client_id": "621183184145-9f45pucnai4o553imtpnep1mdl909i5b.apps.googleusercontent.com", + "client_id": "000000000000-public-other-6-0.apps.googleusercontent.com", "client_type": 3 }, { - "client_id": "621183184145-3epcp830ue13bh54eshg09mfku2me1qv.apps.googleusercontent.com", + "client_id": "000000000000-public-other-6-1.apps.googleusercontent.com", "client_type": 2, "ios_info": { "bundle_id": "co.jp.soramitsu.soraui-example" @@ -260,31 +260,31 @@ }, { "client_info": { - "mobilesdk_app_id": "1:621183184145:android:966f9975216628600730b0", + "mobilesdk_app_id": "1:000000000000:android:0000000000000000000008", "android_client_info": { "package_name": "jp.co.soramitsu.unkai.terminal" } }, "oauth_client": [ { - "client_id": "621183184145-9f45pucnai4o553imtpnep1mdl909i5b.apps.googleusercontent.com", + "client_id": "000000000000-public-7-0.apps.googleusercontent.com", "client_type": 3 } ], "api_key": [ { - "current_key": "AIzaSyAdN45IMOhSas1GG1wuJCbXG1VlnrW_ZxE" + "current_key": "" } ], "services": { "appinvite_service": { "other_platform_oauth_client": [ { - "client_id": "621183184145-9f45pucnai4o553imtpnep1mdl909i5b.apps.googleusercontent.com", + "client_id": "000000000000-public-other-7-0.apps.googleusercontent.com", "client_type": 3 }, { - "client_id": "621183184145-3epcp830ue13bh54eshg09mfku2me1qv.apps.googleusercontent.com", + "client_id": "000000000000-public-other-7-1.apps.googleusercontent.com", "client_type": 2, "ios_info": { "bundle_id": "co.jp.soramitsu.soraui-example" @@ -296,31 +296,31 @@ }, { "client_info": { - "mobilesdk_app_id": "1:621183184145:android:b3de34997fb76ecd0730b0", + "mobilesdk_app_id": "1:000000000000:android:0000000000000000000009", "android_client_info": { "package_name": "jp.co.soramitsu.unkai.terminal.debug" } }, "oauth_client": [ { - "client_id": "621183184145-9f45pucnai4o553imtpnep1mdl909i5b.apps.googleusercontent.com", + "client_id": "000000000000-public-8-0.apps.googleusercontent.com", "client_type": 3 } ], "api_key": [ { - "current_key": "AIzaSyAdN45IMOhSas1GG1wuJCbXG1VlnrW_ZxE" + "current_key": "" } ], "services": { "appinvite_service": { "other_platform_oauth_client": [ { - "client_id": "621183184145-9f45pucnai4o553imtpnep1mdl909i5b.apps.googleusercontent.com", + "client_id": "000000000000-public-other-8-0.apps.googleusercontent.com", "client_type": 3 }, { - "client_id": "621183184145-3epcp830ue13bh54eshg09mfku2me1qv.apps.googleusercontent.com", + "client_id": "000000000000-public-other-8-1.apps.googleusercontent.com", "client_type": 2, "ios_info": { "bundle_id": "co.jp.soramitsu.soraui-example" @@ -332,4 +332,4 @@ } ], "configuration_version": "1" -} \ No newline at end of file +} diff --git a/build.gradle b/build.gradle index 777df0421f..a5682edd2e 100644 --- a/build.gradle +++ b/build.gradle @@ -102,8 +102,10 @@ allprojects { repositories { google() mavenLocal() - maven { url = uri("https://nexus.iroha.tech/repository/maven-soramitsu/") } mavenCentral() + if (readOptionalSecret("INCLUDE_SORAMITSU_NEXUS") == "1") { + maven { url = uri("https://nexus.iroha.tech/repository/maven-soramitsu/") } + } maven { url = uri("https://jitpack.io") } } @@ -115,6 +117,15 @@ allprojects { configurations.configureEach { resolutionStrategy { + dependencySubstitution { + substitute module('jp.co.soramitsu:android-foundation') using project(':public-android-foundation') + substitute module('jp.co.soramitsu:ui-core') using project(':public-ui-core') + substitute module('jp.co.soramitsu.xnetworking:lib-android') using project(':public-xnetworking') + substitute module('jp.co.soramitsu.shared_features:core') using project(':public-shared-features-core') + substitute module('jp.co.soramitsu.shared_features:xcm') using project(':public-shared-features-xcm') + substitute module('jp.co.soramitsu.shared_features:backup') using project(':public-shared-features-backup') + } + // add dependency substitution rules eachDependency { DependencyResolveDetails details -> if (details.requested.group == 'org.bouncycastle' && details.requested.name == 'bcprov-jdk15on') { @@ -123,7 +134,7 @@ allprojects { } // Optional: pin shared_features libs to a specific version (e.g., for Polkadot SDK alignment) - def sharedFeaturesVer = readSecret("SHARED_FEATURES_VERSION_OVERRIDE") + def sharedFeaturesVer = readOptionalSecret("SHARED_FEATURES_VERSION_OVERRIDE") if (sharedFeaturesVer) { force "jp.co.soramitsu.shared_features:core:${sharedFeaturesVer}" force "jp.co.soramitsu.shared_features:xcm:${sharedFeaturesVer}" @@ -155,10 +166,33 @@ tasks.register('clean', Delete) { delete rootProject.layout.buildDirectory } +def unitTestTaskPaths = [ + ':app:testDebugUnitTest', + ':common:testDebugUnitTest', + ':core-db:testDebugUnitTest', + ':feature-account-impl:testDebugUnitTest', + ':feature-crowdloan-impl:testDebugUnitTest', + ':feature-onboarding-impl:testDebugUnitTest', + ':feature-staking-impl:testDebugUnitTest', + ':feature-tonconnect-api:testDebugUnitTest', + ':feature-wallet-impl:testDebugUnitTest', + ':feature-walletconnect-impl:testDebugUnitTest', + ':runtime:testDebugUnitTest' +] +def jacocoReportTaskPaths = [ + ':feature-account-impl:jacocoTestReport', + ':feature-crowdloan-impl:jacocoTestReport', + ':feature-onboarding-impl:jacocoTestReport', + ':feature-staking-impl:jacocoTestReport', + ':feature-wallet-impl:jacocoTestReport' +] + tasks.register('runTest') { group = 'verification' description = 'Runs clean -> detekt -> module tests -> jacoco in order.' dependsOn 'clean', 'detektAll' + dependsOn unitTestTaskPaths + dependsOn jacocoReportTaskPaths } // Helper: print effective Polkadot SDK alignment overrides and defaults @@ -171,11 +205,11 @@ tasks.register('printPolkadotSdkAlignment') { def chainsUrlDebugDefault = 'https://raw.githubusercontent.com/soramitsu/shared-features-utils/develop-free/chains/v13/chains_dev_prod.json' def chainsUrlReleaseDefault = 'https://raw.githubusercontent.com/soramitsu/shared-features-utils/master/chains/v13/chains.json' - def typesUrl = readSecret('TYPES_URL_OVERRIDE') ?: typesUrlDefault - def defaultV13TypesUrl = readSecret('DEFAULT_V13_TYPES_URL_OVERRIDE') ?: defaultV13TypesUrlDefault - def chainsUrlDebug = readSecret('CHAINS_URL_DEBUG_OVERRIDE') ?: (readSecret('CHAINS_URL_OVERRIDE') ?: chainsUrlDebugDefault) - def chainsUrlRelease = readSecret('CHAINS_URL_RELEASE_OVERRIDE') ?: (readSecret('CHAINS_URL_OVERRIDE') ?: chainsUrlReleaseDefault) - def sharedFeaturesVer = readSecret('SHARED_FEATURES_VERSION_OVERRIDE') ?: '(not pinned)' + def typesUrl = readOptionalSecret('TYPES_URL_OVERRIDE') ?: typesUrlDefault + def defaultV13TypesUrl = readOptionalSecret('DEFAULT_V13_TYPES_URL_OVERRIDE') ?: defaultV13TypesUrlDefault + def chainsUrlDebug = readOptionalSecret('CHAINS_URL_DEBUG_OVERRIDE') ?: (readOptionalSecret('CHAINS_URL_OVERRIDE') ?: chainsUrlDebugDefault) + def chainsUrlRelease = readOptionalSecret('CHAINS_URL_RELEASE_OVERRIDE') ?: (readOptionalSecret('CHAINS_URL_OVERRIDE') ?: chainsUrlReleaseDefault) + def sharedFeaturesVer = readOptionalSecret('SHARED_FEATURES_VERSION_OVERRIDE') ?: '(not pinned)' println("Polkadot SDK alignment (effective):") println(" - TYPES_URL: ${typesUrl}") @@ -190,31 +224,35 @@ tasks.register('printPolkadotSdkAlignment') { tasks.register('postMergeCheck') { group = 'verification' description = 'Print alignment, run tests, assemble debug, then lint in sequence.' - dependsOn 'printPolkadotSdkAlignment', 'runTest', ':app:assembleDebug', ':app:lint' + dependsOn 'printPolkadotSdkAlignment', 'runTest', ':app:lint' } gradle.projectsEvaluated { - def moduleRunTests = [] - def moduleJacocoReports = [] - - subprojects.each { proj -> - proj.tasks.matching { it.name == 'runModuleTests' }.configureEach { moduleRunTests += it } - proj.tasks.matching { it.name == 'jacocoTestReport' }.configureEach { moduleJacocoReports += it } - } - tasks.named('runTest').configure { - dependsOn moduleJacocoReports mustRunAfter('printPolkadotSdkAlignment') } tasks.named('detektAll').configure { mustRunAfter('clean') } - moduleRunTests.each { it.mustRunAfter(tasks.named('detektAll').get()) } - moduleJacocoReports.each { report -> - report.mustRunAfter(moduleRunTests) + unitTestTaskPaths.each { taskPath -> + tasks.findByPath(taskPath)?.mustRunAfter(tasks.named('detektAll').get()) + } + jacocoReportTaskPaths.each { taskPath -> + tasks.findByPath(taskPath)?.mustRunAfter(unitTestTaskPaths) } - project(':app').tasks.named('assembleDebug').configure { mustRunAfter(':runTest') } - project(':app').tasks.named('lint').configure { mustRunAfter(':app:assembleDebug') } + def appProject = project(':app') + def appAssembleTaskName = ['assembleDevelopDebug', 'assembleDebug', 'assemble'] + .find { taskName -> appProject.tasks.findByName(taskName) != null } + + if (appAssembleTaskName != null) { + tasks.named('postMergeCheck').configure { + dependsOn(":app:${appAssembleTaskName}") + } + appProject.tasks.named(appAssembleTaskName).configure { mustRunAfter(':runTest') } + appProject.tasks.named('lint').configure { mustRunAfter(":app:${appAssembleTaskName}") } + } else { + logger.warn("No app assemble task found for postMergeCheck ordering.") + } } // Jenkins-friendly alias that avoids nested Gradle builds. @@ -225,7 +263,6 @@ tasks.register('postMergeVerify') { dependsOn('clean') dependsOn('printPolkadotSdkAlignment') dependsOn('detektAll') - dependsOn(':app:assembleDebug') dependsOn(':app:lint') } @@ -239,25 +276,34 @@ gradle.projectsEvaluated { def appProject = project(':app') // Prefer assembleDevelopDebug if present (custom build type), else assembleDebug, else fallback to assemble def appAssembleTaskName = ['assembleDevelopDebug', 'assembleDebug', 'assemble'] - .find { t -> appProject.tasks.findByName(t) != null } ?: 'assemble' + .find { t -> appProject.tasks.findByName(t) != null } tasks.named('postMergeVerify') { dependsOn(testTasks) - // Wire the chosen assemble task - dependsOn(":app:${appAssembleTaskName}") + if (appAssembleTaskName != null) { + dependsOn(":app:${appAssembleTaskName}") + } else { + logger.warn("No app assemble task found for postMergeVerify ordering.") + } } // Ensure fast-fail order: detekt -> assemble -> lint -> tests - appProject.tasks.matching { it.name == appAssembleTaskName }.configureEach { - mustRunAfter(':detektAll') + if (appAssembleTaskName != null) { + appProject.tasks.matching { it.name == appAssembleTaskName }.configureEach { + mustRunAfter(':detektAll') + } } appProject.tasks.matching { it.name == 'lint' }.configureEach { - mustRunAfter(":app:${appAssembleTaskName}") + if (appAssembleTaskName != null) { + mustRunAfter(":app:${appAssembleTaskName}") + } } // Run unit tests after lint to avoid wasting time if lint fails testTasks.each { t -> t.mustRunAfter(':detektAll') - t.mustRunAfter(":app:${appAssembleTaskName}") + if (appAssembleTaskName != null) { + t.mustRunAfter(":app:${appAssembleTaskName}") + } t.mustRunAfter(":app:lint") } } diff --git a/buildSrc/src/main/kotlin/detekt-setup.gradle.kts b/buildSrc/src/main/kotlin/detekt-setup.gradle.kts index 7931486387..b664496231 100644 --- a/buildSrc/src/main/kotlin/detekt-setup.gradle.kts +++ b/buildSrc/src/main/kotlin/detekt-setup.gradle.kts @@ -29,7 +29,7 @@ fun Detekt.setup(autoCorrect: Boolean) { setSource(file(projectDir)) config.setFrom(files("$rootDir/detekt/detekt.yml")) include("**/*.kt") - exclude("**/resources/**", "**/build/**") + exclude("**/resources/**", "**/build/**", "**/fearless-utils-Android/**") // TODO: Remove exclude paths after merge detekt to develop exclude( diff --git a/common/build.gradle b/common/build.gradle index 18787d8ca2..e2228e66e5 100644 --- a/common/build.gradle +++ b/common/build.gradle @@ -59,7 +59,8 @@ android { buildConfigField "String", "WALLET_CONNECT_PROJECT_ID", readSecretInQuotes("FL_WALLET_CONNECT_PROJECT_ID") buildConfigField "String", "FL_DWELLIR_API_KEY", readSecretInQuotes("FL_DWELLIR_API_KEY") buildConfigField "String", "FL_ANDROID_TON_API_KEY", readSecretInQuotes("FL_ANDROID_TON_API_KEY") - buildConfigField "String", "TON_INDEXER_URL", readSecretInQuotes("FL_ANDROID_TON_INDEXER_URL") + def tonIndexerUrl = readOptionalSecret("FL_ANDROID_TON_INDEXER_URL") ?: "https://ti.soramitsu.io" + buildConfigField "String", "TON_INDEXER_URL", "\"${tonIndexerUrl}\"" buildConfigField("String", "DAPPS_URL", "\"https://raw.githubusercontent.com/soramitsu/shared-features-utils/refs/heads/develop-free/appConfigs/dapps.json\"") } diff --git a/common/src/main/java/jp/co/soramitsu/common/address/AddressIconGenerator.kt b/common/src/main/java/jp/co/soramitsu/common/address/AddressIconGenerator.kt index 9709a44e3a..71114c8aff 100644 --- a/common/src/main/java/jp/co/soramitsu/common/address/AddressIconGenerator.kt +++ b/common/src/main/java/jp/co/soramitsu/common/address/AddressIconGenerator.kt @@ -5,12 +5,12 @@ import androidx.annotation.ColorRes import jp.co.soramitsu.common.R import jp.co.soramitsu.common.model.WalletEcosystem import jp.co.soramitsu.common.resources.ResourceManager -import jp.co.soramitsu.shared_utils.exceptions.AddressFormatException -import jp.co.soramitsu.shared_utils.extensions.requireHexPrefix -import jp.co.soramitsu.shared_utils.extensions.toHexString -import jp.co.soramitsu.shared_utils.icon.IconGenerator -import jp.co.soramitsu.shared_utils.runtime.AccountId -import jp.co.soramitsu.shared_utils.ss58.SS58Encoder.toAccountId +import jp.co.soramitsu.fearless_utils.exceptions.AddressFormatException +import jp.co.soramitsu.fearless_utils.extensions.requireHexPrefix +import jp.co.soramitsu.fearless_utils.extensions.toHexString +import jp.co.soramitsu.fearless_utils.icon.IconGenerator +import jp.co.soramitsu.fearless_utils.runtime.AccountId +import jp.co.soramitsu.fearless_utils.ss58.SS58Encoder.toAccountId import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext import java.util.concurrent.ConcurrentHashMap @@ -101,11 +101,7 @@ suspend fun AddressIconGenerator.createAddressIcon(supportedEcosystemWithAddress createWalletIcon(ecosystem, sizeInDp) } else { val address = supportedEcosystemWithAddress.toList().sortedBy { - when (it.first) { - WalletEcosystem.Substrate -> 1 - WalletEcosystem.Ethereum -> 2 - WalletEcosystem.Ton -> 3 - } + it.first.iconSortOrder() }[0].second createAddressIcon(address, sizeInDp) @@ -120,11 +116,7 @@ suspend fun AddressIconGenerator.createAddressModel(supportedEcosystemWithAddres return AddressModel(address, icon, accountName) } else { val address = supportedEcosystemWithAddress.toList().sortedBy { - when (it.first) { - WalletEcosystem.Substrate -> 1 - WalletEcosystem.Ethereum -> 2 - WalletEcosystem.Ton -> 3 - } + it.first.iconSortOrder() }[0].second val icon = createAddressIcon(address, sizeInDp) @@ -213,10 +205,33 @@ class StatelessAddressIconGenerator( override suspend fun createWalletIcon(ecosystem: WalletEcosystem, sizeInDp: Int): PictureDrawable = withContext(Dispatchers.Default) { val sizeInPx = resourceManager.measureInPx(sizeInDp) val icon = when (ecosystem) { - WalletEcosystem.Substrate -> iconGenerator.getSubstrateWalletIcon(sizeInPx) - WalletEcosystem.Ethereum -> iconGenerator.getEvmWalletIcon(sizeInPx) - WalletEcosystem.Ton -> iconGenerator.getTonWalletIcon(sizeInPx) + WalletEcosystem.Substrate -> iconGenerator.getSvgImage(WALLET_ICON_SUBSTRATE_SEED, sizeInPx) + WalletEcosystem.Ethereum -> iconGenerator.generateEthereumAddressIcon(WALLET_ICON_EVM_SEED, sizeInPx) + WalletEcosystem.Ton -> iconGenerator.getSvgImage(WALLET_ICON_TON_SEED, sizeInPx, isAlternative = true) + WalletEcosystem.Bitcoin -> iconGenerator.getSvgImage(WALLET_ICON_BITCOIN_SEED, sizeInPx) + WalletEcosystem.Solana -> iconGenerator.getSvgImage(WALLET_ICON_SOLANA_SEED, sizeInPx) + WalletEcosystem.Iroha -> iconGenerator.getSvgImage(WALLET_ICON_IROHA_SEED, sizeInPx) } icon } + + private companion object { + val WALLET_ICON_SUBSTRATE_SEED = "fearless-wallet-substrate".encodeToByteArray() + val WALLET_ICON_EVM_SEED = "fearless-wallet-evm".encodeToByteArray() + val WALLET_ICON_TON_SEED = "fearless-wallet-ton".encodeToByteArray() + val WALLET_ICON_BITCOIN_SEED = "fearless-wallet-bitcoin".encodeToByteArray() + val WALLET_ICON_SOLANA_SEED = "fearless-wallet-solana".encodeToByteArray() + val WALLET_ICON_IROHA_SEED = "fearless-wallet-iroha".encodeToByteArray() + } +} + +private fun WalletEcosystem.iconSortOrder(): Int { + return when (this) { + WalletEcosystem.Substrate -> 1 + WalletEcosystem.Ethereum -> 2 + WalletEcosystem.Ton -> 3 + WalletEcosystem.Bitcoin -> 4 + WalletEcosystem.Solana -> 5 + WalletEcosystem.Iroha -> 6 + } } diff --git a/common/src/main/java/jp/co/soramitsu/common/compose/theme/Color.kt b/common/src/main/java/jp/co/soramitsu/common/compose/theme/Color.kt index a28ce7b475..4219ac9427 100644 --- a/common/src/main/java/jp/co/soramitsu/common/compose/theme/Color.kt +++ b/common/src/main/java/jp/co/soramitsu/common/compose/theme/Color.kt @@ -9,7 +9,7 @@ import androidx.compose.runtime.State import androidx.compose.runtime.rememberUpdatedState import androidx.compose.ui.graphics.Color import androidx.core.graphics.toColorInt -import jp.co.soramitsu.shared_utils.extensions.requirePrefix +import jp.co.soramitsu.fearless_utils.extensions.requirePrefix val colorPrimary = Color(0xFF004CB7) val colorSelected = Color(0x66FF009A) diff --git a/common/src/main/java/jp/co/soramitsu/common/data/Keypair.kt b/common/src/main/java/jp/co/soramitsu/common/data/Keypair.kt index ef42554d7e..415490b7d2 100644 --- a/common/src/main/java/jp/co/soramitsu/common/data/Keypair.kt +++ b/common/src/main/java/jp/co/soramitsu/common/data/Keypair.kt @@ -1,7 +1,7 @@ package jp.co.soramitsu.common.data -import jp.co.soramitsu.shared_utils.encrypt.keypair.BaseKeypair -import jp.co.soramitsu.shared_utils.encrypt.keypair.substrate.Sr25519Keypair +import jp.co.soramitsu.fearless_utils.encrypt.keypair.BaseKeypair +import jp.co.soramitsu.fearless_utils.encrypt.keypair.substrate.Sr25519Keypair /** * Creates [Sr25519Keypair] if [nonce] is not null diff --git a/common/src/main/java/jp/co/soramitsu/common/data/holders/RuntimeHolder.kt b/common/src/main/java/jp/co/soramitsu/common/data/holders/RuntimeHolder.kt index 04da416184..78b0550518 100644 --- a/common/src/main/java/jp/co/soramitsu/common/data/holders/RuntimeHolder.kt +++ b/common/src/main/java/jp/co/soramitsu/common/data/holders/RuntimeHolder.kt @@ -1,6 +1,6 @@ package jp.co.soramitsu.common.data.holders -import jp.co.soramitsu.shared_utils.runtime.RuntimeSnapshot +import jp.co.soramitsu.fearless_utils.runtime.RuntimeSnapshot interface RuntimeHolder { diff --git a/common/src/main/java/jp/co/soramitsu/common/data/network/AndroidLogger.kt b/common/src/main/java/jp/co/soramitsu/common/data/network/AndroidLogger.kt index 9f77f6b14e..0f78249dd2 100644 --- a/common/src/main/java/jp/co/soramitsu/common/data/network/AndroidLogger.kt +++ b/common/src/main/java/jp/co/soramitsu/common/data/network/AndroidLogger.kt @@ -1,7 +1,7 @@ package jp.co.soramitsu.common.data.network import jp.co.soramitsu.common.BuildConfig -import jp.co.soramitsu.shared_utils.wsrpc.logging.Logger +import jp.co.soramitsu.fearless_utils.wsrpc.logging.Logger const val TAG = "AndroidLogger" diff --git a/common/src/main/java/jp/co/soramitsu/common/data/network/StorageSubscriptionBuilder.kt b/common/src/main/java/jp/co/soramitsu/common/data/network/StorageSubscriptionBuilder.kt index 3a016c8cc5..52c07aad85 100644 --- a/common/src/main/java/jp/co/soramitsu/common/data/network/StorageSubscriptionBuilder.kt +++ b/common/src/main/java/jp/co/soramitsu/common/data/network/StorageSubscriptionBuilder.kt @@ -2,9 +2,9 @@ package jp.co.soramitsu.common.data.network import jp.co.soramitsu.core.model.StorageChange import jp.co.soramitsu.core.updater.SubscriptionBuilder -import jp.co.soramitsu.shared_utils.wsrpc.SocketService -import jp.co.soramitsu.shared_utils.wsrpc.request.runtime.storage.StorageSubscriptionMultiplexer -import jp.co.soramitsu.shared_utils.wsrpc.subscribe +import jp.co.soramitsu.fearless_utils.wsrpc.SocketService +import jp.co.soramitsu.fearless_utils.wsrpc.request.runtime.storage.StorageSubscriptionMultiplexer +import jp.co.soramitsu.fearless_utils.wsrpc.subscribe import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.map diff --git a/common/src/main/java/jp/co/soramitsu/common/data/network/bitcoin/BitcoinBalanceSync.kt b/common/src/main/java/jp/co/soramitsu/common/data/network/bitcoin/BitcoinBalanceSync.kt new file mode 100644 index 0000000000..853cb217d1 --- /dev/null +++ b/common/src/main/java/jp/co/soramitsu/common/data/network/bitcoin/BitcoinBalanceSync.kt @@ -0,0 +1,70 @@ +package jp.co.soramitsu.common.data.network.bitcoin + +import jp.co.soramitsu.common.utils.BitcoinKeyDerivation +import java.lang.Math.addExact + +class BitcoinBalanceSync( + private val discovery: BitcoinReceiveDiscovery +) { + suspend fun balance( + mnemonic: String, + passphrase: String = "", + network: BitcoinKeyDerivation.Network = BitcoinKeyDerivation.Network.Mainnet, + baseUrl: String? = null, + gapLimit: Int? = null, + maxLookahead: Int = BitcoinReceiveDiscovery.DEFAULT_MAX_LOOKAHEAD + ): BitcoinBalanceSyncResult { + val discoveryResult = discovery.discover( + mnemonic = mnemonic, + passphrase = passphrase, + network = network, + baseUrl = baseUrl, + gapLimit = gapLimit, + maxLookahead = maxLookahead + ) + var confirmedSats = 0L + var mempoolSats = 0L + + discoveryResult.addresses.forEach { address -> + if (address.confirmedSats < 0 || address.mempoolSats < 0 || address.totalSats < 0) { + throw BitcoinBalanceSyncException(BitcoinBalanceSyncException.Code.INVALID_ADDRESS_BALANCE) + } + + confirmedSats = safeAdd(confirmedSats, address.confirmedSats) + mempoolSats = safeAdd(mempoolSats, address.mempoolSats) + } + + return BitcoinBalanceSyncResult( + confirmedSats = confirmedSats, + mempoolSats = mempoolSats, + totalSats = safeAdd(confirmedSats, mempoolSats), + usedAddresses = discoveryResult.usedAddresses, + discovery = discoveryResult + ) + } + + private fun safeAdd(left: Long, right: Long): Long { + return try { + addExact(left, right) + } catch (_: ArithmeticException) { + throw BitcoinBalanceSyncException(BitcoinBalanceSyncException.Code.BALANCE_OVERFLOW) + } + } +} + +data class BitcoinBalanceSyncResult( + val confirmedSats: Long, + val mempoolSats: Long, + val totalSats: Long, + val usedAddresses: List, + val discovery: BitcoinReceiveDiscoveryResult +) + +class BitcoinBalanceSyncException( + val code: Code +) : IllegalArgumentException(code.name) { + enum class Code { + INVALID_ADDRESS_BALANCE, + BALANCE_OVERFLOW + } +} diff --git a/common/src/main/java/jp/co/soramitsu/common/data/network/bitcoin/BitcoinFeeEstimator.kt b/common/src/main/java/jp/co/soramitsu/common/data/network/bitcoin/BitcoinFeeEstimator.kt new file mode 100644 index 0000000000..6aec908e04 --- /dev/null +++ b/common/src/main/java/jp/co/soramitsu/common/data/network/bitcoin/BitcoinFeeEstimator.kt @@ -0,0 +1,78 @@ +package jp.co.soramitsu.common.data.network.bitcoin + +class BitcoinFeeEstimator( + private val client: BitcoinIndexerClient +) { + suspend fun estimate( + network: BitcoinIndexerRoutes.Network = BitcoinIndexerRoutes.Network.Mainnet, + baseUrl: String? = null, + targetBlocks: Int = DEFAULT_TARGET_BLOCKS + ): BitcoinFeeEstimate { + return select( + estimates = client.feeEstimates(network, baseUrl), + targetBlocks = targetBlocks + ) + } + + fun select( + estimates: Map, + targetBlocks: Int = DEFAULT_TARGET_BLOCKS + ): BitcoinFeeEstimate { + validateTargetBlocks(targetBlocks) + val entries = estimates.mapNotNull { (blocks, feeRate) -> + val parsedBlocks = blocks.toIntOrNull() + if (parsedBlocks != null && parsedBlocks > 0 && feeRate.isFinite() && feeRate > 0.0) { + parsedBlocks to feeRate + } else { + null + } + }.sortedBy { it.first } + + if (entries.isEmpty()) { + throw BitcoinFeeEstimatorException(BitcoinFeeEstimatorException.Code.FEE_ESTIMATES_UNAVAILABLE) + } + + val selected = entries.firstOrNull { it.first >= targetBlocks } ?: entries.last() + validateFeeRate(selected.second) + + return BitcoinFeeEstimate( + requestedTargetBlocks = targetBlocks, + selectedTargetBlocks = selected.first, + feeRateSatPerVbyte = selected.second + ) + } + + private fun validateTargetBlocks(targetBlocks: Int) { + if (targetBlocks <= 0 || targetBlocks > MAX_TARGET_BLOCKS) { + throw BitcoinFeeEstimatorException(BitcoinFeeEstimatorException.Code.INVALID_FEE_TARGET) + } + } + + private fun validateFeeRate(feeRateSatPerVbyte: Double) { + if (!feeRateSatPerVbyte.isFinite() || feeRateSatPerVbyte <= 0.0 || feeRateSatPerVbyte > MAX_FEE_RATE_SAT_PER_VBYTE) { + throw BitcoinFeeEstimatorException(BitcoinFeeEstimatorException.Code.INVALID_FEE_RATE) + } + } + + companion object { + const val DEFAULT_TARGET_BLOCKS = 2 + const val MAX_TARGET_BLOCKS = 1_008 + const val MAX_FEE_RATE_SAT_PER_VBYTE = 10_000.0 + } +} + +data class BitcoinFeeEstimate( + val requestedTargetBlocks: Int, + val selectedTargetBlocks: Int, + val feeRateSatPerVbyte: Double +) + +class BitcoinFeeEstimatorException( + val code: Code +) : IllegalArgumentException(code.name) { + enum class Code { + INVALID_FEE_TARGET, + INVALID_FEE_RATE, + FEE_ESTIMATES_UNAVAILABLE + } +} diff --git a/common/src/main/java/jp/co/soramitsu/common/data/network/bitcoin/BitcoinIndexerApi.kt b/common/src/main/java/jp/co/soramitsu/common/data/network/bitcoin/BitcoinIndexerApi.kt new file mode 100644 index 0000000000..e71e4a44cd --- /dev/null +++ b/common/src/main/java/jp/co/soramitsu/common/data/network/bitcoin/BitcoinIndexerApi.kt @@ -0,0 +1,29 @@ +package jp.co.soramitsu.common.data.network.bitcoin + +import okhttp3.RequestBody +import retrofit2.http.Body +import retrofit2.http.GET +import retrofit2.http.Headers +import retrofit2.http.POST +import retrofit2.http.Url + +interface BitcoinIndexerApi { + @GET + suspend fun getAddress(@Url url: String): BitcoinEsploraAddress + + @GET + suspend fun getUtxos(@Url url: String): List + + @GET + suspend fun getTransactions(@Url url: String): List + + @GET + suspend fun getFeeEstimates(@Url url: String): Map + + @Headers("Content-Type: text/plain") + @POST + suspend fun broadcastTransaction( + @Url url: String, + @Body body: RequestBody + ): String +} diff --git a/common/src/main/java/jp/co/soramitsu/common/data/network/bitcoin/BitcoinIndexerClient.kt b/common/src/main/java/jp/co/soramitsu/common/data/network/bitcoin/BitcoinIndexerClient.kt new file mode 100644 index 0000000000..b4de33e179 --- /dev/null +++ b/common/src/main/java/jp/co/soramitsu/common/data/network/bitcoin/BitcoinIndexerClient.kt @@ -0,0 +1,106 @@ +package jp.co.soramitsu.common.data.network.bitcoin + +import okhttp3.MediaType.Companion.toMediaType +import okhttp3.RequestBody.Companion.toRequestBody + +interface BitcoinIndexerClient { + suspend fun address( + address: String, + network: BitcoinIndexerRoutes.Network = BitcoinIndexerRoutes.Network.Mainnet, + baseUrl: String? = null + ): BitcoinEsploraAddress + + suspend fun utxos( + address: String, + network: BitcoinIndexerRoutes.Network = BitcoinIndexerRoutes.Network.Mainnet, + baseUrl: String? = null + ): List + + suspend fun transactions( + address: String, + network: BitcoinIndexerRoutes.Network = BitcoinIndexerRoutes.Network.Mainnet, + baseUrl: String? = null, + lastSeenTxid: String? = null, + mempool: Boolean = false + ): List + + suspend fun feeEstimates( + network: BitcoinIndexerRoutes.Network = BitcoinIndexerRoutes.Network.Mainnet, + baseUrl: String? = null + ): Map + + suspend fun broadcastTransaction( + txHex: String, + network: BitcoinIndexerRoutes.Network = BitcoinIndexerRoutes.Network.Mainnet, + baseUrl: String? = null + ): String +} + +class RetrofitBitcoinIndexerClient( + private val api: BitcoinIndexerApi +) : BitcoinIndexerClient { + + override suspend fun address( + address: String, + network: BitcoinIndexerRoutes.Network, + baseUrl: String? + ): BitcoinEsploraAddress { + return api.getAddress(BitcoinIndexerRoutes.addressUrl(address, network, resolveBaseUrl(network, baseUrl))) + } + + override suspend fun utxos( + address: String, + network: BitcoinIndexerRoutes.Network, + baseUrl: String? + ): List { + return api.getUtxos(BitcoinIndexerRoutes.utxosUrl(address, network, resolveBaseUrl(network, baseUrl))) + } + + override suspend fun transactions( + address: String, + network: BitcoinIndexerRoutes.Network, + baseUrl: String?, + lastSeenTxid: String?, + mempool: Boolean + ): List { + return api.getTransactions( + BitcoinIndexerRoutes.transactionsUrl( + address = address, + network = network, + baseUrl = resolveBaseUrl(network, baseUrl), + lastSeenTxid = lastSeenTxid, + mempool = mempool + ) + ) + } + + override suspend fun feeEstimates( + network: BitcoinIndexerRoutes.Network, + baseUrl: String? + ): Map { + return api.getFeeEstimates(BitcoinIndexerRoutes.feeEstimatesUrl(network, resolveBaseUrl(network, baseUrl))) + } + + override suspend fun broadcastTransaction( + txHex: String, + network: BitcoinIndexerRoutes.Network, + baseUrl: String? + ): String { + val body = BitcoinIndexerRoutes.normalizeBroadcastTransactionBody(txHex) + .encodeToByteArray() + .toRequestBody(TEXT_PLAIN_MEDIA_TYPE) + + return api.broadcastTransaction( + BitcoinIndexerRoutes.broadcastTransactionUrl(network, resolveBaseUrl(network, baseUrl)), + body + ) + } + + private fun resolveBaseUrl(network: BitcoinIndexerRoutes.Network, baseUrl: String?): String { + return baseUrl ?: BitcoinIndexerRoutes.baseUrl(network) + } + + private companion object { + val TEXT_PLAIN_MEDIA_TYPE = "text/plain".toMediaType() + } +} diff --git a/common/src/main/java/jp/co/soramitsu/common/data/network/bitcoin/BitcoinIndexerModels.kt b/common/src/main/java/jp/co/soramitsu/common/data/network/bitcoin/BitcoinIndexerModels.kt new file mode 100644 index 0000000000..01d2437a8b --- /dev/null +++ b/common/src/main/java/jp/co/soramitsu/common/data/network/bitcoin/BitcoinIndexerModels.kt @@ -0,0 +1,103 @@ +package jp.co.soramitsu.common.data.network.bitcoin + +import com.google.gson.JsonElement +import com.google.gson.annotations.SerializedName + +data class BitcoinEsploraStats( + @SerializedName("funded_txo_count") + val fundedTxoCount: Int, + @SerializedName("funded_txo_sum") + val fundedTxoSum: Long, + @SerializedName("spent_txo_count") + val spentTxoCount: Int, + @SerializedName("spent_txo_sum") + val spentTxoSum: Long, + @SerializedName("tx_count") + val txCount: Int +) + +data class BitcoinEsploraAddress( + @SerializedName("address") + val address: String, + @SerializedName("chain_stats") + val chainStats: BitcoinEsploraStats, + @SerializedName("mempool_stats") + val mempoolStats: BitcoinEsploraStats +) { + val confirmedSats: Long + get() = chainStats.fundedTxoSum - chainStats.spentTxoSum + + val mempoolSats: Long + get() = mempoolStats.fundedTxoSum - mempoolStats.spentTxoSum + + val totalSats: Long + get() = confirmedSats + mempoolSats +} + +data class BitcoinEsploraTxStatus( + @SerializedName("confirmed") + val confirmed: Boolean, + @SerializedName("block_hash") + val blockHash: String? = null, + @SerializedName("block_height") + val blockHeight: Long? = null, + @SerializedName("block_time") + val blockTime: Long? = null +) + +data class BitcoinEsploraUtxo( + @SerializedName("txid") + val txid: String, + @SerializedName("vout") + val vout: Long, + @SerializedName("value") + val value: Long, + @SerializedName("status") + val status: BitcoinEsploraTxStatus +) + +data class BitcoinEsploraTransaction( + @SerializedName("txid") + val txid: String, + @SerializedName("status") + val status: BitcoinEsploraTxStatus, + @SerializedName("fee") + val fee: Long? = null, + @SerializedName("weight") + val weight: Long? = null, + @SerializedName("size") + val size: Long? = null, + @SerializedName("version") + val version: Long? = null, + @SerializedName("locktime") + val locktime: Long? = null, + @SerializedName("vin") + val vin: List? = null, + @SerializedName("vout") + val vout: List? = null +) + +data class BitcoinEsploraTransactionInput( + @SerializedName("prevout") + val prevout: BitcoinEsploraTransactionOutput? = null +) + +data class BitcoinEsploraTransactionOutput( + @SerializedName("scriptpubkey_address") + val scriptPubKeyAddress: String? = null, + @SerializedName("value") + val value: JsonElement? = null +) { + fun valueSatsOrNull(): Long? { + val primitive = value?.takeIf { it.isJsonPrimitive }?.asJsonPrimitive ?: return null + if (!primitive.isNumber) { + return null + } + + return primitive.asString.takeIf { SATOSHI.matches(it) }?.toLongOrNull() + } + + private companion object { + val SATOSHI = Regex("^\\d+$") + } +} diff --git a/common/src/main/java/jp/co/soramitsu/common/data/network/bitcoin/BitcoinIndexerRoutes.kt b/common/src/main/java/jp/co/soramitsu/common/data/network/bitcoin/BitcoinIndexerRoutes.kt new file mode 100644 index 0000000000..16a64a59cf --- /dev/null +++ b/common/src/main/java/jp/co/soramitsu/common/data/network/bitcoin/BitcoinIndexerRoutes.kt @@ -0,0 +1,128 @@ +package jp.co.soramitsu.common.data.network.bitcoin + +import jp.co.soramitsu.common.model.UniversalWalletRegistry +import java.net.MalformedURLException +import java.net.URL + +object BitcoinIndexerRoutes { + const val MAX_TX_HEX_LENGTH = 800_000 + + fun addressUrl( + address: String, + network: Network = Network.Mainnet, + baseUrl: String = baseUrl(network) + ): String { + return "${normalizeBaseUrl(baseUrl)}/address/${normalizeAddress(address, network)}" + } + + fun utxosUrl( + address: String, + network: Network = Network.Mainnet, + baseUrl: String = baseUrl(network) + ): String { + return "${addressUrl(address, network, baseUrl)}/utxo" + } + + fun transactionsUrl( + address: String, + network: Network = Network.Mainnet, + baseUrl: String = baseUrl(network), + lastSeenTxid: String? = null, + mempool: Boolean = false + ): String { + val normalizedAddress = normalizeAddress(address, network) + val base = "${normalizeBaseUrl(baseUrl)}/address/$normalizedAddress/txs" + + return when { + mempool -> "$base/mempool" + lastSeenTxid != null -> "$base/chain/${normalizeTxid(lastSeenTxid)}" + else -> base + } + } + + fun feeEstimatesUrl( + network: Network = Network.Mainnet, + baseUrl: String = baseUrl(network) + ): String { + return "${normalizeBaseUrl(baseUrl)}/fee-estimates" + } + + fun broadcastTransactionUrl( + network: Network = Network.Mainnet, + baseUrl: String = baseUrl(network) + ): String { + return "${normalizeBaseUrl(baseUrl)}/tx" + } + + fun normalizeBroadcastTransactionBody(txHex: String): String { + val normalized = txHex.trim().lowercase() + if (!TX_HEX.matches(normalized) || normalized.length > MAX_TX_HEX_LENGTH) { + throw BitcoinIndexerRouteException(ErrorCode.INVALID_TX_HEX) + } + + return normalized + } + + fun normalizeBaseUrl(baseUrl: String): String { + val trimmed = baseUrl.trim() + val parsed = try { + URL(trimmed) + } catch (_: MalformedURLException) { + throw BitcoinIndexerRouteException(ErrorCode.INVALID_BASE_URL) + } + val isLocal = parsed.host == "localhost" || parsed.host == "127.0.0.1" + + if (parsed.protocol != "https" && !isLocal) { + throw BitcoinIndexerRouteException(ErrorCode.INVALID_BASE_URL) + } + + return trimmed.trimEnd('/') + } + + fun normalizeAddress(address: String, network: Network): String { + val normalized = address.trim().lowercase() + val matchesNetwork = when (network) { + Network.Mainnet -> BITCOIN_MAINNET_ADDRESS.matches(normalized) + Network.Testnet -> BITCOIN_TESTNET_ADDRESS.matches(normalized) + } + + if (!matchesNetwork) { + throw BitcoinIndexerRouteException(ErrorCode.INVALID_ADDRESS) + } + + return normalized + } + + fun normalizeTxid(txid: String): String { + val normalized = txid.trim().lowercase() + if (!TXID.matches(normalized)) { + throw BitcoinIndexerRouteException(ErrorCode.INVALID_TXID) + } + + return normalized + } + + fun baseUrl(network: Network): String = when (network) { + Network.Mainnet -> UniversalWalletRegistry.BITCOIN_MAINNET_INDEXER_BASE_URL + Network.Testnet -> UniversalWalletRegistry.BITCOIN_TESTNET_INDEXER_BASE_URL + } + + class BitcoinIndexerRouteException(val code: ErrorCode) : IllegalArgumentException(code.name) + + enum class ErrorCode { + INVALID_BASE_URL, + INVALID_ADDRESS, + INVALID_TXID, + INVALID_TX_HEX + } + + enum class Network { + Mainnet, + Testnet + } + + private val BITCOIN_MAINNET_ADDRESS = Regex("^bc1[ac-hj-np-z02-9]{11,90}$") + private val BITCOIN_TESTNET_ADDRESS = Regex("^tb1[ac-hj-np-z02-9]{11,90}$") + private val TXID = Regex("^[0-9a-f]{64}$") + private val TX_HEX = Regex("^(?:[0-9a-f]{2})+$") +} diff --git a/common/src/main/java/jp/co/soramitsu/common/data/network/bitcoin/BitcoinReceiveDiscovery.kt b/common/src/main/java/jp/co/soramitsu/common/data/network/bitcoin/BitcoinReceiveDiscovery.kt new file mode 100644 index 0000000000..41fb1e48df --- /dev/null +++ b/common/src/main/java/jp/co/soramitsu/common/data/network/bitcoin/BitcoinReceiveDiscovery.kt @@ -0,0 +1,154 @@ +package jp.co.soramitsu.common.data.network.bitcoin + +import jp.co.soramitsu.common.model.UniversalWalletRegistry +import jp.co.soramitsu.common.utils.BitcoinKeyDerivation + +class BitcoinReceiveDiscovery( + private val client: BitcoinIndexerClient +) { + suspend fun discover( + mnemonic: String, + passphrase: String = "", + network: BitcoinKeyDerivation.Network = BitcoinKeyDerivation.Network.Mainnet, + baseUrl: String? = null, + gapLimit: Int? = null, + maxLookahead: Int = DEFAULT_MAX_LOOKAHEAD + ): BitcoinReceiveDiscoveryResult { + val resolvedGapLimit = gapLimit ?: defaultGapLimit(network) + validateParams(mnemonic, resolvedGapLimit, maxLookahead) + + val indexerNetwork = network.toIndexerNetwork() + val addresses = mutableListOf() + var consecutiveUnused = 0 + var index = 0 + var lastUsedIndex: Int? = null + + while (consecutiveUnused < resolvedGapLimit && index < maxLookahead) { + val path = BitcoinKeyDerivation.getReceivePath(network = network, index = index.toLong()) + val address = BitcoinKeyDerivation.deriveKey( + mnemonic = mnemonic, + passphrase = passphrase, + derivationPath = path, + network = network + ).address + val stats = client.address(address, indexerNetwork, baseUrl) + val txCount = transactionCount(stats) + val used = txCount > 0 + val discovered = BitcoinReceiveDiscoveredAddress( + address = address, + index = index, + path = path, + confirmedSats = stats.confirmedSats, + mempoolSats = stats.mempoolSats, + totalSats = stats.totalSats, + txCount = txCount, + used = used + ) + + addresses += discovered + + if (used) { + lastUsedIndex = index + consecutiveUnused = 0 + } else { + consecutiveUnused += 1 + } + + index += 1 + } + + if (consecutiveUnused < resolvedGapLimit) { + throw BitcoinReceiveDiscoveryException(BitcoinReceiveDiscoveryException.Code.LOOKAHEAD_EXHAUSTED) + } + + val nextReceiveIndex = (lastUsedIndex ?: -1) + 1 + val nextReceivePath = BitcoinKeyDerivation.getReceivePath(network = network, index = nextReceiveIndex.toLong()) + val nextReceiveAddress = BitcoinKeyDerivation.deriveKey( + mnemonic = mnemonic, + passphrase = passphrase, + derivationPath = nextReceivePath, + network = network + ).address + + return BitcoinReceiveDiscoveryResult( + addresses = addresses, + gapLimit = resolvedGapLimit, + lastUsedIndex = lastUsedIndex, + nextReceiveAddress = nextReceiveAddress, + nextReceiveIndex = nextReceiveIndex, + usedAddresses = addresses.filter { it.used } + ) + } + + private fun validateParams(mnemonic: String, gapLimit: Int, maxLookahead: Int) { + if (mnemonic.isBlank()) { + throw BitcoinReceiveDiscoveryException(BitcoinReceiveDiscoveryException.Code.MNEMONIC_REQUIRED) + } + if (gapLimit <= 0 || gapLimit > MAX_GAP_LIMIT) { + throw BitcoinReceiveDiscoveryException(BitcoinReceiveDiscoveryException.Code.INVALID_GAP_LIMIT) + } + if (maxLookahead < gapLimit || maxLookahead > MAX_LOOKAHEAD) { + throw BitcoinReceiveDiscoveryException(BitcoinReceiveDiscoveryException.Code.INVALID_MAX_LOOKAHEAD) + } + } + + private fun transactionCount(address: BitcoinEsploraAddress): Int { + val chainCount = address.chainStats.txCount + val mempoolCount = address.mempoolStats.txCount + val total = chainCount.toLong() + mempoolCount.toLong() + + if (chainCount < 0 || mempoolCount < 0 || total < 0 || total > Int.MAX_VALUE.toLong()) { + throw BitcoinReceiveDiscoveryException(BitcoinReceiveDiscoveryException.Code.INVALID_TRANSACTION_COUNT) + } + + return total.toInt() + } + + private fun defaultGapLimit(network: BitcoinKeyDerivation.Network): Int = when (network) { + BitcoinKeyDerivation.Network.Mainnet -> UniversalWalletRegistry.bitcoinMainnet.defaultGapLimit + BitcoinKeyDerivation.Network.Testnet -> UniversalWalletRegistry.bitcoinTestnet.defaultGapLimit + } + + private fun BitcoinKeyDerivation.Network.toIndexerNetwork(): BitcoinIndexerRoutes.Network = when (this) { + BitcoinKeyDerivation.Network.Mainnet -> BitcoinIndexerRoutes.Network.Mainnet + BitcoinKeyDerivation.Network.Testnet -> BitcoinIndexerRoutes.Network.Testnet + } + + companion object { + const val DEFAULT_MAX_LOOKAHEAD = 1_000 + const val MAX_GAP_LIMIT = 100 + const val MAX_LOOKAHEAD = 10_000 + } +} + +data class BitcoinReceiveDiscoveredAddress( + val address: String, + val index: Int, + val path: String, + val confirmedSats: Long, + val mempoolSats: Long, + val totalSats: Long, + val txCount: Int, + val used: Boolean +) + +data class BitcoinReceiveDiscoveryResult( + val addresses: List, + val gapLimit: Int, + val lastUsedIndex: Int?, + val nextReceiveAddress: String, + val nextReceiveIndex: Int, + val usedAddresses: List +) + +class BitcoinReceiveDiscoveryException( + val code: Code +) : IllegalArgumentException(code.name) { + enum class Code { + MNEMONIC_REQUIRED, + INVALID_GAP_LIMIT, + INVALID_MAX_LOOKAHEAD, + INVALID_TRANSACTION_COUNT, + LOOKAHEAD_EXHAUSTED + } +} diff --git a/common/src/main/java/jp/co/soramitsu/common/data/network/bitcoin/BitcoinSendPlanner.kt b/common/src/main/java/jp/co/soramitsu/common/data/network/bitcoin/BitcoinSendPlanner.kt new file mode 100644 index 0000000000..c85741cf68 --- /dev/null +++ b/common/src/main/java/jp/co/soramitsu/common/data/network/bitcoin/BitcoinSendPlanner.kt @@ -0,0 +1,163 @@ +package jp.co.soramitsu.common.data.network.bitcoin + +class BitcoinSendPlanner( + private val client: BitcoinIndexerClient, + private val feeEstimator: BitcoinFeeEstimator = BitcoinFeeEstimator(client), + private val utxoSelector: BitcoinUtxoSelector = BitcoinUtxoSelector() +) { + suspend fun plan( + amountSats: Long, + sources: List, + recipientAddress: String, + changeAddress: String? = null, + feeRateSatPerVbyte: Double? = null, + feeTargetBlocks: Int = BitcoinFeeEstimator.DEFAULT_TARGET_BLOCKS, + includeUnconfirmed: Boolean = false, + maxInputs: Int = BitcoinUtxoSelector.DEFAULT_MAX_INPUTS, + network: BitcoinIndexerRoutes.Network = BitcoinIndexerRoutes.Network.Mainnet, + baseUrl: String? = null + ): BitcoinSendPlan { + val normalizedSources = normalizeSources(sources, network) + val normalizedRecipient = normalizeAddress( + recipientAddress, + network, + BitcoinSendPlannerException.Code.INVALID_RECIPIENT_ADDRESS + ) + val normalizedChange = normalizeAddress( + changeAddress ?: normalizedSources.first().address, + network, + BitcoinSendPlannerException.Code.INVALID_CHANGE_ADDRESS + ) + val resolvedFeeRate = feeRateSatPerVbyte ?: feeEstimator.estimate( + network = network, + baseUrl = baseUrl, + targetBlocks = feeTargetBlocks + ).feeRateSatPerVbyte + validateFeeRate(resolvedFeeRate) + + val spendableUtxos = normalizedSources.flatMap { source -> + utxoSelector.spendableUtxos( + source = source, + utxos = client.utxos(source.address, network, baseUrl), + network = network, + includeUnconfirmed = includeUnconfirmed + ) + } + + if (spendableUtxos.isEmpty()) { + throw BitcoinSendPlannerException(BitcoinSendPlannerException.Code.NO_SPENDABLE_UTXOS) + } + + val selection = utxoSelector.select( + amountSats = amountSats, + changeAddress = normalizedChange, + feeRateSatPerVbyte = resolvedFeeRate, + maxInputs = maxInputs, + network = network, + utxos = spendableUtxos + ) + + return BitcoinSendPlan( + amountSats = amountSats, + recipientAddress = normalizedRecipient, + changeAddress = selection.changeAddress, + feeRateSatPerVbyte = resolvedFeeRate, + feeTargetBlocks = if (feeRateSatPerVbyte == null) feeTargetBlocks else null, + includeUnconfirmed = includeUnconfirmed, + network = network, + sourceAddresses = normalizedSources.map { it.address }, + selectedUtxos = selection.selectedUtxos, + inputTotalSats = selection.inputTotalSats, + feeSats = selection.feeSats, + changeSats = selection.changeSats, + absorbedDustSats = selection.absorbedDustSats + ) + } + + private fun normalizeSources( + sources: List, + network: BitcoinIndexerRoutes.Network + ): List { + if (sources.isEmpty()) { + throw BitcoinSendPlannerException(BitcoinSendPlannerException.Code.SOURCES_REQUIRED) + } + if (sources.size > MAX_SOURCES) { + throw BitcoinSendPlannerException(BitcoinSendPlannerException.Code.TOO_MANY_SOURCES) + } + val seen = mutableSetOf() + + return sources.map { source -> + val address = normalizeAddress( + source.address, + network, + BitcoinSendPlannerException.Code.INVALID_SOURCE_ADDRESS + ) + source.derivationPath?.let { + if (!DERIVATION_PATH.matches(it)) { + throw BitcoinSendPlannerException(BitcoinSendPlannerException.Code.INVALID_DERIVATION_PATH) + } + } + + if (!seen.add(address.lowercase())) { + throw BitcoinSendPlannerException(BitcoinSendPlannerException.Code.DUPLICATE_SOURCE_ADDRESS) + } + + BitcoinUtxoSource(address = address, derivationPath = source.derivationPath) + } + } + + private fun normalizeAddress( + address: String, + network: BitcoinIndexerRoutes.Network, + code: BitcoinSendPlannerException.Code + ): String { + return try { + BitcoinIndexerRoutes.normalizeAddress(address, network) + } catch (_: BitcoinIndexerRoutes.BitcoinIndexerRouteException) { + throw BitcoinSendPlannerException(code) + } + } + + private fun validateFeeRate(feeRateSatPerVbyte: Double) { + if (!feeRateSatPerVbyte.isFinite() || feeRateSatPerVbyte <= 0.0 || feeRateSatPerVbyte > BitcoinFeeEstimator.MAX_FEE_RATE_SAT_PER_VBYTE) { + throw BitcoinSendPlannerException(BitcoinSendPlannerException.Code.INVALID_FEE_RATE) + } + } + + private companion object { + const val MAX_SOURCES = 100 + val DERIVATION_PATH = Regex("^m(?:/\\d+'?)+$") + } +} + +data class BitcoinSendPlan( + val amountSats: Long, + val recipientAddress: String, + val changeAddress: String?, + val feeRateSatPerVbyte: Double, + val feeTargetBlocks: Int?, + val includeUnconfirmed: Boolean, + val network: BitcoinIndexerRoutes.Network, + val sourceAddresses: List, + val selectedUtxos: List, + val inputTotalSats: Long, + val feeSats: Long, + val changeSats: Long, + val absorbedDustSats: Long +) + +class BitcoinSendPlannerException( + val code: Code +) : IllegalArgumentException(code.name) { + enum class Code { + DUPLICATE_SOURCE_ADDRESS, + INVALID_CHANGE_ADDRESS, + INVALID_DERIVATION_PATH, + INVALID_FEE_RATE, + INVALID_RECIPIENT_ADDRESS, + INVALID_SOURCE_ADDRESS, + NO_SPENDABLE_UTXOS, + SOURCES_REQUIRED, + TOO_MANY_SOURCES + } +} diff --git a/common/src/main/java/jp/co/soramitsu/common/data/network/bitcoin/BitcoinSendService.kt b/common/src/main/java/jp/co/soramitsu/common/data/network/bitcoin/BitcoinSendService.kt new file mode 100644 index 0000000000..f866590280 --- /dev/null +++ b/common/src/main/java/jp/co/soramitsu/common/data/network/bitcoin/BitcoinSendService.kt @@ -0,0 +1,103 @@ +package jp.co.soramitsu.common.data.network.bitcoin + +import jp.co.soramitsu.common.utils.BitcoinKeyDerivation + +class BitcoinSendService( + private val planner: BitcoinSendPlanner, + private val broadcaster: BitcoinTransactionBroadcaster +) { + constructor(client: BitcoinIndexerClient) : this( + planner = BitcoinSendPlanner(client), + broadcaster = BitcoinTransactionBroadcaster(client) + ) + + suspend fun prepare(request: BitcoinSendRequest): BitcoinPreparedSendTransaction { + val plan = planner.plan( + amountSats = request.amountSats, + sources = request.sources, + recipientAddress = request.recipientAddress, + changeAddress = request.changeAddress, + feeRateSatPerVbyte = request.feeRateSatPerVbyte, + feeTargetBlocks = request.feeTargetBlocks, + includeUnconfirmed = request.includeUnconfirmed, + maxInputs = request.maxInputs, + network = request.network, + baseUrl = request.baseUrl + ) + val transaction = BitcoinTransactionBuilder.buildP2wpkhTransaction( + mnemonic = request.mnemonic, + passphrase = request.passphrase, + inputs = plan.selectedUtxos, + outputs = listOf(BitcoinPaymentOutput(plan.recipientAddress, plan.amountSats)), + changeAddress = plan.changeAddress, + feeSats = plan.feeSats, + network = request.network.toKeyDerivationNetwork() + ) + + if (transaction.feeSats != plan.feeSats || transaction.changeSats != plan.changeSats) { + throw BitcoinSendServiceException(BitcoinSendServiceException.Code.PLANNED_TRANSACTION_MISMATCH) + } + + return BitcoinPreparedSendTransaction( + plan = plan, + transaction = transaction + ) + } + + suspend fun send(request: BitcoinSendRequest): BitcoinSentTransaction { + val prepared = prepare(request) + val broadcast = broadcaster.broadcast( + txHex = prepared.transaction.txHex, + network = request.network, + baseUrl = request.baseUrl + ) + + if (broadcast.txid != prepared.transaction.txid) { + throw BitcoinSendServiceException(BitcoinSendServiceException.Code.BROADCAST_TXID_MISMATCH) + } + + return BitcoinSentTransaction( + broadcastTxid = broadcast.txid, + prepared = prepared + ) + } + + private fun BitcoinIndexerRoutes.Network.toKeyDerivationNetwork(): BitcoinKeyDerivation.Network = when (this) { + BitcoinIndexerRoutes.Network.Mainnet -> BitcoinKeyDerivation.Network.Mainnet + BitcoinIndexerRoutes.Network.Testnet -> BitcoinKeyDerivation.Network.Testnet + } +} + +data class BitcoinSendRequest( + val mnemonic: String, + val amountSats: Long, + val sources: List, + val recipientAddress: String, + val passphrase: String = "", + val changeAddress: String? = null, + val feeRateSatPerVbyte: Double? = null, + val feeTargetBlocks: Int = BitcoinFeeEstimator.DEFAULT_TARGET_BLOCKS, + val includeUnconfirmed: Boolean = false, + val maxInputs: Int = BitcoinUtxoSelector.DEFAULT_MAX_INPUTS, + val network: BitcoinIndexerRoutes.Network = BitcoinIndexerRoutes.Network.Mainnet, + val baseUrl: String? = null +) + +data class BitcoinPreparedSendTransaction( + val plan: BitcoinSendPlan, + val transaction: BitcoinBuiltTransaction +) + +data class BitcoinSentTransaction( + val broadcastTxid: String, + val prepared: BitcoinPreparedSendTransaction +) + +class BitcoinSendServiceException( + val code: Code +) : IllegalArgumentException(code.name) { + enum class Code { + BROADCAST_TXID_MISMATCH, + PLANNED_TRANSACTION_MISMATCH + } +} diff --git a/common/src/main/java/jp/co/soramitsu/common/data/network/bitcoin/BitcoinTransactionBroadcaster.kt b/common/src/main/java/jp/co/soramitsu/common/data/network/bitcoin/BitcoinTransactionBroadcaster.kt new file mode 100644 index 0000000000..fcd58343c7 --- /dev/null +++ b/common/src/main/java/jp/co/soramitsu/common/data/network/bitcoin/BitcoinTransactionBroadcaster.kt @@ -0,0 +1,44 @@ +package jp.co.soramitsu.common.data.network.bitcoin + +class BitcoinTransactionBroadcaster( + private val client: BitcoinIndexerClient +) { + suspend fun broadcast( + txHex: String, + network: BitcoinIndexerRoutes.Network = BitcoinIndexerRoutes.Network.Mainnet, + baseUrl: String? = null + ): BitcoinBroadcastResult { + val normalizedTxHex = try { + BitcoinIndexerRoutes.normalizeBroadcastTransactionBody(txHex) + } catch (_: BitcoinIndexerRoutes.BitcoinIndexerRouteException) { + throw BitcoinBroadcastException(BitcoinBroadcastException.Code.INVALID_TX_HEX) + } + val broadcastResponse = client.broadcastTransaction(normalizedTxHex, network, baseUrl) + val txid = try { + BitcoinIndexerRoutes.normalizeTxid(broadcastResponse) + } catch (_: BitcoinIndexerRoutes.BitcoinIndexerRouteException) { + throw BitcoinBroadcastException(BitcoinBroadcastException.Code.INVALID_TXID_RESPONSE) + } + + return BitcoinBroadcastResult( + network = network, + txHex = normalizedTxHex, + txid = txid + ) + } +} + +data class BitcoinBroadcastResult( + val network: BitcoinIndexerRoutes.Network, + val txHex: String, + val txid: String +) + +class BitcoinBroadcastException( + val code: Code +) : IllegalArgumentException(code.name) { + enum class Code { + INVALID_TX_HEX, + INVALID_TXID_RESPONSE + } +} diff --git a/common/src/main/java/jp/co/soramitsu/common/data/network/bitcoin/BitcoinTransactionBuilder.kt b/common/src/main/java/jp/co/soramitsu/common/data/network/bitcoin/BitcoinTransactionBuilder.kt new file mode 100644 index 0000000000..49b2fc94de --- /dev/null +++ b/common/src/main/java/jp/co/soramitsu/common/data/network/bitcoin/BitcoinTransactionBuilder.kt @@ -0,0 +1,550 @@ +package jp.co.soramitsu.common.data.network.bitcoin + +import jp.co.soramitsu.common.utils.BitcoinKeyDerivation +import org.bouncycastle.crypto.digests.RIPEMD160Digest +import org.bouncycastle.crypto.digests.SHA256Digest +import org.bouncycastle.crypto.ec.CustomNamedCurves +import org.bouncycastle.crypto.params.ECDomainParameters +import org.bouncycastle.crypto.params.ECPrivateKeyParameters +import org.bouncycastle.crypto.signers.ECDSASigner +import org.bouncycastle.crypto.signers.HMacDSAKCalculator +import java.io.ByteArrayOutputStream +import java.math.BigInteger +import java.security.MessageDigest +import kotlin.math.ceil + +object BitcoinTransactionBuilder { + private const val DEFAULT_SEQUENCE = 0xffffffffL + private const val SIGHASH_ALL = 0x01L + private const val TX_LOCKTIME = 0L + private const val TX_VERSION = 2L + private const val MAX_UINT32 = 0xffffffffL + + private val SECP256K1 = CustomNamedCurves.getByName("secp256k1") + private val DOMAIN = ECDomainParameters(SECP256K1.curve, SECP256K1.g, SECP256K1.n, SECP256K1.h) + private val HALF_CURVE_ORDER = SECP256K1.n.shiftRight(1) + private val BECH32_ALPHABET = "qpzry9x8gf2tvdw0s3jn54khce6mua7l" + private val BECH32_GENERATORS = intArrayOf(0x3b6a57b2, 0x26508e6d, 0x1ea119fa, 0x3d4233dd, 0x2a1462b3) + + fun buildP2wpkhTransaction( + mnemonic: String, + passphrase: String = "", + inputs: List, + outputs: List, + changeAddress: String? = null, + feeRateSatPerVbyte: Double? = null, + feeSats: Long? = null, + network: BitcoinKeyDerivation.Network = BitcoinKeyDerivation.Network.Mainnet + ): BitcoinBuiltTransaction { + val normalizedInputs = normalizeInputs(inputs) + val normalizedOutputs = normalizeOutputs(outputs, network) + val inputTotal = sumSats(normalizedInputs.map { it.valueSats }) + val outputTotal = sumSats(normalizedOutputs.map { it.valueSats }) + val fee = normalizeFee( + feeSats = feeSats, + feeRateSatPerVbyte = feeRateSatPerVbyte, + inputCount = normalizedInputs.size, + outputCount = normalizedOutputs.size + if (changeAddress != null) 1 else 0 + ) + val change = inputTotal - outputTotal - fee + + if (change < 0) { + throw BitcoinTransactionException(BitcoinTransactionException.Code.INSUFFICIENT_FUNDS) + } + + val finalOutputs = normalizedOutputs.toMutableList() + if (change > 0) { + val normalizedChangeAddress = changeAddress ?: throw BitcoinTransactionException(BitcoinTransactionException.Code.CHANGE_ADDRESS_REQUIRED) + if (change < BitcoinUtxoSelector.BITCOIN_P2WPKH_DUST_SATS) { + throw BitcoinTransactionException(BitcoinTransactionException.Code.CHANGE_BELOW_DUST) + } + finalOutputs += normalizeOutput(BitcoinPaymentOutput(normalizedChangeAddress, change), network) + } + + val normalizedMnemonic = normalizeMnemonic(mnemonic) + val signingInputs = normalizedInputs.map { input -> + val key = try { + BitcoinKeyDerivation.deriveKey( + mnemonic = normalizedMnemonic, + passphrase = passphrase, + derivationPath = input.derivationPath ?: BitcoinKeyDerivation.getReceivePath(network), + network = network + ) + } catch (_: IllegalArgumentException) { + throw BitcoinTransactionException(BitcoinTransactionException.Code.INVALID_DERIVATION_PATH) + } + val witnessScript = p2wpkhOutputScript(key.publicKey) + + if (input.address != null && input.address.lowercase() != key.address.lowercase()) { + throw BitcoinTransactionException(BitcoinTransactionException.Code.UTXO_ADDRESS_MISMATCH) + } + if (input.scriptPubKey != null && input.scriptPubKey.lowercase() != witnessScript.toHex()) { + throw BitcoinTransactionException(BitcoinTransactionException.Code.UTXO_SCRIPT_MISMATCH) + } + + BitcoinSigningInput( + address = input.address, + derivationPath = input.derivationPath, + scriptPubKey = input.scriptPubKey, + txid = input.txid, + valueSats = input.valueSats, + vout = input.vout, + privateKey = key.privateKey, + publicKey = key.publicKey, + scriptCode = p2wpkhScriptCode(key.publicKey), + witnessScript = witnessScript + ) + } + val serializedOutputs = finalOutputs.map { output -> + BitcoinSerializedOutput(script = p2wpkhOutputScriptFromAddress(output.address), valueSats = output.valueSats) + } + val witnesses = signingInputs.map { signP2wpkhInput(signingInputs, serializedOutputs, it) } + val baseTx = serializeTransaction(signingInputs, serializedOutputs) + val witnessTx = serializeTransaction(signingInputs, serializedOutputs, witnesses) + + return BitcoinBuiltTransaction( + changeSats = change, + feeSats = fee, + inputTotalSats = inputTotal, + outputTotalSats = outputTotal, + txHex = witnessTx.toHex(), + txid = doubleSha256(baseTx).reversedArray().toHex(), + vsize = ceil((baseTx.size * 3 + witnessTx.size) / 4.0).toInt() + ) + } + + fun estimateP2wpkhTransactionVSize(inputCount: Int, outputCount: Int): Int { + return BitcoinUtxoSelector().estimateP2wpkhTransactionVSize(inputCount, outputCount) + } + + private fun normalizeInputs(inputs: List): List { + if (inputs.isEmpty()) { + throw BitcoinTransactionException(BitcoinTransactionException.Code.INPUTS_REQUIRED) + } + val outpoints = mutableSetOf() + + return inputs.map { input -> + val normalizedTxid = input.txid.trim().lowercase() + if (!TXID.matches(normalizedTxid)) { + throw BitcoinTransactionException(BitcoinTransactionException.Code.INVALID_TXID) + } + if (input.vout !in 0..MAX_UINT32) { + throw BitcoinTransactionException(BitcoinTransactionException.Code.INVALID_VOUT) + } + if (input.valueSats <= 0 || input.valueSats > BitcoinUtxoSelector.MAX_SATOSHI) { + throw BitcoinTransactionException(BitcoinTransactionException.Code.INVALID_UTXO_VALUE) + } + input.scriptPubKey?.let { + if (!SCRIPT_PUBKEY.matches(it)) { + throw BitcoinTransactionException(BitcoinTransactionException.Code.INVALID_SCRIPT_PUBKEY) + } + } + input.derivationPath?.let { + if (!DERIVATION_PATH.matches(it)) { + throw BitcoinTransactionException(BitcoinTransactionException.Code.INVALID_DERIVATION_PATH) + } + } + val outpoint = "$normalizedTxid:${input.vout}" + if (!outpoints.add(outpoint)) { + throw BitcoinTransactionException(BitcoinTransactionException.Code.DUPLICATE_UTXO) + } + + input.copy(txid = normalizedTxid) + } + } + + private fun normalizeMnemonic(mnemonic: String): String { + val words = mnemonic.trim().split(Regex("\\s+")).filter { it.isNotBlank() } + if (words.isEmpty()) { + throw BitcoinTransactionException(BitcoinTransactionException.Code.INVALID_MNEMONIC) + } + + return words.joinToString(" ") + } + + private fun normalizeOutputs(outputs: List, network: BitcoinKeyDerivation.Network): List { + if (outputs.isEmpty()) { + throw BitcoinTransactionException(BitcoinTransactionException.Code.OUTPUTS_REQUIRED) + } + + return outputs.map { normalizeOutput(it, network) } + } + + private fun normalizeOutput(output: BitcoinPaymentOutput, network: BitcoinKeyDerivation.Network): BitcoinPaymentOutput { + val indexerNetwork = network.toIndexerNetwork() + val normalizedAddress = try { + BitcoinIndexerRoutes.normalizeAddress(output.address, indexerNetwork) + } catch (_: BitcoinIndexerRoutes.BitcoinIndexerRouteException) { + throw BitcoinTransactionException(BitcoinTransactionException.Code.INVALID_OUTPUT_ADDRESS) + } + if (output.valueSats < BitcoinUtxoSelector.BITCOIN_P2WPKH_DUST_SATS || output.valueSats > BitcoinUtxoSelector.MAX_SATOSHI) { + throw BitcoinTransactionException(BitcoinTransactionException.Code.INVALID_OUTPUT_VALUE) + } + + return BitcoinPaymentOutput(normalizedAddress, output.valueSats) + } + + private fun normalizeFee( + feeSats: Long?, + feeRateSatPerVbyte: Double?, + inputCount: Int, + outputCount: Int + ): Long { + feeSats?.let { + if (it <= 0 || it > BitcoinUtxoSelector.MAX_SATOSHI) { + throw BitcoinTransactionException(BitcoinTransactionException.Code.INVALID_FEE) + } + + return it + } + + val feeRate = feeRateSatPerVbyte ?: throw BitcoinTransactionException(BitcoinTransactionException.Code.FEE_REQUIRED) + if (!feeRate.isFinite() || feeRate <= 0.0 || feeRate > BitcoinUtxoSelector.MAX_FEE_RATE_SAT_PER_VBYTE) { + throw BitcoinTransactionException(BitcoinTransactionException.Code.INVALID_FEE_RATE) + } + + return ceil(estimateP2wpkhTransactionVSize(inputCount, outputCount) * feeRate).toLong() + } + + private fun signP2wpkhInput( + inputs: List, + outputs: List, + input: BitcoinSigningInput + ): List { + val sighash = bip143SignatureHash(inputs, outputs, input) + val signer = ECDSASigner(HMacDSAKCalculator(SHA256Digest())) + signer.init(true, ECPrivateKeyParameters(BigInteger(1, input.privateKey), DOMAIN)) + val components = signer.generateSignature(sighash) + val r = components[0] + val s = if (components[1] > HALF_CURVE_ORDER) SECP256K1.n.subtract(components[1]) else components[1] + val signature = derSignature(r, s) + byteArrayOf(SIGHASH_ALL.toByte()) + + return listOf(signature, input.publicKey) + } + + private fun bip143SignatureHash( + inputs: List, + outputs: List, + input: BitcoinSigningInput + ): ByteArray { + return doubleSha256( + concat( + uint32LE(TX_VERSION), + doubleSha256(concat(inputs.map { serializeOutpoint(it) })), + doubleSha256(concat(inputs.map { uint32LE(DEFAULT_SEQUENCE) })), + serializeOutpoint(input), + varSlice(input.scriptCode), + uint64LE(input.valueSats), + uint32LE(DEFAULT_SEQUENCE), + doubleSha256(concat(outputs.map { serializeOutput(it) })), + uint32LE(TX_LOCKTIME), + uint32LE(SIGHASH_ALL) + ) + ) + } + + private fun serializeTransaction( + inputs: List, + outputs: List, + witnesses: List>? = null + ): ByteArray { + return concat( + listOfNotNull( + uint32LE(TX_VERSION), + witnesses?.let { byteArrayOf(0x00, 0x01) }, + varInt(inputs.size.toLong()), + concat(inputs.map { serializeInput(it) }), + varInt(outputs.size.toLong()), + concat(outputs.map { serializeOutput(it) }), + witnesses?.let { concat(it.map(::serializeWitness)) }, + uint32LE(TX_LOCKTIME) + ) + ) + } + + private fun serializeInput(input: BitcoinSigningInput): ByteArray { + return concat(serializeOutpoint(input), varSlice(ByteArray(0)), uint32LE(DEFAULT_SEQUENCE)) + } + + private fun serializeOutpoint(input: BitcoinSigningInput): ByteArray { + return concat(input.txid.hexToBytes().reversedArray(), uint32LE(input.vout)) + } + + private fun serializeOutput(output: BitcoinSerializedOutput): ByteArray { + return concat(uint64LE(output.valueSats), varSlice(output.script)) + } + + private fun serializeWitness(witness: List): ByteArray { + return concat(varInt(witness.size.toLong()), concat(witness.map(::varSlice))) + } + + private fun p2wpkhOutputScript(publicKey: ByteArray): ByteArray { + return concat(byteArrayOf(0x00, 0x14), hash160(publicKey)) + } + + private fun p2wpkhScriptCode(publicKey: ByteArray): ByteArray { + return concat(byteArrayOf(0x76, 0xa9.toByte(), 0x14), hash160(publicKey), byteArrayOf(0x88.toByte(), 0xac.toByte())) + } + + private fun p2wpkhOutputScriptFromAddress(address: String): ByteArray { + val decoded = bech32Decode(address) + val program = convertBits(decoded.words.drop(1), fromBits = 5, toBits = 8, pad = false) + .map { it.toByte() } + .toByteArray() + + if (decoded.words.firstOrNull() != 0 || program.size != 20) { + throw BitcoinTransactionException(BitcoinTransactionException.Code.INVALID_OUTPUT_ADDRESS) + } + + return concat(byteArrayOf(0x00, 0x14), program) + } + + private fun derSignature(r: BigInteger, s: BigInteger): ByteArray { + val encodedR = derInteger(r) + val encodedS = derInteger(s) + + return concat(byteArrayOf(0x30, (encodedR.size + encodedS.size).toByte()), encodedR, encodedS) + } + + private fun derInteger(value: BigInteger): ByteArray { + val stripped = value.toUnsignedBytes().dropWhile { it == 0.toByte() } + val raw = (stripped.ifEmpty { listOf(0.toByte()) }).toByteArray() + + return if ((raw[0].toInt() and 0x80) != 0) { + concat(byteArrayOf(0x02, (raw.size + 1).toByte(), 0x00), raw) + } else { + concat(byteArrayOf(0x02, raw.size.toByte()), raw) + } + } + + private fun bech32Decode(address: String): Bech32Decoded { + if (address != address.lowercase() && address != address.uppercase()) { + throw BitcoinTransactionException(BitcoinTransactionException.Code.INVALID_OUTPUT_ADDRESS) + } + val normalized = address.lowercase() + val separator = normalized.lastIndexOf('1') + if (separator <= 0 || separator + 7 > normalized.length) { + throw BitcoinTransactionException(BitcoinTransactionException.Code.INVALID_OUTPUT_ADDRESS) + } + val hrp = normalized.substring(0, separator) + val values = normalized.substring(separator + 1).map { char -> + val index = BECH32_ALPHABET.indexOf(char) + if (index < 0) { + throw BitcoinTransactionException(BitcoinTransactionException.Code.INVALID_OUTPUT_ADDRESS) + } + index + } + if (bech32Polymod(expandHrp(hrp) + values) != 1) { + throw BitcoinTransactionException(BitcoinTransactionException.Code.INVALID_OUTPUT_ADDRESS) + } + + return Bech32Decoded(hrp, values.dropLast(6)) + } + + private fun bech32Polymod(values: List): Int { + var checksum = 1 + + values.forEach { value -> + val top = checksum shr 25 + checksum = ((checksum and 0x1ffffff) shl 5) xor value + BECH32_GENERATORS.forEachIndexed { index, generator -> + if (((top shr index) and 1) == 1) { + checksum = checksum xor generator + } + } + } + + return checksum + } + + private fun expandHrp(hrp: String): List = hrp.map { it.code shr 5 } + listOf(0) + hrp.map { it.code and 31 } + + private fun convertBits(values: List, fromBits: Int, toBits: Int, pad: Boolean): List { + var accumulator = 0 + var bits = 0 + val maxValue = (1 shl toBits) - 1 + val maxAccumulator = (1 shl (fromBits + toBits - 1)) - 1 + val result = mutableListOf() + + values.forEach { value -> + if (value < 0 || value ushr fromBits != 0) { + throw BitcoinTransactionException(BitcoinTransactionException.Code.INVALID_OUTPUT_ADDRESS) + } + accumulator = ((accumulator shl fromBits) or value) and maxAccumulator + bits += fromBits + + while (bits >= toBits) { + bits -= toBits + result += (accumulator shr bits) and maxValue + } + } + + if (pad) { + if (bits > 0) { + result += (accumulator shl (toBits - bits)) and maxValue + } + } else if (bits >= fromBits || ((accumulator shl (toBits - bits)) and maxValue) != 0) { + throw BitcoinTransactionException(BitcoinTransactionException.Code.INVALID_OUTPUT_ADDRESS) + } + + return result + } + + private fun hash160(value: ByteArray): ByteArray { + val sha256 = MessageDigest.getInstance("SHA-256").digest(value) + val digest = RIPEMD160Digest() + digest.update(sha256, 0, sha256.size) + val output = ByteArray(20) + digest.doFinal(output, 0) + + return output + } + + private fun doubleSha256(value: ByteArray): ByteArray { + val sha = MessageDigest.getInstance("SHA-256") + return sha.digest(sha.digest(value)) + } + + private fun varSlice(value: ByteArray): ByteArray = concat(varInt(value.size.toLong()), value) + + private fun varInt(value: Long): ByteArray { + if (value < 0) { + throw BitcoinTransactionException(BitcoinTransactionException.Code.INVALID_VARINT) + } + return when { + value < 0xfd -> byteArrayOf(value.toByte()) + value <= 0xffff -> concat(byteArrayOf(0xfd.toByte()), uint16LE(value)) + value <= 0xffffffffL -> concat(byteArrayOf(0xfe.toByte()), uint32LE(value)) + else -> concat(byteArrayOf(0xff.toByte()), uint64LE(value)) + } + } + + private fun uint16LE(value: Long): ByteArray = byteArrayOf((value and 0xff).toByte(), ((value ushr 8) and 0xff).toByte()) + + private fun uint32LE(value: Long): ByteArray = byteArrayOf( + (value and 0xff).toByte(), + ((value ushr 8) and 0xff).toByte(), + ((value ushr 16) and 0xff).toByte(), + ((value ushr 24) and 0xff).toByte() + ) + + private fun uint64LE(value: Long): ByteArray { + var remaining = value + val output = ByteArray(8) + for (index in output.indices) { + output[index] = (remaining and 0xff).toByte() + remaining = remaining ushr 8 + } + + return output + } + + private fun sumSats(values: List): Long { + return values.fold(0L) { total, value -> + val next = try { + Math.addExact(total, value) + } catch (_: ArithmeticException) { + throw BitcoinTransactionException(BitcoinTransactionException.Code.SATOSHI_OVERFLOW) + } + if (next > BitcoinUtxoSelector.MAX_SATOSHI) { + throw BitcoinTransactionException(BitcoinTransactionException.Code.SATOSHI_OVERFLOW) + } + + next + } + } + + private fun BitcoinKeyDerivation.Network.toIndexerNetwork(): BitcoinIndexerRoutes.Network = when (this) { + BitcoinKeyDerivation.Network.Mainnet -> BitcoinIndexerRoutes.Network.Mainnet + BitcoinKeyDerivation.Network.Testnet -> BitcoinIndexerRoutes.Network.Testnet + } + + private fun concat(vararg chunks: ByteArray): ByteArray = concat(chunks.toList()) + + private fun concat(chunks: List): ByteArray { + val output = ByteArrayOutputStream(chunks.sumOf { it.size }) + chunks.forEach { output.write(it) } + return output.toByteArray() + } + + private fun String.hexToBytes(): ByteArray { + return chunked(2).map { it.toInt(16).toByte() }.toByteArray() + } + + private fun ByteArray.toHex(): String = joinToString("") { "%02x".format(it.toInt() and 0xff) } + + private fun BigInteger.toUnsignedBytes(): ByteArray { + val raw = toByteArray() + return if (raw.size > 1 && raw[0] == 0.toByte()) raw.copyOfRange(1, raw.size) else raw + } + + private val TXID = Regex("^[0-9a-f]{64}$", RegexOption.IGNORE_CASE) + private val SCRIPT_PUBKEY = Regex("^(?:[0-9a-fA-F]{2})+$") + private val DERIVATION_PATH = Regex("^m(?:/\\d+'?)+$") +} + +data class BitcoinPaymentOutput( + val address: String, + val valueSats: Long +) + +data class BitcoinBuiltTransaction( + val changeSats: Long, + val feeSats: Long, + val inputTotalSats: Long, + val outputTotalSats: Long, + val txHex: String, + val txid: String, + val vsize: Int +) + +class BitcoinTransactionException( + val code: Code +) : IllegalArgumentException(code.name) { + enum class Code { + CHANGE_ADDRESS_REQUIRED, + CHANGE_BELOW_DUST, + DUPLICATE_UTXO, + FEE_REQUIRED, + INPUTS_REQUIRED, + INSUFFICIENT_FUNDS, + INVALID_DERIVATION_PATH, + INVALID_FEE, + INVALID_FEE_RATE, + INVALID_MNEMONIC, + INVALID_OUTPUT_ADDRESS, + INVALID_OUTPUT_VALUE, + INVALID_SCRIPT_PUBKEY, + INVALID_TXID, + INVALID_UTXO_VALUE, + INVALID_VARINT, + INVALID_VOUT, + OUTPUTS_REQUIRED, + SATOSHI_OVERFLOW, + UTXO_ADDRESS_MISMATCH, + UTXO_SCRIPT_MISMATCH + } +} + +private data class BitcoinSigningInput( + val privateKey: ByteArray, + val publicKey: ByteArray, + val scriptCode: ByteArray, + val witnessScript: ByteArray, + val address: String?, + val derivationPath: String?, + val scriptPubKey: String?, + val txid: String, + val valueSats: Long, + val vout: Long +) + +private data class BitcoinSerializedOutput( + val script: ByteArray, + val valueSats: Long +) + +private data class Bech32Decoded( + val hrp: String, + val words: List +) diff --git a/common/src/main/java/jp/co/soramitsu/common/data/network/bitcoin/BitcoinTransactionHistorySync.kt b/common/src/main/java/jp/co/soramitsu/common/data/network/bitcoin/BitcoinTransactionHistorySync.kt new file mode 100644 index 0000000000..5f1e43bfb9 --- /dev/null +++ b/common/src/main/java/jp/co/soramitsu/common/data/network/bitcoin/BitcoinTransactionHistorySync.kt @@ -0,0 +1,249 @@ +package jp.co.soramitsu.common.data.network.bitcoin + +class BitcoinTransactionHistorySync( + private val client: BitcoinIndexerClient +) { + suspend fun historyWindow( + address: String, + network: BitcoinIndexerRoutes.Network = BitcoinIndexerRoutes.Network.Mainnet, + baseUrl: String? = null + ): BitcoinTransactionHistoryPage { + val normalizedAddress = normalizeHistoryAddress(address, network) + val entries = mutableListOf() + val seenTxids = mutableSetOf() + val seenCursors = mutableSetOf() + var cursor: String? = null + var nextLastSeenTxid: String? = null + var pagesFetched = 0 + + while (pagesFetched < BITCOIN_HISTORY_MAX_PAGES) { + val currentCursor = cursor + if (currentCursor != null && !seenCursors.add(currentCursor)) { + break + } + + val page = fetchHistoryPage( + address = normalizedAddress, + network = network, + baseUrl = baseUrl, + lastSeenTxid = currentCursor, + mempool = false + ) + pagesFetched += 1 + + var newEntries = 0 + page.history.entries.forEach { entry -> + if (seenTxids.add(entry.txid)) { + entries.add(entry) + newEntries += 1 + } + } + + nextLastSeenTxid = page.history.nextLastSeenTxid + + if ( + page.transactionCount < BITCOIN_ESPLORA_HISTORY_PAGE_SIZE || + nextLastSeenTxid == null || + nextLastSeenTxid == currentCursor || + newEntries == 0 + ) { + break + } + + cursor = nextLastSeenTxid + } + + return BitcoinTransactionHistoryPage( + address = normalizedAddress, + network = network, + entries = entries, + nextLastSeenTxid = nextLastSeenTxid + ) + } + + suspend fun history( + address: String, + network: BitcoinIndexerRoutes.Network = BitcoinIndexerRoutes.Network.Mainnet, + baseUrl: String? = null, + lastSeenTxid: String? = null, + mempool: Boolean = false + ): BitcoinTransactionHistoryPage { + val normalizedAddress = normalizeHistoryAddress(address, network) + return fetchHistoryPage( + address = normalizedAddress, + network = network, + baseUrl = baseUrl, + lastSeenTxid = lastSeenTxid, + mempool = mempool + ).history + } + + private suspend fun fetchHistoryPage( + address: String, + network: BitcoinIndexerRoutes.Network, + baseUrl: String?, + lastSeenTxid: String?, + mempool: Boolean + ): BitcoinFetchedHistoryPage { + val transactions = client.transactions( + address = address, + network = network, + baseUrl = baseUrl, + lastSeenTxid = lastSeenTxid, + mempool = mempool + ) + val entries = transactions.mapNotNull { normalizeTransaction(it, address) } + val nextLastSeenTxid = transactions.asReversed().firstNotNullOfOrNull { transaction -> + try { + BitcoinIndexerRoutes.normalizeTxid(transaction.txid) + } catch (_: BitcoinIndexerRoutes.BitcoinIndexerRouteException) { + null + } + } + + return BitcoinFetchedHistoryPage( + history = BitcoinTransactionHistoryPage( + address = address, + network = network, + entries = entries, + nextLastSeenTxid = nextLastSeenTxid + ), + transactionCount = transactions.size + ) + } + + private fun normalizeHistoryAddress( + address: String, + network: BitcoinIndexerRoutes.Network + ): String { + return try { + BitcoinIndexerRoutes.normalizeAddress(address, network) + } catch (_: BitcoinIndexerRoutes.BitcoinIndexerRouteException) { + throw BitcoinTransactionHistoryException(BitcoinTransactionHistoryException.Code.INVALID_ADDRESS) + } + } + + private fun normalizeTransaction( + transaction: BitcoinEsploraTransaction, + address: String + ): BitcoinTransactionHistoryEntry? { + val txid = try { + BitcoinIndexerRoutes.normalizeTxid(transaction.txid) + } catch (_: BitcoinIndexerRoutes.BitcoinIndexerRouteException) { + return null + } + val walletAddress = address.lowercase() + val inputs = transaction.vin.orEmpty().mapNotNull { it.prevout?.parsedOutput() } + val outputs = transaction.vout.orEmpty().mapNotNull { it.parsedOutput() } + val sent = sumValues(inputs) { it.address.lowercase() == walletAddress } ?: return null + val received = sumValues(outputs) { it.address.lowercase() == walletAddress } ?: return null + + if (sent == 0L && received == 0L) { + return null + } + + val isOutgoing = sent > 0 + val fee = transaction.fee ?: 0 + if (fee < 0) { + return null + } + val amount = if (isOutgoing) { + sent - received - fee + } else { + received + } + if (amount <= 0 || amount > BitcoinUtxoSelector.MAX_SATOSHI) { + return null + } + val counterparty = if (isOutgoing) { + outputs.firstOrNull { it.address.lowercase() != walletAddress && it.valueSats > 0 }?.address + } else { + inputs.firstOrNull { it.address.lowercase() != walletAddress && it.valueSats > 0 }?.address + } ?: return null + + return BitcoinTransactionHistoryEntry( + address = address, + amountSats = amount, + blockHash = transaction.status.blockHash ?: txid, + blockHeight = transaction.status.blockHeight, + confirmed = transaction.status.confirmed, + feeSats = if (isOutgoing) fee else 0, + from = if (isOutgoing) address else counterparty, + outgoing = isOutgoing, + timestamp = transaction.status.blockTime ?: 0, + to = if (isOutgoing) counterparty else address, + txid = txid + ) + } + + private fun BitcoinEsploraTransactionOutput.parsedOutput(): BitcoinParsedTransactionOutput? { + val address = scriptPubKeyAddress?.takeIf { it.isNotBlank() } ?: return null + val value = valueSatsOrNull() ?: return null + if (value < 0 || value > BitcoinUtxoSelector.MAX_SATOSHI) { + return null + } + + return BitcoinParsedTransactionOutput(address, value) + } + + private fun sumValues( + outputs: List, + predicate: (BitcoinParsedTransactionOutput) -> Boolean + ): Long? { + return outputs.fold(0L) { total, output -> + if (!predicate(output)) { + return@fold total + } + val sum = total + output.valueSats + if (sum < total || sum > BitcoinUtxoSelector.MAX_SATOSHI) { + return null + } + + sum + } + } + + private companion object { + const val BITCOIN_ESPLORA_HISTORY_PAGE_SIZE = 25 + const val BITCOIN_HISTORY_MAX_PAGES = 12 + } +} + +private data class BitcoinFetchedHistoryPage( + val history: BitcoinTransactionHistoryPage, + val transactionCount: Int +) + +data class BitcoinTransactionHistoryPage( + val address: String, + val network: BitcoinIndexerRoutes.Network, + val entries: List, + val nextLastSeenTxid: String? +) + +data class BitcoinTransactionHistoryEntry( + val address: String, + val amountSats: Long, + val blockHash: String, + val blockHeight: Long?, + val confirmed: Boolean, + val feeSats: Long, + val from: String, + val outgoing: Boolean, + val timestamp: Long, + val to: String, + val txid: String +) + +class BitcoinTransactionHistoryException( + val code: Code +) : IllegalArgumentException(code.name) { + enum class Code { + INVALID_ADDRESS + } +} + +private data class BitcoinParsedTransactionOutput( + val address: String, + val valueSats: Long +) diff --git a/common/src/main/java/jp/co/soramitsu/common/data/network/bitcoin/BitcoinUtxoSelector.kt b/common/src/main/java/jp/co/soramitsu/common/data/network/bitcoin/BitcoinUtxoSelector.kt new file mode 100644 index 0000000000..d6aec34f00 --- /dev/null +++ b/common/src/main/java/jp/co/soramitsu/common/data/network/bitcoin/BitcoinUtxoSelector.kt @@ -0,0 +1,261 @@ +package jp.co.soramitsu.common.data.network.bitcoin + +import java.lang.Math.addExact +import kotlin.math.ceil + +class BitcoinUtxoSelector { + fun spendableUtxos( + source: BitcoinUtxoSource, + utxos: List, + network: BitcoinIndexerRoutes.Network = BitcoinIndexerRoutes.Network.Mainnet, + includeUnconfirmed: Boolean = false + ): List { + val address = try { + BitcoinIndexerRoutes.normalizeAddress(source.address, network) + } catch (_: BitcoinIndexerRoutes.BitcoinIndexerRouteException) { + throw BitcoinUtxoSelectionException(BitcoinUtxoSelectionException.Code.INVALID_SOURCE_ADDRESS) + } + source.derivationPath?.let(::validateDerivationPath) + + return utxos + .filter { includeUnconfirmed || it.status.confirmed } + .map { + BitcoinSpendableUtxo( + address = address, + derivationPath = source.derivationPath, + txid = it.txid, + valueSats = it.value, + vout = it.vout + ) + } + } + + fun select( + amountSats: Long, + changeAddress: String, + feeRateSatPerVbyte: Double, + maxInputs: Int = DEFAULT_MAX_INPUTS, + network: BitcoinIndexerRoutes.Network = BitcoinIndexerRoutes.Network.Mainnet, + utxos: List + ): BitcoinUtxoSelectionResult { + validateAmount(amountSats) + val normalizedChangeAddress = try { + BitcoinIndexerRoutes.normalizeAddress(changeAddress, network) + } catch (_: BitcoinIndexerRoutes.BitcoinIndexerRouteException) { + throw BitcoinUtxoSelectionException(BitcoinUtxoSelectionException.Code.INVALID_CHANGE_ADDRESS) + } + validateFeeRate(feeRateSatPerVbyte) + validateMaxInputs(maxInputs) + val normalizedUtxos = normalizeUtxos(utxos) + val selected = mutableListOf() + var total = 0L + + for (utxo in normalizedUtxos.sortedWith(UTXO_ORDER)) { + selected += utxo + if (selected.size > maxInputs) { + throw BitcoinUtxoSelectionException(BitcoinUtxoSelectionException.Code.TOO_MANY_INPUTS_REQUIRED) + } + total = safeAdd(total, utxo.valueSats) + if (total > MAX_SATOSHI) { + throw BitcoinUtxoSelectionException(BitcoinUtxoSelectionException.Code.SATOSHI_OVERFLOW) + } + + val noChangeFee = estimateFee(selected.size, outputCount = 1, feeRateSatPerVbyte) + val noChangeRemainder = total - amountSats - noChangeFee + + if (noChangeRemainder == 0L) { + return BitcoinUtxoSelectionResult( + selectedUtxos = selected.toList(), + inputTotalSats = total, + feeSats = noChangeFee, + changeAddress = null, + changeSats = 0, + absorbedDustSats = 0 + ) + } + + if (noChangeRemainder > 0 && noChangeRemainder < BITCOIN_P2WPKH_DUST_SATS) { + return BitcoinUtxoSelectionResult( + selectedUtxos = selected.toList(), + inputTotalSats = total, + feeSats = safeAdd(noChangeFee, noChangeRemainder), + changeAddress = null, + changeSats = 0, + absorbedDustSats = noChangeRemainder + ) + } + + val withChangeFee = estimateFee(selected.size, outputCount = 2, feeRateSatPerVbyte) + val change = total - amountSats - withChangeFee + + if (change >= BITCOIN_P2WPKH_DUST_SATS) { + return BitcoinUtxoSelectionResult( + selectedUtxos = selected.toList(), + inputTotalSats = total, + feeSats = withChangeFee, + changeAddress = normalizedChangeAddress, + changeSats = change, + absorbedDustSats = 0 + ) + } + } + + throw BitcoinUtxoSelectionException(BitcoinUtxoSelectionException.Code.INSUFFICIENT_FUNDS) + } + + fun estimateP2wpkhTransactionVSize(inputCount: Int, outputCount: Int): Int { + if (inputCount <= 0) { + throw BitcoinUtxoSelectionException(BitcoinUtxoSelectionException.Code.INVALID_INPUT_COUNT) + } + if (outputCount <= 0) { + throw BitcoinUtxoSelectionException(BitcoinUtxoSelectionException.Code.INVALID_OUTPUT_COUNT) + } + + return TX_OVERHEAD_VBYTES + inputCount * P2WPKH_INPUT_VBYTES + outputCount * P2WPKH_OUTPUT_VBYTES + } + + private fun normalizeUtxos(utxos: List): List { + if (utxos.isEmpty()) { + throw BitcoinUtxoSelectionException(BitcoinUtxoSelectionException.Code.UTXOS_REQUIRED) + } + val outpoints = mutableSetOf() + + return utxos.map { utxo -> + val normalizedTxid = utxo.txid.trim().lowercase() + if (!TXID.matches(normalizedTxid)) { + throw BitcoinUtxoSelectionException(BitcoinUtxoSelectionException.Code.INVALID_TXID) + } + if (utxo.vout !in 0..MAX_UINT32) { + throw BitcoinUtxoSelectionException(BitcoinUtxoSelectionException.Code.INVALID_VOUT) + } + if (utxo.valueSats <= 0 || utxo.valueSats > MAX_SATOSHI) { + throw BitcoinUtxoSelectionException(BitcoinUtxoSelectionException.Code.INVALID_UTXO_VALUE) + } + utxo.scriptPubKey?.let(::validateScriptPubKey) + utxo.derivationPath?.let(::validateDerivationPath) + + val outpoint = "$normalizedTxid:${utxo.vout}" + if (!outpoints.add(outpoint)) { + throw BitcoinUtxoSelectionException(BitcoinUtxoSelectionException.Code.DUPLICATE_UTXO) + } + + utxo.copy(txid = normalizedTxid) + } + } + + private fun validateAmount(amountSats: Long) { + if (amountSats <= 0 || amountSats > MAX_SATOSHI) { + throw BitcoinUtxoSelectionException(BitcoinUtxoSelectionException.Code.INVALID_AMOUNT) + } + if (amountSats < BITCOIN_P2WPKH_DUST_SATS) { + throw BitcoinUtxoSelectionException(BitcoinUtxoSelectionException.Code.AMOUNT_BELOW_DUST) + } + } + + private fun validateFeeRate(feeRateSatPerVbyte: Double) { + if (!feeRateSatPerVbyte.isFinite() || feeRateSatPerVbyte <= 0.0 || feeRateSatPerVbyte > MAX_FEE_RATE_SAT_PER_VBYTE) { + throw BitcoinUtxoSelectionException(BitcoinUtxoSelectionException.Code.INVALID_FEE_RATE) + } + } + + private fun validateMaxInputs(maxInputs: Int) { + if (maxInputs <= 0 || maxInputs > DEFAULT_MAX_INPUTS) { + throw BitcoinUtxoSelectionException(BitcoinUtxoSelectionException.Code.INVALID_MAX_INPUTS) + } + } + + private fun validateScriptPubKey(scriptPubKey: String) { + if (!SCRIPT_PUBKEY.matches(scriptPubKey.trim())) { + throw BitcoinUtxoSelectionException(BitcoinUtxoSelectionException.Code.INVALID_SCRIPT_PUBKEY) + } + } + + private fun validateDerivationPath(derivationPath: String) { + if (!DERIVATION_PATH.matches(derivationPath)) { + throw BitcoinUtxoSelectionException(BitcoinUtxoSelectionException.Code.INVALID_DERIVATION_PATH) + } + } + + private fun estimateFee(inputCount: Int, outputCount: Int, feeRateSatPerVbyte: Double): Long { + val fee = ceil(estimateP2wpkhTransactionVSize(inputCount, outputCount) * feeRateSatPerVbyte) + if (!fee.isFinite() || fee <= 0 || fee > Long.MAX_VALUE) { + throw BitcoinUtxoSelectionException(BitcoinUtxoSelectionException.Code.INVALID_FEE_RATE) + } + + return fee.toLong() + } + + private fun safeAdd(left: Long, right: Long): Long { + return try { + addExact(left, right) + } catch (_: ArithmeticException) { + throw BitcoinUtxoSelectionException(BitcoinUtxoSelectionException.Code.SATOSHI_OVERFLOW) + } + } + + companion object { + const val BITCOIN_P2WPKH_DUST_SATS = 330L + const val DEFAULT_MAX_INPUTS = 100 + const val MAX_FEE_RATE_SAT_PER_VBYTE = 10_000.0 + const val MAX_SATOSHI = 2_100_000_000_000_000L + private const val MAX_UINT32 = 0xffffffffL + private const val P2WPKH_INPUT_VBYTES = 68 + private const val P2WPKH_OUTPUT_VBYTES = 31 + private const val TX_OVERHEAD_VBYTES = 11 + + private val DERIVATION_PATH = Regex("^m(?:/\\d+'?)+$") + private val SCRIPT_PUBKEY = Regex("^(?:[0-9a-fA-F]{2})+$") + private val TXID = Regex("^[0-9a-f]{64}$", RegexOption.IGNORE_CASE) + private val UTXO_ORDER = compareByDescending { it.valueSats } + .thenBy { it.txid.lowercase() } + .thenBy { it.vout } + } +} + +data class BitcoinUtxoSource( + val address: String, + val derivationPath: String? = null +) + +data class BitcoinSpendableUtxo( + val address: String? = null, + val derivationPath: String? = null, + val scriptPubKey: String? = null, + val txid: String, + val valueSats: Long, + val vout: Long +) + +data class BitcoinUtxoSelectionResult( + val selectedUtxos: List, + val inputTotalSats: Long, + val feeSats: Long, + val changeAddress: String?, + val changeSats: Long, + val absorbedDustSats: Long +) + +class BitcoinUtxoSelectionException( + val code: Code +) : IllegalArgumentException(code.name) { + enum class Code { + AMOUNT_BELOW_DUST, + DUPLICATE_UTXO, + INSUFFICIENT_FUNDS, + INVALID_AMOUNT, + INVALID_CHANGE_ADDRESS, + INVALID_DERIVATION_PATH, + INVALID_FEE_RATE, + INVALID_INPUT_COUNT, + INVALID_MAX_INPUTS, + INVALID_OUTPUT_COUNT, + INVALID_SCRIPT_PUBKEY, + INVALID_SOURCE_ADDRESS, + INVALID_TXID, + INVALID_UTXO_VALUE, + INVALID_VOUT, + SATOSHI_OVERFLOW, + TOO_MANY_INPUTS_REQUIRED, + UTXOS_REQUIRED + } +} diff --git a/common/src/main/java/jp/co/soramitsu/common/data/network/iroha/IrohaToriiApi.kt b/common/src/main/java/jp/co/soramitsu/common/data/network/iroha/IrohaToriiApi.kt new file mode 100644 index 0000000000..0ef982555d --- /dev/null +++ b/common/src/main/java/jp/co/soramitsu/common/data/network/iroha/IrohaToriiApi.kt @@ -0,0 +1,44 @@ +package jp.co.soramitsu.common.data.network.iroha + +import okhttp3.RequestBody +import retrofit2.http.Body +import retrofit2.http.GET +import retrofit2.http.Headers +import retrofit2.http.POST +import retrofit2.http.Url + +interface IrohaToriiApi { + @GET + suspend fun health(@Url url: String): String + + @GET + suspend fun accounts(@Url url: String): IrohaAccountListResponse + + @GET + suspend fun account(@Url url: String): IrohaAccountListItem + + @GET + suspend fun accountAssets(@Url url: String): IrohaAccountAssetListResponse + + @GET + suspend fun assetDefinitions(@Url url: String): IrohaAssetDefinitionListResponse + + @GET + suspend fun transactionStatus(@Url url: String): IrohaPipelineTransactionStatusResponse + + @Headers("Content-Type: application/x-norito", "Accept: application/json") + @POST + suspend fun submitTransaction( + @Url url: String, + @Body body: RequestBody + ): IrohaTransactionSubmissionReceipt + + @GET + suspend fun mcpCapabilities(@Url url: String): Map + + @POST + suspend fun mcpJsonRpc( + @Url url: String, + @Body body: IrohaMcpJsonRpcRequest + ): IrohaMcpJsonRpcResponse +} diff --git a/common/src/main/java/jp/co/soramitsu/common/data/network/iroha/IrohaToriiClient.kt b/common/src/main/java/jp/co/soramitsu/common/data/network/iroha/IrohaToriiClient.kt new file mode 100644 index 0000000000..8c7267d01d --- /dev/null +++ b/common/src/main/java/jp/co/soramitsu/common/data/network/iroha/IrohaToriiClient.kt @@ -0,0 +1,181 @@ +package jp.co.soramitsu.common.data.network.iroha + +import jp.co.soramitsu.common.model.UniversalWalletRegistry +import jp.co.soramitsu.common.utils.IrohaAddressCodec +import okhttp3.MediaType.Companion.toMediaType +import okhttp3.RequestBody.Companion.toRequestBody + +interface IrohaToriiClient { + suspend fun health(baseUrl: String? = null): String + + suspend fun accounts( + baseUrl: String? = null, + limit: Int? = null, + offset: Long? = null, + countMode: IrohaToriiRoutes.CountMode? = null + ): IrohaAccountListResponse + + suspend fun account( + accountId: String, + baseUrl: String? = null, + network: UniversalWalletRegistry.IrohaNetwork = UniversalWalletRegistry.taira + ): IrohaAccountListItem + + suspend fun accountAssets( + accountId: String, + baseUrl: String? = null, + limit: Int? = null, + offset: Long? = null, + countMode: IrohaToriiRoutes.CountMode? = null, + asset: String? = null, + scope: String? = null, + network: UniversalWalletRegistry.IrohaNetwork = UniversalWalletRegistry.taira + ): IrohaAccountAssetListResponse + + suspend fun assetDefinitions(baseUrl: String? = null): IrohaAssetDefinitionListResponse + + suspend fun submitTransaction( + noritoBytes: ByteArray, + baseUrl: String? = null + ): IrohaTransactionSubmissionReceipt + + suspend fun transactionStatus( + hash: String, + baseUrl: String? = null, + scope: IrohaToriiRoutes.TransactionStatusScope = IrohaToriiRoutes.TransactionStatusScope.Auto + ): IrohaPipelineTransactionStatusResponse + + suspend fun mcpCapabilities( + network: UniversalWalletRegistry.IrohaNetwork = UniversalWalletRegistry.taira, + baseUrl: String? = null + ): Map + + suspend fun mcpJsonRpc( + request: IrohaMcpJsonRpcRequest, + network: UniversalWalletRegistry.IrohaNetwork = UniversalWalletRegistry.taira, + baseUrl: String? = null + ): IrohaMcpJsonRpcResponse +} + +class RetrofitIrohaToriiClient( + private val api: IrohaToriiApi, + private val defaultNetwork: UniversalWalletRegistry.IrohaNetwork = UniversalWalletRegistry.taira +) : IrohaToriiClient { + + override suspend fun health(baseUrl: String?): String { + return api.health(IrohaToriiRoutes.healthUrl(resolveBaseUrl(baseUrl))) + } + + override suspend fun accounts( + baseUrl: String?, + limit: Int?, + offset: Long?, + countMode: IrohaToriiRoutes.CountMode? + ): IrohaAccountListResponse { + return api.accounts( + IrohaToriiRoutes.accountsUrl( + baseUrl = resolveBaseUrl(baseUrl), + limit = limit, + offset = offset, + countMode = countMode + ) + ) + } + + override suspend fun account( + accountId: String, + baseUrl: String?, + network: UniversalWalletRegistry.IrohaNetwork + ): IrohaAccountListItem { + return api.account( + IrohaToriiRoutes.accountUrl( + normalizeWalletAccountId(accountId, network), + resolveBaseUrl(baseUrl, network) + ) + ) + } + + override suspend fun accountAssets( + accountId: String, + baseUrl: String?, + limit: Int?, + offset: Long?, + countMode: IrohaToriiRoutes.CountMode?, + asset: String?, + scope: String?, + network: UniversalWalletRegistry.IrohaNetwork + ): IrohaAccountAssetListResponse { + return api.accountAssets( + IrohaToriiRoutes.accountAssetsUrl( + accountId = normalizeWalletAccountId(accountId, network), + baseUrl = resolveBaseUrl(baseUrl, network), + limit = limit, + offset = offset, + countMode = countMode, + asset = asset, + scope = scope + ) + ) + } + + override suspend fun assetDefinitions(baseUrl: String?): IrohaAssetDefinitionListResponse { + return api.assetDefinitions(IrohaToriiRoutes.assetDefinitionsUrl(resolveBaseUrl(baseUrl))) + } + + override suspend fun submitTransaction( + noritoBytes: ByteArray, + baseUrl: String? + ): IrohaTransactionSubmissionReceipt { + return api.submitTransaction( + IrohaToriiRoutes.submitTransactionUrl(resolveBaseUrl(baseUrl)), + noritoBytes.toRequestBody(NORITO_MEDIA_TYPE) + ) + } + + override suspend fun transactionStatus( + hash: String, + baseUrl: String?, + scope: IrohaToriiRoutes.TransactionStatusScope + ): IrohaPipelineTransactionStatusResponse { + return api.transactionStatus( + IrohaToriiRoutes.transactionStatusUrl( + hash = hash, + baseUrl = resolveBaseUrl(baseUrl), + scope = scope + ) + ) + } + + override suspend fun mcpCapabilities( + network: UniversalWalletRegistry.IrohaNetwork, + baseUrl: String? + ): Map { + return api.mcpCapabilities(IrohaToriiRoutes.mcpUrl(network, resolveBaseUrl(baseUrl, network))) + } + + override suspend fun mcpJsonRpc( + request: IrohaMcpJsonRpcRequest, + network: UniversalWalletRegistry.IrohaNetwork, + baseUrl: String? + ): IrohaMcpJsonRpcResponse { + return api.mcpJsonRpc(IrohaToriiRoutes.mcpUrl(network, resolveBaseUrl(baseUrl, network)), request) + } + + private fun normalizeWalletAccountId( + accountId: String, + network: UniversalWalletRegistry.IrohaNetwork + ): String { + return IrohaAddressCodec.parse(accountId, network.chainDiscriminant).i105 + } + + private fun resolveBaseUrl( + baseUrl: String?, + network: UniversalWalletRegistry.IrohaNetwork = defaultNetwork + ): String { + return baseUrl ?: IrohaToriiRoutes.requireToriiBaseUrl(network) + } + + private companion object { + val NORITO_MEDIA_TYPE = "application/x-norito".toMediaType() + } +} diff --git a/common/src/main/java/jp/co/soramitsu/common/data/network/iroha/IrohaToriiModels.kt b/common/src/main/java/jp/co/soramitsu/common/data/network/iroha/IrohaToriiModels.kt new file mode 100644 index 0000000000..19cd3130a5 --- /dev/null +++ b/common/src/main/java/jp/co/soramitsu/common/data/network/iroha/IrohaToriiModels.kt @@ -0,0 +1,180 @@ +package jp.co.soramitsu.common.data.network.iroha + +import com.google.gson.annotations.SerializedName + +data class IrohaPageMetadata( + @SerializedName("has_more") + val hasMore: Boolean, + @SerializedName("count_mode") + val countMode: String, + @SerializedName("total") + val total: Long? = null, + @SerializedName("indexed_height") + val indexedHeight: Long? = null, + @SerializedName("indexed_block_hash") + val indexedBlockHash: String? = null, + @SerializedName("query_source") + val querySource: String? = null +) + +data class IrohaAccountListResponse( + @SerializedName("items") + val items: List = emptyList(), + @SerializedName("has_more") + val hasMore: Boolean, + @SerializedName("count_mode") + val countMode: String, + @SerializedName("total") + val total: Long? = null +) + +data class IrohaAccountListItem( + @SerializedName("id") + val id: String, + @SerializedName("primary_alias") + val primaryAlias: String? = null, + @SerializedName("primary_alias_name") + val primaryAliasName: String? = null, + @SerializedName("primary_alias_dataspace") + val primaryAliasDataspace: String? = null, + @SerializedName("primary_alias_domain") + val primaryAliasDomain: String? = null, + @SerializedName("has_primary_alias") + val hasPrimaryAlias: Boolean? = null +) + +data class IrohaAccountAssetListResponse( + @SerializedName("items") + val items: List = emptyList(), + @SerializedName("has_more") + val hasMore: Boolean, + @SerializedName("count_mode") + val countMode: String, + @SerializedName("total") + val total: Long? = null +) + +data class IrohaAccountAssetListItem( + @SerializedName("account_id") + val accountId: String? = null, + @SerializedName("asset") + val asset: String, + @SerializedName("asset_id") + val assetId: String? = null, + @SerializedName("asset_name") + val assetName: String? = null, + @SerializedName("asset_alias") + val assetAlias: String? = null, + @SerializedName("quantity") + val quantity: String, + @SerializedName("scope") + val scope: String? = null +) + +data class IrohaAssetDefinitionListResponse( + @SerializedName("items") + val items: List = emptyList(), + @SerializedName("has_more") + val hasMore: Boolean, + @SerializedName("count_mode") + val countMode: String, + @SerializedName("total") + val total: Long? = null +) + +data class IrohaAssetDefinitionListItem( + @SerializedName("id") + val id: String, + @SerializedName("name") + val name: String? = null, + @SerializedName("alias") + val alias: String? = null, + @SerializedName("owned_by") + val ownedBy: String? = null, + @SerializedName("metadata") + val metadata: Map? = null, + @SerializedName("alias_binding") + val aliasBinding: Map? = null +) + +data class IrohaTransactionSubmissionReceipt( + @SerializedName("payload") + val payload: IrohaTransactionSubmissionPayload, + @SerializedName("signature") + val signature: Any? = null +) + +data class IrohaTransactionSubmissionPayload( + @SerializedName("tx_hash") + val txHash: String, + @SerializedName("entrypoint_hash") + val entrypointHash: String, + @SerializedName("signed_transaction_hash") + val signedTransactionHash: String? = null, + @SerializedName("submitted_at_ms") + val submittedAtMs: Long, + @SerializedName("submitted_at_height") + val submittedAtHeight: Long, + @SerializedName("signer") + val signer: Any? = null +) + +data class IrohaPipelineTransactionStatusResponse( + @SerializedName("hash") + val hash: String, + @SerializedName("status") + val status: IrohaPipelineTransactionStatus, + @SerializedName("scope") + val scope: String, + @SerializedName("resolved_from") + val resolvedFrom: String +) + +data class IrohaPipelineTransactionStatus( + @SerializedName("kind") + val kind: String, + @SerializedName("block_height") + val blockHeight: Long? = null, + @SerializedName("rejection_reason") + val rejectionReason: Any? = null +) + +data class IrohaErrorEnvelope( + @SerializedName("code") + val code: String, + @SerializedName("message") + val message: String, + @SerializedName("details") + val details: Any? = null +) + +data class IrohaMcpJsonRpcRequest( + @SerializedName("jsonrpc") + val jsonrpc: String = "2.0", + @SerializedName("id") + val id: String, + @SerializedName("method") + val method: String, + @SerializedName("params") + val params: Map? = null +) + +data class IrohaMcpJsonRpcResponse( + @SerializedName("jsonrpc") + val jsonrpc: String = "2.0", + @SerializedName("id") + val id: String? = null, + @SerializedName("result") + val result: Any? = null, + @SerializedName("error") + val error: IrohaMcpJsonRpcError? = null +) + +data class IrohaMcpJsonRpcError( + @SerializedName("code") + val code: Int, + @SerializedName("message") + val message: String, + @SerializedName("data") + val data: Any? = null +) diff --git a/common/src/main/java/jp/co/soramitsu/common/data/network/iroha/IrohaToriiRoutes.kt b/common/src/main/java/jp/co/soramitsu/common/data/network/iroha/IrohaToriiRoutes.kt new file mode 100644 index 0000000000..5e8a87aef0 --- /dev/null +++ b/common/src/main/java/jp/co/soramitsu/common/data/network/iroha/IrohaToriiRoutes.kt @@ -0,0 +1,237 @@ +package jp.co.soramitsu.common.data.network.iroha + +import jp.co.soramitsu.common.model.UniversalWalletRegistry +import java.net.MalformedURLException +import java.net.URLEncoder +import java.net.URL + +object IrohaToriiRoutes { + const val DEFAULT_LIMIT = 100 + const val MAX_LIMIT = 500 + + fun healthUrl(baseUrl: String = requireToriiBaseUrl(UniversalWalletRegistry.taira)): String { + return "${normalizeBaseUrl(baseUrl)}/health" + } + + fun mcpUrl( + network: UniversalWalletRegistry.IrohaNetwork = UniversalWalletRegistry.taira, + baseUrl: String = requireToriiBaseUrl(network) + ): String { + return "${normalizeBaseUrl(baseUrl)}${normalizePath(network.mcpPath)}" + } + + fun accountsUrl( + baseUrl: String = requireToriiBaseUrl(UniversalWalletRegistry.taira), + limit: Int? = null, + offset: Long? = null, + countMode: CountMode? = null + ): String { + return appendQuery("${normalizeBaseUrl(baseUrl)}/v1/accounts", pageQuery(limit, offset, countMode)) + } + + fun accountUrl( + accountId: String, + baseUrl: String = requireToriiBaseUrl(UniversalWalletRegistry.taira) + ): String { + return "${normalizeBaseUrl(baseUrl)}/v1/accounts/${encodePathSegment(normalizeAccountId(accountId))}" + } + + fun accountAssetsUrl( + accountId: String, + baseUrl: String = requireToriiBaseUrl(UniversalWalletRegistry.taira), + limit: Int? = null, + offset: Long? = null, + countMode: CountMode? = null, + asset: String? = null, + scope: String? = null + ): String { + val query = pageQuery(limit, offset, countMode).toMutableList() + asset?.let { query += "asset=${encodeQueryValue(normalizeAssetSelector(it))}" } + scope?.let { query += "scope=${encodeQueryValue(normalizeScope(it))}" } + + return appendQuery("${accountUrl(accountId, baseUrl)}/assets", query) + } + + fun assetDefinitionsUrl(baseUrl: String = requireToriiBaseUrl(UniversalWalletRegistry.taira)): String { + return "${normalizeBaseUrl(baseUrl)}/v1/assets/definitions" + } + + fun submitTransactionUrl(baseUrl: String = requireToriiBaseUrl(UniversalWalletRegistry.taira)): String { + return "${normalizeBaseUrl(baseUrl)}/v1/pipeline/transactions" + } + + fun transactionStatusUrl( + hash: String, + baseUrl: String = requireToriiBaseUrl(UniversalWalletRegistry.taira), + scope: TransactionStatusScope = TransactionStatusScope.Auto + ): String { + return "${normalizeBaseUrl(baseUrl)}/v1/pipeline/transactions/status?hash=${normalizeHash(hash)}&scope=${scope.apiValue}" + } + + fun mcpJsonRpcRequest( + method: String, + id: String, + params: Map? = null + ): IrohaMcpJsonRpcRequest { + return IrohaMcpJsonRpcRequest( + id = normalizeJsonRpcId(id), + method = normalizeMcpMethod(method), + params = params + ) + } + + fun normalizeBaseUrl(baseUrl: String): String { + val trimmed = baseUrl.trim() + val parsed = try { + URL(trimmed) + } catch (_: MalformedURLException) { + throw IrohaToriiRouteException(ErrorCode.INVALID_BASE_URL) + } + val isLocal = parsed.host == "localhost" || parsed.host == "127.0.0.1" + + if (parsed.protocol != "https" && !isLocal) { + throw IrohaToriiRouteException(ErrorCode.INVALID_BASE_URL) + } + + return trimmed.trimEnd('/') + } + + fun normalizeAccountId(accountId: String): String { + return normalizePathValue(accountId, ErrorCode.INVALID_ACCOUNT_ID) + } + + fun normalizeAssetSelector(asset: String): String { + val normalized = asset.trim() + if (normalized.isEmpty() || normalized.length > 256 || normalized.any { it <= ' ' } || ASSET_FORBIDDEN.containsMatchIn(normalized)) { + throw IrohaToriiRouteException(ErrorCode.INVALID_ASSET) + } + + return normalized + } + + fun requireToriiBaseUrl(network: UniversalWalletRegistry.IrohaNetwork): String { + return network.toriiBaseUrl ?: throw IrohaToriiRouteException(ErrorCode.MISSING_TORII_BASE_URL) + } + + private fun normalizeScope(scope: String): String { + val normalized = scope.trim() + if (normalized != "global" && !DATASPACE_SCOPE.matches(normalized)) { + throw IrohaToriiRouteException(ErrorCode.INVALID_SCOPE) + } + + return normalized + } + + private fun normalizePath(path: String): String { + val normalized = path.trim() + if (!normalized.startsWith("/") || normalized.contains("..") || normalized.contains("?") || normalized.contains("#")) { + throw IrohaToriiRouteException(ErrorCode.INVALID_PATH) + } + + return normalized.trimEnd('/') + } + + private fun normalizePathValue(value: String, errorCode: ErrorCode): String { + val normalized = value.trim() + if (normalized.isEmpty() || normalized.length > 256 || normalized.any { it <= ' ' } || PATH_FORBIDDEN.containsMatchIn(normalized)) { + throw IrohaToriiRouteException(errorCode) + } + + return normalized + } + + private fun normalizeHash(hash: String): String { + val normalized = hash.trim().removePrefix("0x").lowercase() + if (!HASH_256.matches(normalized)) { + throw IrohaToriiRouteException(ErrorCode.INVALID_HASH) + } + + return normalized + } + + private fun normalizeJsonRpcId(id: String): String { + val normalized = id.trim() + if (!JSON_RPC_ID.matches(normalized)) { + throw IrohaToriiRouteException(ErrorCode.INVALID_JSON_RPC_ID) + } + + return normalized + } + + private fun normalizeMcpMethod(method: String): String { + val normalized = method.trim() + if (!MCP_METHOD.matches(normalized)) { + throw IrohaToriiRouteException(ErrorCode.INVALID_MCP_METHOD) + } + + return normalized + } + + private fun pageQuery(limit: Int?, offset: Long?, countMode: CountMode?): List { + val query = mutableListOf() + + limit?.let { + if (it !in 1..MAX_LIMIT) { + throw IrohaToriiRouteException(ErrorCode.INVALID_LIMIT) + } + query += "limit=$it" + } + offset?.let { + if (it < 0) { + throw IrohaToriiRouteException(ErrorCode.INVALID_OFFSET) + } + query += "offset=$it" + } + countMode?.let { + query += "count_mode=${it.apiValue}" + } + + return query + } + + private fun appendQuery(base: String, query: List): String { + return if (query.isEmpty()) base else "$base?${query.joinToString("&")}" + } + + private fun encodePathSegment(value: String): String { + return encodeQueryValue(value) + } + + private fun encodeQueryValue(value: String): String { + return URLEncoder.encode(value, Charsets.UTF_8.name()).replace("+", "%20") + } + + class IrohaToriiRouteException(val code: ErrorCode) : IllegalArgumentException(code.name) + + enum class ErrorCode { + INVALID_BASE_URL, + MISSING_TORII_BASE_URL, + INVALID_PATH, + INVALID_ACCOUNT_ID, + INVALID_ASSET, + INVALID_SCOPE, + INVALID_HASH, + INVALID_LIMIT, + INVALID_OFFSET, + INVALID_JSON_RPC_ID, + INVALID_MCP_METHOD + } + + enum class CountMode(val apiValue: String) { + Bounded("bounded"), + Exact("exact") + } + + enum class TransactionStatusScope(val apiValue: String) { + Local("local"), + Auto("auto"), + Global("global") + } + + private val PATH_FORBIDDEN = Regex("[/?#]") + private val ASSET_FORBIDDEN = Regex("[/?]") + private val DATASPACE_SCOPE = Regex("^dataspace:[A-Za-z0-9._:-]{1,128}$") + private val HASH_256 = Regex("^[0-9a-f]{64}$") + private val JSON_RPC_ID = Regex("^[A-Za-z0-9._:-]{1,64}$") + private val MCP_METHOD = Regex("^[A-Za-z][A-Za-z0-9_/.-]{0,127}$") +} diff --git a/common/src/main/java/jp/co/soramitsu/common/data/network/rpc/BulkRetriever.kt b/common/src/main/java/jp/co/soramitsu/common/data/network/rpc/BulkRetriever.kt index 4341a3d2f9..aa9f46fafa 100644 --- a/common/src/main/java/jp/co/soramitsu/common/data/network/rpc/BulkRetriever.kt +++ b/common/src/main/java/jp/co/soramitsu/common/data/network/rpc/BulkRetriever.kt @@ -1,11 +1,11 @@ package jp.co.soramitsu.common.data.network.rpc import jp.co.soramitsu.common.data.network.runtime.binding.BlockHash -import jp.co.soramitsu.shared_utils.wsrpc.SocketService -import jp.co.soramitsu.shared_utils.wsrpc.executeAsync -import jp.co.soramitsu.shared_utils.wsrpc.mappers.nonNull -import jp.co.soramitsu.shared_utils.wsrpc.mappers.pojoList -import jp.co.soramitsu.shared_utils.wsrpc.request.runtime.RuntimeRequest +import jp.co.soramitsu.fearless_utils.wsrpc.SocketService +import jp.co.soramitsu.fearless_utils.wsrpc.executeAsync +import jp.co.soramitsu.fearless_utils.wsrpc.mappers.nonNull +import jp.co.soramitsu.fearless_utils.wsrpc.mappers.pojoList +import jp.co.soramitsu.fearless_utils.wsrpc.request.runtime.RuntimeRequest import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.ensureActive import kotlinx.coroutines.withContext diff --git a/common/src/main/java/jp/co/soramitsu/common/data/network/rpc/ChildStateKey.kt b/common/src/main/java/jp/co/soramitsu/common/data/network/rpc/ChildStateKey.kt index 433a047a02..0771e74a5f 100644 --- a/common/src/main/java/jp/co/soramitsu/common/data/network/rpc/ChildStateKey.kt +++ b/common/src/main/java/jp/co/soramitsu/common/data/network/rpc/ChildStateKey.kt @@ -1,6 +1,6 @@ package jp.co.soramitsu.common.data.network.rpc -import jp.co.soramitsu.shared_utils.extensions.toHexString +import jp.co.soramitsu.fearless_utils.extensions.toHexString import java.io.ByteArrayOutputStream private const val CHILD_KEY_DEFAULT = ":child_storage:default:" diff --git a/common/src/main/java/jp/co/soramitsu/common/data/network/rpc/RetrieveAllValues.kt b/common/src/main/java/jp/co/soramitsu/common/data/network/rpc/RetrieveAllValues.kt index 8f62e3ce7e..6b294b87e7 100644 --- a/common/src/main/java/jp/co/soramitsu/common/data/network/rpc/RetrieveAllValues.kt +++ b/common/src/main/java/jp/co/soramitsu/common/data/network/rpc/RetrieveAllValues.kt @@ -1,6 +1,6 @@ package jp.co.soramitsu.common.data.network.rpc -import jp.co.soramitsu.shared_utils.wsrpc.SocketService +import jp.co.soramitsu.fearless_utils.wsrpc.SocketService suspend fun BulkRetriever.retrieveAllValues(socketService: SocketService, keyPrefix: String): Map { val allKeys = retrieveAllKeys(socketService, keyPrefix) diff --git a/common/src/main/java/jp/co/soramitsu/common/data/network/rpc/SocketSingleRequestExecutor.kt b/common/src/main/java/jp/co/soramitsu/common/data/network/rpc/SocketSingleRequestExecutor.kt index 1643ea6efa..e32ab6f7f7 100644 --- a/common/src/main/java/jp/co/soramitsu/common/data/network/rpc/SocketSingleRequestExecutor.kt +++ b/common/src/main/java/jp/co/soramitsu/common/data/network/rpc/SocketSingleRequestExecutor.kt @@ -7,10 +7,10 @@ import com.neovisionaries.ws.client.WebSocketException import com.neovisionaries.ws.client.WebSocketFactory import jp.co.soramitsu.common.base.errors.FearlessException import jp.co.soramitsu.common.resources.ResourceManager -import jp.co.soramitsu.shared_utils.wsrpc.logging.Logger -import jp.co.soramitsu.shared_utils.wsrpc.mappers.ResponseMapper -import jp.co.soramitsu.shared_utils.wsrpc.request.base.RpcRequest -import jp.co.soramitsu.shared_utils.wsrpc.response.RpcResponse +import jp.co.soramitsu.fearless_utils.wsrpc.logging.Logger +import jp.co.soramitsu.fearless_utils.wsrpc.mappers.ResponseMapper +import jp.co.soramitsu.fearless_utils.wsrpc.request.base.RpcRequest +import jp.co.soramitsu.fearless_utils.wsrpc.response.RpcResponse import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.suspendCancellableCoroutine import kotlinx.coroutines.withContext diff --git a/common/src/main/java/jp/co/soramitsu/common/data/network/runtime/binding/AccountInfo.kt b/common/src/main/java/jp/co/soramitsu/common/data/network/runtime/binding/AccountInfo.kt index 4817fe4c42..eceff89cab 100644 --- a/common/src/main/java/jp/co/soramitsu/common/data/network/runtime/binding/AccountInfo.kt +++ b/common/src/main/java/jp/co/soramitsu/common/data/network/runtime/binding/AccountInfo.kt @@ -5,13 +5,13 @@ import jp.co.soramitsu.common.utils.Modules import jp.co.soramitsu.common.utils.orZero import jp.co.soramitsu.common.utils.system import jp.co.soramitsu.core.runtime.storage.returnType -import jp.co.soramitsu.shared_utils.runtime.AccountId -import jp.co.soramitsu.shared_utils.runtime.RuntimeSnapshot -import jp.co.soramitsu.shared_utils.runtime.definitions.types.composite.DictEnum -import jp.co.soramitsu.shared_utils.runtime.definitions.types.composite.Struct -import jp.co.soramitsu.shared_utils.runtime.definitions.types.fromHexOrNull -import jp.co.soramitsu.shared_utils.runtime.metadata.module -import jp.co.soramitsu.shared_utils.runtime.metadata.storage +import jp.co.soramitsu.fearless_utils.runtime.AccountId +import jp.co.soramitsu.fearless_utils.runtime.RuntimeSnapshot +import jp.co.soramitsu.fearless_utils.runtime.definitions.types.composite.DictEnum +import jp.co.soramitsu.fearless_utils.runtime.definitions.types.composite.Struct +import jp.co.soramitsu.fearless_utils.runtime.definitions.types.fromHexOrNull +import jp.co.soramitsu.fearless_utils.runtime.metadata.module +import jp.co.soramitsu.fearless_utils.runtime.metadata.storage interface AssetBalanceData diff --git a/common/src/main/java/jp/co/soramitsu/common/data/network/runtime/binding/BindAccountId.kt b/common/src/main/java/jp/co/soramitsu/common/data/network/runtime/binding/BindAccountId.kt index 84c22fc59b..49eebeb2cc 100644 --- a/common/src/main/java/jp/co/soramitsu/common/data/network/runtime/binding/BindAccountId.kt +++ b/common/src/main/java/jp/co/soramitsu/common/data/network/runtime/binding/BindAccountId.kt @@ -1,6 +1,6 @@ package jp.co.soramitsu.common.data.network.runtime.binding -import jp.co.soramitsu.shared_utils.runtime.AccountId +import jp.co.soramitsu.fearless_utils.runtime.AccountId @HelperBinding fun bindAccountId(dynamicInstance: Any?) = dynamicInstance.cast() diff --git a/common/src/main/java/jp/co/soramitsu/common/data/network/runtime/binding/BindActiveEraIndex.kt b/common/src/main/java/jp/co/soramitsu/common/data/network/runtime/binding/BindActiveEraIndex.kt index 96cd5d84da..fc8b31fcc8 100644 --- a/common/src/main/java/jp/co/soramitsu/common/data/network/runtime/binding/BindActiveEraIndex.kt +++ b/common/src/main/java/jp/co/soramitsu/common/data/network/runtime/binding/BindActiveEraIndex.kt @@ -1,8 +1,8 @@ package jp.co.soramitsu.common.data.network.runtime.binding -import jp.co.soramitsu.shared_utils.runtime.RuntimeSnapshot -import jp.co.soramitsu.shared_utils.runtime.definitions.types.composite.Struct -import jp.co.soramitsu.shared_utils.runtime.definitions.types.fromHex +import jp.co.soramitsu.fearless_utils.runtime.RuntimeSnapshot +import jp.co.soramitsu.fearless_utils.runtime.definitions.types.composite.Struct +import jp.co.soramitsu.fearless_utils.runtime.definitions.types.fromHex import java.math.BigInteger /* diff --git a/common/src/main/java/jp/co/soramitsu/common/data/network/runtime/binding/BindingHelpers.kt b/common/src/main/java/jp/co/soramitsu/common/data/network/runtime/binding/BindingHelpers.kt index 39b28512e6..fb819e5573 100644 --- a/common/src/main/java/jp/co/soramitsu/common/data/network/runtime/binding/BindingHelpers.kt +++ b/common/src/main/java/jp/co/soramitsu/common/data/network/runtime/binding/BindingHelpers.kt @@ -1,13 +1,13 @@ package jp.co.soramitsu.common.data.network.runtime.binding import jp.co.soramitsu.core.runtime.storage.returnType -import jp.co.soramitsu.shared_utils.runtime.RuntimeSnapshot -import jp.co.soramitsu.shared_utils.runtime.definitions.types.Type -import jp.co.soramitsu.shared_utils.runtime.definitions.types.composite.Struct -import jp.co.soramitsu.shared_utils.runtime.definitions.types.fromHexOrNull -import jp.co.soramitsu.shared_utils.runtime.metadata.RuntimeMetadata -import jp.co.soramitsu.shared_utils.runtime.metadata.module -import jp.co.soramitsu.shared_utils.runtime.metadata.storage +import jp.co.soramitsu.fearless_utils.runtime.RuntimeSnapshot +import jp.co.soramitsu.fearless_utils.runtime.definitions.types.Type +import jp.co.soramitsu.fearless_utils.runtime.definitions.types.composite.Struct +import jp.co.soramitsu.fearless_utils.runtime.definitions.types.fromHexOrNull +import jp.co.soramitsu.fearless_utils.runtime.metadata.RuntimeMetadata +import jp.co.soramitsu.fearless_utils.runtime.metadata.module +import jp.co.soramitsu.fearless_utils.runtime.metadata.storage import kotlin.contracts.ExperimentalContracts import kotlin.contracts.contract diff --git a/common/src/main/java/jp/co/soramitsu/common/data/network/runtime/binding/Block.kt b/common/src/main/java/jp/co/soramitsu/common/data/network/runtime/binding/Block.kt index e7a7b3c30c..6db7e105fd 100644 --- a/common/src/main/java/jp/co/soramitsu/common/data/network/runtime/binding/Block.kt +++ b/common/src/main/java/jp/co/soramitsu/common/data/network/runtime/binding/Block.kt @@ -1,6 +1,6 @@ package jp.co.soramitsu.common.data.network.runtime.binding -import jp.co.soramitsu.shared_utils.runtime.RuntimeSnapshot +import jp.co.soramitsu.fearless_utils.runtime.RuntimeSnapshot import java.math.BigInteger typealias BlockNumber = BigInteger diff --git a/common/src/main/java/jp/co/soramitsu/common/data/network/runtime/binding/Constants.kt b/common/src/main/java/jp/co/soramitsu/common/data/network/runtime/binding/Constants.kt index 9b5e4b3fe6..b5ac6e389e 100644 --- a/common/src/main/java/jp/co/soramitsu/common/data/network/runtime/binding/Constants.kt +++ b/common/src/main/java/jp/co/soramitsu/common/data/network/runtime/binding/Constants.kt @@ -1,8 +1,8 @@ package jp.co.soramitsu.common.data.network.runtime.binding -import jp.co.soramitsu.shared_utils.runtime.RuntimeSnapshot -import jp.co.soramitsu.shared_utils.runtime.definitions.types.fromByteArrayOrNull -import jp.co.soramitsu.shared_utils.runtime.metadata.module.Constant +import jp.co.soramitsu.fearless_utils.runtime.RuntimeSnapshot +import jp.co.soramitsu.fearless_utils.runtime.definitions.types.fromByteArrayOrNull +import jp.co.soramitsu.fearless_utils.runtime.metadata.module.Constant import java.math.BigInteger @HelperBinding diff --git a/common/src/main/java/jp/co/soramitsu/common/data/network/runtime/binding/Events.kt b/common/src/main/java/jp/co/soramitsu/common/data/network/runtime/binding/Events.kt index f152c5fa76..6b1ebab799 100644 --- a/common/src/main/java/jp/co/soramitsu/common/data/network/runtime/binding/Events.kt +++ b/common/src/main/java/jp/co/soramitsu/common/data/network/runtime/binding/Events.kt @@ -1,8 +1,8 @@ package jp.co.soramitsu.common.data.network.runtime.binding -import jp.co.soramitsu.shared_utils.runtime.definitions.types.composite.DictEnum -import jp.co.soramitsu.shared_utils.runtime.definitions.types.composite.Struct -import jp.co.soramitsu.shared_utils.runtime.definitions.types.generics.GenericEvent +import jp.co.soramitsu.fearless_utils.runtime.definitions.types.composite.DictEnum +import jp.co.soramitsu.fearless_utils.runtime.definitions.types.composite.Struct +import jp.co.soramitsu.fearless_utils.runtime.definitions.types.generics.GenericEvent import java.math.BigInteger class EventRecord(val phase: Phase, val event: E) diff --git a/common/src/main/java/jp/co/soramitsu/common/data/network/runtime/binding/ExtrinsicStatusEvent.kt b/common/src/main/java/jp/co/soramitsu/common/data/network/runtime/binding/ExtrinsicStatusEvent.kt index 8638dee37c..3ebd96f925 100644 --- a/common/src/main/java/jp/co/soramitsu/common/data/network/runtime/binding/ExtrinsicStatusEvent.kt +++ b/common/src/main/java/jp/co/soramitsu/common/data/network/runtime/binding/ExtrinsicStatusEvent.kt @@ -2,11 +2,11 @@ package jp.co.soramitsu.common.data.network.runtime.binding import jp.co.soramitsu.common.utils.index import jp.co.soramitsu.common.utils.system -import jp.co.soramitsu.shared_utils.runtime.RuntimeSnapshot -import jp.co.soramitsu.shared_utils.runtime.definitions.types.fromHex -import jp.co.soramitsu.shared_utils.runtime.definitions.types.generics.GenericEvent -import jp.co.soramitsu.shared_utils.runtime.metadata.event -import jp.co.soramitsu.shared_utils.runtime.metadata.storage +import jp.co.soramitsu.fearless_utils.runtime.RuntimeSnapshot +import jp.co.soramitsu.fearless_utils.runtime.definitions.types.fromHex +import jp.co.soramitsu.fearless_utils.runtime.definitions.types.generics.GenericEvent +import jp.co.soramitsu.fearless_utils.runtime.metadata.event +import jp.co.soramitsu.fearless_utils.runtime.metadata.storage enum class ExtrinsicStatusEvent { SUCCESS, FAILURE diff --git a/common/src/main/java/jp/co/soramitsu/common/data/network/runtime/binding/MultiAddress.kt b/common/src/main/java/jp/co/soramitsu/common/data/network/runtime/binding/MultiAddress.kt index 7328390e6a..1bdf2977f5 100644 --- a/common/src/main/java/jp/co/soramitsu/common/data/network/runtime/binding/MultiAddress.kt +++ b/common/src/main/java/jp/co/soramitsu/common/data/network/runtime/binding/MultiAddress.kt @@ -1,7 +1,7 @@ package jp.co.soramitsu.common.data.network.runtime.binding import jp.co.soramitsu.core.models.MultiAddress -import jp.co.soramitsu.shared_utils.runtime.definitions.types.composite.DictEnum +import jp.co.soramitsu.fearless_utils.runtime.definitions.types.composite.DictEnum fun bindMultiAddress(dynamicInstance: DictEnum.Entry<*>): MultiAddress { return when (dynamicInstance.name) { @@ -14,4 +14,11 @@ fun bindMultiAddress(dynamicInstance: DictEnum.Entry<*>): MultiAddress { } } -fun bindMultiAddressId(dynamicInstance: DictEnum.Entry<*>) = (bindMultiAddress(dynamicInstance) as? MultiAddress.Id)?.value +fun bindMultiAddressId(dynamicInstance: DictEnum.Entry<*>): ByteArray? { + return when (val address = bindMultiAddress(dynamicInstance)) { + is MultiAddress.Id -> address.accountId + is MultiAddress.Address32 -> address.accountId + is MultiAddress.Address20 -> address.accountId + else -> null + } +} diff --git a/common/src/main/java/jp/co/soramitsu/common/data/network/solana/SolanaBalanceSync.kt b/common/src/main/java/jp/co/soramitsu/common/data/network/solana/SolanaBalanceSync.kt new file mode 100644 index 0000000000..1dcb0927bb --- /dev/null +++ b/common/src/main/java/jp/co/soramitsu/common/data/network/solana/SolanaBalanceSync.kt @@ -0,0 +1,234 @@ +package jp.co.soramitsu.common.data.network.solana + +import jp.co.soramitsu.common.model.UniversalWalletEcosystem +import jp.co.soramitsu.common.model.UniversalWalletIndexedAssetBalance +import jp.co.soramitsu.common.model.UniversalWalletRegistry +import kotlinx.coroutines.CancellationException + +class SolanaBalanceSync( + private val client: SolanaIndexerClient +) { + suspend fun balances( + wallet: String, + network: UniversalWalletRegistry.SolanaNetwork = UniversalWalletRegistry.solanaMainnet, + baseUrl: String? = null, + includeTokenMetadata: Boolean = true + ): SolanaBalanceSyncResult { + val resolvedBaseUrl = baseUrl ?: network.indexerBaseUrl + SolanaIndexerRoutes.balancesUrl(wallet, resolvedBaseUrl) + + client.verifyServiceInfo(resolvedBaseUrl) + val response = client.balances(wallet, resolvedBaseUrl) + if (response.wallet != wallet) { + throw SolanaBalanceSyncException(SolanaBalanceSyncException.Code.WALLET_MISMATCH) + } + if (response.syncedAt <= 0) { + throw SolanaBalanceSyncException(SolanaBalanceSyncException.Code.INVALID_SYNC_TIMESTAMP) + } + + val native = nativeBalance(response.native, wallet, network, response.syncedAt) + val normalizedTokens = response.tokens.mapNotNull { tokenBalanceOrNull(it, wallet, network, response.syncedAt) } + val metadataByMint = if (includeTokenMetadata) { + tokenMetadataByMint(normalizedTokens.map { it.source.mint }, resolvedBaseUrl) + } else { + emptyMap() + } + val tokens = normalizedTokens.map { normalized -> + normalized.balance.copy( + symbol = tokenSymbol(normalized.source.mint, metadataByMint[normalized.source.mint]), + name = tokenName(normalized.source.mint, metadataByMint[normalized.source.mint]) + ) + } + val allBalances = listOf(native) + tokens + + return SolanaBalanceSyncResult( + wallet = wallet, + networkId = network.id, + chainId = network.chainId, + syncedAtMillis = response.syncedAt, + nativeBalance = native, + tokenBalances = tokens, + balances = allBalances + ) + } + + private fun nativeBalance( + native: SolanaNativeBalance, + wallet: String, + network: UniversalWalletRegistry.SolanaNetwork, + syncedAtMillis: Long + ): UniversalWalletIndexedAssetBalance { + if (native.type != "native" || + native.mint != network.nativeAsset.id || + !isUnsignedInteger(native.lamports) || + native.decimals !in DECIMAL_RANGE || + !isHumanText(native.uiAmountString, 80) + ) { + throw SolanaBalanceSyncException(SolanaBalanceSyncException.Code.INVALID_NATIVE_BALANCE) + } + + return UniversalWalletIndexedAssetBalance( + accountId = network.id, + ecosystem = UniversalWalletEcosystem.Solana, + chainId = network.chainId, + assetId = network.nativeAsset.id, + amount = native.lamports, + decimals = native.decimals, + isNative = true, + symbol = network.nativeAsset.symbol, + name = network.name, + uiAmountString = native.uiAmountString, + syncedAtMillis = syncedAtMillis + ).requireValid(SolanaBalanceSyncException.Code.INVALID_NATIVE_BALANCE) + } + + private fun tokenBalanceOrNull( + token: SolanaTokenBalance, + wallet: String, + network: UniversalWalletRegistry.SolanaNetwork, + syncedAtMillis: Long + ): NormalizedSolanaTokenBalance? { + if (token.type != "token" || + token.owner != wallet || + token.isNative || + token.program !in SUPPORTED_TOKEN_PROGRAMS || + !isBase58PublicKey(token.accountAddress) || + !isBase58PublicKey(token.mint) || + !isBase58PublicKey(token.programId) || + !isUnsignedInteger(token.amount) || + token.decimals !in DECIMAL_RANGE || + !isHumanText(token.uiAmountString, 80) + ) { + return null + } + + val balance = UniversalWalletIndexedAssetBalance( + accountId = network.id, + ecosystem = UniversalWalletEcosystem.Solana, + chainId = network.chainId, + assetId = token.mint, + amount = token.amount, + decimals = token.decimals, + isNative = false, + symbol = shortenMint(token.mint), + name = token.mint, + uiAmountString = token.uiAmountString, + tokenAccountId = token.accountAddress, + contractAddress = token.mint, + tokenProgram = token.program, + syncedAtMillis = syncedAtMillis + ) + + return if (balance.validationErrors().isEmpty()) { + NormalizedSolanaTokenBalance(source = token, balance = balance) + } else { + null + } + } + + private suspend fun tokenMetadataByMint( + mints: List, + baseUrl: String + ): Map { + val uniqueMints = mints.distinct() + if (uniqueMints.isEmpty()) { + return emptyMap() + } + + val metadata = mutableMapOf() + try { + uniqueMints.chunked(SolanaIndexerRoutes.MAX_METADATA_BATCH_SIZE).forEach { chunk -> + client.tokenMetadataBatch(chunk, baseUrl).tokens.forEach { tokenMetadata -> + if (tokenMetadata.exists && isBase58PublicKey(tokenMetadata.mint)) { + metadata[tokenMetadata.mint] = tokenMetadata + } + } + } + } catch (error: CancellationException) { + throw error + } catch (_: Exception) { + return emptyMap() + } + + return metadata + } + + private fun tokenSymbol(mint: String, metadata: SolanaTokenMetadata?): String { + return safeHumanText(metadata?.symbol, 32) ?: shortenMint(mint) + } + + private fun tokenName(mint: String, metadata: SolanaTokenMetadata?): String { + return safeHumanText(metadata?.name, 96) ?: mint + } + + private fun UniversalWalletIndexedAssetBalance.requireValid( + code: SolanaBalanceSyncException.Code + ): UniversalWalletIndexedAssetBalance { + if (validationErrors().isNotEmpty()) { + throw SolanaBalanceSyncException(code) + } + + return this + } + + private data class NormalizedSolanaTokenBalance( + val source: SolanaTokenBalance, + val balance: UniversalWalletIndexedAssetBalance + ) + + companion object { + private val DECIMAL_RANGE = 0..255 + private val UNSIGNED_INTEGER = Regex("^(0|[1-9][0-9]*)$") + private val BASE58_PUBLIC_KEY = Regex("^[1-9A-HJ-NP-Za-km-z]{32,44}$") + private val CONTROL_CHARACTERS = Regex("[\\p{Cntrl}]") + private val SUPPORTED_TOKEN_PROGRAMS = setOf("spl-token", "token-2022") + + private fun isUnsignedInteger(value: String): Boolean { + return UNSIGNED_INTEGER.matches(value) + } + + private fun isBase58PublicKey(value: String): Boolean { + return BASE58_PUBLIC_KEY.matches(value) + } + + private fun isHumanText(value: String, maxLength: Int): Boolean { + val normalized = value.trim() + return normalized.isNotEmpty() && + normalized.length <= maxLength && + !CONTROL_CHARACTERS.containsMatchIn(normalized) + } + + private fun safeHumanText(value: String?, maxLength: Int): String? { + val normalized = value?.trim().orEmpty() + return normalized.takeIf { isHumanText(it, maxLength) } + } + + private fun shortenMint(mint: String): String { + return if (mint.length <= 12) { + mint + } else { + "${mint.take(4)}...${mint.takeLast(4)}" + } + } + } +} + +data class SolanaBalanceSyncResult( + val wallet: String, + val networkId: String, + val chainId: String, + val syncedAtMillis: Long, + val nativeBalance: UniversalWalletIndexedAssetBalance, + val tokenBalances: List, + val balances: List +) + +class SolanaBalanceSyncException( + val code: Code +) : IllegalArgumentException(code.name) { + enum class Code { + WALLET_MISMATCH, + INVALID_SYNC_TIMESTAMP, + INVALID_NATIVE_BALANCE + } +} diff --git a/common/src/main/java/jp/co/soramitsu/common/data/network/solana/SolanaIndexerApi.kt b/common/src/main/java/jp/co/soramitsu/common/data/network/solana/SolanaIndexerApi.kt new file mode 100644 index 0000000000..2329b935ff --- /dev/null +++ b/common/src/main/java/jp/co/soramitsu/common/data/network/solana/SolanaIndexerApi.kt @@ -0,0 +1,32 @@ +package jp.co.soramitsu.common.data.network.solana + +import retrofit2.http.Body +import retrofit2.http.GET +import retrofit2.http.POST +import retrofit2.http.Url + +interface SolanaIndexerApi { + @GET + suspend fun getServiceInfo(@Url url: String): SolanaIndexerServiceInfo + + @GET + suspend fun getBalances(@Url url: String): SolanaWalletBalancesResponse + + @GET + suspend fun getAssets(@Url url: String): SolanaWalletAssetsResponse + + @GET + suspend fun getState(@Url url: String): SolanaWalletStateResponse + + @GET + suspend fun getTransactions(@Url url: String): SolanaWalletTransactionsResponse + + @GET + suspend fun getTokenMetadata(@Url url: String): SolanaTokenMetadata + + @POST + suspend fun getTokenMetadataBatch( + @Url url: String, + @Body body: SolanaTokenMetadataBatchRequest + ): SolanaTokenMetadataBatchResponse +} diff --git a/common/src/main/java/jp/co/soramitsu/common/data/network/solana/SolanaIndexerClient.kt b/common/src/main/java/jp/co/soramitsu/common/data/network/solana/SolanaIndexerClient.kt new file mode 100644 index 0000000000..1eaaa06971 --- /dev/null +++ b/common/src/main/java/jp/co/soramitsu/common/data/network/solana/SolanaIndexerClient.kt @@ -0,0 +1,93 @@ +package jp.co.soramitsu.common.data.network.solana + +import jp.co.soramitsu.common.model.UniversalWalletRegistry + +interface SolanaIndexerClient { + suspend fun serviceInfo(baseUrl: String? = null): SolanaIndexerServiceInfo + suspend fun verifyServiceInfo(baseUrl: String? = null): SolanaIndexerServiceInfo + suspend fun balances(wallet: String, baseUrl: String? = null): SolanaWalletBalancesResponse + suspend fun assets(wallet: String, baseUrl: String? = null): SolanaWalletAssetsResponse + suspend fun state(wallet: String, baseUrl: String? = null): SolanaWalletStateResponse + + suspend fun transactions( + wallet: String, + baseUrl: String? = null, + before: String? = null, + limit: Int = SolanaIndexerRoutes.DEFAULT_LIMIT + ): SolanaWalletTransactionsResponse + + suspend fun tokenMetadata(mint: String, baseUrl: String? = null): SolanaTokenMetadata + + suspend fun tokenMetadataBatch( + mints: List, + baseUrl: String? = null + ): SolanaTokenMetadataBatchResponse +} + +class SolanaIndexerIdentityException( + val serviceInfo: SolanaIndexerServiceInfo +) : IllegalStateException("unexpected_solana_indexer_service_info") + +class RetrofitSolanaIndexerClient( + private val api: SolanaIndexerApi, + private val defaultBaseUrl: String = UniversalWalletRegistry.SOLANA_INDEXER_BASE_URL +) : SolanaIndexerClient { + + override suspend fun serviceInfo(baseUrl: String?): SolanaIndexerServiceInfo { + return api.getServiceInfo(SolanaIndexerRoutes.serviceInfoUrl(resolveBaseUrl(baseUrl))) + } + + override suspend fun verifyServiceInfo(baseUrl: String?): SolanaIndexerServiceInfo { + val info = serviceInfo(baseUrl) + if (!info.isExpectedSiServiceInfo()) { + throw SolanaIndexerIdentityException(info) + } + return info + } + + override suspend fun balances(wallet: String, baseUrl: String?): SolanaWalletBalancesResponse { + return api.getBalances(SolanaIndexerRoutes.balancesUrl(wallet, resolveBaseUrl(baseUrl))) + } + + override suspend fun assets(wallet: String, baseUrl: String?): SolanaWalletAssetsResponse { + return api.getAssets(SolanaIndexerRoutes.assetsUrl(wallet, resolveBaseUrl(baseUrl))) + } + + override suspend fun state(wallet: String, baseUrl: String?): SolanaWalletStateResponse { + return api.getState(SolanaIndexerRoutes.stateUrl(wallet, resolveBaseUrl(baseUrl))) + } + + override suspend fun transactions( + wallet: String, + baseUrl: String?, + before: String?, + limit: Int + ): SolanaWalletTransactionsResponse { + return api.getTransactions( + SolanaIndexerRoutes.transactionsUrl( + wallet = wallet, + baseUrl = resolveBaseUrl(baseUrl), + before = before, + limit = limit + ) + ) + } + + override suspend fun tokenMetadata(mint: String, baseUrl: String?): SolanaTokenMetadata { + return api.getTokenMetadata(SolanaIndexerRoutes.tokenMetadataUrl(mint, resolveBaseUrl(baseUrl))) + } + + override suspend fun tokenMetadataBatch( + mints: List, + baseUrl: String? + ): SolanaTokenMetadataBatchResponse { + return api.getTokenMetadataBatch( + SolanaIndexerRoutes.tokenMetadataBatchUrl(resolveBaseUrl(baseUrl)), + SolanaIndexerRoutes.tokenMetadataBatchRequest(mints) + ) + } + + private fun resolveBaseUrl(baseUrl: String?): String { + return baseUrl ?: defaultBaseUrl + } +} diff --git a/common/src/main/java/jp/co/soramitsu/common/data/network/solana/SolanaIndexerModels.kt b/common/src/main/java/jp/co/soramitsu/common/data/network/solana/SolanaIndexerModels.kt new file mode 100644 index 0000000000..87dc4452ae --- /dev/null +++ b/common/src/main/java/jp/co/soramitsu/common/data/network/solana/SolanaIndexerModels.kt @@ -0,0 +1,286 @@ +package jp.co.soramitsu.common.data.network.solana + +import com.google.gson.annotations.SerializedName + +data class SolanaIndexerServiceInfo( + @SerializedName("schemaVersion") + val schemaVersion: Int, + @SerializedName("serviceId") + val serviceId: String, + @SerializedName("serviceName") + val serviceName: String, + @SerializedName("ecosystem") + val ecosystem: String, + @SerializedName("chainId") + val chainId: String, + @SerializedName("network") + val network: String, + @SerializedName("publicBaseUrl") + val publicBaseUrl: String, + @SerializedName("readOnly") + val readOnly: Boolean, + @SerializedName("capabilities") + val capabilities: List = emptyList(), + @SerializedName("endpoints") + val endpoints: Map = emptyMap() +) + +fun SolanaIndexerServiceInfo.isExpectedSiServiceInfo(): Boolean { + return schemaVersion == 1 && + serviceId == "si.soramitsu.io" && + ecosystem == "solana" && + chainId == "solana:mainnet" && + publicBaseUrl == "https://si.soramitsu.io" && + readOnly +} + +data class SolanaNativeBalance( + @SerializedName("type") + val type: String = "native", + @SerializedName("mint") + val mint: String = "SOL", + @SerializedName("lamports") + val lamports: String, + @SerializedName("decimals") + val decimals: Int = 9, + @SerializedName("uiAmountString") + val uiAmountString: String +) + +data class SolanaTokenBalance( + @SerializedName("type") + val type: String = "token", + @SerializedName("accountAddress") + val accountAddress: String, + @SerializedName("mint") + val mint: String, + @SerializedName("owner") + val owner: String, + @SerializedName("program") + val program: String, + @SerializedName("programId") + val programId: String, + @SerializedName("amount") + val amount: String, + @SerializedName("decimals") + val decimals: Int, + @SerializedName("uiAmountString") + val uiAmountString: String, + @SerializedName("state") + val state: String? = null, + @SerializedName("isNative") + val isNative: Boolean = false, + @SerializedName("delegatedAmount") + val delegatedAmount: String? = null, + @SerializedName("rentExemptReserve") + val rentExemptReserve: String? = null +) + +data class SolanaWalletBalancesResponse( + @SerializedName("wallet") + val wallet: String, + @SerializedName("native") + val native: SolanaNativeBalance, + @SerializedName("tokens") + val tokens: List = emptyList(), + @SerializedName("total") + val total: Int, + @SerializedName("syncedAt") + val syncedAt: Long +) + +data class SolanaWalletAssetsResponse( + @SerializedName("wallet") + val wallet: String, + @SerializedName("assets") + val assets: List = emptyList(), + @SerializedName("total") + val total: Int, + @SerializedName("syncedAt") + val syncedAt: Long +) + +data class SolanaWalletAsset( + @SerializedName("type") + val type: String, + @SerializedName("mint") + val mint: String, + @SerializedName("lamports") + val lamports: String? = null, + @SerializedName("accountAddress") + val accountAddress: String? = null, + @SerializedName("owner") + val owner: String? = null, + @SerializedName("program") + val program: String? = null, + @SerializedName("programId") + val programId: String? = null, + @SerializedName("amount") + val amount: String? = null, + @SerializedName("decimals") + val decimals: Int, + @SerializedName("uiAmountString") + val uiAmountString: String, + @SerializedName("state") + val state: String? = null, + @SerializedName("isNative") + val isNative: Boolean? = null, + @SerializedName("delegatedAmount") + val delegatedAmount: String? = null, + @SerializedName("rentExemptReserve") + val rentExemptReserve: String? = null +) + +data class SolanaWalletStateResponse( + @SerializedName("wallet") + val wallet: String, + @SerializedName("exists") + val exists: Boolean, + @SerializedName("lamports") + val lamports: String, + @SerializedName("owner") + val owner: String? = null, + @SerializedName("executable") + val executable: Boolean, + @SerializedName("rentEpoch") + val rentEpoch: String? = null, + @SerializedName("dataLength") + val dataLength: Int, + @SerializedName("syncedAt") + val syncedAt: Long +) + +data class SolanaTokenBalanceChange( + @SerializedName("mint") + val mint: String, + @SerializedName("preAmount") + val preAmount: String, + @SerializedName("postAmount") + val postAmount: String, + @SerializedName("amountDelta") + val amountDelta: String, + @SerializedName("decimals") + val decimals: Int, + @SerializedName("uiAmountDeltaString") + val uiAmountDeltaString: String +) + +data class SolanaWalletTransactionRecord( + @SerializedName("signature") + val signature: String, + @SerializedName("slot") + val slot: Long, + @SerializedName("timestamp") + val timestamp: Long, + @SerializedName("status") + val status: String, + @SerializedName("feeLamports") + val feeLamports: String? = null, + @SerializedName("nativeBalanceChangeLamports") + val nativeBalanceChangeLamports: String? = null, + @SerializedName("tokenBalanceChanges") + val tokenBalanceChanges: List = emptyList(), + @SerializedName("programIds") + val programIds: List = emptyList(), + @SerializedName("solswapRoute") + val solswapRoute: String? = null +) + +data class SolanaWalletTransactionsResponse( + @SerializedName("wallet") + val wallet: String, + @SerializedName("before") + val before: String? = null, + @SerializedName("nextBefore") + val nextBefore: String? = null, + @SerializedName("limit") + val limit: Int, + @SerializedName("total") + val total: Int, + @SerializedName("syncedAt") + val syncedAt: Long, + @SerializedName("transactions") + val transactions: List = emptyList() +) + +data class SolanaTokenMetadata( + @SerializedName("mint") + val mint: String, + @SerializedName("exists") + val exists: Boolean, + @SerializedName("program") + val program: String, + @SerializedName("programId") + val programId: String? = null, + @SerializedName("extensions") + val extensions: List? = null, + @SerializedName("transferFeeConfig") + val transferFeeConfig: SolanaTokenTransferFeeConfig? = null, + @SerializedName("transferHook") + val transferHook: SolanaTokenTransferHook? = null, + @SerializedName("decimals") + val decimals: Int? = null, + @SerializedName("supply") + val supply: String? = null, + @SerializedName("uiSupplyString") + val uiSupplyString: String? = null, + @SerializedName("mintAuthority") + val mintAuthority: String? = null, + @SerializedName("freezeAuthority") + val freezeAuthority: String? = null, + @SerializedName("isInitialized") + val isInitialized: Boolean? = null, + @SerializedName("name") + val name: String? = null, + @SerializedName("symbol") + val symbol: String? = null, + @SerializedName("uri") + val uri: String? = null, + @SerializedName("syncedAt") + val syncedAt: Long +) + +data class SolanaTokenTransferFee( + @SerializedName("epoch") + val epoch: String? = null, + @SerializedName("maximumFee") + val maximumFee: String? = null, + @SerializedName("transferFeeBasisPoints") + val transferFeeBasisPoints: Int? = null +) + +data class SolanaTokenTransferFeeConfig( + @SerializedName("transferFeeConfigAuthority") + val transferFeeConfigAuthority: String? = null, + @SerializedName("withdrawWithheldAuthority") + val withdrawWithheldAuthority: String? = null, + @SerializedName("withheldAmount") + val withheldAmount: String? = null, + @SerializedName("olderTransferFee") + val olderTransferFee: SolanaTokenTransferFee? = null, + @SerializedName("newerTransferFee") + val newerTransferFee: SolanaTokenTransferFee? = null +) + +data class SolanaTokenTransferHook( + @SerializedName("authority") + val authority: String? = null, + @SerializedName("programId") + val programId: String? = null, + @SerializedName("extraAccountMetasAddress") + val extraAccountMetasAddress: String? = null +) + +data class SolanaTokenMetadataBatchRequest( + @SerializedName("mints") + val mints: List +) + +data class SolanaTokenMetadataBatchResponse( + @SerializedName("total") + val total: Int, + @SerializedName("syncedAt") + val syncedAt: Long, + @SerializedName("tokens") + val tokens: List = emptyList() +) diff --git a/common/src/main/java/jp/co/soramitsu/common/data/network/solana/SolanaIndexerRoutes.kt b/common/src/main/java/jp/co/soramitsu/common/data/network/solana/SolanaIndexerRoutes.kt new file mode 100644 index 0000000000..516d4f4147 --- /dev/null +++ b/common/src/main/java/jp/co/soramitsu/common/data/network/solana/SolanaIndexerRoutes.kt @@ -0,0 +1,117 @@ +package jp.co.soramitsu.common.data.network.solana + +import jp.co.soramitsu.common.model.UniversalWalletRegistry +import java.net.MalformedURLException +import java.net.URL + +object SolanaIndexerRoutes { + const val DEFAULT_LIMIT = 100 + const val MAX_LIMIT = 250 + const val MAX_METADATA_BATCH_SIZE = 100 + + fun serviceInfoUrl(baseUrl: String = UniversalWalletRegistry.SOLANA_INDEXER_BASE_URL): String { + return "${normalizeBaseUrl(baseUrl)}/api/indexer/v1/service-info" + } + + fun balancesUrl(wallet: String, baseUrl: String = UniversalWalletRegistry.SOLANA_INDEXER_BASE_URL): String { + return accountUrl(baseUrl, wallet, "balances") + } + + fun assetsUrl(wallet: String, baseUrl: String = UniversalWalletRegistry.SOLANA_INDEXER_BASE_URL): String { + return accountUrl(baseUrl, wallet, "assets") + } + + fun stateUrl(wallet: String, baseUrl: String = UniversalWalletRegistry.SOLANA_INDEXER_BASE_URL): String { + return accountUrl(baseUrl, wallet, "state") + } + + fun transactionsUrl( + wallet: String, + baseUrl: String = UniversalWalletRegistry.SOLANA_INDEXER_BASE_URL, + before: String? = null, + limit: Int = DEFAULT_LIMIT + ): String { + if (limit !in 1..MAX_LIMIT) { + throw SolanaIndexerRouteException(ErrorCode.INVALID_LIMIT) + } + + val normalizedWallet = normalizePublicKey(wallet, ErrorCode.INVALID_WALLET) + val url = StringBuilder("${normalizeBaseUrl(baseUrl)}/api/indexer/v1/accounts/$normalizedWallet/txs") + .append("?limit=") + .append(limit) + + before?.let { + url.append("&before=").append(normalizeSignature(it)) + } + + return url.toString() + } + + fun tokenMetadataUrl(mint: String, baseUrl: String = UniversalWalletRegistry.SOLANA_INDEXER_BASE_URL): String { + return "${normalizeBaseUrl(baseUrl)}/api/indexer/v1/tokens/${normalizePublicKey(mint, ErrorCode.INVALID_MINT)}/metadata" + } + + fun tokenMetadataBatchUrl(baseUrl: String = UniversalWalletRegistry.SOLANA_INDEXER_BASE_URL): String { + return "${normalizeBaseUrl(baseUrl)}/api/indexer/v1/tokens/metadata" + } + + fun tokenMetadataBatchRequest(mints: List): SolanaTokenMetadataBatchRequest { + if (mints.isEmpty() || mints.size > MAX_METADATA_BATCH_SIZE) { + throw SolanaIndexerRouteException(ErrorCode.INVALID_MINTS) + } + + return SolanaTokenMetadataBatchRequest( + mints = mints.map { normalizePublicKey(it, ErrorCode.INVALID_MINT) } + ) + } + + fun normalizeBaseUrl(baseUrl: String): String { + val trimmed = baseUrl.trim() + val parsed = try { + URL(trimmed) + } catch (_: MalformedURLException) { + throw SolanaIndexerRouteException(ErrorCode.INVALID_BASE_URL) + } + val isLocal = parsed.host == "localhost" || parsed.host == "127.0.0.1" + + if (parsed.protocol != "https" && !isLocal) { + throw SolanaIndexerRouteException(ErrorCode.INVALID_BASE_URL) + } + + return trimmed.trimEnd('/') + } + + private fun accountUrl(baseUrl: String, wallet: String, section: String): String { + return "${normalizeBaseUrl(baseUrl)}/api/indexer/v1/accounts/${normalizePublicKey(wallet, ErrorCode.INVALID_WALLET)}/$section" + } + + private fun normalizePublicKey(value: String, errorCode: ErrorCode): String { + if (!BASE58_PUBLIC_KEY.matches(value)) { + throw SolanaIndexerRouteException(errorCode) + } + + return value + } + + private fun normalizeSignature(value: String): String { + if (!BASE58_SIGNATURE.matches(value)) { + throw SolanaIndexerRouteException(ErrorCode.INVALID_BEFORE) + } + + return value + } + + class SolanaIndexerRouteException(val code: ErrorCode) : IllegalArgumentException(code.name) + + enum class ErrorCode { + INVALID_BASE_URL, + INVALID_WALLET, + INVALID_MINT, + INVALID_MINTS, + INVALID_BEFORE, + INVALID_LIMIT + } + + private val BASE58_PUBLIC_KEY = Regex("^[1-9A-HJ-NP-Za-km-z]{32,44}$") + private val BASE58_SIGNATURE = Regex("^[1-9A-HJ-NP-Za-km-z]{64,128}$") +} diff --git a/common/src/main/java/jp/co/soramitsu/common/data/network/solana/SolanaRpcApi.kt b/common/src/main/java/jp/co/soramitsu/common/data/network/solana/SolanaRpcApi.kt new file mode 100644 index 0000000000..da11f6b7a9 --- /dev/null +++ b/common/src/main/java/jp/co/soramitsu/common/data/network/solana/SolanaRpcApi.kt @@ -0,0 +1,14 @@ +package jp.co.soramitsu.common.data.network.solana + +import com.google.gson.JsonObject +import retrofit2.http.Body +import retrofit2.http.POST +import retrofit2.http.Url + +interface SolanaRpcApi { + @POST + suspend fun postJsonRpc( + @Url url: String, + @Body body: JsonObject + ): JsonObject +} diff --git a/common/src/main/java/jp/co/soramitsu/common/data/network/solana/SolanaRpcClient.kt b/common/src/main/java/jp/co/soramitsu/common/data/network/solana/SolanaRpcClient.kt new file mode 100644 index 0000000000..eec452fd26 --- /dev/null +++ b/common/src/main/java/jp/co/soramitsu/common/data/network/solana/SolanaRpcClient.kt @@ -0,0 +1,534 @@ +package jp.co.soramitsu.common.data.network.solana + +import com.google.gson.Gson +import com.google.gson.JsonArray +import com.google.gson.JsonElement +import com.google.gson.JsonNull +import com.google.gson.JsonObject +import com.google.gson.JsonPrimitive +import jp.co.soramitsu.common.model.UniversalWalletRegistry +import java.math.BigInteger +import java.net.MalformedURLException +import java.net.URL + +interface SolanaRpcClient { + suspend fun latestBlockhash( + commitment: SolanaRpcCommitment = SolanaRpcCommitment.Confirmed, + rpcUrl: String? = null + ): SolanaLatestBlockhashResponse + + suspend fun feeForMessage( + messageBase64: String, + commitment: SolanaRpcCommitment = SolanaRpcCommitment.Confirmed, + rpcUrl: String? = null + ): SolanaFeeForMessageResponse + + suspend fun minimumBalanceForRentExemption( + dataLength: Int, + commitment: SolanaRpcCommitment = SolanaRpcCommitment.Confirmed, + rpcUrl: String? = null + ): Long + + suspend fun accountExists( + address: String, + commitment: SolanaRpcCommitment = SolanaRpcCommitment.Confirmed, + rpcUrl: String? = null + ): Boolean + + suspend fun simulateTransaction( + transactionBase64: String, + options: SolanaSimulationOptions = SolanaSimulationOptions(), + rpcUrl: String? = null + ): SolanaSimulationResponse + + suspend fun sendRawTransaction( + transactionBase64: String, + options: SolanaBroadcastOptions = SolanaBroadcastOptions(), + rpcUrl: String? = null + ): String +} + +class RetrofitSolanaRpcClient( + private val api: SolanaRpcApi, + private val defaultRpcUrl: String = UniversalWalletRegistry.SOLANA_MAINNET_RPC_URL +) : SolanaRpcClient { + private var nextId = 1L + + init { + SolanaRpcRoutes.normalizeRpcUrl(defaultRpcUrl) + } + + override suspend fun latestBlockhash( + commitment: SolanaRpcCommitment, + rpcUrl: String? + ): SolanaLatestBlockhashResponse { + return request( + method = "getLatestBlockhash", + params = jsonArray(jsonObject("commitment" to commitment.value)), + rpcUrl = rpcUrl, + normalize = ::normalizeLatestBlockhashResponse + ) + } + + override suspend fun feeForMessage( + messageBase64: String, + commitment: SolanaRpcCommitment, + rpcUrl: String? + ): SolanaFeeForMessageResponse { + return request( + method = "getFeeForMessage", + params = jsonArray( + SolanaRpcRoutes.normalizeTransactionBase64(messageBase64), + jsonObject("commitment" to commitment.value) + ), + rpcUrl = rpcUrl, + normalize = ::normalizeFeeForMessageResponse + ) + } + + override suspend fun minimumBalanceForRentExemption( + dataLength: Int, + commitment: SolanaRpcCommitment, + rpcUrl: String? + ): Long { + return request( + method = "getMinimumBalanceForRentExemption", + params = jsonArray( + SolanaRpcRoutes.normalizeDataLength(dataLength), + jsonObject("commitment" to commitment.value) + ), + rpcUrl = rpcUrl + ) { value -> + requireUnsignedLong(value, SolanaRpcException.Code.INVALID_RENT_RESPONSE) + } + } + + override suspend fun accountExists( + address: String, + commitment: SolanaRpcCommitment, + rpcUrl: String? + ): Boolean { + return request( + method = "getAccountInfo", + params = jsonArray( + SolanaRpcRoutes.normalizePublicKey(address), + jsonObject( + "commitment" to commitment.value, + "encoding" to "base64" + ) + ), + rpcUrl = rpcUrl, + normalize = ::normalizeAccountInfoExists + ) + } + + override suspend fun simulateTransaction( + transactionBase64: String, + options: SolanaSimulationOptions, + rpcUrl: String? + ): SolanaSimulationResponse { + if (options.sigVerify && options.replaceRecentBlockhash) { + throw SolanaRpcException(SolanaRpcException.Code.INVALID_SIMULATION_OPTIONS) + } + + return request( + method = "simulateTransaction", + params = jsonArray( + SolanaRpcRoutes.normalizeTransactionBase64(transactionBase64), + jsonObject( + "commitment" to options.commitment.value, + "encoding" to "base64", + "replaceRecentBlockhash" to options.replaceRecentBlockhash, + "sigVerify" to options.sigVerify + ) + ), + rpcUrl = rpcUrl, + normalize = ::normalizeSimulationResponse + ) + } + + override suspend fun sendRawTransaction( + transactionBase64: String, + options: SolanaBroadcastOptions, + rpcUrl: String? + ): String { + val normalizedRetries = options.maxRetries?.also { + if (it !in 0..10) { + throw SolanaRpcException(SolanaRpcException.Code.INVALID_MAX_RETRIES) + } + } + val optionBody = jsonObject( + "encoding" to "base64", + "preflightCommitment" to options.preflightCommitment.value, + "skipPreflight" to options.skipPreflight + ) + if (normalizedRetries != null) { + optionBody.addProperty("maxRetries", normalizedRetries) + } + + return request( + method = "sendTransaction", + params = jsonArray( + SolanaRpcRoutes.normalizeTransactionBase64(transactionBase64), + optionBody + ), + rpcUrl = rpcUrl, + normalize = ::normalizeSignature + ) + } + + private suspend fun request( + method: String, + params: JsonArray, + rpcUrl: String?, + normalize: (JsonElement) -> T + ): T { + val id = nextId++ + val body = jsonObject( + "id" to id, + "jsonrpc" to "2.0", + "method" to method + ) + body.add("params", params) + + val response = api.postJsonRpc(SolanaRpcRoutes.normalizeRpcUrl(rpcUrl ?: defaultRpcUrl), body) + val jsonrpc = response["jsonrpc"]?.takeIf { it.isJsonPrimitive && it.asJsonPrimitive.isString }?.asString + if (jsonrpc != "2.0" || response["id"]?.asLongOrNull() != id) { + throw SolanaRpcException(SolanaRpcException.Code.INVALID_RPC_RESPONSE) + } + response["error"]?.takeUnless { it is JsonNull }?.let { + throw SolanaRpcException( + code = SolanaRpcException.Code.RPC_ERROR, + rpcError = normalizeRpcError(it) + ) + } + val result = response["result"] ?: throw SolanaRpcException(SolanaRpcException.Code.INVALID_RPC_RESPONSE) + + return normalize(result) + } + + private fun normalizeLatestBlockhashResponse(value: JsonElement): SolanaLatestBlockhashResponse { + val objectValue = value.asObjectOrThrow(SolanaRpcException.Code.INVALID_BLOCKHASH_RESPONSE) + return SolanaLatestBlockhashResponse( + context = normalizeContext(objectValue["context"], SolanaRpcException.Code.INVALID_BLOCKHASH_RESPONSE), + value = normalizeLatestBlockhash(objectValue["value"], SolanaRpcException.Code.INVALID_BLOCKHASH_RESPONSE) + ) + } + + private fun normalizeFeeForMessageResponse(value: JsonElement): SolanaFeeForMessageResponse { + val objectValue = value.asObjectOrThrow(SolanaRpcException.Code.INVALID_FEE_RESPONSE) + val feeValue = objectValue["value"] + return SolanaFeeForMessageResponse( + context = normalizeContext(objectValue["context"], SolanaRpcException.Code.INVALID_FEE_RESPONSE), + value = if (feeValue == null || feeValue is JsonNull) { + null + } else { + requireUnsignedLong(feeValue, SolanaRpcException.Code.INVALID_FEE_RESPONSE) + } + ) + } + + private fun normalizeSimulationResponse(value: JsonElement): SolanaSimulationResponse { + val objectValue = value.asObjectOrThrow(SolanaRpcException.Code.INVALID_SIMULATION_RESPONSE) + val simulationValue = objectValue["value"].asObjectOrThrow(SolanaRpcException.Code.INVALID_SIMULATION_RESPONSE) + val logs = simulationValue["logs"]?.takeUnless { it is JsonNull }?.let { logElement -> + if (!logElement.isJsonArray || logElement.asJsonArray.any { !it.isJsonPrimitive || !it.asJsonPrimitive.isString }) { + throw SolanaRpcException(SolanaRpcException.Code.INVALID_SIMULATION_RESPONSE) + } + logElement.asJsonArray.map { it.asString } + } + val unitsConsumed = simulationValue["unitsConsumed"]?.takeUnless { it is JsonNull }?.let { + requireUnsignedLong(it, SolanaRpcException.Code.INVALID_SIMULATION_RESPONSE) + } + val replacementBlockhash = simulationValue["replacementBlockhash"]?.takeUnless { it is JsonNull }?.let { + normalizeLatestBlockhash(it, SolanaRpcException.Code.INVALID_SIMULATION_RESPONSE) + } + + return SolanaSimulationResponse( + context = normalizeContext(objectValue["context"], SolanaRpcException.Code.INVALID_SIMULATION_RESPONSE), + value = SolanaSimulationValue( + errorJson = simulationValue["err"]?.takeUnless { it is JsonNull }?.let { GSON.toJson(it) }, + logs = logs, + replacementBlockhash = replacementBlockhash, + unitsConsumed = unitsConsumed + ) + ) + } + + private fun normalizeAccountInfoExists(value: JsonElement): Boolean { + val objectValue = value.asObjectOrThrow(SolanaRpcException.Code.INVALID_ACCOUNT_INFO_RESPONSE) + normalizeContext(objectValue["context"], SolanaRpcException.Code.INVALID_ACCOUNT_INFO_RESPONSE) + val accountValue = objectValue["value"] ?: throw SolanaRpcException(SolanaRpcException.Code.INVALID_ACCOUNT_INFO_RESPONSE) + + return when { + accountValue is JsonNull -> false + accountValue.isJsonObject -> true + else -> throw SolanaRpcException(SolanaRpcException.Code.INVALID_ACCOUNT_INFO_RESPONSE) + } + } + + private fun normalizeLatestBlockhash(value: JsonElement?, code: SolanaRpcException.Code): SolanaLatestBlockhash { + val objectValue = value.asObjectOrThrow(code) + return SolanaLatestBlockhash( + blockhash = normalizeBase58(objectValue["blockhash"], code), + lastValidBlockHeight = requireUnsignedLong(objectValue["lastValidBlockHeight"], code) + ) + } + + private fun normalizeContext(value: JsonElement?, code: SolanaRpcException.Code): SolanaRpcContext { + val objectValue = value.asObjectOrThrow(code) + val apiVersion = objectValue["apiVersion"]?.takeUnless { it is JsonNull }?.let { + if (!it.isJsonPrimitive || !it.asJsonPrimitive.isString) { + throw SolanaRpcException(code) + } + it.asString + } + return SolanaRpcContext( + apiVersion = apiVersion, + slot = requireUnsignedLong(objectValue["slot"], code) + ) + } + + private fun normalizeSignature(value: JsonElement): String { + if (!value.isJsonPrimitive || !value.asJsonPrimitive.isString || !BASE58_SIGNATURE.matches(value.asString)) { + throw SolanaRpcException(SolanaRpcException.Code.INVALID_SIGNATURE_RESPONSE) + } + + return value.asString + } + + private fun normalizeBase58(value: JsonElement?, code: SolanaRpcException.Code): String { + if (value == null || !value.isJsonPrimitive || !value.asJsonPrimitive.isString || !BASE58_32_TO_64.matches(value.asString)) { + throw SolanaRpcException(code) + } + + return value.asString + } + + private fun requireUnsignedLong(value: JsonElement?, code: SolanaRpcException.Code): Long { + if (value == null || !value.isJsonPrimitive || !value.asJsonPrimitive.isNumber) { + throw SolanaRpcException(code) + } + val text = value.asString + if (!UNSIGNED_INTEGER.matches(text)) { + throw SolanaRpcException(code) + } + val parsed = BigInteger(text) + if (parsed > LONG_MAX) { + throw SolanaRpcException(code) + } + + return parsed.toLong() + } + + private fun normalizeRpcError(value: JsonElement): SolanaJsonRpcErrorPayload { + val objectValue = value.takeIf { it.isJsonObject }?.asJsonObject + return SolanaJsonRpcErrorPayload( + code = objectValue?.get("code")?.asIntOrNull() ?: -1, + message = objectValue?.get("message")?.takeIf { it.isJsonPrimitive && it.asJsonPrimitive.isString }?.asString + ?: "Unknown Solana RPC error", + dataJson = objectValue?.get("data")?.let { GSON.toJson(it) } + ) + } + + private fun JsonElement?.asObjectOrThrow(code: SolanaRpcException.Code): JsonObject { + if (this == null || !isJsonObject) { + throw SolanaRpcException(code) + } + + return asJsonObject + } + + private fun JsonElement.asLongOrNull(): Long? { + if (!isJsonPrimitive || !asJsonPrimitive.isNumber) { + return null + } + val text = asString + if (!UNSIGNED_INTEGER.matches(text)) { + return null + } + val parsed = BigInteger(text) + if (parsed > LONG_MAX) { + return null + } + + return parsed.toLong() + } + + private fun JsonElement.asIntOrNull(): Int? { + if (!isJsonPrimitive || !asJsonPrimitive.isNumber) { + return null + } + val text = asString + if (!SIGNED_INTEGER.matches(text)) { + return null + } + + return runCatching { BigInteger(text).intValueExact() }.getOrNull() + } + + private fun jsonArray(vararg values: Any): JsonArray { + return JsonArray().apply { + values.forEach { add(toJsonElement(it)) } + } + } + + private fun jsonObject(vararg values: Pair): JsonObject { + return JsonObject().apply { + values.forEach { (key, value) -> add(key, toJsonElement(value)) } + } + } + + private fun toJsonElement(value: Any): JsonElement { + return when (value) { + is Boolean -> JsonPrimitive(value) + is Int -> JsonPrimitive(value) + is Long -> JsonPrimitive(value) + is String -> JsonPrimitive(value) + is JsonElement -> value + else -> throw IllegalArgumentException("Unsupported Solana RPC JSON value") + } + } + + private companion object { + val LONG_MAX = BigInteger.valueOf(Long.MAX_VALUE) + val GSON = Gson() + val UNSIGNED_INTEGER = Regex("^(0|[1-9][0-9]*)$") + val SIGNED_INTEGER = Regex("^-?(0|[1-9][0-9]*)$") + val BASE58_32_TO_64 = Regex("^[1-9A-HJ-NP-Za-km-z]{32,64}$") + val BASE58_SIGNATURE = Regex("^[1-9A-HJ-NP-Za-km-z]{64,128}$") + } +} + +object SolanaRpcRoutes { + private const val MAX_TRANSACTION_BASE64_LENGTH = 1_000_000 + private const val MAX_RENT_DATA_LENGTH = 10_000_000 + + fun normalizeRpcUrl(rpcUrl: String): String { + val trimmed = rpcUrl.trim() + val parsed = try { + URL(trimmed) + } catch (_: MalformedURLException) { + throw SolanaRpcException(SolanaRpcException.Code.INVALID_RPC_URL) + } + val isLocal = parsed.host == "localhost" || parsed.host == "127.0.0.1" + if (parsed.protocol != "https" && !isLocal) { + throw SolanaRpcException(SolanaRpcException.Code.INVALID_RPC_URL) + } + + return trimmed + .substringBefore("#") + .substringBefore("?") + .trimEnd('/') + } + + fun normalizeTransactionBase64(transactionBase64: String): String { + if (transactionBase64.isEmpty() || + transactionBase64.length > MAX_TRANSACTION_BASE64_LENGTH || + transactionBase64.length % 4 != 0 || + !BASE64_TRANSACTION.matches(transactionBase64) + ) { + throw SolanaRpcException(SolanaRpcException.Code.INVALID_TRANSACTION) + } + + return transactionBase64 + } + + fun normalizeDataLength(dataLength: Int): Int { + if (dataLength !in 0..MAX_RENT_DATA_LENGTH) { + throw SolanaRpcException(SolanaRpcException.Code.INVALID_DATA_LENGTH) + } + + return dataLength + } + + fun normalizePublicKey(publicKey: String): String { + val trimmed = publicKey.trim() + if (!BASE58_PUBLIC_KEY.matches(trimmed)) { + throw SolanaRpcException(SolanaRpcException.Code.INVALID_ACCOUNT_ADDRESS) + } + + return trimmed + } + + private val BASE64_TRANSACTION = Regex("^(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=)?$") + private val BASE58_PUBLIC_KEY = Regex("^[1-9A-HJ-NP-Za-km-z]{32,44}$") +} + +enum class SolanaRpcCommitment(val value: String) { + Processed("processed"), + Confirmed("confirmed"), + Finalized("finalized") +} + +data class SolanaBroadcastOptions( + val maxRetries: Int? = null, + val preflightCommitment: SolanaRpcCommitment = SolanaRpcCommitment.Confirmed, + val skipPreflight: Boolean = false +) + +data class SolanaSimulationOptions( + val commitment: SolanaRpcCommitment = SolanaRpcCommitment.Confirmed, + val replaceRecentBlockhash: Boolean = true, + val sigVerify: Boolean = false +) + +data class SolanaRpcContext( + val apiVersion: String? = null, + val slot: Long +) + +data class SolanaLatestBlockhash( + val blockhash: String, + val lastValidBlockHeight: Long +) + +data class SolanaLatestBlockhashResponse( + val context: SolanaRpcContext, + val value: SolanaLatestBlockhash +) + +data class SolanaFeeForMessageResponse( + val context: SolanaRpcContext, + val value: Long? +) + +data class SolanaSimulationValue( + val errorJson: String?, + val logs: List?, + val replacementBlockhash: SolanaLatestBlockhash?, + val unitsConsumed: Long? +) + +data class SolanaSimulationResponse( + val context: SolanaRpcContext, + val value: SolanaSimulationValue +) + +data class SolanaJsonRpcErrorPayload( + val code: Int, + val message: String, + val dataJson: String? = null +) + +class SolanaRpcException( + val code: Code, + val rpcError: SolanaJsonRpcErrorPayload? = null +) : IllegalArgumentException(code.name) { + enum class Code { + INVALID_RPC_URL, + INVALID_ACCOUNT_ADDRESS, + INVALID_TRANSACTION, + INVALID_MAX_RETRIES, + INVALID_DATA_LENGTH, + INVALID_SIMULATION_OPTIONS, + INVALID_RPC_RESPONSE, + RPC_ERROR, + INVALID_BLOCKHASH_RESPONSE, + INVALID_FEE_RESPONSE, + INVALID_RENT_RESPONSE, + INVALID_ACCOUNT_INFO_RESPONSE, + INVALID_SIMULATION_RESPONSE, + INVALID_SIGNATURE_RESPONSE + } +} diff --git a/common/src/main/java/jp/co/soramitsu/common/data/network/solana/SolanaSendService.kt b/common/src/main/java/jp/co/soramitsu/common/data/network/solana/SolanaSendService.kt new file mode 100644 index 0000000000..82d03c8d6d --- /dev/null +++ b/common/src/main/java/jp/co/soramitsu/common/data/network/solana/SolanaSendService.kt @@ -0,0 +1,421 @@ +package jp.co.soramitsu.common.data.network.solana + +import jp.co.soramitsu.common.model.UniversalWalletDerivationPaths +import jp.co.soramitsu.common.model.UniversalWalletRegistry +import jp.co.soramitsu.common.utils.SolanaKeyDerivation +import jp.co.soramitsu.common.utils.SolanaTransactionSigner +import java.math.BigInteger + +class SolanaSendService( + private val rpcClient: SolanaRpcClient, + private val balanceProvider: SolanaSendBalanceProvider? = null +) { + suspend fun prepare(request: SolanaSendRequest): SolanaPreparedTransferTransaction { + SolanaTransferTransactionBuilder.requireLamports(request.lamports) + SolanaTransferTransactionBuilder.normalizeRecipientAddress(request.recipientAddress) + + val account = try { + SolanaKeyDerivation.deriveAccount( + mnemonic = request.mnemonic, + passphrase = request.passphrase, + derivationPath = request.derivationPath + ) + } catch (error: IllegalArgumentException) { + throw SolanaSendServiceException(SolanaSendServiceException.Code.INVALID_ACCOUNT) + } + + val blockhash = rpcClient.latestBlockhash( + commitment = request.commitment, + rpcUrl = request.rpcUrl + ) + val unsigned = SolanaTransferTransactionBuilder.buildNativeTransfer( + senderAddress = account.address, + recipientAddress = request.recipientAddress, + lamports = request.lamports, + recentBlockhash = blockhash.value.blockhash + ) + val fee = rpcClient + .feeForMessage( + messageBase64 = unsigned.messageBase64, + commitment = request.commitment, + rpcUrl = request.rpcUrl + ) + .value + ?: throw SolanaSendServiceException(SolanaSendServiceException.Code.FEE_UNAVAILABLE) + if (request.validateBalance) { + requireSufficientSolBalance( + wallet = account.address, + requiredLamports = BigInteger.valueOf(request.lamports).add(BigInteger.valueOf(fee)) + ) + } + val signed = SolanaTransactionSigner.signSerializedTransaction( + mnemonic = request.mnemonic, + transactionBase64 = unsigned.transactionBase64, + expectedSigner = account.address, + derivationPath = request.derivationPath, + passphrase = request.passphrase + ) + val simulation = if (request.simulateBeforeSend) { + rpcClient.simulateTransaction( + transactionBase64 = signed.signedTransactionBase64, + options = request.simulationOptions, + rpcUrl = request.rpcUrl + ).also { + if (it.value.errorJson != null) { + throw SolanaSendServiceException(SolanaSendServiceException.Code.SIMULATION_FAILED) + } + } + } else { + null + } + + return SolanaPreparedTransferTransaction( + blockhash = blockhash, + feeLamports = fee, + signed = signed, + simulation = simulation, + unsigned = unsigned + ) + } + + suspend fun send(request: SolanaSendRequest): SolanaSentTransferTransaction { + val prepared = prepare(request) + val signature = rpcClient.sendRawTransaction( + transactionBase64 = prepared.signed.signedTransactionBase64, + options = request.broadcastOptions, + rpcUrl = request.rpcUrl + ) + + if (signature != prepared.signed.signatureBase58) { + throw SolanaSendServiceException(SolanaSendServiceException.Code.BROADCAST_SIGNATURE_MISMATCH) + } + + return SolanaSentTransferTransaction( + prepared = prepared, + signature = signature + ) + } + + suspend fun prepareTokenTransfer(request: SolanaTokenSendRequest): SolanaPreparedTokenTransferTransaction { + val account = try { + SolanaKeyDerivation.deriveAccount( + mnemonic = request.mnemonic, + passphrase = request.passphrase, + derivationPath = request.derivationPath + ) + } catch (error: IllegalArgumentException) { + throw SolanaSendServiceException(SolanaSendServiceException.Code.INVALID_ACCOUNT) + } + + validateToken2022TransferPolicy(request) + + val blockhash = rpcClient.latestBlockhash( + commitment = request.commitment, + rpcUrl = request.rpcUrl + ) + val destinationAssociatedTokenAccount = request.destinationWalletAddress?.let { + val associated = SolanaTransferTransactionBuilder.deriveAssociatedTokenAccountAddress( + walletAddress = it, + mintAddress = request.mintAddress, + tokenProgram = request.tokenProgram + ) + request.destinationTokenAccount + ?.takeIf { value -> value.isNotBlank() } + ?.trim() + ?.takeUnless { value -> value == associated } + ?.let { + throw SolanaTransferTransactionException( + SolanaTransferTransactionException.Code.INVALID_ASSOCIATED_TOKEN_ACCOUNT + ) + } + associated + } + val destinationAssociatedTokenAccountExists = destinationAssociatedTokenAccount?.let { + rpcClient.accountExists( + address = it, + commitment = request.commitment, + rpcUrl = request.rpcUrl + ) + } ?: false + val unsigned = SolanaTransferTransactionBuilder.buildTokenSendChecked( + ownerAddress = account.address, + sourceTokenAccount = request.sourceTokenAccount, + destinationTokenAccount = if (destinationAssociatedTokenAccountExists) { + destinationAssociatedTokenAccount + } else { + request.destinationTokenAccount + }, + mintAddress = request.mintAddress, + rawAmount = request.rawAmount, + decimals = request.decimals, + recentBlockhash = blockhash.value.blockhash, + tokenProgram = request.tokenProgram, + extraAccounts = request.extraAccounts, + destinationWalletAddress = if (destinationAssociatedTokenAccountExists) { + null + } else { + request.destinationWalletAddress + }, + associatedTokenAccountInstruction = request.associatedTokenAccountInstruction + ) + val fee = rpcClient + .feeForMessage( + messageBase64 = unsigned.messageBase64, + commitment = request.commitment, + rpcUrl = request.rpcUrl + ) + .value + ?: throw SolanaSendServiceException(SolanaSendServiceException.Code.FEE_UNAVAILABLE) + if (request.validateBalance) { + val rentLamports = if (unsigned.createsDestinationAssociatedTokenAccount && balanceProvider != null) { + rpcClient.minimumBalanceForRentExemption( + dataLength = SOLANA_TOKEN_ACCOUNT_DATA_LENGTH, + commitment = request.commitment, + rpcUrl = request.rpcUrl + ) + } else { + 0L + } + requireSufficientTokenTransferBalance( + wallet = account.address, + request = request, + requiredNativeLamports = BigInteger.valueOf(fee).add(BigInteger.valueOf(rentLamports)) + ) + } + val signed = SolanaTransactionSigner.signSerializedTransaction( + mnemonic = request.mnemonic, + transactionBase64 = unsigned.transactionBase64, + expectedSigner = account.address, + derivationPath = request.derivationPath, + passphrase = request.passphrase + ) + val simulation = if (request.simulateBeforeSend) { + rpcClient.simulateTransaction( + transactionBase64 = signed.signedTransactionBase64, + options = request.simulationOptions, + rpcUrl = request.rpcUrl + ).also { + if (it.value.errorJson != null) { + throw SolanaSendServiceException(SolanaSendServiceException.Code.SIMULATION_FAILED) + } + } + } else { + null + } + + return SolanaPreparedTokenTransferTransaction( + blockhash = blockhash, + feeLamports = fee, + signed = signed, + simulation = simulation, + unsigned = unsigned + ) + } + + suspend fun sendTokenTransfer(request: SolanaTokenSendRequest): SolanaSentTokenTransferTransaction { + val prepared = prepareTokenTransfer(request) + val signature = rpcClient.sendRawTransaction( + transactionBase64 = prepared.signed.signedTransactionBase64, + options = request.broadcastOptions, + rpcUrl = request.rpcUrl + ) + + if (signature != prepared.signed.signatureBase58) { + throw SolanaSendServiceException(SolanaSendServiceException.Code.BROADCAST_SIGNATURE_MISMATCH) + } + + return SolanaSentTokenTransferTransaction( + prepared = prepared, + signature = signature + ) + } + + private suspend fun requireSufficientSolBalance( + wallet: String, + requiredLamports: BigInteger + ) { + val balances = balanceProvider?.balances(wallet) ?: return + val available = balances.nativeBalance.amount.toUnsignedBigIntegerOrNull() + ?: throw SolanaSendServiceException(SolanaSendServiceException.Code.INSUFFICIENT_SOL_BALANCE) + + if (available < requiredLamports) { + throw SolanaSendServiceException(SolanaSendServiceException.Code.INSUFFICIENT_SOL_BALANCE) + } + } + + private suspend fun requireSufficientTokenTransferBalance( + wallet: String, + request: SolanaTokenSendRequest, + requiredNativeLamports: BigInteger + ) { + val balances = balanceProvider?.balances(wallet) ?: return + val availableNative = balances.nativeBalance.amount.toUnsignedBigIntegerOrNull() + ?: throw SolanaSendServiceException(SolanaSendServiceException.Code.INSUFFICIENT_SOL_BALANCE) + if (availableNative < requiredNativeLamports) { + throw SolanaSendServiceException(SolanaSendServiceException.Code.INSUFFICIENT_SOL_BALANCE) + } + + val tokenBalance = balances.tokenBalances.firstOrNull { + it.tokenAccountId == request.sourceTokenAccount && it.contractAddress == request.mintAddress + } ?: throw SolanaSendServiceException(SolanaSendServiceException.Code.INSUFFICIENT_TOKEN_BALANCE) + if (tokenBalance.decimals != request.decimals) { + throw SolanaSendServiceException(SolanaSendServiceException.Code.TOKEN_BALANCE_MISMATCH) + } + + val availableToken = tokenBalance.amount.toUnsignedBigIntegerOrNull() + ?: throw SolanaSendServiceException(SolanaSendServiceException.Code.INSUFFICIENT_TOKEN_BALANCE) + if (availableToken < BigInteger.valueOf(request.rawAmount)) { + throw SolanaSendServiceException(SolanaSendServiceException.Code.INSUFFICIENT_TOKEN_BALANCE) + } + } + + private fun validateToken2022TransferPolicy(request: SolanaTokenSendRequest) { + val metadata = request.tokenMetadata ?: return + if (!metadata.exists || metadata.mint != request.mintAddress) { + throw SolanaSendServiceException(SolanaSendServiceException.Code.TOKEN_METADATA_MISMATCH) + } + + val expectedProgramName = when (request.tokenProgram) { + SolanaTokenProgram.SplToken -> "spl-token" + SolanaTokenProgram.Token2022 -> "token-2022" + } + if (metadata.program != expectedProgramName || metadata.programId?.let { it != request.tokenProgram.programAddress } == true) { + throw SolanaSendServiceException(SolanaSendServiceException.Code.TOKEN_METADATA_MISMATCH) + } + if (request.tokenProgram != SolanaTokenProgram.Token2022) { + return + } + + val extensions = metadata.extensions.orEmpty().toSet() + if ("nonTransferable" in extensions) { + throw SolanaSendServiceException(SolanaSendServiceException.Code.UNSUPPORTED_TOKEN_2022_EXTENSION) + } + if ((metadata.transferFeeConfig != null || "transferFeeConfig" in extensions) && !request.acknowledgeToken2022TransferFee) { + throw SolanaSendServiceException(SolanaSendServiceException.Code.TOKEN_2022_TRANSFER_FEE_NOT_ACKNOWLEDGED) + } + if ((metadata.transferHook != null || "transferHook" in extensions) && request.extraAccounts.isEmpty()) { + throw SolanaSendServiceException(SolanaSendServiceException.Code.TOKEN_2022_TRANSFER_HOOK_ACCOUNTS_MISSING) + } + } + + private fun String.toUnsignedBigIntegerOrNull(): BigInteger? { + return takeIf { UNSIGNED_INTEGER.matches(it) }?.let(::BigInteger) + } + + private companion object { + const val SOLANA_TOKEN_ACCOUNT_DATA_LENGTH = 165 + val UNSIGNED_INTEGER = Regex("^(0|[1-9][0-9]*)$") + } +} + +data class SolanaSendRequest( + val mnemonic: String, + val recipientAddress: String, + val lamports: Long, + val passphrase: String = "", + val derivationPath: String = UniversalWalletDerivationPaths.SOLANA_DEFAULT, + val commitment: SolanaRpcCommitment = SolanaRpcCommitment.Confirmed, + val rpcUrl: String? = null, + val simulationOptions: SolanaSimulationOptions = SolanaSimulationOptions( + commitment = commitment, + replaceRecentBlockhash = false, + sigVerify = true + ), + val broadcastOptions: SolanaBroadcastOptions = SolanaBroadcastOptions( + preflightCommitment = commitment + ), + val simulateBeforeSend: Boolean = true, + val validateBalance: Boolean = true +) + +data class SolanaPreparedTransferTransaction( + val blockhash: SolanaLatestBlockhashResponse, + val feeLamports: Long, + val signed: SolanaTransactionSigner.SignedSolanaTransaction, + val simulation: SolanaSimulationResponse?, + val unsigned: SolanaUnsignedTransferTransaction +) + +data class SolanaSentTransferTransaction( + val prepared: SolanaPreparedTransferTransaction, + val signature: String +) + +data class SolanaTokenSendRequest( + val mnemonic: String, + val sourceTokenAccount: String, + val destinationTokenAccount: String? = null, + val mintAddress: String, + val rawAmount: Long, + val decimals: Int, + val passphrase: String = "", + val derivationPath: String = UniversalWalletDerivationPaths.SOLANA_DEFAULT, + val commitment: SolanaRpcCommitment = SolanaRpcCommitment.Confirmed, + val rpcUrl: String? = null, + val tokenProgram: SolanaTokenProgram = SolanaTokenProgram.SplToken, + val extraAccounts: List = emptyList(), + val tokenMetadata: SolanaTokenMetadata? = null, + val acknowledgeToken2022TransferFee: Boolean = false, + val destinationWalletAddress: String? = null, + val associatedTokenAccountInstruction: SolanaAssociatedTokenAccountInstruction = SolanaAssociatedTokenAccountInstruction.CreateIdempotent, + val simulationOptions: SolanaSimulationOptions = SolanaSimulationOptions( + commitment = commitment, + replaceRecentBlockhash = false, + sigVerify = true + ), + val broadcastOptions: SolanaBroadcastOptions = SolanaBroadcastOptions( + preflightCommitment = commitment + ), + val simulateBeforeSend: Boolean = true, + val validateBalance: Boolean = true +) + +data class SolanaPreparedTokenTransferTransaction( + val blockhash: SolanaLatestBlockhashResponse, + val feeLamports: Long, + val signed: SolanaTransactionSigner.SignedSolanaTransaction, + val simulation: SolanaSimulationResponse?, + val unsigned: SolanaUnsignedTokenSendTransaction +) + +data class SolanaSentTokenTransferTransaction( + val prepared: SolanaPreparedTokenTransferTransaction, + val signature: String +) + +class SolanaSendServiceException( + val code: Code +) : IllegalArgumentException(code.name) { + enum class Code { + INVALID_ACCOUNT, + FEE_UNAVAILABLE, + SIMULATION_FAILED, + BROADCAST_SIGNATURE_MISMATCH, + INSUFFICIENT_SOL_BALANCE, + INSUFFICIENT_TOKEN_BALANCE, + TOKEN_BALANCE_MISMATCH, + TOKEN_METADATA_MISMATCH, + TOKEN_2022_TRANSFER_FEE_NOT_ACKNOWLEDGED, + TOKEN_2022_TRANSFER_HOOK_ACCOUNTS_MISSING, + UNSUPPORTED_TOKEN_2022_EXTENSION + } +} + +interface SolanaSendBalanceProvider { + suspend fun balances(wallet: String): SolanaBalanceSyncResult +} + +class SolanaBalanceSyncSendBalanceProvider( + private val balanceSync: SolanaBalanceSync, + private val network: UniversalWalletRegistry.SolanaNetwork = UniversalWalletRegistry.solanaMainnet, + private val baseUrl: String? = null +) : SolanaSendBalanceProvider { + override suspend fun balances(wallet: String): SolanaBalanceSyncResult { + return balanceSync.balances( + wallet = wallet, + network = network, + baseUrl = baseUrl, + includeTokenMetadata = false + ) + } +} diff --git a/common/src/main/java/jp/co/soramitsu/common/data/network/solana/SolanaTransactionHistorySync.kt b/common/src/main/java/jp/co/soramitsu/common/data/network/solana/SolanaTransactionHistorySync.kt new file mode 100644 index 0000000000..731f0da50b --- /dev/null +++ b/common/src/main/java/jp/co/soramitsu/common/data/network/solana/SolanaTransactionHistorySync.kt @@ -0,0 +1,217 @@ +package jp.co.soramitsu.common.data.network.solana + +import jp.co.soramitsu.common.model.UniversalWalletEcosystem +import jp.co.soramitsu.common.model.UniversalWalletIndexedOperationType +import jp.co.soramitsu.common.model.UniversalWalletIndexedTransaction +import jp.co.soramitsu.common.model.UniversalWalletIndexedTransactionDirection +import jp.co.soramitsu.common.model.UniversalWalletIndexedTransactionStatus +import jp.co.soramitsu.common.model.UniversalWalletIndexerPageInfo +import jp.co.soramitsu.common.model.UniversalWalletRegistry +import java.lang.Math.multiplyExact +import java.math.BigInteger + +class SolanaTransactionHistorySync( + private val client: SolanaIndexerClient +) { + suspend fun history( + wallet: String, + assetId: String = UniversalWalletRegistry.solanaMainnet.nativeAsset.id, + isNative: Boolean = true, + network: UniversalWalletRegistry.SolanaNetwork = UniversalWalletRegistry.solanaMainnet, + baseUrl: String? = null, + before: String? = null, + limit: Int = SolanaIndexerRoutes.DEFAULT_LIMIT + ): SolanaTransactionHistoryPage { + val resolvedBaseUrl = try { + SolanaIndexerRoutes.normalizeBaseUrl(baseUrl ?: network.indexerBaseUrl) + } catch (_: SolanaIndexerRoutes.SolanaIndexerRouteException) { + throw SolanaTransactionHistoryException(SolanaTransactionHistoryException.Code.INVALID_INPUT) + } + validateInputs(wallet, assetId, isNative, network, resolvedBaseUrl, before, limit) + + client.verifyServiceInfo(resolvedBaseUrl) + val response = client.transactions( + wallet = wallet, + baseUrl = resolvedBaseUrl, + before = before, + limit = limit + ) + if (response.wallet != wallet) { + throw SolanaTransactionHistoryException(SolanaTransactionHistoryException.Code.WALLET_MISMATCH) + } + if (response.syncedAt <= 0) { + throw SolanaTransactionHistoryException(SolanaTransactionHistoryException.Code.INVALID_PAGE) + } + + val transactions = response.transactions.mapNotNull { + normalizeTransaction( + transaction = it, + wallet = wallet, + assetId = assetId, + isNative = isNative, + network = network, + syncedAtMillis = response.syncedAt + ) + } + val nextCursor = response.nextBefore?.takeIf { isBase58Signature(it) } + val pageInfo = UniversalWalletIndexerPageInfo( + nextCursor = nextCursor, + limit = response.limit, + total = response.total, + syncedAtMillis = response.syncedAt + ) + if (pageInfo.validationErrors().isNotEmpty()) { + throw SolanaTransactionHistoryException(SolanaTransactionHistoryException.Code.INVALID_PAGE) + } + + return SolanaTransactionHistoryPage( + wallet = wallet, + networkId = network.id, + chainId = network.chainId, + assetId = assetId, + isNative = isNative, + transactions = transactions, + pageInfo = pageInfo + ) + } + + private fun validateInputs( + wallet: String, + assetId: String, + isNative: Boolean, + network: UniversalWalletRegistry.SolanaNetwork, + baseUrl: String, + before: String?, + limit: Int + ) { + try { + SolanaIndexerRoutes.transactionsUrl(wallet, baseUrl, before, limit) + if (isNative) { + if (assetId != network.nativeAsset.id) { + throw SolanaTransactionHistoryException(SolanaTransactionHistoryException.Code.INVALID_INPUT) + } + } else { + SolanaIndexerRoutes.tokenMetadataUrl(assetId, baseUrl) + } + } catch (error: SolanaTransactionHistoryException) { + throw error + } catch (_: SolanaIndexerRoutes.SolanaIndexerRouteException) { + throw SolanaTransactionHistoryException(SolanaTransactionHistoryException.Code.INVALID_INPUT) + } + } + + private fun normalizeTransaction( + transaction: SolanaWalletTransactionRecord, + wallet: String, + assetId: String, + isNative: Boolean, + network: UniversalWalletRegistry.SolanaNetwork, + syncedAtMillis: Long + ): UniversalWalletIndexedTransaction? { + if (!isBase58Signature(transaction.signature) || transaction.slot < 0) { + return null + } + val amount = transaction.amountDelta(assetId, isNative) ?: return null + if (amount == BigInteger.ZERO) { + return null + } + val fee = unsignedIntegerOrNull(transaction.feeLamports ?: "0") ?: return null + val timestampMillis = try { + multiplyExact(transaction.timestamp, 1000L) + } catch (_: ArithmeticException) { + return null + } + if (timestampMillis <= 0) { + return null + } + val status = when (transaction.status) { + "success" -> UniversalWalletIndexedTransactionStatus.Confirmed + "failed" -> UniversalWalletIndexedTransactionStatus.Failed + else -> return null + } + val direction = if (amount.signum() < 0) { + UniversalWalletIndexedTransactionDirection.Outgoing + } else { + UniversalWalletIndexedTransactionDirection.Incoming + } + val indexed = UniversalWalletIndexedTransaction( + accountId = network.id, + ecosystem = UniversalWalletEcosystem.Solana.id, + chainId = network.chainId, + transactionId = transaction.signature, + status = status, + direction = direction, + operationType = if (transaction.solswapRoute == null) { + UniversalWalletIndexedOperationType.Transfer + } else { + UniversalWalletIndexedOperationType.Swap + }, + timestampMillis = timestampMillis, + amount = amount.abs().toString(), + assetId = assetId, + feeAmount = fee.toString(), + feeAssetId = network.nativeAsset.id, + blockNumber = transaction.slot.toString(), + cursor = transaction.signature, + syncedAtMillis = syncedAtMillis + ) + + return indexed.takeIf { it.validationErrors().isEmpty() } + } + + private fun SolanaWalletTransactionRecord.amountDelta( + assetId: String, + isNative: Boolean + ): BigInteger? { + if (isNative) { + return signedIntegerOrNull(nativeBalanceChangeLamports) + } + + var total: BigInteger? = null + tokenBalanceChanges.forEach { change -> + if (change.mint == assetId) { + val delta = signedIntegerOrNull(change.amountDelta) ?: return@forEach + total = (total ?: BigInteger.ZERO) + delta + } + } + return total + } + + companion object { + private val SIGNED_INTEGER = Regex("^-?(0|[1-9][0-9]*)$") + private val UNSIGNED_INTEGER = Regex("^(0|[1-9][0-9]*)$") + private val BASE58_SIGNATURE = Regex("^[1-9A-HJ-NP-Za-km-z]{64,128}$") + + private fun signedIntegerOrNull(value: String?): BigInteger? { + return value?.takeIf { SIGNED_INTEGER.matches(it) }?.let { BigInteger(it) } + } + + private fun unsignedIntegerOrNull(value: String): BigInteger? { + return value.takeIf { UNSIGNED_INTEGER.matches(it) }?.let { BigInteger(it) } + } + + private fun isBase58Signature(value: String): Boolean { + return BASE58_SIGNATURE.matches(value) + } + } +} + +data class SolanaTransactionHistoryPage( + val wallet: String, + val networkId: String, + val chainId: String, + val assetId: String, + val isNative: Boolean, + val transactions: List, + val pageInfo: UniversalWalletIndexerPageInfo +) + +class SolanaTransactionHistoryException( + val code: Code +) : IllegalArgumentException(code.name) { + enum class Code { + INVALID_INPUT, + WALLET_MISMATCH, + INVALID_PAGE + } +} diff --git a/common/src/main/java/jp/co/soramitsu/common/data/network/solana/SolanaTransferTransactionBuilder.kt b/common/src/main/java/jp/co/soramitsu/common/data/network/solana/SolanaTransferTransactionBuilder.kt new file mode 100644 index 0000000000..1aa3448e89 --- /dev/null +++ b/common/src/main/java/jp/co/soramitsu/common/data/network/solana/SolanaTransferTransactionBuilder.kt @@ -0,0 +1,693 @@ +package jp.co.soramitsu.common.data.network.solana + +import org.bouncycastle.util.encoders.Base64 +import org.bouncycastle.math.ec.rfc8032.Ed25519 +import java.io.ByteArrayOutputStream +import java.math.BigInteger +import java.security.MessageDigest + +object SolanaTransferTransactionBuilder { + const val SYSTEM_PROGRAM_ADDRESS = "11111111111111111111111111111111" + const val SPL_TOKEN_PROGRAM_ADDRESS = "TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA" + const val TOKEN_2022_PROGRAM_ADDRESS = "TokenzQdBNbLqP5VEhdkAS6EPFLC1PHnBqCXEpPxuEb" + const val ASSOCIATED_TOKEN_PROGRAM_ADDRESS = "ATokenGPvbdGVxr1b2hvZbsiqW5xWH25efTNsLJA8knL" + + private const val PUBLIC_KEY_BYTES = 32 + private const val SIGNATURE_BYTES = 64 + private const val SYSTEM_TRANSFER_INSTRUCTION = 2L + private const val TOKEN_TRANSFER_CHECKED_INSTRUCTION = 12 + private const val MAX_EXTRA_TOKEN_ACCOUNTS = 32 + private val PROGRAM_DERIVED_ADDRESS_MARKER = "ProgramDerivedAddress".encodeToByteArray() + private val BASE58_ALPHABET = "123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz".toCharArray() + private val BASE58_INDEX = BASE58_ALPHABET.withIndex().associate { it.value to it.index } + + fun buildNativeTransfer( + senderAddress: String, + recipientAddress: String, + lamports: Long, + recentBlockhash: String + ): SolanaUnsignedTransferTransaction { + requireLamports(lamports) + + val normalizedSender = normalizeSenderAddress(senderAddress) + val normalizedRecipient = normalizeRecipientAddress(recipientAddress) + val normalizedBlockhash = normalizeRecentBlockhash(recentBlockhash) + val sender = decodePublicKey(normalizedSender, SolanaTransferTransactionException.Code.INVALID_SENDER) + val recipient = decodePublicKey(normalizedRecipient, SolanaTransferTransactionException.Code.INVALID_RECIPIENT) + val systemProgram = decodePublicKey(SYSTEM_PROGRAM_ADDRESS, SolanaTransferTransactionException.Code.INVALID_SENDER) + val blockhash = decodePublicKey(normalizedBlockhash, SolanaTransferTransactionException.Code.INVALID_RECENT_BLOCKHASH) + val instructionData = littleEndianU32(SYSTEM_TRANSFER_INSTRUCTION) + littleEndianU64(lamports) + + val message = ByteArrayOutputStream().apply { + write(byteArrayOf(1, 0, 1)) + write(compactU16(3)) + write(sender) + write(recipient) + write(systemProgram) + write(blockhash) + write(compactU16(1)) + write(2) + write(compactU16(2)) + write(byteArrayOf(0, 1)) + write(compactU16(instructionData.size)) + write(instructionData) + }.toByteArray() + + val transaction = transactionWithSingleEmptySignature(message) + + return SolanaUnsignedTransferTransaction( + lamports = lamports, + message = message, + messageBase64 = Base64.toBase64String(message), + recentBlockhash = normalizedBlockhash, + recipientAddress = normalizedRecipient, + senderAddress = normalizedSender, + transaction = transaction, + transactionBase64 = Base64.toBase64String(transaction) + ) + } + + fun buildTokenTransferChecked( + ownerAddress: String, + sourceTokenAccount: String, + destinationTokenAccount: String, + mintAddress: String, + rawAmount: Long, + decimals: Int, + recentBlockhash: String, + tokenProgram: SolanaTokenProgram = SolanaTokenProgram.SplToken, + extraAccounts: List = emptyList() + ): SolanaUnsignedTokenTransferTransaction { + if (rawAmount <= 0) { + throw SolanaTransferTransactionException(SolanaTransferTransactionException.Code.INVALID_TOKEN_AMOUNT) + } + if (decimals !in 0..255) { + throw SolanaTransferTransactionException(SolanaTransferTransactionException.Code.INVALID_TOKEN_DECIMALS) + } + if (extraAccounts.size > MAX_EXTRA_TOKEN_ACCOUNTS) { + throw SolanaTransferTransactionException(SolanaTransferTransactionException.Code.TOO_MANY_EXTRA_ACCOUNTS) + } + + val normalizedOwner = normalizePublicKey(ownerAddress, SolanaTransferTransactionException.Code.INVALID_SENDER) + val normalizedSource = normalizePublicKey(sourceTokenAccount, SolanaTransferTransactionException.Code.INVALID_SOURCE_TOKEN_ACCOUNT) + val normalizedDestination = normalizePublicKey(destinationTokenAccount, SolanaTransferTransactionException.Code.INVALID_DESTINATION_TOKEN_ACCOUNT) + val normalizedMint = normalizePublicKey(mintAddress, SolanaTransferTransactionException.Code.INVALID_MINT) + val normalizedProgram = normalizePublicKey(tokenProgram.programAddress, SolanaTransferTransactionException.Code.INVALID_TOKEN_PROGRAM) + val normalizedBlockhash = normalizeRecentBlockhash(recentBlockhash) + val normalizedExtras = extraAccounts.map { + it.copy(address = normalizePublicKey(it.address, SolanaTransferTransactionException.Code.INVALID_EXTRA_ACCOUNT)) + } + val allKeys = listOf( + normalizedOwner, + normalizedSource, + normalizedDestination, + normalizedMint, + normalizedProgram + ) + normalizedExtras.map { it.address } + if (allKeys.distinct().size != allKeys.size) { + throw SolanaTransferTransactionException(SolanaTransferTransactionException.Code.DUPLICATE_ACCOUNT) + } + + val writableExtras = normalizedExtras.filter { it.isWritable } + val readonlyExtras = normalizedExtras.filterNot { it.isWritable } + val accountKeys = listOf( + normalizedOwner, + normalizedSource, + normalizedDestination + ) + writableExtras.map { it.address } + listOf( + normalizedMint, + normalizedProgram + ) + readonlyExtras.map { it.address } + val accountIndex = accountKeys.withIndex().associate { it.value to it.index } + val instructionAccounts = listOf( + accountIndex.getValue(normalizedSource), + accountIndex.getValue(normalizedMint), + accountIndex.getValue(normalizedDestination), + accountIndex.getValue(normalizedOwner) + ) + normalizedExtras.map { accountIndex.getValue(it.address) } + val instructionData = byteArrayOf(TOKEN_TRANSFER_CHECKED_INSTRUCTION.toByte()) + + littleEndianU64(rawAmount) + + byteArrayOf(decimals.toByte()) + val message = ByteArrayOutputStream().apply { + write(byteArrayOf(1, 0, (2 + readonlyExtras.size).toByte())) + write(compactU16(accountKeys.size)) + accountKeys.forEach { write(decodePublicKey(it, SolanaTransferTransactionException.Code.INVALID_EXTRA_ACCOUNT)) } + write(decodePublicKey(normalizedBlockhash, SolanaTransferTransactionException.Code.INVALID_RECENT_BLOCKHASH)) + write(compactU16(1)) + write(accountIndex.getValue(normalizedProgram)) + write(compactU16(instructionAccounts.size)) + write(instructionAccounts.map { it.toByte() }.toByteArray()) + write(compactU16(instructionData.size)) + write(instructionData) + }.toByteArray() + val transaction = transactionWithSingleEmptySignature(message) + + return SolanaUnsignedTokenTransferTransaction( + decimals = decimals, + destinationTokenAccount = normalizedDestination, + extraAccounts = normalizedExtras, + message = message, + messageBase64 = Base64.toBase64String(message), + mintAddress = normalizedMint, + ownerAddress = normalizedOwner, + rawAmount = rawAmount, + recentBlockhash = normalizedBlockhash, + sourceTokenAccount = normalizedSource, + tokenProgram = tokenProgram, + transaction = transaction, + transactionBase64 = Base64.toBase64String(transaction) + ) + } + + fun buildCreateAssociatedTokenAccount( + fundingAddress: String, + walletAddress: String, + associatedTokenAccount: String? = null, + mintAddress: String, + recentBlockhash: String, + tokenProgram: SolanaTokenProgram = SolanaTokenProgram.SplToken, + instruction: SolanaAssociatedTokenAccountInstruction = SolanaAssociatedTokenAccountInstruction.CreateIdempotent + ): SolanaUnsignedAssociatedTokenAccountCreateTransaction { + val normalizedFunding = normalizePublicKey(fundingAddress, SolanaTransferTransactionException.Code.INVALID_FUNDING_ACCOUNT) + val normalizedWallet = normalizePublicKey(walletAddress, SolanaTransferTransactionException.Code.INVALID_WALLET) + val normalizedMint = normalizePublicKey(mintAddress, SolanaTransferTransactionException.Code.INVALID_MINT) + val normalizedTokenProgram = normalizePublicKey(tokenProgram.programAddress, SolanaTransferTransactionException.Code.INVALID_TOKEN_PROGRAM) + val normalizedAssociatedProgram = normalizePublicKey( + ASSOCIATED_TOKEN_PROGRAM_ADDRESS, + SolanaTransferTransactionException.Code.INVALID_ASSOCIATED_TOKEN_PROGRAM + ) + val derivedAssociated = deriveAssociatedTokenAccountAddress( + normalizedWallet, + normalizedMint, + normalizedTokenProgram, + normalizedAssociatedProgram + ) + val normalizedAssociated = associatedTokenAccount + ?.takeIf { it.isNotBlank() } + ?.let { normalizePublicKey(it, SolanaTransferTransactionException.Code.INVALID_ASSOCIATED_TOKEN_ACCOUNT) } + ?: derivedAssociated + if (normalizedAssociated != derivedAssociated) { + throw SolanaTransferTransactionException(SolanaTransferTransactionException.Code.INVALID_ASSOCIATED_TOKEN_ACCOUNT) + } + val normalizedBlockhash = normalizeRecentBlockhash(recentBlockhash) + if (normalizedAssociated == normalizedFunding || + normalizedAssociated == normalizedWallet || + normalizedAssociated == normalizedMint || + normalizedAssociated == SYSTEM_PROGRAM_ADDRESS || + normalizedAssociated == normalizedTokenProgram || + normalizedAssociated == normalizedAssociatedProgram || + normalizedMint == normalizedWallet || + normalizedMint == normalizedTokenProgram || + normalizedMint == normalizedAssociatedProgram + ) { + throw SolanaTransferTransactionException(SolanaTransferTransactionException.Code.DUPLICATE_ACCOUNT) + } + val readonlyAccounts = listOf( + normalizedWallet, + normalizedMint, + SYSTEM_PROGRAM_ADDRESS, + normalizedTokenProgram, + normalizedAssociatedProgram + ).distinct().filterNot { it == normalizedFunding || it == normalizedAssociated } + val accountKeys = listOf(normalizedFunding, normalizedAssociated) + readonlyAccounts + val accountIndex = accountKeys.withIndex().associate { it.value to it.index } + val instructionAccounts = listOf( + accountIndex.getValue(normalizedFunding), + accountIndex.getValue(normalizedAssociated), + accountIndex.getValue(normalizedWallet), + accountIndex.getValue(normalizedMint), + accountIndex.getValue(SYSTEM_PROGRAM_ADDRESS), + accountIndex.getValue(normalizedTokenProgram) + ) + val message = ByteArrayOutputStream().apply { + write(byteArrayOf(1, 0, readonlyAccounts.size.toByte())) + write(compactU16(accountKeys.size)) + accountKeys.forEach { + write(decodePublicKey(it, SolanaTransferTransactionException.Code.INVALID_ASSOCIATED_TOKEN_ACCOUNT)) + } + write(decodePublicKey(normalizedBlockhash, SolanaTransferTransactionException.Code.INVALID_RECENT_BLOCKHASH)) + write(compactU16(1)) + write(accountIndex.getValue(normalizedAssociatedProgram)) + write(compactU16(instructionAccounts.size)) + write(instructionAccounts.map { it.toByte() }.toByteArray()) + write(compactU16(1)) + write(instruction.discriminator) + }.toByteArray() + val transaction = transactionWithSingleEmptySignature(message) + + return SolanaUnsignedAssociatedTokenAccountCreateTransaction( + associatedTokenAccount = normalizedAssociated, + fundingAddress = normalizedFunding, + instruction = instruction, + message = message, + messageBase64 = Base64.toBase64String(message), + mintAddress = normalizedMint, + recentBlockhash = normalizedBlockhash, + tokenProgram = tokenProgram, + transaction = transaction, + transactionBase64 = Base64.toBase64String(transaction), + walletAddress = normalizedWallet + ) + } + + fun buildTokenSendChecked( + ownerAddress: String, + sourceTokenAccount: String, + destinationTokenAccount: String? = null, + mintAddress: String, + rawAmount: Long, + decimals: Int, + recentBlockhash: String, + tokenProgram: SolanaTokenProgram = SolanaTokenProgram.SplToken, + extraAccounts: List = emptyList(), + destinationWalletAddress: String? = null, + associatedTokenAccountInstruction: SolanaAssociatedTokenAccountInstruction = SolanaAssociatedTokenAccountInstruction.CreateIdempotent + ): SolanaUnsignedTokenSendTransaction { + if (rawAmount <= 0) { + throw SolanaTransferTransactionException(SolanaTransferTransactionException.Code.INVALID_TOKEN_AMOUNT) + } + if (decimals !in 0..255) { + throw SolanaTransferTransactionException(SolanaTransferTransactionException.Code.INVALID_TOKEN_DECIMALS) + } + if (extraAccounts.size > MAX_EXTRA_TOKEN_ACCOUNTS) { + throw SolanaTransferTransactionException(SolanaTransferTransactionException.Code.TOO_MANY_EXTRA_ACCOUNTS) + } + + val normalizedOwner = normalizePublicKey(ownerAddress, SolanaTransferTransactionException.Code.INVALID_SENDER) + val normalizedSource = normalizePublicKey(sourceTokenAccount, SolanaTransferTransactionException.Code.INVALID_SOURCE_TOKEN_ACCOUNT) + val normalizedDestinationWallet = destinationWalletAddress?.let { + normalizePublicKey(it, SolanaTransferTransactionException.Code.INVALID_WALLET) + } + val normalizedMint = normalizePublicKey(mintAddress, SolanaTransferTransactionException.Code.INVALID_MINT) + val normalizedProgram = normalizePublicKey(tokenProgram.programAddress, SolanaTransferTransactionException.Code.INVALID_TOKEN_PROGRAM) + val normalizedAssociatedProgram = normalizePublicKey( + ASSOCIATED_TOKEN_PROGRAM_ADDRESS, + SolanaTransferTransactionException.Code.INVALID_ASSOCIATED_TOKEN_PROGRAM + ) + val derivedAssociated = normalizedDestinationWallet?.let { + deriveAssociatedTokenAccountAddress( + walletAddress = it, + mintAddress = normalizedMint, + tokenProgramAddress = normalizedProgram, + associatedTokenProgramAddress = normalizedAssociatedProgram + ) + } + val normalizedDestination = destinationTokenAccount + ?.takeIf { it.isNotBlank() } + ?.let { normalizePublicKey(it, SolanaTransferTransactionException.Code.INVALID_DESTINATION_TOKEN_ACCOUNT) } + ?: derivedAssociated + ?: throw SolanaTransferTransactionException(SolanaTransferTransactionException.Code.INVALID_DESTINATION_TOKEN_ACCOUNT) + if (derivedAssociated != null && normalizedDestination != derivedAssociated) { + throw SolanaTransferTransactionException(SolanaTransferTransactionException.Code.INVALID_ASSOCIATED_TOKEN_ACCOUNT) + } + val normalizedBlockhash = normalizeRecentBlockhash(recentBlockhash) + val normalizedExtras = extraAccounts.map { + it.copy(address = normalizePublicKey(it.address, SolanaTransferTransactionException.Code.INVALID_EXTRA_ACCOUNT)) + } + + val uniqueRoleKeys = mutableListOf( + normalizedOwner, + normalizedSource, + normalizedDestination + ) + if (normalizedDestinationWallet != null && normalizedDestinationWallet != normalizedOwner) { + uniqueRoleKeys += normalizedDestinationWallet + } + uniqueRoleKeys += normalizedMint + if (normalizedDestinationWallet != null) { + uniqueRoleKeys += SYSTEM_PROGRAM_ADDRESS + } + uniqueRoleKeys += normalizedProgram + if (normalizedDestinationWallet != null) { + uniqueRoleKeys += normalizedAssociatedProgram + } + uniqueRoleKeys += normalizedExtras.map { it.address } + if (uniqueRoleKeys.distinct().size != uniqueRoleKeys.size) { + throw SolanaTransferTransactionException(SolanaTransferTransactionException.Code.DUPLICATE_ACCOUNT) + } + + val writableExtras = normalizedExtras.filter { it.isWritable } + val readonlyExtras = normalizedExtras.filterNot { it.isWritable } + val unsignedWritableAccounts = listOf( + normalizedSource, + normalizedDestination + ) + writableExtras.map { it.address } + val unsignedReadonlyAccounts = mutableListOf() + if (normalizedDestinationWallet != null && normalizedDestinationWallet != normalizedOwner) { + unsignedReadonlyAccounts += normalizedDestinationWallet + } + unsignedReadonlyAccounts += normalizedMint + if (normalizedDestinationWallet != null) { + unsignedReadonlyAccounts += SYSTEM_PROGRAM_ADDRESS + } + unsignedReadonlyAccounts += normalizedProgram + if (normalizedDestinationWallet != null) { + unsignedReadonlyAccounts += normalizedAssociatedProgram + } + unsignedReadonlyAccounts += readonlyExtras.map { it.address } + + val accountKeys = listOf(normalizedOwner) + unsignedWritableAccounts + unsignedReadonlyAccounts + val accountIndex = accountKeys.withIndex().associate { it.value to it.index } + val instructions = mutableListOf() + + if (normalizedDestinationWallet != null) { + instructions += CompiledSolanaInstruction( + programAddress = normalizedAssociatedProgram, + accountAddresses = listOf( + normalizedOwner, + normalizedDestination, + normalizedDestinationWallet, + normalizedMint, + SYSTEM_PROGRAM_ADDRESS, + normalizedProgram + ), + data = byteArrayOf(associatedTokenAccountInstruction.discriminator.toByte()) + ) + } + + instructions += CompiledSolanaInstruction( + programAddress = normalizedProgram, + accountAddresses = listOf( + normalizedSource, + normalizedMint, + normalizedDestination, + normalizedOwner + ) + normalizedExtras.map { it.address }, + data = byteArrayOf(TOKEN_TRANSFER_CHECKED_INSTRUCTION.toByte()) + + littleEndianU64(rawAmount) + + byteArrayOf(decimals.toByte()) + ) + + val message = ByteArrayOutputStream().apply { + write(byteArrayOf(1, 0, unsignedReadonlyAccounts.size.toByte())) + write(compactU16(accountKeys.size)) + accountKeys.forEach { + write(decodePublicKey(it, SolanaTransferTransactionException.Code.INVALID_EXTRA_ACCOUNT)) + } + write(decodePublicKey(normalizedBlockhash, SolanaTransferTransactionException.Code.INVALID_RECENT_BLOCKHASH)) + write(compactU16(instructions.size)) + instructions.forEach { instruction -> + write(accountIndex.getValue(instruction.programAddress)) + write(compactU16(instruction.accountAddresses.size)) + write(instruction.accountAddresses.map { accountIndex.getValue(it).toByte() }.toByteArray()) + write(compactU16(instruction.data.size)) + write(instruction.data) + } + }.toByteArray() + val transaction = transactionWithSingleEmptySignature(message) + + return SolanaUnsignedTokenSendTransaction( + createsDestinationAssociatedTokenAccount = normalizedDestinationWallet != null, + decimals = decimals, + destinationTokenAccount = normalizedDestination, + destinationWalletAddress = normalizedDestinationWallet, + extraAccounts = normalizedExtras, + message = message, + messageBase64 = Base64.toBase64String(message), + mintAddress = normalizedMint, + ownerAddress = normalizedOwner, + rawAmount = rawAmount, + recentBlockhash = normalizedBlockhash, + sourceTokenAccount = normalizedSource, + tokenProgram = tokenProgram, + transaction = transaction, + transactionBase64 = Base64.toBase64String(transaction) + ) + } + + fun deriveAssociatedTokenAccountAddress( + walletAddress: String, + mintAddress: String, + tokenProgram: SolanaTokenProgram = SolanaTokenProgram.SplToken + ): String { + val normalizedWallet = normalizePublicKey(walletAddress, SolanaTransferTransactionException.Code.INVALID_WALLET) + val normalizedMint = normalizePublicKey(mintAddress, SolanaTransferTransactionException.Code.INVALID_MINT) + val normalizedTokenProgram = normalizePublicKey(tokenProgram.programAddress, SolanaTransferTransactionException.Code.INVALID_TOKEN_PROGRAM) + val normalizedAssociatedProgram = normalizePublicKey( + ASSOCIATED_TOKEN_PROGRAM_ADDRESS, + SolanaTransferTransactionException.Code.INVALID_ASSOCIATED_TOKEN_PROGRAM + ) + + return deriveAssociatedTokenAccountAddress( + walletAddress = normalizedWallet, + mintAddress = normalizedMint, + tokenProgramAddress = normalizedTokenProgram, + associatedTokenProgramAddress = normalizedAssociatedProgram + ) + } + + fun requireLamports(lamports: Long): Long { + if (lamports <= 0) { + throw SolanaTransferTransactionException(SolanaTransferTransactionException.Code.INVALID_LAMPORTS) + } + + return lamports + } + + fun normalizeSenderAddress(senderAddress: String): String { + return normalizePublicKey(senderAddress, SolanaTransferTransactionException.Code.INVALID_SENDER) + } + + fun normalizeRecipientAddress(recipientAddress: String): String { + return normalizePublicKey(recipientAddress, SolanaTransferTransactionException.Code.INVALID_RECIPIENT) + } + + fun normalizeRecentBlockhash(recentBlockhash: String): String { + return normalizePublicKey(recentBlockhash, SolanaTransferTransactionException.Code.INVALID_RECENT_BLOCKHASH) + } + + private fun normalizePublicKey(value: String, code: SolanaTransferTransactionException.Code): String { + val normalized = value.trim() + decodePublicKey(normalized, code) + + return normalized + } + + private fun decodePublicKey(value: String, code: SolanaTransferTransactionException.Code): ByteArray { + val decoded = base58Decode(value, code) + if (decoded.size != PUBLIC_KEY_BYTES) { + throw SolanaTransferTransactionException(code) + } + + return decoded + } + + private fun deriveAssociatedTokenAccountAddress( + walletAddress: String, + mintAddress: String, + tokenProgramAddress: String, + associatedTokenProgramAddress: String + ): String { + val seeds = listOf( + decodePublicKey(walletAddress, SolanaTransferTransactionException.Code.INVALID_WALLET), + decodePublicKey(tokenProgramAddress, SolanaTransferTransactionException.Code.INVALID_TOKEN_PROGRAM), + decodePublicKey(mintAddress, SolanaTransferTransactionException.Code.INVALID_MINT) + ) + val programId = decodePublicKey( + associatedTokenProgramAddress, + SolanaTransferTransactionException.Code.INVALID_ASSOCIATED_TOKEN_PROGRAM + ) + + for (bump in 255 downTo 0) { + val candidate = createProgramAddress(seeds + byteArrayOf(bump.toByte()), programId) ?: continue + return base58Encode(candidate) + } + + throw SolanaTransferTransactionException(SolanaTransferTransactionException.Code.INVALID_ASSOCIATED_TOKEN_ACCOUNT) + } + + private fun createProgramAddress(seeds: List, programId: ByteArray): ByteArray? { + val digest = MessageDigest.getInstance("SHA-256") + seeds.forEach { digest.update(it) } + digest.update(programId) + digest.update(PROGRAM_DERIVED_ADDRESS_MARKER) + val candidate = digest.digest() + + return candidate.takeUnless { isOnEd25519Curve(it) } + } + + private fun isOnEd25519Curve(publicKey: ByteArray): Boolean { + return Ed25519.validatePublicKeyFull(publicKey, 0) + } + + private fun base58Encode(bytes: ByteArray): String { + if (bytes.isEmpty()) return "" + + var number = BigInteger(1, bytes) + val radix = BigInteger.valueOf(BASE58_ALPHABET.size.toLong()) + val encoded = StringBuilder() + + while (number > BigInteger.ZERO) { + val divRem = number.divideAndRemainder(radix) + encoded.append(BASE58_ALPHABET[divRem[1].toInt()]) + number = divRem[0] + } + + repeat(bytes.takeWhile { it == 0.toByte() }.size) { + encoded.append(BASE58_ALPHABET[0]) + } + + return encoded.reverse().toString() + } + + private fun base58Decode(value: String, code: SolanaTransferTransactionException.Code): ByteArray { + if (value.isEmpty()) { + throw SolanaTransferTransactionException(code) + } + + var number = BigInteger.ZERO + val radix = BigInteger.valueOf(BASE58_ALPHABET.size.toLong()) + + value.forEach { character -> + val digit = BASE58_INDEX[character] ?: throw SolanaTransferTransactionException(code) + number = number.multiply(radix).add(BigInteger.valueOf(digit.toLong())) + } + + val body = if (number == BigInteger.ZERO) { + ByteArray(0) + } else { + number.toByteArray().dropWhile { it == 0.toByte() }.toByteArray() + } + val leadingZeroes = value.takeWhile { it == BASE58_ALPHABET[0] }.length + + return ByteArray(leadingZeroes) + body + } + + private fun compactU16(value: Int): ByteArray { + require(value in 0..0xffff) { "Compact-u16 value out of range" } + + val result = mutableListOf() + var next = value + + do { + var byte = next and 0x7f + next = next shr 7 + if (next > 0) { + byte = byte or 0x80 + } + result.add(byte.toByte()) + } while (next > 0) + + return result.toByteArray() + } + + private fun littleEndianU32(value: Long): ByteArray { + return ByteArray(4) { index -> ((value ushr (index * 8)) and 0xff).toByte() } + } + + private fun littleEndianU64(value: Long): ByteArray { + return ByteArray(8) { index -> ((value ushr (index * 8)) and 0xff).toByte() } + } + + private fun transactionWithSingleEmptySignature(message: ByteArray): ByteArray { + return ByteArrayOutputStream().apply { + write(compactU16(1)) + write(ByteArray(SIGNATURE_BYTES)) + write(message) + }.toByteArray() + } + + private data class CompiledSolanaInstruction( + val programAddress: String, + val accountAddresses: List, + val data: ByteArray + ) +} + +data class SolanaUnsignedTransferTransaction( + val lamports: Long, + val message: ByteArray, + val messageBase64: String, + val recentBlockhash: String, + val recipientAddress: String, + val senderAddress: String, + val transaction: ByteArray, + val transactionBase64: String +) + +enum class SolanaTokenProgram(val programAddress: String) { + SplToken(SolanaTransferTransactionBuilder.SPL_TOKEN_PROGRAM_ADDRESS), + Token2022(SolanaTransferTransactionBuilder.TOKEN_2022_PROGRAM_ADDRESS) +} + +enum class SolanaAssociatedTokenAccountInstruction(val discriminator: Int) { + Create(0), + CreateIdempotent(1) +} + +data class SolanaTokenTransferExtraAccount( + val address: String, + val isWritable: Boolean = false +) + +data class SolanaUnsignedTokenTransferTransaction( + val decimals: Int, + val destinationTokenAccount: String, + val extraAccounts: List, + val message: ByteArray, + val messageBase64: String, + val mintAddress: String, + val ownerAddress: String, + val rawAmount: Long, + val recentBlockhash: String, + val sourceTokenAccount: String, + val tokenProgram: SolanaTokenProgram, + val transaction: ByteArray, + val transactionBase64: String +) + +data class SolanaUnsignedAssociatedTokenAccountCreateTransaction( + val associatedTokenAccount: String, + val fundingAddress: String, + val instruction: SolanaAssociatedTokenAccountInstruction, + val message: ByteArray, + val messageBase64: String, + val mintAddress: String, + val recentBlockhash: String, + val tokenProgram: SolanaTokenProgram, + val transaction: ByteArray, + val transactionBase64: String, + val walletAddress: String +) + +data class SolanaUnsignedTokenSendTransaction( + val createsDestinationAssociatedTokenAccount: Boolean, + val decimals: Int, + val destinationTokenAccount: String, + val destinationWalletAddress: String?, + val extraAccounts: List, + val message: ByteArray, + val messageBase64: String, + val mintAddress: String, + val ownerAddress: String, + val rawAmount: Long, + val recentBlockhash: String, + val sourceTokenAccount: String, + val tokenProgram: SolanaTokenProgram, + val transaction: ByteArray, + val transactionBase64: String +) + +class SolanaTransferTransactionException( + val code: Code +) : IllegalArgumentException(code.name) { + enum class Code { + INVALID_SENDER, + INVALID_RECIPIENT, + INVALID_RECENT_BLOCKHASH, + INVALID_LAMPORTS, + INVALID_SOURCE_TOKEN_ACCOUNT, + INVALID_DESTINATION_TOKEN_ACCOUNT, + INVALID_MINT, + INVALID_TOKEN_PROGRAM, + INVALID_TOKEN_AMOUNT, + INVALID_TOKEN_DECIMALS, + INVALID_EXTRA_ACCOUNT, + TOO_MANY_EXTRA_ACCOUNTS, + DUPLICATE_ACCOUNT, + INVALID_FUNDING_ACCOUNT, + INVALID_WALLET, + INVALID_ASSOCIATED_TOKEN_ACCOUNT, + INVALID_ASSOCIATED_TOKEN_PROGRAM + } +} diff --git a/common/src/main/java/jp/co/soramitsu/common/data/network/ton/TonIndexerApi.kt b/common/src/main/java/jp/co/soramitsu/common/data/network/ton/TonIndexerApi.kt new file mode 100644 index 0000000000..5958d68a1a --- /dev/null +++ b/common/src/main/java/jp/co/soramitsu/common/data/network/ton/TonIndexerApi.kt @@ -0,0 +1,50 @@ +package jp.co.soramitsu.common.data.network.ton + +import retrofit2.http.Body +import retrofit2.http.GET +import retrofit2.http.POST +import retrofit2.http.Url + +interface TonIndexerApi { + @GET + suspend fun getHealth(@Url url: String): TonHealthStatus + + @GET + suspend fun getContracts(@Url url: String): TonContractsResponse + + @GET + suspend fun getServiceInfo(@Url url: String): TonIndexerServiceInfo + + @GET + suspend fun getBalance(@Url url: String): TonBalanceResponse + + @GET + suspend fun getBalances(@Url url: String): TonBalancesResponse + + @GET + suspend fun getAssets(@Url url: String): TonBalancesResponse + + @GET + suspend fun getState(@Url url: String): TonAccountStateResponse + + @GET + suspend fun getTransactions(@Url url: String): TonTransactionsResponse + + @GET + suspend fun getSwaps(@Url url: String): TonSwapsResponse + + @GET + suspend fun getJettonTransferPayload(@Url url: String): TonJettonTransferPayloadResponse + + @POST + suspend fun runGetMethod( + @Url url: String, + @Body body: TonRunGetMethodRequest + ): TonRunGetMethodResponse + + @POST + suspend fun runGetMethods( + @Url url: String, + @Body body: TonRunGetMethodsRequest + ): TonRunGetMethodsResponse +} diff --git a/common/src/main/java/jp/co/soramitsu/common/data/network/ton/TonIndexerClient.kt b/common/src/main/java/jp/co/soramitsu/common/data/network/ton/TonIndexerClient.kt new file mode 100644 index 0000000000..b34168a8ac --- /dev/null +++ b/common/src/main/java/jp/co/soramitsu/common/data/network/ton/TonIndexerClient.kt @@ -0,0 +1,181 @@ +package jp.co.soramitsu.common.data.network.ton + +import jp.co.soramitsu.common.model.UniversalWalletRegistry + +interface TonIndexerClient { + suspend fun health(baseUrl: String? = null): TonHealthStatus + suspend fun contracts(baseUrl: String? = null): TonContractsResponse + suspend fun serviceInfo(baseUrl: String? = null): TonIndexerServiceInfo + suspend fun verifyServiceInfo(baseUrl: String? = null): TonIndexerServiceInfo + suspend fun balance(address: String, baseUrl: String? = null): TonBalanceResponse + suspend fun balances(address: String, baseUrl: String? = null): TonBalancesResponse + suspend fun assets(address: String, baseUrl: String? = null): TonBalancesResponse + suspend fun state(address: String, baseUrl: String? = null): TonAccountStateResponse + + suspend fun transactions( + address: String, + baseUrl: String? = null, + page: Int = TonIndexerRoutes.DEFAULT_TX_PAGE, + cursorLt: String? = null, + cursorHash: String? = null + ): TonTransactionsResponse + + suspend fun swaps( + address: String, + baseUrl: String? = null, + limit: Int = TonIndexerRoutes.DEFAULT_SWAP_LIMIT, + fromUtime: Long? = null, + toUtime: Long? = null, + payToken: String? = null, + receiveToken: String? = null, + executionType: TonIndexerRoutes.TonSwapExecutionType? = null, + status: TonIndexerRoutes.TonSwapStatus? = null, + includeReverse: Boolean? = null + ): TonSwapsResponse + + suspend fun jettonTransferPayload( + jetton: String, + owner: String, + baseUrl: String? = null + ): TonJettonTransferPayloadResponse + + suspend fun runGetMethod( + address: String, + method: String, + stack: List> = emptyList(), + baseUrl: String? = null + ): TonRunGetMethodResponse + + suspend fun runGetMethods( + calls: List, + baseUrl: String? = null + ): TonRunGetMethodsResponse +} + +class TonIndexerIdentityException( + val serviceInfo: TonIndexerServiceInfo +) : IllegalStateException("unexpected_ton_indexer_service_info") + +class RetrofitTonIndexerClient( + private val api: TonIndexerApi, + private val defaultBaseUrl: String = UniversalWalletRegistry.TON_INDEXER_BASE_URL +) : TonIndexerClient { + + override suspend fun health(baseUrl: String?): TonHealthStatus { + return api.getHealth(TonIndexerRoutes.healthUrl(resolveBaseUrl(baseUrl))) + } + + override suspend fun contracts(baseUrl: String?): TonContractsResponse { + return api.getContracts(TonIndexerRoutes.contractsUrl(resolveBaseUrl(baseUrl))) + } + + override suspend fun serviceInfo(baseUrl: String?): TonIndexerServiceInfo { + return api.getServiceInfo(TonIndexerRoutes.serviceInfoUrl(resolveBaseUrl(baseUrl))) + } + + override suspend fun verifyServiceInfo(baseUrl: String?): TonIndexerServiceInfo { + val info = serviceInfo(baseUrl) + if (!info.isExpectedTiServiceInfo()) { + throw TonIndexerIdentityException(info) + } + return info + } + + override suspend fun balance(address: String, baseUrl: String?): TonBalanceResponse { + return api.getBalance(TonIndexerRoutes.balanceUrl(address, resolveBaseUrl(baseUrl))) + } + + override suspend fun balances(address: String, baseUrl: String?): TonBalancesResponse { + return api.getBalances(TonIndexerRoutes.balancesUrl(address, resolveBaseUrl(baseUrl))) + } + + override suspend fun assets(address: String, baseUrl: String?): TonBalancesResponse { + return api.getAssets(TonIndexerRoutes.assetsUrl(address, resolveBaseUrl(baseUrl))) + } + + override suspend fun state(address: String, baseUrl: String?): TonAccountStateResponse { + return api.getState(TonIndexerRoutes.stateUrl(address, resolveBaseUrl(baseUrl))) + } + + override suspend fun transactions( + address: String, + baseUrl: String?, + page: Int, + cursorLt: String?, + cursorHash: String? + ): TonTransactionsResponse { + return api.getTransactions( + TonIndexerRoutes.transactionsUrl( + address = address, + baseUrl = resolveBaseUrl(baseUrl), + page = page, + cursorLt = cursorLt, + cursorHash = cursorHash + ) + ) + } + + override suspend fun swaps( + address: String, + baseUrl: String?, + limit: Int, + fromUtime: Long?, + toUtime: Long?, + payToken: String?, + receiveToken: String?, + executionType: TonIndexerRoutes.TonSwapExecutionType?, + status: TonIndexerRoutes.TonSwapStatus?, + includeReverse: Boolean? + ): TonSwapsResponse { + return api.getSwaps( + TonIndexerRoutes.swapsUrl( + address = address, + baseUrl = resolveBaseUrl(baseUrl), + limit = limit, + fromUtime = fromUtime, + toUtime = toUtime, + payToken = payToken, + receiveToken = receiveToken, + executionType = executionType, + status = status, + includeReverse = includeReverse + ) + ) + } + + override suspend fun jettonTransferPayload( + jetton: String, + owner: String, + baseUrl: String? + ): TonJettonTransferPayloadResponse { + return api.getJettonTransferPayload( + TonIndexerRoutes.jettonTransferPayloadUrl(jetton, owner, resolveBaseUrl(baseUrl)) + ) + } + + override suspend fun runGetMethod( + address: String, + method: String, + stack: List>, + baseUrl: String? + ): TonRunGetMethodResponse { + return api.runGetMethod( + TonIndexerRoutes.runGetMethodUrl(resolveBaseUrl(baseUrl)), + TonIndexerRoutes.runGetMethodRequest(address, method, stack) + ) + } + + override suspend fun runGetMethods( + calls: List, + baseUrl: String? + ): TonRunGetMethodsResponse { + return api.runGetMethods( + TonIndexerRoutes.runGetMethodsUrl(resolveBaseUrl(baseUrl)), + TonIndexerRoutes.runGetMethodsRequest(calls) + ) + } + + private fun resolveBaseUrl(baseUrl: String?): String { + return baseUrl ?: defaultBaseUrl + } +} diff --git a/common/src/main/java/jp/co/soramitsu/common/data/network/ton/TonIndexerModels.kt b/common/src/main/java/jp/co/soramitsu/common/data/network/ton/TonIndexerModels.kt index 951df8bfc0..d52398113b 100644 --- a/common/src/main/java/jp/co/soramitsu/common/data/network/ton/TonIndexerModels.kt +++ b/common/src/main/java/jp/co/soramitsu/common/data/network/ton/TonIndexerModels.kt @@ -2,26 +2,111 @@ package jp.co.soramitsu.common.data.network.ton import com.google.gson.annotations.SerializedName -data class TonIndexerBalancesResponse( +data class TonHealthStatus( + @SerializedName("lastMasterSeqno") + val lastMasterSeqno: Long? = null, + @SerializedName("indexerLagSec") + val indexerLagSec: Double? = null, + @SerializedName("liteserverPoolStatus") + val liteserverPoolStatus: String? = null +) + +data class TonContractsResponse( + @SerializedName("network") + val network: String? = null, + @SerializedName("count") + val count: Int, + @SerializedName("contracts") + val contracts: Map = emptyMap() +) + +data class TonIndexerServiceInfo( + @SerializedName("schemaVersion") + val schemaVersion: Int, + @SerializedName("serviceId") + val serviceId: String, + @SerializedName("serviceName") + val serviceName: String, + @SerializedName("ecosystem") + val ecosystem: String, + @SerializedName("chainId") + val chainId: String, + @SerializedName("network") + val network: String, + @SerializedName("publicBaseUrl") + val publicBaseUrl: String, + @SerializedName("readOnly") + val readOnly: Boolean, + @SerializedName("capabilities") + val capabilities: List = emptyList(), + @SerializedName("endpoints") + val endpoints: Map = emptyMap() +) + +fun TonIndexerServiceInfo.isExpectedTiServiceInfo(): Boolean { + return schemaVersion == 1 && + serviceId == "ti.soramitsu.io" && + ecosystem == "ton" && + chainId == "ton:mainnet" && + publicBaseUrl == "https://ti.soramitsu.io" && + readOnly +} + +data class TonBalanceResponse( + @SerializedName("ton") + val ton: TonNativeBalance, + @SerializedName("jettons") + val jettons: List = emptyList(), + @SerializedName("confirmed") + val confirmed: Boolean, + @SerializedName("updated_at") + val updatedAt: Long, + @SerializedName("network") + val network: String +) + +data class TonNativeBalance( + @SerializedName("balance") + val balance: String, + @SerializedName("last_tx_lt") + val lastTxLt: String? = null, + @SerializedName("last_tx_hash") + val lastTxHash: String? = null +) + +data class TonJettonBalance( + @SerializedName("master") + val master: String, + @SerializedName("wallet") + val wallet: String, + @SerializedName("balance") + val balance: String, + @SerializedName("decimals") + val decimals: Int? = null, + @SerializedName("symbol") + val symbol: String? = null +) + +data class TonBalancesResponse( @SerializedName("address") val address: String, @SerializedName("ton_raw") val tonRaw: String, @SerializedName("ton") - val ton: String? = null, + val ton: String, @SerializedName("assets") - val assets: List = emptyList(), + val assets: List = emptyList(), @SerializedName("confirmed") - val confirmed: Boolean = true, + val confirmed: Boolean, @SerializedName("updated_at") - val updatedAt: Long = 0, + val updatedAt: Long, @SerializedName("network") - val network: String? = null + val network: String ) -data class TonIndexerAssetBalance( +data class TonAssetBalance( @SerializedName("kind") - val kind: String?, + val kind: String, @SerializedName("symbol") val symbol: String? = null, @SerializedName("address") @@ -36,117 +121,85 @@ data class TonIndexerAssetBalance( val decimals: Int ) -data class TonIndexerStateResponse( +data class TonAccountStateResponse( @SerializedName("address") val address: String, - @SerializedName("last_tx_lt") + @SerializedName("balance") + val balance: String, + @SerializedName("lastTxLt") val lastTxLt: String? = null, - @SerializedName("last_tx_hash") + @SerializedName("lastTxHash") val lastTxHash: String? = null, - @SerializedName("last_seen_utime") - val lastSeenUtime: Long? = null, - @SerializedName("last_confirmed_seqno") - val lastConfirmedSeqno: Int? = null, - @SerializedName("account_state") + @SerializedName("accountState") val accountState: String? = null, - @SerializedName("code_boc") + @SerializedName("codeBoc") val codeBoc: String? = null, - @SerializedName("data_boc") + @SerializedName("dataBoc") val dataBoc: String? = null, - @SerializedName("network") - val network: String? = null -) - -data class TonIndexerRunGetMethodRequest( - @SerializedName("address") - val address: String, - @SerializedName("method") - val method: String, - @SerializedName("stack") - val stack: List> = emptyList() + @SerializedName("updatedAt") + val updatedAt: Long ) -data class TonIndexerRunGetMethodResponse( - @SerializedName("exit_code") - val exitCode: Int, - @SerializedName("gas_used") - val gasUsed: Int, - @SerializedName("stack") - val stack: List> = emptyList() +data class TonJettonTransferPayloadResponse( + @SerializedName("custom_payload") + val customPayload: String? = null, + @SerializedName("state_init") + val stateInit: String? = null ) -data class TonIndexerTransactionsResponse( +data class TonTransactionsResponse( @SerializedName("page") - val page: Int = 1, + val page: Int, @SerializedName("page_size") - val pageSize: Int = 0, + val pageSize: Int, @SerializedName("total_txs") - val totalTxs: Int = 0, + val totalTxs: Int, @SerializedName("total_pages") val totalPages: Int? = null, @SerializedName("total_pages_min") - val totalPagesMin: Int = 0, + val totalPagesMin: Int, @SerializedName("history_complete") - val historyComplete: Boolean = false, + val historyComplete: Boolean, @SerializedName("txs") - val txs: List = emptyList(), + val txs: List = emptyList(), @SerializedName("network") - val network: String? = null + val network: String ) -data class TonIndexerTransaction( +data class TonTransactionEntry( @SerializedName("txId") - val txId: String? = null, + val txId: String, @SerializedName("utime") - val utime: Long = 0, + val utime: Long, @SerializedName("status") - val status: String? = null, + val status: String, @SerializedName("reason") val reason: String? = null, @SerializedName("txType") - val txType: String? = null, + val txType: String, @SerializedName("inSource") val inSource: String? = null, @SerializedName("inValue") val inValue: String? = null, @SerializedName("outCount") - val outCount: Int = 0, + val outCount: Int, @SerializedName("detail") - val detail: TonIndexerTransactionDetail? = null, + val detail: Map = emptyMap(), + @SerializedName("kind") + val kind: String, @SerializedName("actions") - val actions: List = emptyList(), + val actions: List> = emptyList(), @SerializedName("lt") val lt: String, @SerializedName("hash") - val hash: String = "", + val hash: String, @SerializedName("inMessage") - val inMessage: TonIndexerMessage? = null, + val inMessage: TonMessageSummary? = null, @SerializedName("outMessages") - val outMessages: List? = null, - @SerializedName("fee") - val fee: String? = null, - @SerializedName("total_fees") - val totalFees: String? = null -) - -data class TonIndexerTransactionDetail( - @SerializedName("kind") - val kind: String? = null, - @SerializedName("asset") - val asset: String? = null, - @SerializedName("amount") - val amount: String? = null, - @SerializedName("payToken") - val payToken: String? = null, - @SerializedName("receiveToken") - val receiveToken: String? = null, - @SerializedName("payAmount") - val payAmount: String? = null, - @SerializedName("receiveAmount") - val receiveAmount: String? = null + val outMessages: List = emptyList() ) -data class TonIndexerMessage( +data class TonMessageSummary( @SerializedName("source") val source: String? = null, @SerializedName("destination") @@ -154,7 +207,88 @@ data class TonIndexerMessage( @SerializedName("value") val value: String? = null, @SerializedName("op") - val op: Int? = null, + val op: Long? = null, @SerializedName("body") val body: String? = null ) + +data class TonSwapsResponse( + @SerializedName("address") + val address: String, + @SerializedName("swaps") + val swaps: List = emptyList(), + @SerializedName("count") + val count: Int, + @SerializedName("network") + val network: String +) + +data class TonSwapExecution( + @SerializedName("txId") + val txId: String, + @SerializedName("lt") + val lt: String, + @SerializedName("hash") + val hash: String, + @SerializedName("utime") + val utime: Long, + @SerializedName("status") + val status: String, + @SerializedName("reason") + val reason: String? = null, + @SerializedName("payToken") + val payToken: String? = null, + @SerializedName("receiveToken") + val receiveToken: String? = null, + @SerializedName("payAmount") + val payAmount: String? = null, + @SerializedName("receiveAmount") + val receiveAmount: String? = null, + @SerializedName("queryId") + val queryId: String? = null, + @SerializedName("executionType") + val executionType: String? = null +) + +data class TonRunGetMethodRequest( + @SerializedName("address") + val address: String, + @SerializedName("method") + val method: String, + @SerializedName("stack") + val stack: List> = emptyList() +) + +data class TonRunGetMethodsRequest( + @SerializedName("calls") + val calls: List +) + +data class TonRunGetMethodResponse( + @SerializedName("exit_code") + val exitCode: Int, + @SerializedName("gas_used") + val gasUsed: Long, + @SerializedName("stack") + val stack: List> = emptyList() +) + +data class TonRunGetMethodsResponse( + @SerializedName("results") + val results: List = emptyList() +) + +data class TonRunGetMethodBatchResult( + @SerializedName("ok") + val ok: Boolean, + @SerializedName("exit_code") + val exitCode: Int? = null, + @SerializedName("gas_used") + val gasUsed: Long? = null, + @SerializedName("stack") + val stack: List> = emptyList(), + @SerializedName("code") + val code: String? = null, + @SerializedName("error") + val error: String? = null +) diff --git a/common/src/main/java/jp/co/soramitsu/common/data/network/ton/TonIndexerRoutes.kt b/common/src/main/java/jp/co/soramitsu/common/data/network/ton/TonIndexerRoutes.kt new file mode 100644 index 0000000000..5ab66077a4 --- /dev/null +++ b/common/src/main/java/jp/co/soramitsu/common/data/network/ton/TonIndexerRoutes.kt @@ -0,0 +1,243 @@ +package jp.co.soramitsu.common.data.network.ton + +import jp.co.soramitsu.common.model.UniversalWalletRegistry +import java.net.MalformedURLException +import java.net.URLEncoder +import java.net.URL + +object TonIndexerRoutes { + const val DEFAULT_TX_PAGE = 1 + const val DEFAULT_SWAP_LIMIT = 100 + const val MAX_SWAP_LIMIT = 500 + const val MAX_RUN_GET_BATCH_SIZE = 64 + + fun healthUrl(baseUrl: String = UniversalWalletRegistry.TON_INDEXER_BASE_URL): String { + return "${normalizeBaseUrl(baseUrl)}/api/indexer/v1/health" + } + + fun contractsUrl(baseUrl: String = UniversalWalletRegistry.TON_INDEXER_BASE_URL): String { + return "${normalizeBaseUrl(baseUrl)}/api/indexer/v1/contracts" + } + + fun serviceInfoUrl(baseUrl: String = UniversalWalletRegistry.TON_INDEXER_BASE_URL): String { + return "${normalizeBaseUrl(baseUrl)}/api/indexer/v1/service-info" + } + + fun balanceUrl(address: String, baseUrl: String = UniversalWalletRegistry.TON_INDEXER_BASE_URL): String { + return accountUrl(baseUrl, address, "balance") + } + + fun balancesUrl(address: String, baseUrl: String = UniversalWalletRegistry.TON_INDEXER_BASE_URL): String { + return accountUrl(baseUrl, address, "balances") + } + + fun assetsUrl(address: String, baseUrl: String = UniversalWalletRegistry.TON_INDEXER_BASE_URL): String { + return accountUrl(baseUrl, address, "assets") + } + + fun stateUrl(address: String, baseUrl: String = UniversalWalletRegistry.TON_INDEXER_BASE_URL): String { + return accountUrl(baseUrl, address, "state") + } + + fun transactionsUrl( + address: String, + baseUrl: String = UniversalWalletRegistry.TON_INDEXER_BASE_URL, + page: Int = DEFAULT_TX_PAGE, + cursorLt: String? = null, + cursorHash: String? = null + ): String { + if (page < 1) { + throw TonIndexerRouteException(ErrorCode.INVALID_PAGE) + } + if ((cursorLt == null) != (cursorHash == null)) { + throw TonIndexerRouteException(ErrorCode.CURSOR_MISMATCH) + } + + val url = StringBuilder(accountUrl(baseUrl, address, "txs")).append("?page=").append(page) + + if (cursorLt != null && cursorHash != null) { + url.append("&cursor_lt=").append(normalizeLt(cursorLt)) + url.append("&cursor_hash=").append(encodeQueryValue(normalizeHash(cursorHash))) + } + + return url.toString() + } + + fun swapsUrl( + address: String, + baseUrl: String = UniversalWalletRegistry.TON_INDEXER_BASE_URL, + limit: Int = DEFAULT_SWAP_LIMIT, + fromUtime: Long? = null, + toUtime: Long? = null, + payToken: String? = null, + receiveToken: String? = null, + executionType: TonSwapExecutionType? = null, + status: TonSwapStatus? = null, + includeReverse: Boolean? = null + ): String { + if (limit !in 1..MAX_SWAP_LIMIT) { + throw TonIndexerRouteException(ErrorCode.INVALID_LIMIT) + } + if (fromUtime != null && fromUtime < 1 || toUtime != null && toUtime < 1) { + throw TonIndexerRouteException(ErrorCode.INVALID_UTIME) + } + if (fromUtime != null && toUtime != null && fromUtime > toUtime) { + throw TonIndexerRouteException(ErrorCode.INVALID_UTIME_RANGE) + } + + val query = mutableListOf("limit=$limit") + fromUtime?.let { query += "from_utime=$it" } + toUtime?.let { query += "to_utime=$it" } + payToken?.let { query += "pay_token=${encodeQueryValue(normalizeTokenFilter(it))}" } + receiveToken?.let { query += "receive_token=${encodeQueryValue(normalizeTokenFilter(it))}" } + executionType?.let { query += "execution_type=${it.apiValue}" } + status?.let { query += "status=${it.apiValue}" } + includeReverse?.let { query += "include_reverse=${if (it) "true" else "false"}" } + + return "${accountUrl(baseUrl, address, "swaps")}?${query.joinToString("&")}" + } + + fun jettonTransferPayloadUrl( + jetton: String, + owner: String, + baseUrl: String = UniversalWalletRegistry.TON_INDEXER_BASE_URL + ): String { + return "${normalizeBaseUrl(baseUrl)}/api/indexer/v1/jettons/${normalizeAddress(jetton)}/transfer/${normalizeAddress(owner)}/payload" + } + + fun runGetMethodUrl(baseUrl: String = UniversalWalletRegistry.TON_INDEXER_BASE_URL): String { + return "${normalizeBaseUrl(baseUrl)}/api/indexer/v1/runGetMethod" + } + + fun runGetMethodsUrl(baseUrl: String = UniversalWalletRegistry.TON_INDEXER_BASE_URL): String { + return "${normalizeBaseUrl(baseUrl)}/api/indexer/v1/runGetMethods" + } + + fun runGetMethodRequest( + address: String, + method: String, + stack: List> = emptyList() + ): TonRunGetMethodRequest { + return TonRunGetMethodRequest( + address = normalizeAddress(address), + method = normalizeGetterMethod(method), + stack = stack + ) + } + + fun runGetMethodsRequest(calls: List): TonRunGetMethodsRequest { + if (calls.isEmpty() || calls.size > MAX_RUN_GET_BATCH_SIZE) { + throw TonIndexerRouteException(ErrorCode.INVALID_CALLS) + } + + return TonRunGetMethodsRequest(calls) + } + + fun normalizeBaseUrl(baseUrl: String): String { + val trimmed = baseUrl.trim() + val parsed = try { + URL(trimmed) + } catch (_: MalformedURLException) { + throw TonIndexerRouteException(ErrorCode.INVALID_BASE_URL) + } + val isLocal = parsed.host == "localhost" || parsed.host == "127.0.0.1" + + if (parsed.protocol != "https" && !isLocal) { + throw TonIndexerRouteException(ErrorCode.INVALID_BASE_URL) + } + + return trimmed.trimEnd('/') + } + + fun normalizeAddress(address: String): String { + val normalized = address.trim() + val isFriendly = TON_FRIENDLY_ADDRESS.matches(normalized) + val isRaw = TON_RAW_ADDRESS.matches(normalized.lowercase()) + + if (!isFriendly && !isRaw) { + throw TonIndexerRouteException(ErrorCode.INVALID_ADDRESS) + } + + return if (isRaw) normalized.lowercase() else normalized + } + + private fun accountUrl(baseUrl: String, address: String, section: String): String { + return "${normalizeBaseUrl(baseUrl)}/api/indexer/v1/accounts/${normalizeAddress(address)}/$section" + } + + private fun normalizeLt(value: String): String { + val normalized = value.trim() + if (!LT.matches(normalized)) { + throw TonIndexerRouteException(ErrorCode.INVALID_CURSOR) + } + + return normalized + } + + private fun normalizeHash(value: String): String { + val normalized = value.trim() + if (!BASE64_HASH.matches(normalized)) { + throw TonIndexerRouteException(ErrorCode.INVALID_CURSOR) + } + + return normalized + } + + private fun normalizeTokenFilter(value: String): String { + val normalized = value.trim() + if (!TOKEN_FILTER.matches(normalized)) { + throw TonIndexerRouteException(ErrorCode.INVALID_TOKEN_FILTER) + } + + return normalized + } + + private fun normalizeGetterMethod(value: String): String { + val normalized = value.trim() + if (!GETTER_METHOD.matches(normalized)) { + throw TonIndexerRouteException(ErrorCode.INVALID_METHOD) + } + + return normalized + } + + private fun encodeQueryValue(value: String): String { + return URLEncoder.encode(value, Charsets.UTF_8.name()).replace("+", "%20") + } + + class TonIndexerRouteException(val code: ErrorCode) : IllegalArgumentException(code.name) + + enum class ErrorCode { + INVALID_BASE_URL, + INVALID_ADDRESS, + INVALID_PAGE, + CURSOR_MISMATCH, + INVALID_CURSOR, + INVALID_LIMIT, + INVALID_UTIME, + INVALID_UTIME_RANGE, + INVALID_TOKEN_FILTER, + INVALID_METHOD, + INVALID_CALLS + } + + enum class TonSwapExecutionType(val apiValue: String) { + Market("market"), + Limit("limit"), + Twap("twap"), + Unknown("unknown") + } + + enum class TonSwapStatus(val apiValue: String) { + Success("success"), + Failed("failed"), + Pending("pending") + } + + private val TON_FRIENDLY_ADDRESS = Regex("^[A-Za-z0-9_-]{48}$") + private val TON_RAW_ADDRESS = Regex("^(?:-1|0):[0-9a-f]{64}$") + private val LT = Regex("^\\d+$") + private val BASE64_HASH = Regex("^[A-Za-z0-9_+/=-]{40,64}$") + private val TOKEN_FILTER = Regex("^[A-Za-z0-9._:-]{1,32}$") + private val GETTER_METHOD = Regex("^[A-Za-z_][A-Za-z0-9_]{0,63}$") +} diff --git a/common/src/main/java/jp/co/soramitsu/common/data/network/ton/TonRuntimeIndexerModels.kt b/common/src/main/java/jp/co/soramitsu/common/data/network/ton/TonRuntimeIndexerModels.kt new file mode 100644 index 0000000000..77a4264c62 --- /dev/null +++ b/common/src/main/java/jp/co/soramitsu/common/data/network/ton/TonRuntimeIndexerModels.kt @@ -0,0 +1,122 @@ +package jp.co.soramitsu.common.data.network.ton + +import com.google.gson.annotations.SerializedName + +data class TonIndexerBalancesResponse( + @SerializedName("address") + val address: String = "", + @SerializedName("ton_raw") + val tonRaw: String = "0", + @SerializedName("assets") + val assets: List = emptyList(), + @SerializedName("updated_at") + val updatedAt: Long = 0L +) + +data class TonIndexerAssetBalance( + @SerializedName("kind") + val kind: String = "", + @SerializedName("symbol") + val symbol: String? = null, + @SerializedName("address") + val address: String? = null, + @SerializedName("wallet") + val wallet: String? = null, + @SerializedName("balance_raw") + val balanceRaw: String = "0", + @SerializedName("decimals") + val decimals: Int = 0 +) + +data class TonIndexerStateResponse( + @SerializedName("address") + val address: String = "", + @SerializedName("accountState") + val accountState: String? = null, + @SerializedName("lastSeenUtime") + val lastSeenUtime: Long? = null, + @SerializedName("lastConfirmedSeqno") + val lastConfirmedSeqno: Int? = null +) + +data class TonIndexerRunGetMethodRequest( + @SerializedName("address") + val address: String, + @SerializedName("method") + val method: String, + @SerializedName("stack") + val stack: List> = emptyList() +) + +data class TonIndexerRunGetMethodResponse( + @SerializedName("exit_code") + val exitCode: Int = 0, + @SerializedName("gas_used") + val gasUsed: Long = 0L, + @SerializedName("stack") + val stack: List> = emptyList() +) + +data class TonIndexerTransactionsResponse( + @SerializedName("page") + val page: Int = 1, + @SerializedName("page_size") + val pageSize: Int = 0, + @SerializedName("total_txs") + val totalTxs: Int = 0, + @SerializedName("total_pages") + val totalPages: Int? = null, + @SerializedName("history_complete") + val historyComplete: Boolean = false, + @SerializedName("txs") + val txs: List = emptyList() +) + +data class TonIndexerTransaction( + @SerializedName("txId") + val txId: String = "", + @SerializedName("utime") + val utime: Long = 0L, + @SerializedName("status") + val status: String = "", + @SerializedName("reason") + val reason: String? = null, + @SerializedName("txType") + val txType: String = "", + @SerializedName("inSource") + val inSource: String? = null, + @SerializedName("inValue") + val inValue: String? = null, + @SerializedName("detail") + val detail: TonIndexerTransactionDetail? = null, + @SerializedName("lt") + val lt: String = "", + @SerializedName("hash") + val hash: String = "", + @SerializedName("fee") + val fee: String? = null, + @SerializedName("totalFees") + val totalFees: String? = null, + @SerializedName("inMessage") + val inMessage: TonIndexerMessage? = null, + @SerializedName("outMessages") + val outMessages: List? = null +) + +data class TonIndexerTransactionDetail( + @SerializedName("kind") + val kind: String = "", + @SerializedName("asset") + val asset: String? = null, + @SerializedName("amount") + val amount: String? = null +) + +data class TonIndexerMessage( + @SerializedName("source") + val source: String? = null, + @SerializedName("destination") + val destination: String? = null, + @SerializedName("value") + val value: String? = null +) diff --git a/common/src/main/java/jp/co/soramitsu/common/data/secrets/v1/Keypair.kt b/common/src/main/java/jp/co/soramitsu/common/data/secrets/v1/Keypair.kt index 092b54bc38..e4b9424c2d 100644 --- a/common/src/main/java/jp/co/soramitsu/common/data/secrets/v1/Keypair.kt +++ b/common/src/main/java/jp/co/soramitsu/common/data/secrets/v1/Keypair.kt @@ -1,7 +1,7 @@ package jp.co.soramitsu.common.data.secrets.v1 -import jp.co.soramitsu.shared_utils.encrypt.keypair.BaseKeypair -import jp.co.soramitsu.shared_utils.encrypt.keypair.substrate.Sr25519Keypair +import jp.co.soramitsu.fearless_utils.encrypt.keypair.BaseKeypair +import jp.co.soramitsu.fearless_utils.encrypt.keypair.substrate.Sr25519Keypair /** * Creates [Sr25519Keypair] if [nonce] is not null diff --git a/common/src/main/java/jp/co/soramitsu/common/data/secrets/v1/SecretStoreV1.kt b/common/src/main/java/jp/co/soramitsu/common/data/secrets/v1/SecretStoreV1.kt index 393a704901..a8b313c67e 100644 --- a/common/src/main/java/jp/co/soramitsu/common/data/secrets/v1/SecretStoreV1.kt +++ b/common/src/main/java/jp/co/soramitsu/common/data/secrets/v1/SecretStoreV1.kt @@ -6,7 +6,7 @@ import jp.co.soramitsu.core.model.SecuritySource import jp.co.soramitsu.core.model.WithDerivationPath import jp.co.soramitsu.core.model.WithMnemonic import jp.co.soramitsu.core.model.WithSeed -import jp.co.soramitsu.shared_utils.encrypt.keypair.substrate.Sr25519Keypair +import jp.co.soramitsu.fearless_utils.encrypt.keypair.substrate.Sr25519Keypair import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext diff --git a/common/src/main/java/jp/co/soramitsu/common/data/secrets/v1/SourceInternal.kt b/common/src/main/java/jp/co/soramitsu/common/data/secrets/v1/SourceInternal.kt index 61c7bcd1c0..c2c7f7a0fd 100644 --- a/common/src/main/java/jp/co/soramitsu/common/data/secrets/v1/SourceInternal.kt +++ b/common/src/main/java/jp/co/soramitsu/common/data/secrets/v1/SourceInternal.kt @@ -1,8 +1,8 @@ package jp.co.soramitsu.common.data.secrets.v1 -import jp.co.soramitsu.shared_utils.scale.Schema -import jp.co.soramitsu.shared_utils.scale.byteArray -import jp.co.soramitsu.shared_utils.scale.string +import jp.co.soramitsu.fearless_utils.scale.Schema +import jp.co.soramitsu.fearless_utils.scale.byteArray +import jp.co.soramitsu.fearless_utils.scale.string internal enum class SourceType { CREATE, SEED, MNEMONIC, JSON, UNSPECIFIED diff --git a/common/src/main/java/jp/co/soramitsu/common/data/secrets/v2/MetaAccountSecrets.kt b/common/src/main/java/jp/co/soramitsu/common/data/secrets/v2/MetaAccountSecrets.kt index 5b374429f6..12b8d7996d 100644 --- a/common/src/main/java/jp/co/soramitsu/common/data/secrets/v2/MetaAccountSecrets.kt +++ b/common/src/main/java/jp/co/soramitsu/common/data/secrets/v2/MetaAccountSecrets.kt @@ -1,13 +1,13 @@ package jp.co.soramitsu.common.data.secrets.v2 import jp.co.soramitsu.common.utils.invoke -import jp.co.soramitsu.shared_utils.encrypt.keypair.Keypair -import jp.co.soramitsu.shared_utils.encrypt.keypair.substrate.Sr25519Keypair -import jp.co.soramitsu.shared_utils.scale.EncodableStruct -import jp.co.soramitsu.shared_utils.scale.Schema -import jp.co.soramitsu.shared_utils.scale.byteArray -import jp.co.soramitsu.shared_utils.scale.schema -import jp.co.soramitsu.shared_utils.scale.string +import jp.co.soramitsu.fearless_utils.encrypt.keypair.Keypair +import jp.co.soramitsu.fearless_utils.encrypt.keypair.substrate.Sr25519Keypair +import jp.co.soramitsu.fearless_utils.scale.EncodableStruct +import jp.co.soramitsu.fearless_utils.scale.Schema +import jp.co.soramitsu.fearless_utils.scale.byteArray +import jp.co.soramitsu.fearless_utils.scale.schema +import jp.co.soramitsu.fearless_utils.scale.string object KeyPairSchema : Schema() { val PrivateKey by byteArray() diff --git a/common/src/main/java/jp/co/soramitsu/common/data/secrets/v2/SecretStoreV2.kt b/common/src/main/java/jp/co/soramitsu/common/data/secrets/v2/SecretStoreV2.kt index a33741f0c4..62a2d3031a 100644 --- a/common/src/main/java/jp/co/soramitsu/common/data/secrets/v2/SecretStoreV2.kt +++ b/common/src/main/java/jp/co/soramitsu/common/data/secrets/v2/SecretStoreV2.kt @@ -2,11 +2,11 @@ package jp.co.soramitsu.common.data.secrets.v2 import jp.co.soramitsu.common.data.secrets.v1.Keypair import jp.co.soramitsu.common.data.storage.encrypt.EncryptedPreferences -import jp.co.soramitsu.shared_utils.encrypt.keypair.Keypair -import jp.co.soramitsu.shared_utils.extensions.toHexString -import jp.co.soramitsu.shared_utils.runtime.AccountId -import jp.co.soramitsu.shared_utils.scale.EncodableStruct -import jp.co.soramitsu.shared_utils.scale.toHexString +import jp.co.soramitsu.fearless_utils.encrypt.keypair.Keypair +import jp.co.soramitsu.fearless_utils.extensions.toHexString +import jp.co.soramitsu.fearless_utils.runtime.AccountId +import jp.co.soramitsu.fearless_utils.scale.EncodableStruct +import jp.co.soramitsu.fearless_utils.scale.toHexString import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext diff --git a/common/src/main/java/jp/co/soramitsu/common/data/secrets/v3/EthereumSecretStore.kt b/common/src/main/java/jp/co/soramitsu/common/data/secrets/v3/EthereumSecretStore.kt index 02b5f54a8a..46e40e4244 100644 --- a/common/src/main/java/jp/co/soramitsu/common/data/secrets/v3/EthereumSecretStore.kt +++ b/common/src/main/java/jp/co/soramitsu/common/data/secrets/v3/EthereumSecretStore.kt @@ -3,13 +3,13 @@ package jp.co.soramitsu.common.data.secrets.v3 import jp.co.soramitsu.common.data.secrets.v2.KeyPairSchema import jp.co.soramitsu.common.data.storage.encrypt.EncryptedPreferences import jp.co.soramitsu.common.utils.invoke -import jp.co.soramitsu.shared_utils.encrypt.keypair.Keypair -import jp.co.soramitsu.shared_utils.scale.EncodableStruct -import jp.co.soramitsu.shared_utils.scale.Schema -import jp.co.soramitsu.shared_utils.scale.byteArray -import jp.co.soramitsu.shared_utils.scale.schema -import jp.co.soramitsu.shared_utils.scale.string -import jp.co.soramitsu.shared_utils.scale.toHexString +import jp.co.soramitsu.fearless_utils.encrypt.keypair.Keypair +import jp.co.soramitsu.fearless_utils.scale.EncodableStruct +import jp.co.soramitsu.fearless_utils.scale.Schema +import jp.co.soramitsu.fearless_utils.scale.byteArray +import jp.co.soramitsu.fearless_utils.scale.schema +import jp.co.soramitsu.fearless_utils.scale.string +import jp.co.soramitsu.fearless_utils.scale.toHexString private const val ETHEREUM_SECRETS = "ETHEREUM_SECRETS" diff --git a/common/src/main/java/jp/co/soramitsu/common/data/secrets/v3/SecretStore.kt b/common/src/main/java/jp/co/soramitsu/common/data/secrets/v3/SecretStore.kt index a84b9373ef..4743f455b8 100644 --- a/common/src/main/java/jp/co/soramitsu/common/data/secrets/v3/SecretStore.kt +++ b/common/src/main/java/jp/co/soramitsu/common/data/secrets/v3/SecretStore.kt @@ -1,7 +1,7 @@ package jp.co.soramitsu.common.data.secrets.v3 -import jp.co.soramitsu.shared_utils.scale.EncodableStruct -import jp.co.soramitsu.shared_utils.scale.Schema +import jp.co.soramitsu.fearless_utils.scale.EncodableStruct +import jp.co.soramitsu.fearless_utils.scale.Schema interface SecretStore> { diff --git a/common/src/main/java/jp/co/soramitsu/common/data/secrets/v3/SubstrateSecretStore.kt b/common/src/main/java/jp/co/soramitsu/common/data/secrets/v3/SubstrateSecretStore.kt index 022855f505..14c46c916c 100644 --- a/common/src/main/java/jp/co/soramitsu/common/data/secrets/v3/SubstrateSecretStore.kt +++ b/common/src/main/java/jp/co/soramitsu/common/data/secrets/v3/SubstrateSecretStore.kt @@ -3,14 +3,14 @@ package jp.co.soramitsu.common.data.secrets.v3 import jp.co.soramitsu.common.data.secrets.v2.KeyPairSchema import jp.co.soramitsu.common.data.storage.encrypt.EncryptedPreferences import jp.co.soramitsu.common.utils.invoke -import jp.co.soramitsu.shared_utils.encrypt.keypair.Keypair -import jp.co.soramitsu.shared_utils.encrypt.keypair.substrate.Sr25519Keypair -import jp.co.soramitsu.shared_utils.scale.EncodableStruct -import jp.co.soramitsu.shared_utils.scale.Schema -import jp.co.soramitsu.shared_utils.scale.byteArray -import jp.co.soramitsu.shared_utils.scale.schema -import jp.co.soramitsu.shared_utils.scale.string -import jp.co.soramitsu.shared_utils.scale.toHexString +import jp.co.soramitsu.fearless_utils.encrypt.keypair.Keypair +import jp.co.soramitsu.fearless_utils.encrypt.keypair.substrate.Sr25519Keypair +import jp.co.soramitsu.fearless_utils.scale.EncodableStruct +import jp.co.soramitsu.fearless_utils.scale.Schema +import jp.co.soramitsu.fearless_utils.scale.byteArray +import jp.co.soramitsu.fearless_utils.scale.schema +import jp.co.soramitsu.fearless_utils.scale.string +import jp.co.soramitsu.fearless_utils.scale.toHexString private const val SUBSTRATE_SECRETS = "SUBSTRATE_SECRETS" diff --git a/common/src/main/java/jp/co/soramitsu/common/data/secrets/v3/TonSecretStore.kt b/common/src/main/java/jp/co/soramitsu/common/data/secrets/v3/TonSecretStore.kt index 33791d9e00..eb868d34f8 100644 --- a/common/src/main/java/jp/co/soramitsu/common/data/secrets/v3/TonSecretStore.kt +++ b/common/src/main/java/jp/co/soramitsu/common/data/secrets/v3/TonSecretStore.kt @@ -2,11 +2,11 @@ package jp.co.soramitsu.common.data.secrets.v3 import jp.co.soramitsu.common.data.storage.encrypt.EncryptedPreferences import jp.co.soramitsu.common.utils.invoke -import jp.co.soramitsu.shared_utils.encrypt.keypair.Keypair -import jp.co.soramitsu.shared_utils.scale.EncodableStruct -import jp.co.soramitsu.shared_utils.scale.Schema -import jp.co.soramitsu.shared_utils.scale.byteArray -import jp.co.soramitsu.shared_utils.scale.toHexString +import jp.co.soramitsu.fearless_utils.encrypt.keypair.Keypair +import jp.co.soramitsu.fearless_utils.scale.EncodableStruct +import jp.co.soramitsu.fearless_utils.scale.Schema +import jp.co.soramitsu.fearless_utils.scale.byteArray +import jp.co.soramitsu.fearless_utils.scale.toHexString private const val TON_SECRETS = "TON_SECRETS" diff --git a/common/src/main/java/jp/co/soramitsu/common/di/modules/CommonModule.kt b/common/src/main/java/jp/co/soramitsu/common/di/modules/CommonModule.kt index c61b0b92e3..5e2330d372 100644 --- a/common/src/main/java/jp/co/soramitsu/common/di/modules/CommonModule.kt +++ b/common/src/main/java/jp/co/soramitsu/common/di/modules/CommonModule.kt @@ -43,8 +43,8 @@ import jp.co.soramitsu.core.extrinsic.ExtrinsicBuilderFactory import jp.co.soramitsu.core.extrinsic.ExtrinsicService import jp.co.soramitsu.core.extrinsic.keypair_provider.KeypairProvider import jp.co.soramitsu.core.rpc.RpcCalls -import jp.co.soramitsu.shared_utils.encrypt.Signer -import jp.co.soramitsu.shared_utils.icon.IconGenerator +import jp.co.soramitsu.fearless_utils.encrypt.Signer +import jp.co.soramitsu.fearless_utils.icon.IconGenerator import java.security.SecureRandom import java.util.Random import javax.inject.Qualifier diff --git a/common/src/main/java/jp/co/soramitsu/common/di/modules/NetworkModule.kt b/common/src/main/java/jp/co/soramitsu/common/di/modules/NetworkModule.kt index 6262137590..a571e402c4 100644 --- a/common/src/main/java/jp/co/soramitsu/common/di/modules/NetworkModule.kt +++ b/common/src/main/java/jp/co/soramitsu/common/di/modules/NetworkModule.kt @@ -18,15 +18,33 @@ import jp.co.soramitsu.common.data.network.AndroidLogger import jp.co.soramitsu.common.data.network.AppLinksProvider import jp.co.soramitsu.common.data.network.HttpExceptionHandler import jp.co.soramitsu.common.data.network.NetworkApiCreator +import jp.co.soramitsu.common.data.network.bitcoin.BitcoinIndexerApi +import jp.co.soramitsu.common.data.network.bitcoin.BitcoinIndexerClient +import jp.co.soramitsu.common.data.network.bitcoin.BitcoinTransactionHistorySync +import jp.co.soramitsu.common.data.network.bitcoin.RetrofitBitcoinIndexerClient +import jp.co.soramitsu.common.data.network.iroha.IrohaToriiApi +import jp.co.soramitsu.common.data.network.iroha.IrohaToriiClient +import jp.co.soramitsu.common.data.network.iroha.RetrofitIrohaToriiClient import jp.co.soramitsu.common.data.network.nomis.NomisApi import jp.co.soramitsu.common.data.network.rpc.SocketSingleRequestExecutor +import jp.co.soramitsu.common.data.network.solana.RetrofitSolanaIndexerClient +import jp.co.soramitsu.common.data.network.solana.RetrofitSolanaRpcClient +import jp.co.soramitsu.common.data.network.solana.SolanaBalanceSync +import jp.co.soramitsu.common.data.network.solana.SolanaIndexerApi +import jp.co.soramitsu.common.data.network.solana.SolanaIndexerClient +import jp.co.soramitsu.common.data.network.solana.SolanaRpcApi +import jp.co.soramitsu.common.data.network.solana.SolanaRpcClient +import jp.co.soramitsu.common.data.network.solana.SolanaTransactionHistorySync import jp.co.soramitsu.common.data.network.ton.TonApi +import jp.co.soramitsu.common.data.network.ton.TonIndexerApi +import jp.co.soramitsu.common.data.network.ton.TonIndexerClient +import jp.co.soramitsu.common.data.network.ton.RetrofitTonIndexerClient import jp.co.soramitsu.common.resources.ResourceManager -import jp.co.soramitsu.shared_utils.wsrpc.SocketService -import jp.co.soramitsu.shared_utils.wsrpc.logging.Logger -import jp.co.soramitsu.shared_utils.wsrpc.recovery.Reconnector -import jp.co.soramitsu.shared_utils.wsrpc.request.CoroutinesRequestExecutor -import jp.co.soramitsu.shared_utils.wsrpc.request.RequestExecutor +import jp.co.soramitsu.fearless_utils.wsrpc.SocketService +import jp.co.soramitsu.fearless_utils.wsrpc.logging.Logger +import jp.co.soramitsu.fearless_utils.wsrpc.recovery.Reconnector +import jp.co.soramitsu.fearless_utils.wsrpc.request.CoroutinesRequestExecutor +import jp.co.soramitsu.fearless_utils.wsrpc.request.RequestExecutor import jp.co.soramitsu.xnetworking.lib.datasources.blockexplorer.api.BlockExplorerRepository import jp.co.soramitsu.xnetworking.lib.datasources.blockexplorer.impl.BlockExplorerRepositoryImpl import jp.co.soramitsu.xnetworking.lib.datasources.chainsconfig.api.ConfigDAO @@ -214,6 +232,84 @@ class NetworkModule { return NetworkApiCreator(okHttpClient, "https://placeholder.com") } + @Provides + @Singleton + fun provideBitcoinIndexerApi(apiCreator: NetworkApiCreator): BitcoinIndexerApi { + return apiCreator.create(BitcoinIndexerApi::class.java) + } + + @Provides + @Singleton + fun provideBitcoinIndexerClient(api: BitcoinIndexerApi): BitcoinIndexerClient { + return RetrofitBitcoinIndexerClient(api) + } + + @Provides + @Singleton + fun provideBitcoinTransactionHistorySync(client: BitcoinIndexerClient): BitcoinTransactionHistorySync { + return BitcoinTransactionHistorySync(client) + } + + @Provides + @Singleton + fun provideSolanaIndexerApi(apiCreator: NetworkApiCreator): SolanaIndexerApi { + return apiCreator.create(SolanaIndexerApi::class.java) + } + + @Provides + @Singleton + fun provideSolanaIndexerClient(api: SolanaIndexerApi): SolanaIndexerClient { + return RetrofitSolanaIndexerClient(api) + } + + @Provides + @Singleton + fun provideSolanaRpcApi(apiCreator: NetworkApiCreator): SolanaRpcApi { + return apiCreator.create(SolanaRpcApi::class.java) + } + + @Provides + @Singleton + fun provideSolanaRpcClient(api: SolanaRpcApi): SolanaRpcClient { + return RetrofitSolanaRpcClient(api) + } + + @Provides + @Singleton + fun provideSolanaBalanceSync(client: SolanaIndexerClient): SolanaBalanceSync { + return SolanaBalanceSync(client) + } + + @Provides + @Singleton + fun provideSolanaTransactionHistorySync(client: SolanaIndexerClient): SolanaTransactionHistorySync { + return SolanaTransactionHistorySync(client) + } + + @Provides + @Singleton + fun provideTonIndexerApi(apiCreator: NetworkApiCreator): TonIndexerApi { + return apiCreator.create(TonIndexerApi::class.java) + } + + @Provides + @Singleton + fun provideTonIndexerClient(api: TonIndexerApi): TonIndexerClient { + return RetrofitTonIndexerClient(api) + } + + @Provides + @Singleton + fun provideIrohaToriiApi(apiCreator: NetworkApiCreator): IrohaToriiApi { + return apiCreator.create(IrohaToriiApi::class.java) + } + + @Provides + @Singleton + fun provideIrohaToriiClient(api: IrohaToriiApi): IrohaToriiClient { + return RetrofitIrohaToriiClient(api) + } + @Provides @Singleton fun httpExceptionHandler( diff --git a/common/src/main/java/jp/co/soramitsu/common/model/AssetKey.kt b/common/src/main/java/jp/co/soramitsu/common/model/AssetKey.kt index 0664359cd9..f8089a00a7 100644 --- a/common/src/main/java/jp/co/soramitsu/common/model/AssetKey.kt +++ b/common/src/main/java/jp/co/soramitsu/common/model/AssetKey.kt @@ -1,6 +1,6 @@ package jp.co.soramitsu.common.model -import jp.co.soramitsu.shared_utils.runtime.AccountId +import jp.co.soramitsu.fearless_utils.runtime.AccountId data class AssetKey( val metaId: Long, diff --git a/common/src/main/java/jp/co/soramitsu/common/model/UniversalWalletDerivationPaths.kt b/common/src/main/java/jp/co/soramitsu/common/model/UniversalWalletDerivationPaths.kt new file mode 100644 index 0000000000..545ef43e79 --- /dev/null +++ b/common/src/main/java/jp/co/soramitsu/common/model/UniversalWalletDerivationPaths.kt @@ -0,0 +1,13 @@ +package jp.co.soramitsu.common.model + +object UniversalWalletDerivationPaths { + const val SUBSTRATE_ROOT = "" + const val EVM_DEFAULT = "m/44'/60'/0'/0/0" + const val BITCOIN_MAINNET_ACCOUNT = "m/84'/0'/0'" + const val BITCOIN_MAINNET_FIRST_RECEIVE = "m/84'/0'/0'/0/0" + const val BITCOIN_TESTNET_ACCOUNT = "m/84'/1'/0'" + const val BITCOIN_TESTNET_FIRST_RECEIVE = "m/84'/1'/0'/0/0" + const val SOLANA_DEFAULT = "m/44'/501'/0'/0'" + const val TON_DEFAULT = "m/44'/607'/0'/0'/0'" + const val IROHA_DEFAULT = "m/44'/617'/0'/0'" +} diff --git a/common/src/main/java/jp/co/soramitsu/common/model/UniversalWalletEcosystem.kt b/common/src/main/java/jp/co/soramitsu/common/model/UniversalWalletEcosystem.kt new file mode 100644 index 0000000000..2245c03b59 --- /dev/null +++ b/common/src/main/java/jp/co/soramitsu/common/model/UniversalWalletEcosystem.kt @@ -0,0 +1,14 @@ +package jp.co.soramitsu.common.model + +enum class UniversalWalletEcosystem(val id: String) { + Substrate("substrate"), + Evm("evm"), + Ton("ton"), + Bitcoin("bitcoin"), + Solana("solana"), + Iroha("iroha"); + + companion object { + fun fromId(id: String): UniversalWalletEcosystem? = values().firstOrNull { it.id == id } + } +} diff --git a/common/src/main/java/jp/co/soramitsu/common/model/UniversalWalletIdentity.kt b/common/src/main/java/jp/co/soramitsu/common/model/UniversalWalletIdentity.kt new file mode 100644 index 0000000000..6423b98bda --- /dev/null +++ b/common/src/main/java/jp/co/soramitsu/common/model/UniversalWalletIdentity.kt @@ -0,0 +1,212 @@ +package jp.co.soramitsu.common.model + +import com.google.gson.annotations.SerializedName + +data class UniversalWalletIdentity( + @SerializedName("schemaVersion") + val schemaVersion: Int = SCHEMA_VERSION, + @SerializedName("walletId") + val walletId: String, + @SerializedName("displayName") + val displayName: String, + @SerializedName("source") + val source: UniversalWalletSource, + @SerializedName("status") + val status: UniversalWalletStatus, + @SerializedName("publicAccounts") + val publicAccounts: List, + @SerializedName("createdAtMillis") + val createdAtMillis: Long, + @SerializedName("updatedAtMillis") + val updatedAtMillis: Long? = null, + @SerializedName("legacyExportOnlyReason") + val legacyExportOnlyReason: String? = null +) { + fun validationErrors(): Set { + val errors = linkedSetOf() + + if (schemaVersion != SCHEMA_VERSION) { + errors += ValidationError.InvalidSchemaVersion + } + if (!WALLET_ID.matches(walletId)) { + errors += ValidationError.InvalidWalletId + } + if (!isHumanText(displayName, maxLength = 64)) { + errors += ValidationError.InvalidDisplayName + } + if (createdAtMillis <= 0 || updatedAtMillis?.let { it < createdAtMillis } == true) { + errors += ValidationError.InvalidTimestamps + } + + if (status != UniversalWalletStatus.LegacyExportOnly && publicAccounts.isEmpty()) { + errors += ValidationError.PublicAccountsRequired + } + if (status == UniversalWalletStatus.Active) { + val present = publicAccounts.mapNotNull { UniversalWalletEcosystem.fromId(it.ecosystem) }.toSet() + if (present != UniversalWalletEcosystem.values().toSet()) { + errors += ValidationError.MissingActiveEcosystem + } + } + if (status == UniversalWalletStatus.LegacyExportOnly) { + if (source != UniversalWalletSource.LegacyImport) { + errors += ValidationError.InvalidLegacySource + } + if (!isHumanText(legacyExportOnlyReason, maxLength = 160)) { + errors += ValidationError.LegacyReasonRequired + } + } else if (!legacyExportOnlyReason.isNullOrBlank()) { + errors += ValidationError.LegacyReasonNotAllowed + } + + val accountIds = mutableSetOf() + publicAccounts.forEach { account -> + errors += account.validationErrors() + if (!accountIds.add(account.accountId)) { + errors += ValidationError.DuplicateAccountId + } + } + + return errors + } + + fun requireValid(): UniversalWalletIdentity { + val errors = validationErrors() + if (errors.isNotEmpty()) { + throw UniversalWalletIdentityException(errors) + } + + return this + } + + data class UniversalWalletPublicAccount( + @SerializedName("accountId") + val accountId: String, + @SerializedName("ecosystem") + val ecosystem: String, + @SerializedName("address") + val address: String, + @SerializedName("chainId") + val chainId: String? = null, + @SerializedName("derivationPath") + val derivationPath: String? = null, + @SerializedName("publicKeyHex") + val publicKeyHex: String? = null, + @SerializedName("isDefault") + val isDefault: Boolean = true + ) { + constructor( + accountId: String, + ecosystem: UniversalWalletEcosystem, + address: String, + chainId: String? = null, + derivationPath: String? = null, + publicKeyHex: String? = null, + isDefault: Boolean = true + ) : this( + accountId = accountId, + ecosystem = ecosystem.id, + address = address, + chainId = chainId, + derivationPath = derivationPath, + publicKeyHex = publicKeyHex, + isDefault = isDefault + ) + + fun validationErrors(): Set { + val errors = linkedSetOf() + + if (!ACCOUNT_ID.matches(accountId)) { + errors += ValidationError.InvalidAccountId + } + if (UniversalWalletEcosystem.fromId(ecosystem) == null) { + errors += ValidationError.InvalidEcosystem + } + if (!isMachineText(address, maxLength = 256)) { + errors += ValidationError.InvalidAddress + } + if (chainId != null && !CHAIN_ID.matches(chainId)) { + errors += ValidationError.InvalidChainId + } + if (!derivationPath.isNullOrBlank() && !DERIVATION_PATH.matches(derivationPath)) { + errors += ValidationError.InvalidDerivationPath + } + if (publicKeyHex != null && (!PUBLIC_KEY_HEX.matches(publicKeyHex) || publicKeyHex.length % 2 != 0)) { + errors += ValidationError.InvalidPublicKeyHex + } + + return errors + } + } + + enum class UniversalWalletSource { + @SerializedName("created-24-word") + Created24Word, + + @SerializedName("imported-12-word") + Imported12Word, + + @SerializedName("imported-24-word") + Imported24Word, + + @SerializedName("legacy-import") + LegacyImport + } + + enum class UniversalWalletStatus { + @SerializedName("active") + Active, + + @SerializedName("migration-required") + MigrationRequired, + + @SerializedName("legacy-export-only") + LegacyExportOnly + } + + enum class ValidationError { + InvalidSchemaVersion, + InvalidWalletId, + InvalidDisplayName, + InvalidTimestamps, + PublicAccountsRequired, + MissingActiveEcosystem, + InvalidLegacySource, + LegacyReasonRequired, + LegacyReasonNotAllowed, + DuplicateAccountId, + InvalidAccountId, + InvalidEcosystem, + InvalidAddress, + InvalidChainId, + InvalidDerivationPath, + InvalidPublicKeyHex + } + + class UniversalWalletIdentityException( + val errors: Set + ) : IllegalArgumentException(errors.joinToString(",") { it.name }) + + companion object { + const val SCHEMA_VERSION = 2 + + private val WALLET_ID = Regex("^uw2_[A-Za-z0-9_-]{16,64}$") + private val ACCOUNT_ID = Regex("^[a-z0-9][a-z0-9._:-]{1,63}$") + private val CHAIN_ID = Regex("^[A-Za-z0-9._:-]{2,128}$") + private val DERIVATION_PATH = Regex("^m(?:/[0-9]+'?)*$") + private val PUBLIC_KEY_HEX = Regex("^[0-9a-f]{64,260}$") + + private fun isHumanText(value: String?, maxLength: Int): Boolean { + val normalized = value?.trim() ?: return false + return normalized.isNotEmpty() && + normalized.length <= maxLength && + normalized.none { it.isISOControl() } + } + + private fun isMachineText(value: String, maxLength: Int): Boolean { + return value.isNotEmpty() && + value.length <= maxLength && + value == value.trim() && + value.none { it <= ' ' || it.isISOControl() } + } + } +} diff --git a/common/src/main/java/jp/co/soramitsu/common/model/UniversalWalletIndexerContract.kt b/common/src/main/java/jp/co/soramitsu/common/model/UniversalWalletIndexerContract.kt new file mode 100644 index 0000000000..1519e08d84 --- /dev/null +++ b/common/src/main/java/jp/co/soramitsu/common/model/UniversalWalletIndexerContract.kt @@ -0,0 +1,408 @@ +package jp.co.soramitsu.common.model + +import com.google.gson.annotations.SerializedName + +data class UniversalWalletIndexedAssetBalance( + @SerializedName("accountId") + val accountId: String, + @SerializedName("ecosystem") + val ecosystem: String, + @SerializedName("chainId") + val chainId: String, + @SerializedName("assetId") + val assetId: String, + @SerializedName("amount") + val amount: String, + @SerializedName("decimals") + val decimals: Int, + @SerializedName("isNative") + val isNative: Boolean, + @SerializedName("symbol") + val symbol: String? = null, + @SerializedName("name") + val name: String? = null, + @SerializedName("uiAmountString") + val uiAmountString: String? = null, + @SerializedName("tokenAccountId") + val tokenAccountId: String? = null, + @SerializedName("contractAddress") + val contractAddress: String? = null, + @SerializedName("tokenProgram") + val tokenProgram: String? = null, + @SerializedName("syncedAtMillis") + val syncedAtMillis: Long +) { + constructor( + accountId: String, + ecosystem: UniversalWalletEcosystem, + chainId: String, + assetId: String, + amount: String, + decimals: Int, + isNative: Boolean, + symbol: String? = null, + name: String? = null, + uiAmountString: String? = null, + tokenAccountId: String? = null, + contractAddress: String? = null, + tokenProgram: String? = null, + syncedAtMillis: Long + ) : this( + accountId = accountId, + ecosystem = ecosystem.id, + chainId = chainId, + assetId = assetId, + amount = amount, + decimals = decimals, + isNative = isNative, + symbol = symbol, + name = name, + uiAmountString = uiAmountString, + tokenAccountId = tokenAccountId, + contractAddress = contractAddress, + tokenProgram = tokenProgram, + syncedAtMillis = syncedAtMillis + ) + + fun validationErrors(): Set { + val errors = linkedSetOf() + UniversalWalletIndexerContractValidator.validateEnvelope( + accountId = accountId, + ecosystem = ecosystem, + chainId = chainId, + syncedAtMillis = syncedAtMillis, + errors = errors + ) + if (!UniversalWalletIndexerContractValidator.isMachineText(assetId, maxLength = 160)) { + errors += UniversalWalletIndexerValidationError.InvalidAssetId + } + if (!UniversalWalletIndexerContractValidator.isUnsignedInteger(amount)) { + errors += UniversalWalletIndexerValidationError.InvalidAmount + } + if (decimals !in UniversalWalletIndexerContractValidator.DECIMAL_RANGE) { + errors += UniversalWalletIndexerValidationError.InvalidDecimals + } + if (!symbol.isNullOrBlank() && !UniversalWalletIndexerContractValidator.isHumanText(symbol, maxLength = 32)) { + errors += UniversalWalletIndexerValidationError.InvalidSymbol + } + if (!name.isNullOrBlank() && !UniversalWalletIndexerContractValidator.isHumanText(name, maxLength = 96)) { + errors += UniversalWalletIndexerValidationError.InvalidName + } + if (!uiAmountString.isNullOrBlank() && !UniversalWalletIndexerContractValidator.isHumanText(uiAmountString, maxLength = 80)) { + errors += UniversalWalletIndexerValidationError.InvalidAmount + } + if (!tokenAccountId.isNullOrBlank() && !UniversalWalletIndexerContractValidator.isMachineText(tokenAccountId, maxLength = 256)) { + errors += UniversalWalletIndexerValidationError.InvalidAccountId + } + if (!contractAddress.isNullOrBlank() && !UniversalWalletIndexerContractValidator.isMachineText(contractAddress, maxLength = 256)) { + errors += UniversalWalletIndexerValidationError.InvalidAddress + } + if (!tokenProgram.isNullOrBlank() && !UniversalWalletIndexerContractValidator.isMachineText(tokenProgram, maxLength = 64)) { + errors += UniversalWalletIndexerValidationError.InvalidAssetId + } + + return errors + } +} + +data class UniversalWalletIndexedTransaction( + @SerializedName("accountId") + val accountId: String, + @SerializedName("ecosystem") + val ecosystem: String, + @SerializedName("chainId") + val chainId: String, + @SerializedName("transactionId") + val transactionId: String, + @SerializedName("status") + val status: UniversalWalletIndexedTransactionStatus, + @SerializedName("direction") + val direction: UniversalWalletIndexedTransactionDirection, + @SerializedName("operationType") + val operationType: UniversalWalletIndexedOperationType, + @SerializedName("timestampMillis") + val timestampMillis: Long? = null, + @SerializedName("amount") + val amount: String? = null, + @SerializedName("assetId") + val assetId: String? = null, + @SerializedName("feeAmount") + val feeAmount: String? = null, + @SerializedName("feeAssetId") + val feeAssetId: String? = null, + @SerializedName("counterpartyAddress") + val counterpartyAddress: String? = null, + @SerializedName("blockNumber") + val blockNumber: String? = null, + @SerializedName("cursor") + val cursor: String? = null, + @SerializedName("explorerUrl") + val explorerUrl: String? = null, + @SerializedName("syncedAtMillis") + val syncedAtMillis: Long +) { + fun validationErrors(): Set { + val errors = linkedSetOf() + UniversalWalletIndexerContractValidator.validateEnvelope( + accountId = accountId, + ecosystem = ecosystem, + chainId = chainId, + syncedAtMillis = syncedAtMillis, + errors = errors + ) + if (!UniversalWalletIndexerContractValidator.isMachineText(transactionId, maxLength = 256)) { + errors += UniversalWalletIndexerValidationError.InvalidTransactionId + } + if (timestampMillis != null && timestampMillis <= 0) { + errors += UniversalWalletIndexerValidationError.InvalidTimestamp + } + if (amount != null && !UniversalWalletIndexerContractValidator.isUnsignedInteger(amount)) { + errors += UniversalWalletIndexerValidationError.InvalidAmount + } + if (assetId != null && !UniversalWalletIndexerContractValidator.isMachineText(assetId, maxLength = 160)) { + errors += UniversalWalletIndexerValidationError.InvalidAssetId + } + if (feeAmount != null && !UniversalWalletIndexerContractValidator.isUnsignedInteger(feeAmount)) { + errors += UniversalWalletIndexerValidationError.InvalidAmount + } + if (feeAssetId != null && !UniversalWalletIndexerContractValidator.isMachineText(feeAssetId, maxLength = 160)) { + errors += UniversalWalletIndexerValidationError.InvalidAssetId + } + if (counterpartyAddress != null && !UniversalWalletIndexerContractValidator.isMachineText(counterpartyAddress, maxLength = 256)) { + errors += UniversalWalletIndexerValidationError.InvalidAddress + } + if (blockNumber != null && !UniversalWalletIndexerContractValidator.isUnsignedInteger(blockNumber)) { + errors += UniversalWalletIndexerValidationError.InvalidBlockNumber + } + if (cursor != null && !UniversalWalletIndexerContractValidator.isMachineText(cursor, maxLength = 512)) { + errors += UniversalWalletIndexerValidationError.InvalidCursor + } + if (explorerUrl != null && !UniversalWalletIndexerContractValidator.isHttpsUrl(explorerUrl)) { + errors += UniversalWalletIndexerValidationError.InvalidUrl + } + + return errors + } +} + +data class UniversalWalletIndexedTokenMetadata( + @SerializedName("ecosystem") + val ecosystem: String, + @SerializedName("chainId") + val chainId: String, + @SerializedName("assetId") + val assetId: String, + @SerializedName("decimals") + val decimals: Int, + @SerializedName("symbol") + val symbol: String? = null, + @SerializedName("name") + val name: String? = null, + @SerializedName("iconUrl") + val iconUrl: String? = null, + @SerializedName("metadataUrl") + val metadataUrl: String? = null, + @SerializedName("isVerified") + val isVerified: Boolean = false, + @SerializedName("syncedAtMillis") + val syncedAtMillis: Long +) { + fun validationErrors(): Set { + val errors = linkedSetOf() + + if (UniversalWalletEcosystem.fromId(ecosystem) == null) { + errors += UniversalWalletIndexerValidationError.InvalidEcosystem + } + if (!UniversalWalletIndexerContractValidator.CHAIN_ID.matches(chainId)) { + errors += UniversalWalletIndexerValidationError.InvalidChainId + } + if (!UniversalWalletIndexerContractValidator.isMachineText(assetId, maxLength = 160)) { + errors += UniversalWalletIndexerValidationError.InvalidAssetId + } + if (decimals !in UniversalWalletIndexerContractValidator.DECIMAL_RANGE) { + errors += UniversalWalletIndexerValidationError.InvalidDecimals + } + if (!symbol.isNullOrBlank() && !UniversalWalletIndexerContractValidator.isHumanText(symbol, maxLength = 32)) { + errors += UniversalWalletIndexerValidationError.InvalidSymbol + } + if (!name.isNullOrBlank() && !UniversalWalletIndexerContractValidator.isHumanText(name, maxLength = 96)) { + errors += UniversalWalletIndexerValidationError.InvalidName + } + if (iconUrl != null && !UniversalWalletIndexerContractValidator.isAssetUrl(iconUrl)) { + errors += UniversalWalletIndexerValidationError.InvalidUrl + } + if (metadataUrl != null && !UniversalWalletIndexerContractValidator.isAssetUrl(metadataUrl)) { + errors += UniversalWalletIndexerValidationError.InvalidUrl + } + if (syncedAtMillis <= 0) { + errors += UniversalWalletIndexerValidationError.InvalidSyncedAt + } + + return errors + } +} + +data class UniversalWalletIndexerPageInfo( + @SerializedName("nextCursor") + val nextCursor: String? = null, + @SerializedName("limit") + val limit: Int, + @SerializedName("total") + val total: Int? = null, + @SerializedName("syncedAtMillis") + val syncedAtMillis: Long +) { + fun validationErrors(): Set { + val errors = linkedSetOf() + + if (nextCursor != null && !UniversalWalletIndexerContractValidator.isMachineText(nextCursor, maxLength = 512)) { + errors += UniversalWalletIndexerValidationError.InvalidCursor + } + if (limit !in 1..UniversalWalletIndexerContractValidator.MAX_PAGE_LIMIT) { + errors += UniversalWalletIndexerValidationError.InvalidLimit + } + if (total != null && total < 0) { + errors += UniversalWalletIndexerValidationError.InvalidTotal + } + if (syncedAtMillis <= 0) { + errors += UniversalWalletIndexerValidationError.InvalidSyncedAt + } + + return errors + } +} + +enum class UniversalWalletIndexedTransactionStatus { + @SerializedName("pending") + Pending, + + @SerializedName("confirmed") + Confirmed, + + @SerializedName("failed") + Failed +} + +enum class UniversalWalletIndexedTransactionDirection { + @SerializedName("incoming") + Incoming, + + @SerializedName("outgoing") + Outgoing, + + @SerializedName("self") + Self, + + @SerializedName("unknown") + Unknown +} + +enum class UniversalWalletIndexedOperationType { + @SerializedName("transfer") + Transfer, + + @SerializedName("swap") + Swap, + + @SerializedName("stake") + Stake, + + @SerializedName("unstake") + Unstake, + + @SerializedName("governance") + Governance, + + @SerializedName("offline-cash") + OfflineCash, + + @SerializedName("sccp") + Sccp, + + @SerializedName("contract-call") + ContractCall, + + @SerializedName("mint") + Mint, + + @SerializedName("burn") + Burn, + + @SerializedName("fee") + Fee, + + @SerializedName("unknown") + Unknown +} + +enum class UniversalWalletIndexerValidationError { + InvalidAccountId, + InvalidEcosystem, + InvalidChainId, + InvalidAssetId, + InvalidAmount, + InvalidDecimals, + InvalidSymbol, + InvalidName, + InvalidAddress, + InvalidTransactionId, + InvalidTimestamp, + InvalidBlockNumber, + InvalidCursor, + InvalidLimit, + InvalidTotal, + InvalidUrl, + InvalidSyncedAt +} + +object UniversalWalletIndexerContractValidator { + val ACCOUNT_ID = Regex("^[a-z0-9][a-z0-9._:-]{1,63}$") + val CHAIN_ID = Regex("^[A-Za-z0-9._:-]{2,128}$") + val DECIMAL_RANGE = 0..255 + const val MAX_PAGE_LIMIT = 250 + + private val UNSIGNED_INTEGER = Regex("^(0|[1-9][0-9]*)$") + private val HTTPS_URL = Regex("^https://[^\\s]+$") + private val ASSET_URL = Regex("^(https|ipfs)://[^\\s]+$") + + fun validateEnvelope( + accountId: String, + ecosystem: String, + chainId: String, + syncedAtMillis: Long, + errors: MutableSet + ) { + if (!ACCOUNT_ID.matches(accountId)) { + errors += UniversalWalletIndexerValidationError.InvalidAccountId + } + if (UniversalWalletEcosystem.fromId(ecosystem) == null) { + errors += UniversalWalletIndexerValidationError.InvalidEcosystem + } + if (!CHAIN_ID.matches(chainId)) { + errors += UniversalWalletIndexerValidationError.InvalidChainId + } + if (syncedAtMillis <= 0) { + errors += UniversalWalletIndexerValidationError.InvalidSyncedAt + } + } + + fun isUnsignedInteger(value: String): Boolean = UNSIGNED_INTEGER.matches(value) + + fun isHttpsUrl(value: String): Boolean = HTTPS_URL.matches(value) + + fun isAssetUrl(value: String): Boolean = ASSET_URL.matches(value) + + fun isHumanText(value: String, maxLength: Int): Boolean { + val normalized = value.trim() + return normalized.isNotEmpty() && + normalized.length <= maxLength && + normalized.none { it.isISOControl() } + } + + fun isMachineText(value: String, maxLength: Int): Boolean { + return value.isNotEmpty() && + value.length <= maxLength && + value == value.trim() && + value.none { it <= ' ' || it.isISOControl() } + } +} diff --git a/common/src/main/java/jp/co/soramitsu/common/model/UniversalWalletMigrationContract.kt b/common/src/main/java/jp/co/soramitsu/common/model/UniversalWalletMigrationContract.kt new file mode 100644 index 0000000000..8e87a5ffe4 --- /dev/null +++ b/common/src/main/java/jp/co/soramitsu/common/model/UniversalWalletMigrationContract.kt @@ -0,0 +1,206 @@ +package jp.co.soramitsu.common.model + +import com.google.gson.annotations.SerializedName + +const val UNIVERSAL_WALLET_CUTOFF_AT_MILLIS: Long = 1_710_000_000_000L + +data class UniversalWalletMigrationSnapshot( + @SerializedName("schemaVersion") + val schemaVersion: Int = SCHEMA_VERSION, + @SerializedName("platform") + val platform: UniversalWalletMigrationPlatform, + @SerializedName("hasUniversalWallet") + val hasUniversalWallet: Boolean, + @SerializedName("legacyVaults") + val legacyVaults: List = emptyList(), + @SerializedName("cutoffAtMillis") + val cutoffAtMillis: Long, + @SerializedName("evaluatedAtMillis") + val evaluatedAtMillis: Long +) { + fun requiredAction(): UniversalWalletMigrationRequiredAction { + return when { + hasUniversalWallet -> UniversalWalletMigrationRequiredAction.NormalAccess + legacyVaults.isNotEmpty() -> UniversalWalletMigrationRequiredAction.MigrateBeforeAccess + else -> UniversalWalletMigrationRequiredAction.CreateUniversalWallet + } + } + + fun allowsNormalWalletAccess(): Boolean = requiredAction() == UniversalWalletMigrationRequiredAction.NormalAccess + + fun allowsLegacySecretExport(): Boolean = legacyVaults.any { it.canExportSecrets } + + fun validationErrors(): Set { + val errors = linkedSetOf() + + if (schemaVersion != SCHEMA_VERSION) { + errors += UniversalWalletMigrationValidationError.InvalidSchemaVersion + } + if (cutoffAtMillis <= 0 || evaluatedAtMillis <= 0) { + errors += UniversalWalletMigrationValidationError.InvalidTimestamp + } + + val vaultIds = mutableSetOf() + legacyVaults.forEach { vault -> + errors += vault.validationErrors() + if (!vaultIds.add(vault.vaultId)) { + errors += UniversalWalletMigrationValidationError.DuplicateVaultId + } + } + + return errors + } + + companion object { + const val SCHEMA_VERSION = 1 + } +} + +data class UniversalWalletLegacyVaultDescriptor( + @SerializedName("vaultId") + val vaultId: String, + @SerializedName("accountId") + val accountId: String, + @SerializedName("ecosystem") + val ecosystem: String, + @SerializedName("address") + val address: String, + @SerializedName("displayName") + val displayName: String? = null, + @SerializedName("mode") + val mode: UniversalWalletLegacyVaultMode = UniversalWalletLegacyVaultMode.ExportOnly, + @SerializedName("exportOnlyReason") + val exportOnlyReason: String, + @SerializedName("canExportSecrets") + val canExportSecrets: Boolean = true, + @SerializedName("canSignTransactions") + val canSignTransactions: Boolean = false, + @SerializedName("discoveredAtMillis") + val discoveredAtMillis: Long, + @SerializedName("lastExportedAtMillis") + val lastExportedAtMillis: Long? = null +) { + constructor( + vaultId: String, + accountId: String, + ecosystem: UniversalWalletEcosystem, + address: String, + displayName: String? = null, + mode: UniversalWalletLegacyVaultMode = UniversalWalletLegacyVaultMode.ExportOnly, + exportOnlyReason: String, + canExportSecrets: Boolean = true, + canSignTransactions: Boolean = false, + discoveredAtMillis: Long, + lastExportedAtMillis: Long? = null + ) : this( + vaultId = vaultId, + accountId = accountId, + ecosystem = ecosystem.id, + address = address, + displayName = displayName, + mode = mode, + exportOnlyReason = exportOnlyReason, + canExportSecrets = canExportSecrets, + canSignTransactions = canSignTransactions, + discoveredAtMillis = discoveredAtMillis, + lastExportedAtMillis = lastExportedAtMillis + ) + + fun validationErrors(): Set { + val errors = linkedSetOf() + + if (!UniversalWalletMigrationContractValidator.VAULT_ID.matches(vaultId)) { + errors += UniversalWalletMigrationValidationError.InvalidVaultId + } + if (!UniversalWalletMigrationContractValidator.ACCOUNT_ID.matches(accountId)) { + errors += UniversalWalletMigrationValidationError.InvalidAccountId + } + if (UniversalWalletEcosystem.fromId(ecosystem) == null) { + errors += UniversalWalletMigrationValidationError.InvalidEcosystem + } + if (!UniversalWalletMigrationContractValidator.isMachineText(address, maxLength = 256)) { + errors += UniversalWalletMigrationValidationError.InvalidAddress + } + if (displayName != null && !UniversalWalletMigrationContractValidator.isHumanText(displayName, maxLength = 64)) { + errors += UniversalWalletMigrationValidationError.InvalidDisplayName + } + if (mode != UniversalWalletLegacyVaultMode.ExportOnly) { + errors += UniversalWalletMigrationValidationError.InvalidLegacyMode + } + if (!UniversalWalletMigrationContractValidator.isHumanText(exportOnlyReason, maxLength = 160)) { + errors += UniversalWalletMigrationValidationError.InvalidExportReason + } + if (!canExportSecrets) { + errors += UniversalWalletMigrationValidationError.ExportDisabled + } + if (canSignTransactions) { + errors += UniversalWalletMigrationValidationError.LegacySigningEnabled + } + if (discoveredAtMillis <= 0 || lastExportedAtMillis?.let { it < discoveredAtMillis } == true) { + errors += UniversalWalletMigrationValidationError.InvalidTimestamp + } + + return errors + } +} + +enum class UniversalWalletMigrationPlatform { + @SerializedName("android") + Android, + + @SerializedName("ios") + Ios, + + @SerializedName("web") + Web +} + +enum class UniversalWalletMigrationRequiredAction { + @SerializedName("normal-access") + NormalAccess, + + @SerializedName("create-universal-wallet") + CreateUniversalWallet, + + @SerializedName("migrate-before-access") + MigrateBeforeAccess +} + +enum class UniversalWalletLegacyVaultMode { + @SerializedName("export-only") + ExportOnly +} + +enum class UniversalWalletMigrationValidationError { + InvalidSchemaVersion, + InvalidVaultId, + DuplicateVaultId, + InvalidAccountId, + InvalidEcosystem, + InvalidAddress, + InvalidDisplayName, + InvalidLegacyMode, + InvalidExportReason, + ExportDisabled, + LegacySigningEnabled, + InvalidTimestamp +} + +object UniversalWalletMigrationContractValidator { + val VAULT_ID = Regex("^legacy_[A-Za-z0-9_-]{8,64}$") + val ACCOUNT_ID = Regex("^[a-z0-9][a-z0-9._:-]{1,63}$") + + fun isHumanText(value: String, maxLength: Int): Boolean { + val normalized = value.trim() + return normalized.isNotEmpty() && + normalized.length <= maxLength && + normalized.none { it.isISOControl() } + } + + fun isMachineText(value: String, maxLength: Int): Boolean { + return value.isNotEmpty() && + value.length <= maxLength && + value == value.trim() && + value.none { it <= ' ' || it.isISOControl() } + } +} diff --git a/common/src/main/java/jp/co/soramitsu/common/model/UniversalWalletRegistry.kt b/common/src/main/java/jp/co/soramitsu/common/model/UniversalWalletRegistry.kt new file mode 100644 index 0000000000..aad2e80989 --- /dev/null +++ b/common/src/main/java/jp/co/soramitsu/common/model/UniversalWalletRegistry.kt @@ -0,0 +1,266 @@ +package jp.co.soramitsu.common.model + +object UniversalWalletRegistry { + const val BITCOIN_MAINNET_INDEXER_BASE_URL = "https://blockstream.info/api" + const val BITCOIN_TESTNET_INDEXER_BASE_URL = "https://blockstream.info/testnet/api" + const val TON_INDEXER_BASE_URL = "https://ti.soramitsu.io" + const val SOLANA_INDEXER_BASE_URL = "https://si.soramitsu.io" + const val SOLANA_MAINNET_RPC_URL = "https://api.mainnet-beta.solana.com" + const val SOLANA_DEVNET_RPC_URL = "https://api.devnet.solana.com" + + val bitcoinMainnet = BitcoinNetwork( + id = "bitcoin-mainnet", + chainId = "bitcoin:mainnet", + name = "Bitcoin", + slip44CoinType = 0, + addressHrp = "bc", + accountPath = UniversalWalletDerivationPaths.BITCOIN_MAINNET_ACCOUNT, + firstReceivePath = UniversalWalletDerivationPaths.BITCOIN_MAINNET_FIRST_RECEIVE, + indexerBaseUrl = BITCOIN_MAINNET_INDEXER_BASE_URL, + defaultGapLimit = 20, + enabledByDefault = true, + nativeAsset = BitcoinNativeAsset( + id = "BTC", + symbol = "BTC", + decimals = 8 + ) + ) + + val bitcoinTestnet = BitcoinNetwork( + id = "bitcoin-testnet", + chainId = "bitcoin:testnet", + name = "Bitcoin Testnet", + slip44CoinType = 1, + addressHrp = "tb", + accountPath = UniversalWalletDerivationPaths.BITCOIN_TESTNET_ACCOUNT, + firstReceivePath = UniversalWalletDerivationPaths.BITCOIN_TESTNET_FIRST_RECEIVE, + indexerBaseUrl = BITCOIN_TESTNET_INDEXER_BASE_URL, + defaultGapLimit = 20, + enabledByDefault = false, + nativeAsset = BitcoinNativeAsset( + id = "BTC", + symbol = "BTC", + decimals = 8 + ) + ) + + val solanaMainnet = SolanaNetwork( + id = "solana-mainnet", + chainId = "solana:mainnet", + name = "Solana", + indexerBaseUrl = SOLANA_INDEXER_BASE_URL, + rpcUrl = SOLANA_MAINNET_RPC_URL, + enabledByDefault = true, + nativeAsset = SolanaNativeAsset( + id = "SOL", + symbol = "SOL", + decimals = 9 + ) + ) + + val solanaDevnet = SolanaNetwork( + id = "solana-devnet", + chainId = "solana:devnet", + name = "Solana Devnet", + indexerBaseUrl = SOLANA_INDEXER_BASE_URL, + rpcUrl = SOLANA_DEVNET_RPC_URL, + enabledByDefault = false, + nativeAsset = SolanaNativeAsset( + id = "SOL", + symbol = "SOL", + decimals = 9 + ) + ) + + val taira = IrohaNetwork( + id = "taira-testnet", + chainId = "iroha3-taira", + chainDiscriminant = 369, + toriiBaseUrl = "https://taira.sora.org", + mcpPath = "/v1/mcp", + enabledByDefault = true, + features = listOf("transfer") + ) + + val nexus = IrohaNetwork( + id = "sora-nexus-mainnet", + chainId = "sora:nexus:global", + chainDiscriminant = 753, + toriiBaseUrl = "https://minamoto.sora.org", + mcpPath = "/v1/mcp", + enabledByDefault = false, + features = listOf("transfer", "offline-cash", "sccp", "governance") + ) + + val bitcoinMainnetRegistryEntry = bitcoinMainnet.toRegistryEntry() + val bitcoinTestnetRegistryEntry = bitcoinTestnet.toRegistryEntry() + val tonMainnetRegistryEntry = UniversalWalletChainRegistryEntry( + id = "ton-mainnet", + ecosystem = UniversalWalletEcosystem.Ton, + chainId = "ton:mainnet", + displayName = "TON", + enabledByDefault = true, + nativeAsset = UniversalWalletRegistryAsset( + id = "TON", + symbol = "TON", + decimals = 9, + name = "Toncoin" + ), + derivationPath = UniversalWalletDerivationPaths.TON_DEFAULT, + slip44CoinType = 607, + endpoints = listOf( + UniversalWalletRegistryEndpoint( + id = "ton-mainnet-indexer", + kind = UniversalWalletRegistryEndpointKind.Indexer, + url = TON_INDEXER_BASE_URL, + readOnly = true + ) + ) + ) + val solanaMainnetRegistryEntry = solanaMainnet.toRegistryEntry( + derivationPath = UniversalWalletDerivationPaths.SOLANA_DEFAULT, + slip44CoinType = 501 + ) + val solanaDevnetRegistryEntry = solanaDevnet.toRegistryEntry( + derivationPath = UniversalWalletDerivationPaths.SOLANA_DEFAULT, + slip44CoinType = 501 + ) + val tairaRegistryEntry = taira.toRegistryEntry(displayName = "Taira Testnet") + val nexusRegistryEntry = nexus.toRegistryEntry(displayName = "SORA Nexus") + val chainRegistry = UniversalWalletChainRegistry( + chains = listOf( + bitcoinMainnetRegistryEntry, + bitcoinTestnetRegistryEntry, + tonMainnetRegistryEntry, + solanaMainnetRegistryEntry, + solanaDevnetRegistryEntry, + tairaRegistryEntry, + nexusRegistryEntry + ) + ) + + data class IrohaNetwork( + val id: String, + val chainId: String, + val chainDiscriminant: Int, + val toriiBaseUrl: String?, + val mcpPath: String, + val enabledByDefault: Boolean, + val features: List + ) + + data class BitcoinNetwork( + val id: String, + val chainId: String, + val name: String, + val slip44CoinType: Int, + val addressHrp: String, + val accountPath: String, + val firstReceivePath: String, + val indexerBaseUrl: String, + val defaultGapLimit: Int, + val enabledByDefault: Boolean, + val nativeAsset: BitcoinNativeAsset + ) + + data class BitcoinNativeAsset( + val id: String, + val symbol: String, + val decimals: Int + ) + + data class SolanaNetwork( + val id: String, + val chainId: String, + val name: String, + val indexerBaseUrl: String, + val rpcUrl: String, + val enabledByDefault: Boolean, + val nativeAsset: SolanaNativeAsset + ) + + data class SolanaNativeAsset( + val id: String, + val symbol: String, + val decimals: Int + ) + + private fun BitcoinNetwork.toRegistryEntry() = UniversalWalletChainRegistryEntry( + id = id, + ecosystem = UniversalWalletEcosystem.Bitcoin, + chainId = chainId, + displayName = name, + enabledByDefault = enabledByDefault, + nativeAsset = UniversalWalletRegistryAsset( + id = nativeAsset.id, + symbol = nativeAsset.symbol, + decimals = nativeAsset.decimals, + name = name + ), + derivationPath = accountPath, + slip44CoinType = slip44CoinType, + endpoints = listOf( + UniversalWalletRegistryEndpoint( + id = "$id-indexer", + kind = UniversalWalletRegistryEndpointKind.Indexer, + url = indexerBaseUrl, + readOnly = true + ) + ) + ) + + private fun SolanaNetwork.toRegistryEntry( + derivationPath: String, + slip44CoinType: Int + ) = UniversalWalletChainRegistryEntry( + id = id, + ecosystem = UniversalWalletEcosystem.Solana, + chainId = chainId, + displayName = name, + enabledByDefault = enabledByDefault, + nativeAsset = UniversalWalletRegistryAsset( + id = nativeAsset.id, + symbol = nativeAsset.symbol, + decimals = nativeAsset.decimals, + name = name + ), + derivationPath = derivationPath, + slip44CoinType = slip44CoinType, + endpoints = listOf( + UniversalWalletRegistryEndpoint( + id = "$id-indexer", + kind = UniversalWalletRegistryEndpointKind.Indexer, + url = indexerBaseUrl, + readOnly = true + ), + UniversalWalletRegistryEndpoint( + id = "$id-rpc", + kind = UniversalWalletRegistryEndpointKind.Rpc, + url = rpcUrl, + readOnly = false, + priority = 1 + ) + ) + ) + + private fun IrohaNetwork.toRegistryEntry(displayName: String) = UniversalWalletChainRegistryEntry( + id = id, + ecosystem = UniversalWalletEcosystem.Iroha, + chainId = chainId, + displayName = displayName, + enabledByDefault = enabledByDefault, + derivationPath = UniversalWalletDerivationPaths.IROHA_DEFAULT, + slip44CoinType = 617, + features = features, + endpoints = toriiBaseUrl?.let { baseUrl -> + listOf( + UniversalWalletRegistryEndpoint( + id = "$id-torii-mcp", + kind = UniversalWalletRegistryEndpointKind.ToriiMcp, + url = baseUrl.removeSuffix("/") + mcpPath, + readOnly = false + ) + ) + } ?: emptyList() + ) +} diff --git a/common/src/main/java/jp/co/soramitsu/common/model/UniversalWalletRegistryContract.kt b/common/src/main/java/jp/co/soramitsu/common/model/UniversalWalletRegistryContract.kt new file mode 100644 index 0000000000..2a2155aa73 --- /dev/null +++ b/common/src/main/java/jp/co/soramitsu/common/model/UniversalWalletRegistryContract.kt @@ -0,0 +1,254 @@ +package jp.co.soramitsu.common.model + +import com.google.gson.annotations.SerializedName + +data class UniversalWalletChainRegistry( + @SerializedName("schemaVersion") + val schemaVersion: Int = SCHEMA_VERSION, + @SerializedName("chains") + val chains: List +) { + fun validationErrors(): Set { + val errors = linkedSetOf() + + if (schemaVersion != SCHEMA_VERSION) { + errors += UniversalWalletRegistryValidationError.InvalidSchemaVersion + } + if (chains.isEmpty()) { + errors += UniversalWalletRegistryValidationError.ChainsRequired + } + + val ids = mutableSetOf() + val chainIds = mutableSetOf() + chains.forEach { chain -> + errors += chain.validationErrors() + if (!ids.add(chain.id)) { + errors += UniversalWalletRegistryValidationError.DuplicateChainId + } + if (!chainIds.add(chain.chainId)) { + errors += UniversalWalletRegistryValidationError.DuplicateChainId + } + } + + return errors + } + + companion object { + const val SCHEMA_VERSION = 1 + } +} + +data class UniversalWalletChainRegistryEntry( + @SerializedName("id") + val id: String, + @SerializedName("ecosystem") + val ecosystem: String, + @SerializedName("chainId") + val chainId: String, + @SerializedName("displayName") + val displayName: String, + @SerializedName("enabledByDefault") + val enabledByDefault: Boolean, + @SerializedName("nativeAsset") + val nativeAsset: UniversalWalletRegistryAsset? = null, + @SerializedName("derivationPath") + val derivationPath: String? = null, + @SerializedName("slip44CoinType") + val slip44CoinType: Int? = null, + @SerializedName("features") + val features: List = emptyList(), + @SerializedName("endpoints") + val endpoints: List = emptyList() +) { + constructor( + id: String, + ecosystem: UniversalWalletEcosystem, + chainId: String, + displayName: String, + enabledByDefault: Boolean, + nativeAsset: UniversalWalletRegistryAsset? = null, + derivationPath: String? = null, + slip44CoinType: Int? = null, + features: List = emptyList(), + endpoints: List = emptyList() + ) : this( + id = id, + ecosystem = ecosystem.id, + chainId = chainId, + displayName = displayName, + enabledByDefault = enabledByDefault, + nativeAsset = nativeAsset, + derivationPath = derivationPath, + slip44CoinType = slip44CoinType, + features = features, + endpoints = endpoints + ) + + fun validationErrors(): Set { + val errors = linkedSetOf() + + if (!UniversalWalletRegistryContractValidator.ID.matches(id)) { + errors += UniversalWalletRegistryValidationError.InvalidId + } + if (UniversalWalletEcosystem.fromId(ecosystem) == null) { + errors += UniversalWalletRegistryValidationError.InvalidEcosystem + } + if (!UniversalWalletRegistryContractValidator.CHAIN_ID.matches(chainId)) { + errors += UniversalWalletRegistryValidationError.InvalidChainId + } + if (!UniversalWalletRegistryContractValidator.isHumanText(displayName, maxLength = 80)) { + errors += UniversalWalletRegistryValidationError.InvalidDisplayName + } + if (enabledByDefault && endpoints.isEmpty()) { + errors += UniversalWalletRegistryValidationError.EndpointRequired + } + if (nativeAsset != null) { + errors += nativeAsset.validationErrors() + } + if (!derivationPath.isNullOrBlank() && !UniversalWalletRegistryContractValidator.DERIVATION_PATH.matches(derivationPath)) { + errors += UniversalWalletRegistryValidationError.InvalidDerivationPath + } + if (slip44CoinType != null && slip44CoinType < 0) { + errors += UniversalWalletRegistryValidationError.InvalidSlip44CoinType + } + + val featureIds = mutableSetOf() + features.forEach { feature -> + if (!UniversalWalletRegistryContractValidator.FEATURE_IDS.contains(feature)) { + errors += UniversalWalletRegistryValidationError.InvalidFeatureId + } + if (!featureIds.add(feature)) { + errors += UniversalWalletRegistryValidationError.DuplicateFeatureId + } + } + + val endpointIds = mutableSetOf() + endpoints.forEach { endpoint -> + errors += endpoint.validationErrors() + if (!endpointIds.add(endpoint.id)) { + errors += UniversalWalletRegistryValidationError.DuplicateEndpointId + } + } + + return errors + } +} + +data class UniversalWalletRegistryAsset( + @SerializedName("id") + val id: String, + @SerializedName("symbol") + val symbol: String, + @SerializedName("decimals") + val decimals: Int, + @SerializedName("name") + val name: String? = null +) { + fun validationErrors(): Set { + val errors = linkedSetOf() + + if (!UniversalWalletRegistryContractValidator.ASSET_ID.matches(id)) { + errors += UniversalWalletRegistryValidationError.InvalidAssetId + } + if (!UniversalWalletRegistryContractValidator.SYMBOL.matches(symbol)) { + errors += UniversalWalletRegistryValidationError.InvalidAssetSymbol + } + if (decimals !in 0..255) { + errors += UniversalWalletRegistryValidationError.InvalidAssetDecimals + } + if (name != null && !UniversalWalletRegistryContractValidator.isHumanText(name, maxLength = 80)) { + errors += UniversalWalletRegistryValidationError.InvalidAssetName + } + + return errors + } +} + +data class UniversalWalletRegistryEndpoint( + @SerializedName("id") + val id: String, + @SerializedName("kind") + val kind: UniversalWalletRegistryEndpointKind, + @SerializedName("url") + val url: String, + @SerializedName("readOnly") + val readOnly: Boolean, + @SerializedName("priority") + val priority: Int = 0 +) { + fun validationErrors(): Set { + val errors = linkedSetOf() + + if (!UniversalWalletRegistryContractValidator.ID.matches(id)) { + errors += UniversalWalletRegistryValidationError.InvalidEndpointId + } + if (!UniversalWalletRegistryContractValidator.isPublicOrLocalUrl(url)) { + errors += UniversalWalletRegistryValidationError.InvalidEndpointUrl + } + if (priority < 0) { + errors += UniversalWalletRegistryValidationError.InvalidEndpointPriority + } + if (!readOnly && kind == UniversalWalletRegistryEndpointKind.Indexer) { + errors += UniversalWalletRegistryValidationError.PublicWriteIndexer + } + + return errors + } +} + +enum class UniversalWalletRegistryEndpointKind { + @SerializedName("indexer") + Indexer, + + @SerializedName("rpc") + Rpc, + + @SerializedName("torii-mcp") + ToriiMcp, + + @SerializedName("explorer") + Explorer +} + +enum class UniversalWalletRegistryValidationError { + InvalidSchemaVersion, + ChainsRequired, + DuplicateChainId, + InvalidId, + InvalidEcosystem, + InvalidChainId, + InvalidDisplayName, + EndpointRequired, + InvalidDerivationPath, + InvalidSlip44CoinType, + InvalidAssetId, + InvalidAssetSymbol, + InvalidAssetDecimals, + InvalidAssetName, + DuplicateEndpointId, + InvalidEndpointId, + InvalidEndpointUrl, + InvalidEndpointPriority, + DuplicateFeatureId, + InvalidFeatureId, + PublicWriteIndexer +} + +object UniversalWalletRegistryContractValidator { + val ID = Regex("^[a-z0-9][a-z0-9._:-]{1,63}$") + val CHAIN_ID = Regex("^[A-Za-z0-9._:-]{2,128}$") + val ASSET_ID = Regex("^[A-Za-z0-9._:-]{1,128}$") + val SYMBOL = Regex("^[A-Z0-9]{2,16}$") + val DERIVATION_PATH = Regex("^m(?:/[0-9]+'?)*$") + val FEATURE_IDS = setOf("transfer", "offline-cash", "sccp", "governance") + private val PUBLIC_OR_LOCAL_URL = Regex("^(https://[^\\s]+|http://(?:localhost|127\\.0\\.0\\.1)(?::[0-9]+)?(?:/[^\\s]*)?)$") + + fun isPublicOrLocalUrl(value: String): Boolean = PUBLIC_OR_LOCAL_URL.matches(value) + + fun isHumanText(value: String, maxLength: Int): Boolean { + val normalized = value.trim() + return normalized.isNotEmpty() && + normalized.length <= maxLength && + normalized.none { it.isISOControl() } + } +} diff --git a/common/src/main/java/jp/co/soramitsu/common/model/UniversalWalletSigningContract.kt b/common/src/main/java/jp/co/soramitsu/common/model/UniversalWalletSigningContract.kt new file mode 100644 index 0000000000..71fea51d7a --- /dev/null +++ b/common/src/main/java/jp/co/soramitsu/common/model/UniversalWalletSigningContract.kt @@ -0,0 +1,379 @@ +package jp.co.soramitsu.common.model + +import com.google.gson.annotations.SerializedName + +data class UniversalWalletSigningRequest( + @SerializedName("requestId") + val requestId: String, + @SerializedName("accountId") + val accountId: String, + @SerializedName("ecosystem") + val ecosystem: String, + @SerializedName("chainId") + val chainId: String, + @SerializedName("origin") + val origin: String, + @SerializedName("method") + val method: UniversalWalletSigningMethod, + @SerializedName("message") + val message: UniversalWalletSigningPayload? = null, + @SerializedName("transactionBase64") + val transactionBase64: String? = null, + @SerializedName("transactionsBase64") + val transactionsBase64: List = emptyList(), + @SerializedName("createdAtMillis") + val createdAtMillis: Long, + @SerializedName("expiresAtMillis") + val expiresAtMillis: Long? = null +) { + fun validationErrors(): Set { + val errors = linkedSetOf() + + UniversalWalletSigningContractValidator.validateEnvelope( + requestId = requestId, + accountId = accountId, + ecosystem = ecosystem, + chainId = chainId, + origin = origin, + createdAtMillis = createdAtMillis, + expiresAtMillis = expiresAtMillis, + errors = errors + ) + + when (method) { + UniversalWalletSigningMethod.SignMessage -> { + if (message?.validationErrors()?.also(errors::addAll).isNullOrEmpty()) { + if (message == null) { + errors += UniversalWalletSigningValidationError.MessageRequired + } + } + if (transactionBase64 != null || transactionsBase64.isNotEmpty()) { + errors += UniversalWalletSigningValidationError.PayloadNotAllowed + } + } + UniversalWalletSigningMethod.SignTransaction, + UniversalWalletSigningMethod.SignAndSendTransaction -> { + if (!UniversalWalletSigningContractValidator.isBase64Payload(transactionBase64, maxLength = UniversalWalletSigningContractValidator.MAX_TRANSACTION_BASE64_LENGTH)) { + errors += UniversalWalletSigningValidationError.TransactionRequired + } + if (message != null || transactionsBase64.isNotEmpty()) { + errors += UniversalWalletSigningValidationError.PayloadNotAllowed + } + } + UniversalWalletSigningMethod.SignAllTransactions -> { + if (transactionsBase64.isEmpty() || transactionsBase64.size > UniversalWalletSigningContractValidator.MAX_TRANSACTION_BATCH) { + errors += UniversalWalletSigningValidationError.InvalidBatch + } + if (transactionsBase64.any { !UniversalWalletSigningContractValidator.isBase64Payload(it, maxLength = UniversalWalletSigningContractValidator.MAX_TRANSACTION_BASE64_LENGTH) }) { + errors += UniversalWalletSigningValidationError.InvalidBase64 + } + if (message != null || transactionBase64 != null) { + errors += UniversalWalletSigningValidationError.PayloadNotAllowed + } + } + } + + return errors + } +} + +data class UniversalWalletSigningPayload( + @SerializedName("encoding") + val encoding: UniversalWalletSigningPayloadEncoding, + @SerializedName("value") + val value: String, + @SerializedName("display") + val display: UniversalWalletSigningDisplay = UniversalWalletSigningDisplay.Raw +) { + fun validationErrors(): Set { + val errors = linkedSetOf() + + when (encoding) { + UniversalWalletSigningPayloadEncoding.Base64 -> { + if (!UniversalWalletSigningContractValidator.isBase64Payload(value, maxLength = UniversalWalletSigningContractValidator.MAX_MESSAGE_BASE64_LENGTH)) { + errors += UniversalWalletSigningValidationError.InvalidBase64 + } + } + UniversalWalletSigningPayloadEncoding.Hex -> { + if (!UniversalWalletSigningContractValidator.isHexPayload(value, maxLength = UniversalWalletSigningContractValidator.MAX_MESSAGE_HEX_LENGTH)) { + errors += UniversalWalletSigningValidationError.InvalidHex + } + } + UniversalWalletSigningPayloadEncoding.Utf8 -> { + if (!UniversalWalletSigningContractValidator.isHumanText(value, maxLength = UniversalWalletSigningContractValidator.MAX_MESSAGE_TEXT_LENGTH)) { + errors += UniversalWalletSigningValidationError.InvalidMessage + } + } + } + + return errors + } +} + +data class UniversalWalletSigningResult( + @SerializedName("requestId") + val requestId: String, + @SerializedName("accountId") + val accountId: String, + @SerializedName("ecosystem") + val ecosystem: String, + @SerializedName("chainId") + val chainId: String, + @SerializedName("method") + val method: UniversalWalletSigningMethod, + @SerializedName("status") + val status: UniversalWalletSigningResultStatus, + @SerializedName("publicKey") + val publicKey: String? = null, + @SerializedName("signatureHex") + val signatureHex: String? = null, + @SerializedName("signatureBase64") + val signatureBase64: String? = null, + @SerializedName("signatureBase58") + val signatureBase58: String? = null, + @SerializedName("signedTransactionBase64") + val signedTransactionBase64: String? = null, + @SerializedName("signedTransactionsBase64") + val signedTransactionsBase64: List = emptyList(), + @SerializedName("transactionHash") + val transactionHash: String? = null, + @SerializedName("errorCode") + val errorCode: String? = null, + @SerializedName("signedAtMillis") + val signedAtMillis: Long +) { + fun validationErrors(): Set { + val errors = linkedSetOf() + + UniversalWalletSigningContractValidator.validateEnvelope( + requestId = requestId, + accountId = accountId, + ecosystem = ecosystem, + chainId = chainId, + origin = "result", + createdAtMillis = signedAtMillis, + expiresAtMillis = null, + errors = errors + ) + + if (status == UniversalWalletSigningResultStatus.Approved) { + if (publicKey != null && !UniversalWalletSigningContractValidator.isMachineText(publicKey, maxLength = 256)) { + errors += UniversalWalletSigningValidationError.InvalidPublicKey + } + if (publicKey == null) { + errors += UniversalWalletSigningValidationError.PublicKeyRequired + } + + validateApprovedPayload(errors) + if (errorCode != null) { + errors += UniversalWalletSigningValidationError.ErrorNotAllowed + } + } else { + if (errorCode.isNullOrBlank() || !UniversalWalletSigningContractValidator.ERROR_CODE.matches(errorCode)) { + errors += UniversalWalletSigningValidationError.ErrorCodeRequired + } + if (publicKey != null || signatureHex != null || signatureBase64 != null || signatureBase58 != null || + signedTransactionBase64 != null || signedTransactionsBase64.isNotEmpty() || transactionHash != null + ) { + errors += UniversalWalletSigningValidationError.SignatureNotAllowed + } + } + + return errors + } + + private fun validateApprovedPayload(errors: MutableSet) { + val hasSignature = signatureHex != null || signatureBase64 != null || signatureBase58 != null + if (signatureHex != null && !UniversalWalletSigningContractValidator.SIGNATURE_HEX.matches(signatureHex)) { + errors += UniversalWalletSigningValidationError.InvalidSignature + } + if (signatureBase64 != null && !UniversalWalletSigningContractValidator.isBase64Payload(signatureBase64, maxLength = 512)) { + errors += UniversalWalletSigningValidationError.InvalidSignature + } + if (signatureBase58 != null && !UniversalWalletSigningContractValidator.BASE58_SIGNATURE.matches(signatureBase58)) { + errors += UniversalWalletSigningValidationError.InvalidSignature + } + + when (method) { + UniversalWalletSigningMethod.SignMessage -> { + if (!hasSignature) { + errors += UniversalWalletSigningValidationError.SignatureRequired + } + if (signedTransactionBase64 != null || signedTransactionsBase64.isNotEmpty() || transactionHash != null) { + errors += UniversalWalletSigningValidationError.PayloadNotAllowed + } + } + UniversalWalletSigningMethod.SignTransaction -> { + if (!UniversalWalletSigningContractValidator.isBase64Payload(signedTransactionBase64, maxLength = UniversalWalletSigningContractValidator.MAX_TRANSACTION_BASE64_LENGTH)) { + errors += UniversalWalletSigningValidationError.SignedTransactionRequired + } + if (signedTransactionsBase64.isNotEmpty() || transactionHash != null) { + errors += UniversalWalletSigningValidationError.PayloadNotAllowed + } + } + UniversalWalletSigningMethod.SignAndSendTransaction -> { + if (!UniversalWalletSigningContractValidator.isBase64Payload(signedTransactionBase64, maxLength = UniversalWalletSigningContractValidator.MAX_TRANSACTION_BASE64_LENGTH)) { + errors += UniversalWalletSigningValidationError.SignedTransactionRequired + } + if (!UniversalWalletSigningContractValidator.isMachineText(transactionHash.orEmpty(), maxLength = 256)) { + errors += UniversalWalletSigningValidationError.TransactionHashRequired + } + if (signedTransactionsBase64.isNotEmpty()) { + errors += UniversalWalletSigningValidationError.PayloadNotAllowed + } + } + UniversalWalletSigningMethod.SignAllTransactions -> { + if (signedTransactionsBase64.isEmpty() || signedTransactionsBase64.size > UniversalWalletSigningContractValidator.MAX_TRANSACTION_BATCH) { + errors += UniversalWalletSigningValidationError.SignedTransactionRequired + } + if (signedTransactionsBase64.any { !UniversalWalletSigningContractValidator.isBase64Payload(it, maxLength = UniversalWalletSigningContractValidator.MAX_TRANSACTION_BASE64_LENGTH) }) { + errors += UniversalWalletSigningValidationError.InvalidBase64 + } + if (signedTransactionBase64 != null || transactionHash != null) { + errors += UniversalWalletSigningValidationError.PayloadNotAllowed + } + } + } + } +} + +enum class UniversalWalletSigningMethod { + @SerializedName("sign-message") + SignMessage, + + @SerializedName("sign-transaction") + SignTransaction, + + @SerializedName("sign-and-send-transaction") + SignAndSendTransaction, + + @SerializedName("sign-all-transactions") + SignAllTransactions +} + +enum class UniversalWalletSigningPayloadEncoding { + @SerializedName("base64") + Base64, + + @SerializedName("hex") + Hex, + + @SerializedName("utf8") + Utf8 +} + +enum class UniversalWalletSigningDisplay { + @SerializedName("raw") + Raw, + + @SerializedName("utf8") + Utf8, + + @SerializedName("hex") + Hex +} + +enum class UniversalWalletSigningResultStatus { + @SerializedName("approved") + Approved, + + @SerializedName("rejected") + Rejected, + + @SerializedName("failed") + Failed +} + +enum class UniversalWalletSigningValidationError { + InvalidRequestId, + InvalidAccountId, + InvalidEcosystem, + InvalidChainId, + InvalidOrigin, + InvalidTimestamp, + MessageRequired, + TransactionRequired, + InvalidBatch, + InvalidBase64, + InvalidHex, + InvalidMessage, + PayloadNotAllowed, + PublicKeyRequired, + InvalidPublicKey, + SignatureRequired, + InvalidSignature, + SignedTransactionRequired, + TransactionHashRequired, + ErrorCodeRequired, + ErrorNotAllowed, + SignatureNotAllowed +} + +object UniversalWalletSigningContractValidator { + const val MAX_MESSAGE_TEXT_LENGTH = 64 * 1024 + const val MAX_MESSAGE_BASE64_LENGTH = 88 * 1024 + const val MAX_MESSAGE_HEX_LENGTH = 128 * 1024 + const val MAX_TRANSACTION_BASE64_LENGTH = 352 * 1024 + const val MAX_TRANSACTION_BATCH = 16 + + val REQUEST_ID = Regex("^sign_[A-Za-z0-9_-]{16,64}$") + val ACCOUNT_ID = Regex("^[a-z0-9][a-z0-9._:-]{1,63}$") + val CHAIN_ID = Regex("^[A-Za-z0-9._:-]{2,128}$") + val BASE64 = Regex("^(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=)?$") + val HEX = Regex("^(?:[0-9a-f]{2})+$") + val SIGNATURE_HEX = Regex("^[0-9a-f]{64,260}$") + val BASE58_SIGNATURE = Regex("^[1-9A-HJ-NP-Za-km-z]{64,128}$") + val ERROR_CODE = Regex("^[a-z][a-z0-9_:-]{1,63}$") + + fun validateEnvelope( + requestId: String, + accountId: String, + ecosystem: String, + chainId: String, + origin: String, + createdAtMillis: Long, + expiresAtMillis: Long?, + errors: MutableSet + ) { + if (!REQUEST_ID.matches(requestId)) { + errors += UniversalWalletSigningValidationError.InvalidRequestId + } + if (!ACCOUNT_ID.matches(accountId)) { + errors += UniversalWalletSigningValidationError.InvalidAccountId + } + if (UniversalWalletEcosystem.fromId(ecosystem) == null) { + errors += UniversalWalletSigningValidationError.InvalidEcosystem + } + if (!CHAIN_ID.matches(chainId)) { + errors += UniversalWalletSigningValidationError.InvalidChainId + } + if (!isMachineText(origin, maxLength = 512)) { + errors += UniversalWalletSigningValidationError.InvalidOrigin + } + if (createdAtMillis <= 0 || expiresAtMillis?.let { it <= createdAtMillis } == true) { + errors += UniversalWalletSigningValidationError.InvalidTimestamp + } + } + + fun isBase64Payload(value: String?, maxLength: Int): Boolean { + return value != null && value.isNotEmpty() && value.length <= maxLength && BASE64.matches(value) + } + + fun isHexPayload(value: String, maxLength: Int): Boolean { + return value.isNotEmpty() && value.length <= maxLength && HEX.matches(value) + } + + fun isHumanText(value: String, maxLength: Int): Boolean { + val normalized = value.trim() + return normalized.isNotEmpty() && + normalized.length <= maxLength && + normalized.none { it.isISOControl() } + } + + fun isMachineText(value: String, maxLength: Int): Boolean { + return value.isNotEmpty() && + value.length <= maxLength && + value == value.trim() && + value.none { it <= ' ' || it.isISOControl() } + } +} diff --git a/common/src/main/java/jp/co/soramitsu/common/model/WalletEcosystem.kt b/common/src/main/java/jp/co/soramitsu/common/model/WalletEcosystem.kt index dd7534eb8f..a51a37334d 100644 --- a/common/src/main/java/jp/co/soramitsu/common/model/WalletEcosystem.kt +++ b/common/src/main/java/jp/co/soramitsu/common/model/WalletEcosystem.kt @@ -3,7 +3,12 @@ package jp.co.soramitsu.common.model import jp.co.soramitsu.core.models.Ecosystem enum class WalletEcosystem { - Substrate, Ethereum, Ton + Substrate, + Ethereum, + Ton, + Bitcoin, + Solana, + Iroha } fun Ecosystem.toAccountType() = when (this) { @@ -11,4 +16,4 @@ fun Ecosystem.toAccountType() = when (this) { Ecosystem.Ton -> WalletEcosystem.Ton Ecosystem.EthereumBased, Ecosystem.Ethereum -> WalletEcosystem.Ethereum -} \ No newline at end of file +} diff --git a/common/src/main/java/jp/co/soramitsu/common/utils/Base58Ext.kt b/common/src/main/java/jp/co/soramitsu/common/utils/Base58Ext.kt index 34a048cf15..c0412f367e 100644 --- a/common/src/main/java/jp/co/soramitsu/common/utils/Base58Ext.kt +++ b/common/src/main/java/jp/co/soramitsu/common/utils/Base58Ext.kt @@ -1,6 +1,6 @@ package jp.co.soramitsu.common.utils -import jp.co.soramitsu.shared_utils.encrypt.Base58 +import jp.co.soramitsu.fearless_utils.encrypt.Base58 object Base58Ext { diff --git a/common/src/main/java/jp/co/soramitsu/common/utils/BitcoinKeyDerivation.kt b/common/src/main/java/jp/co/soramitsu/common/utils/BitcoinKeyDerivation.kt new file mode 100644 index 0000000000..eb01fd4752 --- /dev/null +++ b/common/src/main/java/jp/co/soramitsu/common/utils/BitcoinKeyDerivation.kt @@ -0,0 +1,403 @@ +package jp.co.soramitsu.common.utils + +import jp.co.soramitsu.common.model.UniversalWalletDerivationPaths +import org.bouncycastle.crypto.digests.RIPEMD160Digest +import org.bouncycastle.crypto.ec.CustomNamedCurves +import java.math.BigInteger +import java.nio.ByteBuffer +import java.security.MessageDigest +import javax.crypto.Mac +import javax.crypto.SecretKeyFactory +import javax.crypto.spec.PBEKeySpec +import javax.crypto.spec.SecretKeySpec + +object BitcoinKeyDerivation { + + private const val BIP32_SEED_KEY = "Bitcoin seed" + private const val HARDENED_OFFSET = 0x80000000L + private const val MAX_CHILD_INDEX = 0x7fffffffL + private const val PRIVATE_KEY_LENGTH = 32 + private const val PUBLIC_KEY_LENGTH = 33 + private const val CHAIN_CODE_LENGTH = 32 + private const val BIP39_SEED_LENGTH_BITS = 512 + private const val BIP39_ROUNDS = 2048 + private const val XPUB_VERSION = 0x0488B21E + private const val XPUB_PAYLOAD_LENGTH = 78 + private const val UINT_32_BYTES = 4 + + private val BASE58_ALPHABET = "123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz".toCharArray() + private val BECH32_ALPHABET = "qpzry9x8gf2tvdw0s3jn54khce6mua7l".toCharArray() + private val BECH32_GENERATORS = intArrayOf(0x3b6a57b2, 0x26508e6d, 0x1ea119fa, 0x3d4233dd, 0x2a1462b3) + private val SECP256K1 = CustomNamedCurves.getByName("secp256k1") + private val SECP256K1_N = SECP256K1.n + + fun deriveAccount( + mnemonic: String, + passphrase: String = "", + network: Network = Network.Mainnet + ): BitcoinAccount { + val normalizedMnemonic = normalizeMnemonic(mnemonic) + val seed = bip39Seed(normalizedMnemonic, passphrase) + val accountPath = accountPath(network) + val firstReceivePath = firstReceivePath(network) + val accountNode = derivePrivateKey(seed, accountPath) + val receiveNode = derivePrivateKey(seed, firstReceivePath) + val receivePublicKey = publicKeyFromPrivateKey(receiveNode.privateKey) + + return BitcoinAccount( + network = network, + accountPath = accountPath, + firstReceivePath = firstReceivePath, + accountXpub = serializeXpub(accountNode), + privateKey = receiveNode.privateKey, + chainCode = receiveNode.chainCode, + publicKey = receivePublicKey, + firstReceiveAddress = addressFromPublicKey(receivePublicKey, network) + ) + } + + fun deriveKey( + mnemonic: String, + passphrase: String = "", + derivationPath: String, + network: Network = Network.Mainnet + ): BitcoinDerivedKey { + val normalizedMnemonic = normalizeMnemonic(mnemonic) + val node = derivePrivateKey(bip39Seed(normalizedMnemonic, passphrase), derivationPath) + val publicKey = publicKeyFromPrivateKey(node.privateKey) + + return BitcoinDerivedKey( + network = network, + derivationPath = derivationPath, + privateKey = node.privateKey, + chainCode = node.chainCode, + publicKey = publicKey, + address = addressFromPublicKey(publicKey, network) + ) + } + + fun derivePrivateKey(seed: ByteArray, derivationPath: String): ExtendedPrivateKey { + require(seed.isNotEmpty()) { "Seed must not be empty" } + + var digest = hmacSha512(BIP32_SEED_KEY.toByteArray(Charsets.UTF_8), seed) + var node = ExtendedPrivateKey( + privateKey = validatePrivateKey(digest.copyOfRange(0, PRIVATE_KEY_LENGTH)), + chainCode = digest.copyOfRange(PRIVATE_KEY_LENGTH, digest.size), + depth = 0, + parentFingerprint = ByteArray(4), + childNumber = 0 + ) + + parseDerivationPath(derivationPath).forEach { component -> + node = deriveChild(node, component) + } + + return node + } + + fun publicKeyFromPrivateKey(privateKey: ByteArray): ByteArray { + require(privateKey.size == PRIVATE_KEY_LENGTH) { "Bitcoin private key must be 32 bytes" } + + val privateKeyInteger = BigInteger(1, privateKey) + require(privateKeyInteger > BigInteger.ZERO && privateKeyInteger < SECP256K1_N) { + "Bitcoin private key is out of secp256k1 range" + } + + return SECP256K1.g.multiply(privateKeyInteger).normalize().getEncoded(true) + } + + fun addressFromPublicKey(publicKey: ByteArray, network: Network): String { + require(publicKey.size == PUBLIC_KEY_LENGTH) { "Bitcoin compressed public key must be 33 bytes" } + + return bech32Encode(hrp(network), listOf(0) + convertBits(hash160(publicKey).toList(), 8, 5, true)) + } + + fun getReceivePath(network: Network = Network.Mainnet, index: Long = 0, change: Long = 0): String { + require(index in 0..MAX_CHILD_INDEX) { "Invalid Bitcoin receive index" } + require(change == 0L || change == 1L) { "Invalid Bitcoin change index" } + + val coinType = when (network) { + Network.Mainnet -> 0 + Network.Testnet -> 1 + } + + return "m/84'/$coinType'/0'/$change/$index" + } + + private fun deriveChild(parent: ExtendedPrivateKey, component: PathComponent): ExtendedPrivateKey { + val serializedIndex = component.serializedIndex + val data = if (component.hardened) { + byteArrayOf(0) + parent.privateKey + uint32(serializedIndex) + } else { + publicKeyFromPrivateKey(parent.privateKey) + uint32(serializedIndex) + } + + val digest = hmacSha512(parent.chainCode, data) + val tweak = BigInteger(1, digest.copyOfRange(0, PRIVATE_KEY_LENGTH)) + require(tweak < SECP256K1_N) { "Bitcoin child private key tweak is out of range" } + + val parentKey = BigInteger(1, parent.privateKey) + val childKey = tweak.add(parentKey).mod(SECP256K1_N) + require(childKey > BigInteger.ZERO) { "Bitcoin child private key is zero" } + + return ExtendedPrivateKey( + privateKey = childKey.toFixedLengthBytes(PRIVATE_KEY_LENGTH), + chainCode = digest.copyOfRange(PRIVATE_KEY_LENGTH, digest.size), + depth = parent.depth + 1, + parentFingerprint = fingerprint(publicKeyFromPrivateKey(parent.privateKey)), + childNumber = serializedIndex + ) + } + + private fun serializeXpub(node: ExtendedPrivateKey): String { + val publicKey = publicKeyFromPrivateKey(node.privateKey) + val payload = ByteBuffer.allocate(XPUB_PAYLOAD_LENGTH) + .putInt(XPUB_VERSION) + .put(node.depth.toByte()) + .put(node.parentFingerprint) + .putInt(node.childNumber.toInt()) + .put(node.chainCode) + .put(publicKey) + .array() + + return base58CheckEncode(payload) + } + + private fun accountPath(network: Network): String = when (network) { + Network.Mainnet -> UniversalWalletDerivationPaths.BITCOIN_MAINNET_ACCOUNT + Network.Testnet -> UniversalWalletDerivationPaths.BITCOIN_TESTNET_ACCOUNT + } + + private fun firstReceivePath(network: Network): String = when (network) { + Network.Mainnet -> UniversalWalletDerivationPaths.BITCOIN_MAINNET_FIRST_RECEIVE + Network.Testnet -> UniversalWalletDerivationPaths.BITCOIN_TESTNET_FIRST_RECEIVE + } + + private fun parseDerivationPath(derivationPath: String): List { + require(derivationPath.isNotBlank()) { "Derivation path must not be empty" } + require(derivationPath == "m" || derivationPath.startsWith("m/")) { "Derivation path must start with m" } + + if (derivationPath == "m") { + return emptyList() + } + + return derivationPath + .removePrefix("m/") + .split("/") + .map { component -> + val hardened = component.endsWith("'") + val index = if (hardened) component.dropLast(1) else component + + require(index.isNotEmpty() && index.all(Char::isDigit)) { "Derivation path contains an invalid index" } + + val parsed = index.toLong() + require(parsed <= MAX_CHILD_INDEX) { "Derivation index is out of range" } + + PathComponent(parsed, hardened) + } + } + + private fun normalizeMnemonic(mnemonic: String): String { + val words = mnemonic.trim().split(Regex("\\s+")).filter { it.isNotBlank() } + require(words.isNotEmpty()) { "Mnemonic must not be empty" } + + return words.joinToString(" ") + } + + private fun bip39Seed(mnemonic: String, passphrase: String): ByteArray { + val spec = PBEKeySpec( + mnemonic.toCharArray(), + "mnemonic$passphrase".toByteArray(Charsets.UTF_8), + BIP39_ROUNDS, + BIP39_SEED_LENGTH_BITS + ) + + return SecretKeyFactory + .getInstance("PBKDF2WithHmacSHA512") + .generateSecret(spec) + .encoded + } + + private fun hmacSha512(key: ByteArray, data: ByteArray): ByteArray { + val mac = Mac.getInstance("HmacSHA512") + mac.init(SecretKeySpec(key, "HmacSHA512")) + + return mac.doFinal(data) + } + + private fun hash160(value: ByteArray): ByteArray { + val sha256 = MessageDigest.getInstance("SHA-256").digest(value) + val digest = RIPEMD160Digest() + digest.update(sha256, 0, sha256.size) + val output = ByteArray(20) + digest.doFinal(output, 0) + + return output + } + + private fun fingerprint(publicKey: ByteArray): ByteArray = hash160(publicKey).copyOfRange(0, 4) + + private fun validatePrivateKey(privateKey: ByteArray): ByteArray { + val value = BigInteger(1, privateKey) + require(value > BigInteger.ZERO && value < SECP256K1_N) { "Bitcoin private key is out of secp256k1 range" } + + return privateKey + } + + private fun uint32(value: Long): ByteArray = ByteBuffer + .allocate(UINT_32_BYTES) + .putInt(value.toInt()) + .array() + + private fun BigInteger.toFixedLengthBytes(size: Int): ByteArray { + val raw = toByteArray() + val unsigned = if (raw.size > 1 && raw[0] == 0.toByte()) raw.copyOfRange(1, raw.size) else raw + require(unsigned.size <= size) { "Integer does not fit into requested byte length" } + + return ByteArray(size - unsigned.size) + unsigned + } + + private fun base58CheckEncode(payload: ByteArray): String { + val checksum = MessageDigest.getInstance("SHA-256") + .digest(MessageDigest.getInstance("SHA-256").digest(payload)) + .copyOfRange(0, 4) + + return base58Encode(payload + checksum) + } + + private fun base58Encode(bytes: ByteArray): String { + if (bytes.isEmpty()) { + return "" + } + + var value = BigInteger(1, bytes) + val output = StringBuilder() + val radix = BigInteger.valueOf(BASE58_ALPHABET.size.toLong()) + + while (value > BigInteger.ZERO) { + val divRem = value.divideAndRemainder(radix) + output.append(BASE58_ALPHABET[divRem[1].toInt()]) + value = divRem[0] + } + + bytes.takeWhile { it == 0.toByte() }.forEach { _ -> + output.append(BASE58_ALPHABET[0]) + } + + return output.reverse().toString() + } + + private fun bech32Encode(hrp: String, values: List): String { + require(hrp.isNotBlank()) { "Bitcoin bech32 HRP must not be empty" } + + val checksum = bech32CreateChecksum(hrp, values) + val encodedValues = values + checksum + + return hrp + "1" + encodedValues.joinToString("") { BECH32_ALPHABET[it].toString() } + } + + private fun bech32CreateChecksum(hrp: String, values: List): List { + val polymodValues = expandHrp(hrp) + values + List(6) { 0 } + val polymod = bech32Polymod(polymodValues) xor 1 + + return (0 until 6).map { index -> + (polymod shr (5 * (5 - index))) and 31 + } + } + + private fun bech32Polymod(values: List): Int { + var checksum = 1 + + values.forEach { value -> + val top = checksum shr 25 + checksum = ((checksum and 0x1ffffff) shl 5) xor value + + BECH32_GENERATORS.forEachIndexed { index, generator -> + if (((top shr index) and 1) == 1) { + checksum = checksum xor generator + } + } + } + + return checksum + } + + private fun expandHrp(hrp: String): List = + hrp.map { it.code shr 5 } + listOf(0) + hrp.map { it.code and 31 } + + private fun convertBits(values: List, fromBits: Int, toBits: Int, pad: Boolean): List { + var accumulator = 0 + var bits = 0 + val maxValue = (1 shl toBits) - 1 + val maxAccumulator = (1 shl (fromBits + toBits - 1)) - 1 + val result = mutableListOf() + + values.forEach { raw -> + val value = raw.toInt() and 0xff + require(value ushr fromBits == 0) { "Invalid bit group" } + + accumulator = ((accumulator shl fromBits) or value) and maxAccumulator + bits += fromBits + + while (bits >= toBits) { + bits -= toBits + result.add((accumulator shr bits) and maxValue) + } + } + + if (pad) { + if (bits > 0) { + result.add((accumulator shl (toBits - bits)) and maxValue) + } + } else { + require(bits < fromBits && ((accumulator shl (toBits - bits)) and maxValue) == 0) { "Invalid padding" } + } + + return result + } + + private fun hrp(network: Network): String = when (network) { + Network.Mainnet -> "bc" + Network.Testnet -> "tb" + } + + enum class Network { + Mainnet, + Testnet + } + + data class BitcoinAccount( + val network: Network, + val accountPath: String, + val firstReceivePath: String, + val accountXpub: String, + val privateKey: ByteArray, + val chainCode: ByteArray, + val publicKey: ByteArray, + val firstReceiveAddress: String + ) + + data class BitcoinDerivedKey( + val network: Network, + val derivationPath: String, + val privateKey: ByteArray, + val chainCode: ByteArray, + val publicKey: ByteArray, + val address: String + ) + + data class ExtendedPrivateKey( + val privateKey: ByteArray, + val chainCode: ByteArray, + val depth: Int, + val parentFingerprint: ByteArray, + val childNumber: Long + ) + + private data class PathComponent( + val index: Long, + val hardened: Boolean + ) { + val serializedIndex: Long = index + if (hardened) HARDENED_OFFSET else 0 + } +} diff --git a/common/src/main/java/jp/co/soramitsu/common/utils/CryptoUtils.kt b/common/src/main/java/jp/co/soramitsu/common/utils/CryptoUtils.kt index ba739da701..a0cfb26323 100644 --- a/common/src/main/java/jp/co/soramitsu/common/utils/CryptoUtils.kt +++ b/common/src/main/java/jp/co/soramitsu/common/utils/CryptoUtils.kt @@ -1,9 +1,9 @@ package jp.co.soramitsu.common.utils import android.util.Base64 -import jp.co.soramitsu.shared_utils.encrypt.keypair.ECDSAUtils -import jp.co.soramitsu.shared_utils.extensions.toHexString -import jp.co.soramitsu.shared_utils.hash.Hasher.blake2b256 +import jp.co.soramitsu.fearless_utils.encrypt.keypair.ECDSAUtils +import jp.co.soramitsu.fearless_utils.extensions.toHexString +import jp.co.soramitsu.fearless_utils.hash.Hasher.blake2b256 import org.bouncycastle.jcajce.provider.digest.Keccak import java.security.MessageDigest import javax.crypto.Mac diff --git a/common/src/main/java/jp/co/soramitsu/common/utils/FearlessLibExt.kt b/common/src/main/java/jp/co/soramitsu/common/utils/FearlessLibExt.kt index 384c851e7c..3b8e858bce 100644 --- a/common/src/main/java/jp/co/soramitsu/common/utils/FearlessLibExt.kt +++ b/common/src/main/java/jp/co/soramitsu/common/utils/FearlessLibExt.kt @@ -5,32 +5,32 @@ import io.emeraldpay.polkaj.scale.ScaleCodecWriter import java.io.ByteArrayOutputStream import jp.co.soramitsu.common.data.network.runtime.binding.bindNullableNumberConstant import jp.co.soramitsu.common.data.network.runtime.binding.bindNumberConstant -import jp.co.soramitsu.shared_utils.encrypt.junction.BIP32JunctionDecoder -import jp.co.soramitsu.shared_utils.encrypt.mnemonic.Mnemonic -import jp.co.soramitsu.shared_utils.encrypt.seed.SeedFactory -import jp.co.soramitsu.shared_utils.encrypt.seed.ethereum.EthereumSeedFactory -import jp.co.soramitsu.shared_utils.encrypt.seed.substrate.SubstrateSeedFactory -import jp.co.soramitsu.shared_utils.extensions.fromHex -import jp.co.soramitsu.shared_utils.extensions.fromUnsignedBytes -import jp.co.soramitsu.shared_utils.extensions.toHexString -import jp.co.soramitsu.shared_utils.hash.Hasher.blake2b256 -import jp.co.soramitsu.shared_utils.runtime.RuntimeSnapshot -import jp.co.soramitsu.shared_utils.runtime.definitions.types.generics.GenericCall -import jp.co.soramitsu.shared_utils.runtime.definitions.types.generics.GenericEvent -import jp.co.soramitsu.shared_utils.runtime.metadata.RuntimeMetadata -import jp.co.soramitsu.shared_utils.runtime.metadata.module -import jp.co.soramitsu.shared_utils.runtime.metadata.module.Module -import jp.co.soramitsu.shared_utils.runtime.metadata.module.StorageEntry -import jp.co.soramitsu.shared_utils.runtime.metadata.moduleOrNull -import jp.co.soramitsu.shared_utils.runtime.metadata.storageKey -import jp.co.soramitsu.shared_utils.scale.EncodableStruct -import jp.co.soramitsu.shared_utils.scale.Schema -import jp.co.soramitsu.shared_utils.scale.dataType.DataType -import jp.co.soramitsu.shared_utils.scale.dataType.uint32 -import jp.co.soramitsu.shared_utils.scale.dataType.uint64 -import jp.co.soramitsu.shared_utils.ss58.SS58Encoder.toAccountId -import jp.co.soramitsu.shared_utils.wsrpc.mappers.nonNull -import jp.co.soramitsu.shared_utils.wsrpc.mappers.pojo +import jp.co.soramitsu.fearless_utils.encrypt.junction.BIP32JunctionDecoder +import jp.co.soramitsu.fearless_utils.encrypt.mnemonic.Mnemonic +import jp.co.soramitsu.fearless_utils.encrypt.seed.SeedFactory +import jp.co.soramitsu.fearless_utils.encrypt.seed.ethereum.EthereumSeedFactory +import jp.co.soramitsu.fearless_utils.encrypt.seed.substrate.SubstrateSeedFactory +import jp.co.soramitsu.fearless_utils.extensions.fromHex +import jp.co.soramitsu.fearless_utils.extensions.fromUnsignedBytes +import jp.co.soramitsu.fearless_utils.extensions.toHexString +import jp.co.soramitsu.fearless_utils.hash.Hasher.blake2b256 +import jp.co.soramitsu.fearless_utils.runtime.RuntimeSnapshot +import jp.co.soramitsu.fearless_utils.runtime.definitions.types.generics.GenericCall +import jp.co.soramitsu.fearless_utils.runtime.definitions.types.generics.GenericEvent +import jp.co.soramitsu.fearless_utils.runtime.metadata.RuntimeMetadata +import jp.co.soramitsu.fearless_utils.runtime.metadata.module +import jp.co.soramitsu.fearless_utils.runtime.metadata.module.Module +import jp.co.soramitsu.fearless_utils.runtime.metadata.module.StorageEntry +import jp.co.soramitsu.fearless_utils.runtime.metadata.moduleOrNull +import jp.co.soramitsu.fearless_utils.runtime.metadata.storageKey +import jp.co.soramitsu.fearless_utils.scale.EncodableStruct +import jp.co.soramitsu.fearless_utils.scale.Schema +import jp.co.soramitsu.fearless_utils.scale.dataType.DataType +import jp.co.soramitsu.fearless_utils.scale.dataType.uint32 +import jp.co.soramitsu.fearless_utils.scale.dataType.uint64 +import jp.co.soramitsu.fearless_utils.ss58.SS58Encoder.toAccountId +import jp.co.soramitsu.fearless_utils.wsrpc.mappers.nonNull +import jp.co.soramitsu.fearless_utils.wsrpc.mappers.pojo val BIP32JunctionDecoder.DEFAULT_DERIVATION_PATH: String get() = "//44//60//0/0/0" diff --git a/common/src/main/java/jp/co/soramitsu/common/utils/IrohaAddressCodec.kt b/common/src/main/java/jp/co/soramitsu/common/utils/IrohaAddressCodec.kt new file mode 100644 index 0000000000..23b4f308bc --- /dev/null +++ b/common/src/main/java/jp/co/soramitsu/common/utils/IrohaAddressCodec.kt @@ -0,0 +1,395 @@ +package jp.co.soramitsu.common.utils + +import jp.co.soramitsu.common.model.UniversalWalletRegistry + +object IrohaAddressCodec { + const val TAIRA_DISCRIMINANT = 369 + const val NEXUS_DISCRIMINANT = 753 + const val DEV_DISCRIMINANT = 0 + + private const val I105_DISCRIMINANT_MAX = 0x3fff + private const val I105_SENTINEL_SORA = "sora" + private const val I105_SENTINEL_TEST = "test" + private const val I105_SENTINEL_DEV = "dev" + private const val I105_SENTINEL_FALLBACK_PREFIX = "n" + private const val I105_CHECKSUM_LEN = 6 + private const val I105_BASE = 105 + private const val BECH32M_CONST = 0x2bc830a3 + private const val I105_HRP = "snx" + private const val CONTROLLER_SINGLE_KEY_TAG = 0x00 + private const val CURVE_ED25519 = 0x01 + private const val ED25519_PUBLIC_KEY_LENGTH = 32 + private const val BASE58_ALPHABET = "123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz" + private val IROHA_POEM_KANA_HALFWIDTH = listOf( + '\uff72', + '\uff9b', + '\uff8a', + '\uff86', + '\uff8e', + '\uff8d', + '\uff84', + '\uff81', + '\uff98', + '\uff87', + '\uff99', + '\uff66', + '\uff9c', + '\uff76', + '\uff96', + '\uff80', + '\uff9a', + '\uff7f', + '\uff82', + '\uff88', + '\uff85', + '\uff97', + '\uff91', + '\uff73', + '\u30f0', + '\uff89', + '\uff75', + '\uff78', + '\uff94', + '\uff8f', + '\uff79', + '\uff8c', + '\uff7a', + '\uff74', + '\uff83', + '\uff71', + '\uff7b', + '\uff77', + '\uff95', + '\uff92', + '\uff90', + '\uff7c', + '\u30f1', + '\uff8b', + '\uff93', + '\uff7e', + '\uff7d' + ) + private val I105_ALPHABET = BASE58_ALPHABET.toList() + IROHA_POEM_KANA_HALFWIDTH + private val I105_DIGIT_TABLE = I105_ALPHABET.withIndex().associate { it.value to it.index } + private val BECH32_GENERATORS = intArrayOf(0x3b6a57b2, 0x26508e6d, 0x1ea119fa, 0x3d4233dd, 0x2a1462b3) + + enum class NetworkKind { + TAIRA, + NEXUS, + DEV, + CUSTOM + } + + enum class ErrorCode { + ERR_INVALID_LENGTH, + ERR_CHECKSUM_MISMATCH, + ERR_INVALID_HEX_ADDRESS, + ERR_MISSING_I105_SENTINEL, + ERR_I105_TOO_SHORT, + ERR_INVALID_I105_BASE, + ERR_INVALID_I105_CHAR, + ERR_INVALID_I105_DIGIT, + ERR_UNSUPPORTED_ADDRESS_FORMAT, + ERR_UNEXPECTED_NETWORK_PREFIX, + ERR_INVALID_I105_PREFIX, + ERR_INVALID_HEADER_VERSION, + ERR_INVALID_NORM_VERSION, + ERR_UNKNOWN_ADDRESS_CLASS, + ERR_UNEXPECTED_EXTENSION_FLAG, + ERR_UNKNOWN_CONTROLLER_TAG, + ERR_UNKNOWN_CURVE, + ERR_UNEXPECTED_TRAILING_BYTES + } + + data class Details( + val chainDiscriminant: Int, + val network: NetworkKind, + val canonicalHex: String, + val publicKeyHex: String, + val i105: String + ) + + class IrohaAddressException(val code: ErrorCode, val detail: Any? = null) : IllegalArgumentException(code.name) + + fun canonicalHex(publicKeyHex: String): String = publicKeyToCanonicalBytes(publicKeyHex).toHex() + + fun encode(publicKeyHex: String, chainDiscriminant: Int): String = + encodeCanonicalHex(canonicalHex(publicKeyHex), chainDiscriminant) + + fun encodeCanonicalHex(canonicalHex: String, chainDiscriminant: Int): String = + encodeI105Literal(resolveDiscriminant(chainDiscriminant), normalizeHexBytes(canonicalHex)) + + fun parse(address: String, expectedDiscriminant: Int? = null): Details { + if (address.isEmpty() || address != address.trim()) throw IrohaAddressException(ErrorCode.ERR_UNSUPPORTED_ADDRESS_FORMAT) + if (address.startsWith("0x") || address.startsWith("0X")) throw IrohaAddressException(ErrorCode.ERR_UNSUPPORTED_ADDRESS_FORMAT) + + val decoded = decodeI105Literal(address) + val expected = expectedDiscriminant?.let(::resolveDiscriminant) + + if (expected != null && decoded.chainDiscriminant != expected) { + throw IrohaAddressException( + ErrorCode.ERR_UNEXPECTED_NETWORK_PREFIX, + mapOf("expected" to expected, "found" to decoded.chainDiscriminant) + ) + } + + val publicKeyHex = decodeCanonicalSingleEd25519(decoded.canonicalBytes) + val i105 = encodeI105Literal(decoded.chainDiscriminant, decoded.canonicalBytes) + + if (i105 != address) throw IrohaAddressException(ErrorCode.ERR_UNSUPPORTED_ADDRESS_FORMAT) + + return Details( + chainDiscriminant = decoded.chainDiscriminant, + network = networkFromDiscriminant(decoded.chainDiscriminant), + canonicalHex = decoded.canonicalBytes.toHex(), + publicKeyHex = publicKeyHex, + i105 = i105 + ) + } + + fun isValid(address: String, expectedDiscriminant: Int? = null): Boolean = runCatching { + parse(address, expectedDiscriminant) + }.isSuccess + + fun networkKind(address: String): NetworkKind? = runCatching { parse(address).network }.getOrNull() + + private data class DecodedLiteral(val chainDiscriminant: Int, val canonicalBytes: ByteArray) + + private fun resolveDiscriminant(chainDiscriminant: Int): Int { + if (chainDiscriminant !in 0..I105_DISCRIMINANT_MAX) { + throw IrohaAddressException(ErrorCode.ERR_INVALID_I105_PREFIX, chainDiscriminant) + } + + return chainDiscriminant + } + + private fun networkFromDiscriminant(discriminant: Int): NetworkKind { + return when (discriminant) { + UniversalWalletRegistry.taira.chainDiscriminant -> NetworkKind.TAIRA + UniversalWalletRegistry.nexus.chainDiscriminant -> NetworkKind.NEXUS + DEV_DISCRIMINANT -> NetworkKind.DEV + else -> NetworkKind.CUSTOM + } + } + + private fun sentinelForDiscriminant(discriminant: Int): String { + return when (discriminant) { + UniversalWalletRegistry.nexus.chainDiscriminant -> I105_SENTINEL_SORA + UniversalWalletRegistry.taira.chainDiscriminant -> I105_SENTINEL_TEST + DEV_DISCRIMINANT -> I105_SENTINEL_DEV + else -> "$I105_SENTINEL_FALLBACK_PREFIX$discriminant" + } + } + + private fun discriminantFromSentinel(input: String): Int? { + if (input.startsWith(I105_SENTINEL_SORA)) return UniversalWalletRegistry.nexus.chainDiscriminant + if (input.startsWith(I105_SENTINEL_TEST)) return UniversalWalletRegistry.taira.chainDiscriminant + if (input.startsWith(I105_SENTINEL_DEV)) return DEV_DISCRIMINANT + if (!input.startsWith(I105_SENTINEL_FALLBACK_PREFIX)) return null + + val digits = input.drop(1).take(5).takeWhile { it.isDigit() } + if (digits.isEmpty()) return null + + val discriminant = digits.toIntOrNull() ?: return null + + return discriminant.takeIf { it <= I105_DISCRIMINANT_MAX } + } + + private fun encodeI105Literal(chainDiscriminant: Int, canonicalBytes: ByteArray): String { + val digits = encodeBaseN(canonicalBytes, I105_BASE) + val checksum = i105ChecksumDigits(canonicalBytes) + + return buildString { + append(sentinelForDiscriminant(chainDiscriminant)) + (digits + checksum).forEach { append(i105DigitSymbol(it)) } + } + } + + private fun decodeI105Literal(input: String): DecodedLiteral { + val discriminant = discriminantFromSentinel(input) + ?: throw IrohaAddressException(ErrorCode.ERR_MISSING_I105_SENTINEL) + val sentinel = sentinelForDiscriminant(discriminant) + + if (!input.startsWith(sentinel)) throw IrohaAddressException(ErrorCode.ERR_UNSUPPORTED_ADDRESS_FORMAT) + + return DecodedLiteral(discriminant, decodeI105Payload(input.drop(sentinel.length))) + } + + private fun decodeI105Payload(payload: String): ByteArray { + val digits = payload.map { I105_DIGIT_TABLE[it] ?: throw IrohaAddressException(ErrorCode.ERR_INVALID_I105_CHAR, it) } + + if (digits.size <= I105_CHECKSUM_LEN) throw IrohaAddressException(ErrorCode.ERR_I105_TOO_SHORT) + + val splitAt = digits.size - I105_CHECKSUM_LEN + val canonicalBytes = decodeBaseN(digits.take(splitAt), I105_BASE) + val expected = i105ChecksumDigits(canonicalBytes) + + if (digits.drop(splitAt) != expected) throw IrohaAddressException(ErrorCode.ERR_CHECKSUM_MISMATCH) + + return canonicalBytes + } + + private fun i105DigitSymbol(digit: Int): Char { + return I105_ALPHABET.getOrNull(digit) + ?: throw IrohaAddressException(ErrorCode.ERR_INVALID_I105_DIGIT, digit) + } + + private fun encodeBaseN(bytes: ByteArray, base: Int): List { + if (base < 2) throw IrohaAddressException(ErrorCode.ERR_INVALID_I105_BASE) + if (bytes.isEmpty()) return listOf(0) + + val value = bytes.map { it.toInt() and 0xff }.toMutableList() + val leadingZeros = value.takeWhile { it == 0 }.size + val digits = mutableListOf() + var start = leadingZeros + + while (start < value.size) { + var remainder = 0 + + for (index in start until value.size) { + val accumulator = (remainder shl 8) or value[index] + value[index] = accumulator / base + remainder = accumulator % base + } + + digits.add(remainder) + + while (start < value.size && value[start] == 0) start += 1 + } + + repeat(leadingZeros) { digits.add(0) } + if (digits.isEmpty()) digits.add(0) + + return digits.asReversed() + } + + private fun decodeBaseN(digits: List, base: Int): ByteArray { + if (base < 2) throw IrohaAddressException(ErrorCode.ERR_INVALID_I105_BASE) + if (digits.isEmpty()) throw IrohaAddressException(ErrorCode.ERR_INVALID_LENGTH) + + val value = digits.toMutableList() + val leadingZeros = value.takeWhile { it == 0 }.size + val bytes = mutableListOf() + var start = leadingZeros + + while (start < value.size) { + var remainder = 0 + + for (index in start until value.size) { + val digit = value[index] + if (digit >= base) throw IrohaAddressException(ErrorCode.ERR_INVALID_I105_DIGIT, digit) + + val accumulator = remainder * base + digit + value[index] = accumulator / 256 + remainder = accumulator % 256 + } + + bytes.add(remainder) + + while (start < value.size && value[start] == 0) start += 1 + } + + repeat(leadingZeros) { bytes.add(0) } + + return bytes.asReversed().map { it.toByte() }.toByteArray() + } + + private fun i105ChecksumDigits(canonicalBytes: ByteArray): List { + val data = convertToBase32(canonicalBytes) + val values = expandHrp(I105_HRP) + data + List(I105_CHECKSUM_LEN) { 0 } + val polymod = bech32Polymod(values) xor BECH32M_CONST + + return List(I105_CHECKSUM_LEN) { index -> + (polymod ushr (5 * (I105_CHECKSUM_LEN - 1 - index))) and 0x1f + } + } + + private fun convertToBase32(bytes: ByteArray): List { + var accumulator = 0 + var bits = 0 + val result = mutableListOf() + + bytes.forEach { rawByte -> + accumulator = ((accumulator shl 8) or (rawByte.toInt() and 0xff)) and 0xfff + bits += 8 + + while (bits >= 5) { + bits -= 5 + result.add((accumulator ushr bits) and 0x1f) + } + } + + if (bits > 0) result.add((accumulator shl (5 - bits)) and 0x1f) + + return result + } + + private fun bech32Polymod(values: List): Int { + var checksum = 1 + + values.forEach { value -> + val top = checksum ushr 25 + checksum = ((checksum and 0x1ffffff) shl 5) xor value + + BECH32_GENERATORS.forEachIndexed { index, generator -> + if (((top ushr index) and 1) == 1) checksum = checksum xor generator + } + } + + return checksum + } + + private fun expandHrp(hrp: String): List { + return hrp.map { it.code ushr 5 } + listOf(0) + hrp.map { it.code and 31 } + } + + private fun decodeCanonicalSingleEd25519(canonicalBytes: ByteArray): String { + if (canonicalBytes.isEmpty()) throw IrohaAddressException(ErrorCode.ERR_INVALID_LENGTH) + + val header = canonicalBytes[0].toInt() and 0xff + val version = header ushr 5 + val classBits = (header ushr 3) and 0b11 + val normVersion = (header ushr 1) and 0b11 + val extFlag = (header and 1) == 1 + + if (extFlag) throw IrohaAddressException(ErrorCode.ERR_UNEXPECTED_EXTENSION_FLAG) + if (version != 0) throw IrohaAddressException(ErrorCode.ERR_INVALID_HEADER_VERSION, version) + if (normVersion != 1) throw IrohaAddressException(ErrorCode.ERR_INVALID_NORM_VERSION, normVersion) + if (classBits != 0) throw IrohaAddressException(ErrorCode.ERR_UNKNOWN_ADDRESS_CLASS, classBits) + if (canonicalBytes.size < 4) throw IrohaAddressException(ErrorCode.ERR_INVALID_LENGTH) + + val tag = canonicalBytes[1].toInt() and 0xff + val curve = canonicalBytes[2].toInt() and 0xff + val length = canonicalBytes[3].toInt() and 0xff + + if (tag != CONTROLLER_SINGLE_KEY_TAG) throw IrohaAddressException(ErrorCode.ERR_UNKNOWN_CONTROLLER_TAG, tag) + if (curve != CURVE_ED25519) throw IrohaAddressException(ErrorCode.ERR_UNKNOWN_CURVE, curve) + if (length != ED25519_PUBLIC_KEY_LENGTH) throw IrohaAddressException(ErrorCode.ERR_INVALID_LENGTH) + if (canonicalBytes.size < 4 + length) throw IrohaAddressException(ErrorCode.ERR_INVALID_LENGTH) + if (canonicalBytes.size != 4 + length) throw IrohaAddressException(ErrorCode.ERR_UNEXPECTED_TRAILING_BYTES) + + return canonicalBytes.copyOfRange(4, 4 + length).toHex().removePrefix("0x") + } + + private fun publicKeyToCanonicalBytes(publicKeyHex: String): ByteArray { + val publicKey = normalizeHexBytes(publicKeyHex, ED25519_PUBLIC_KEY_LENGTH) + return byteArrayOf(0x02, CONTROLLER_SINGLE_KEY_TAG.toByte(), CURVE_ED25519.toByte(), publicKey.size.toByte()) + publicKey + } + + private fun normalizeHexBytes(value: String, expectedBytes: Int? = null): ByteArray { + val hex = value.removePrefix("0x").removePrefix("0X") + + if (hex.isEmpty() || hex.length % 2 != 0 || !Regex("^[0-9a-fA-F]+$").matches(hex)) { + throw IrohaAddressException(ErrorCode.ERR_INVALID_HEX_ADDRESS) + } + if (expectedBytes != null && hex.length != expectedBytes * 2) { + throw IrohaAddressException(ErrorCode.ERR_INVALID_LENGTH) + } + + return hex.chunked(2).map { it.toInt(16).toByte() }.toByteArray() + } + + private fun ByteArray.toHex(): String = joinToString(prefix = "0x", separator = "") { + (it.toInt() and 0xff).toString(16).padStart(2, '0') + } +} diff --git a/common/src/main/java/jp/co/soramitsu/common/utils/IrohaKeyDerivation.kt b/common/src/main/java/jp/co/soramitsu/common/utils/IrohaKeyDerivation.kt new file mode 100644 index 0000000000..bd6f766cdb --- /dev/null +++ b/common/src/main/java/jp/co/soramitsu/common/utils/IrohaKeyDerivation.kt @@ -0,0 +1,63 @@ +package jp.co.soramitsu.common.utils + +import jp.co.soramitsu.common.model.UniversalWalletDerivationPaths +import jp.co.soramitsu.common.model.UniversalWalletRegistry + +object IrohaKeyDerivation { + + fun deriveAccount( + mnemonic: String, + passphrase: String = "", + derivationPath: String = UniversalWalletDerivationPaths.IROHA_DEFAULT + ): IrohaAccount { + val ed25519 = SolanaKeyDerivation.deriveAccount( + mnemonic = mnemonic, + passphrase = passphrase, + derivationPath = derivationPath + ) + val publicKeyHex = ed25519.publicKey.toHex() + + return IrohaAccount( + derivationPath = derivationPath, + privateKey = ed25519.privateKey, + chainCode = ed25519.chainCode, + publicKey = ed25519.publicKey, + publicKeyHex = publicKeyHex, + canonicalHex = IrohaAddressCodec.canonicalHex(publicKeyHex) + ) + } + + fun deriveAddress( + mnemonic: String, + passphrase: String = "", + derivationPath: String = UniversalWalletDerivationPaths.IROHA_DEFAULT, + chainDiscriminant: Int = UniversalWalletRegistry.taira.chainDiscriminant + ): IrohaAddress { + val account = deriveAccount(mnemonic, passphrase, derivationPath) + + return IrohaAddress( + account = account, + chainDiscriminant = chainDiscriminant, + i105 = IrohaAddressCodec.encode(account.publicKeyHex, chainDiscriminant) + ) + } + + private fun ByteArray.toHex(): String = joinToString("") { byte -> + (byte.toInt() and 0xff).toString(16).padStart(2, '0') + } + + data class IrohaAccount( + val derivationPath: String, + val privateKey: ByteArray, + val chainCode: ByteArray, + val publicKey: ByteArray, + val publicKeyHex: String, + val canonicalHex: String + ) + + data class IrohaAddress( + val account: IrohaAccount, + val chainDiscriminant: Int, + val i105: String + ) +} diff --git a/common/src/main/java/jp/co/soramitsu/common/utils/RetryUntilDone.kt b/common/src/main/java/jp/co/soramitsu/common/utils/RetryUntilDone.kt index 40515013e6..85465059c5 100644 --- a/common/src/main/java/jp/co/soramitsu/common/utils/RetryUntilDone.kt +++ b/common/src/main/java/jp/co/soramitsu/common/utils/RetryUntilDone.kt @@ -1,7 +1,7 @@ package jp.co.soramitsu.common.utils -import jp.co.soramitsu.shared_utils.wsrpc.recovery.LinearReconnectStrategy -import jp.co.soramitsu.shared_utils.wsrpc.recovery.ReconnectStrategy +import jp.co.soramitsu.fearless_utils.wsrpc.recovery.LinearReconnectStrategy +import jp.co.soramitsu.fearless_utils.wsrpc.recovery.ReconnectStrategy import kotlinx.coroutines.delay suspend inline fun retryUntilDone( diff --git a/common/src/main/java/jp/co/soramitsu/common/utils/SolanaKeyDerivation.kt b/common/src/main/java/jp/co/soramitsu/common/utils/SolanaKeyDerivation.kt new file mode 100644 index 0000000000..ae9444efe4 --- /dev/null +++ b/common/src/main/java/jp/co/soramitsu/common/utils/SolanaKeyDerivation.kt @@ -0,0 +1,168 @@ +package jp.co.soramitsu.common.utils + +import jp.co.soramitsu.common.model.UniversalWalletDerivationPaths +import org.bouncycastle.crypto.params.Ed25519PrivateKeyParameters +import java.math.BigInteger +import java.nio.ByteBuffer +import javax.crypto.Mac +import javax.crypto.SecretKeyFactory +import javax.crypto.spec.PBEKeySpec +import javax.crypto.spec.SecretKeySpec + +object SolanaKeyDerivation { + + private const val ED25519_SEED_KEY = "ed25519 seed" + private const val HARDENED_OFFSET = 0x80000000L + private const val MAX_CHILD_INDEX = 0x7fffffffL + private const val PUBLIC_KEY_LENGTH = 32 + private const val PRIVATE_KEY_LENGTH = 32 + private const val BIP39_SEED_LENGTH_BITS = 512 + private const val BIP39_ROUNDS = 2048 + private const val UINT_32_BYTES = 4 + private val BASE58_ALPHABET = "123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz".toCharArray() + + fun deriveAccount( + mnemonic: String, + passphrase: String = "", + derivationPath: String = UniversalWalletDerivationPaths.SOLANA_DEFAULT + ): SolanaAccount { + val normalizedMnemonic = normalizeMnemonic(mnemonic) + val seed = bip39Seed(normalizedMnemonic, passphrase) + val key = derivePrivateKey(seed, derivationPath) + val publicKey = publicKeyFromPrivateKey(key.privateKey) + + return SolanaAccount( + derivationPath = derivationPath, + privateKey = key.privateKey, + chainCode = key.chainCode, + publicKey = publicKey, + address = addressFromPublicKey(publicKey) + ) + } + + fun derivePrivateKey(seed: ByteArray, derivationPath: String = UniversalWalletDerivationPaths.SOLANA_DEFAULT): DerivedPrivateKey { + require(seed.isNotEmpty()) { "Seed must not be empty" } + + val nodes = parseHardenedDerivationPath(derivationPath) + var digest = hmacSha512(ED25519_SEED_KEY.toByteArray(Charsets.UTF_8), seed) + var privateKey = digest.copyOfRange(0, PRIVATE_KEY_LENGTH) + var chainCode = digest.copyOfRange(PRIVATE_KEY_LENGTH, digest.size) + + nodes.forEach { index -> + val data = ByteArray(1 + PRIVATE_KEY_LENGTH + UINT_32_BYTES) + data[0] = 0 + privateKey.copyInto(data, destinationOffset = 1) + ByteBuffer + .allocate(UINT_32_BYTES) + .putInt((index + HARDENED_OFFSET).toInt()) + .array() + .copyInto(data, destinationOffset = 1 + PRIVATE_KEY_LENGTH) + + digest = hmacSha512(chainCode, data) + privateKey = digest.copyOfRange(0, PRIVATE_KEY_LENGTH) + chainCode = digest.copyOfRange(PRIVATE_KEY_LENGTH, digest.size) + } + + return DerivedPrivateKey(privateKey, chainCode) + } + + fun publicKeyFromPrivateKey(privateKey: ByteArray): ByteArray { + require(privateKey.size == PRIVATE_KEY_LENGTH) { "Solana Ed25519 private key seed must be 32 bytes" } + + return Ed25519PrivateKeyParameters(privateKey, 0) + .generatePublicKey() + .encoded + } + + fun addressFromPublicKey(publicKey: ByteArray): String { + require(publicKey.size == PUBLIC_KEY_LENGTH) { "Solana public key must be 32 bytes" } + + return base58Encode(publicKey) + } + + private fun parseHardenedDerivationPath(derivationPath: String): List { + require(derivationPath.isNotBlank()) { "Derivation path must not be empty" } + require(derivationPath == "m" || derivationPath.startsWith("m/")) { "Derivation path must start with m" } + + if (derivationPath == "m") { + return emptyList() + } + + return derivationPath + .removePrefix("m/") + .split("/") + .map { component -> + require(component.endsWith("'")) { "Solana derivation only supports hardened components" } + + val index = component.dropLast(1) + require(index.isNotEmpty() && index.all(Char::isDigit)) { "Derivation path contains an invalid index" } + + val parsed = index.toLong() + require(parsed <= MAX_CHILD_INDEX) { "Derivation index is out of range" } + parsed + } + } + + private fun normalizeMnemonic(mnemonic: String): String { + val words = mnemonic.trim().split(Regex("\\s+")).filter { it.isNotBlank() } + require(words.isNotEmpty()) { "Mnemonic must not be empty" } + + return words.joinToString(" ") + } + + private fun bip39Seed(mnemonic: String, passphrase: String): ByteArray { + val spec = PBEKeySpec( + mnemonic.toCharArray(), + "mnemonic$passphrase".toByteArray(Charsets.UTF_8), + BIP39_ROUNDS, + BIP39_SEED_LENGTH_BITS + ) + + return SecretKeyFactory + .getInstance("PBKDF2WithHmacSHA512") + .generateSecret(spec) + .encoded + } + + private fun hmacSha512(key: ByteArray, data: ByteArray): ByteArray { + val mac = Mac.getInstance("HmacSHA512") + mac.init(SecretKeySpec(key, "HmacSHA512")) + + return mac.doFinal(data) + } + + private fun base58Encode(bytes: ByteArray): String { + if (bytes.isEmpty()) { + return "" + } + + var value = BigInteger(1, bytes) + val output = StringBuilder() + val radix = BigInteger.valueOf(BASE58_ALPHABET.size.toLong()) + + while (value > BigInteger.ZERO) { + val divRem = value.divideAndRemainder(radix) + output.append(BASE58_ALPHABET[divRem[1].toInt()]) + value = divRem[0] + } + + bytes.takeWhile { it == 0.toByte() }.forEach { _ -> + output.append(BASE58_ALPHABET[0]) + } + + return output.reverse().toString() + } + + data class SolanaAccount( + val derivationPath: String, + val privateKey: ByteArray, + val chainCode: ByteArray, + val publicKey: ByteArray, + val address: String + ) + + data class DerivedPrivateKey( + val privateKey: ByteArray, + val chainCode: ByteArray + ) +} diff --git a/common/src/main/java/jp/co/soramitsu/common/utils/SolanaSigner.kt b/common/src/main/java/jp/co/soramitsu/common/utils/SolanaSigner.kt new file mode 100644 index 0000000000..00fffb0895 --- /dev/null +++ b/common/src/main/java/jp/co/soramitsu/common/utils/SolanaSigner.kt @@ -0,0 +1,99 @@ +package jp.co.soramitsu.common.utils + +import org.bouncycastle.crypto.params.Ed25519PrivateKeyParameters +import org.bouncycastle.crypto.params.Ed25519PublicKeyParameters +import org.bouncycastle.crypto.signers.Ed25519Signer +import java.math.BigInteger + +object SolanaSigner { + private const val PRIVATE_KEY_LENGTH = 32 + private const val PUBLIC_KEY_LENGTH = 32 + private const val SIGNATURE_LENGTH = 64 + private const val MAX_MESSAGE_LENGTH = 64 * 1024 + private val BASE58_ALPHABET = "123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz".toCharArray() + + fun signMessage( + mnemonic: String, + message: ByteArray, + passphrase: String = "", + derivationPath: String = jp.co.soramitsu.common.model.UniversalWalletDerivationPaths.SOLANA_DEFAULT + ): SolanaSignature { + val account = SolanaKeyDerivation.deriveAccount( + mnemonic = mnemonic, + passphrase = passphrase, + derivationPath = derivationPath + ) + val signature = signMessage(account.privateKey, message) + + return SolanaSignature( + derivationPath = account.derivationPath, + address = account.address, + publicKey = account.publicKey, + message = message.copyOf(), + signature = signature, + signatureBase58 = base58Encode(signature), + signatureHex = signature.toHex() + ) + } + + fun signMessage(privateKey: ByteArray, message: ByteArray): ByteArray { + require(privateKey.size == PRIVATE_KEY_LENGTH) { "Solana Ed25519 private key seed must be 32 bytes" } + require(message.isNotEmpty()) { "Solana message must not be empty" } + require(message.size <= MAX_MESSAGE_LENGTH) { "Solana message is too large" } + + val signer = Ed25519Signer() + signer.init(true, Ed25519PrivateKeyParameters(privateKey, 0)) + signer.update(message, 0, message.size) + + return signer.generateSignature() + } + + fun verifyMessage(publicKey: ByteArray, message: ByteArray, signature: ByteArray): Boolean { + require(publicKey.size == PUBLIC_KEY_LENGTH) { "Solana public key must be 32 bytes" } + require(signature.size == SIGNATURE_LENGTH) { "Solana signature must be 64 bytes" } + require(message.isNotEmpty()) { "Solana message must not be empty" } + require(message.size <= MAX_MESSAGE_LENGTH) { "Solana message is too large" } + + val verifier = Ed25519Signer() + verifier.init(false, Ed25519PublicKeyParameters(publicKey, 0)) + verifier.update(message, 0, message.size) + + return verifier.verifySignature(signature) + } + + private fun base58Encode(bytes: ByteArray): String { + if (bytes.isEmpty()) { + return "" + } + + var value = BigInteger(1, bytes) + val output = StringBuilder() + val radix = BigInteger.valueOf(BASE58_ALPHABET.size.toLong()) + + while (value > BigInteger.ZERO) { + val divRem = value.divideAndRemainder(radix) + output.append(BASE58_ALPHABET[divRem[1].toInt()]) + value = divRem[0] + } + + bytes.takeWhile { it == 0.toByte() }.forEach { _ -> + output.append(BASE58_ALPHABET[0]) + } + + return output.reverse().toString() + } + + private fun ByteArray.toHex(): String = joinToString("") { byte -> + (byte.toInt() and 0xff).toString(16).padStart(2, '0') + } + + data class SolanaSignature( + val derivationPath: String, + val address: String, + val publicKey: ByteArray, + val message: ByteArray, + val signature: ByteArray, + val signatureBase58: String, + val signatureHex: String + ) +} diff --git a/common/src/main/java/jp/co/soramitsu/common/utils/SolanaTransactionSigner.kt b/common/src/main/java/jp/co/soramitsu/common/utils/SolanaTransactionSigner.kt new file mode 100644 index 0000000000..07709a2eda --- /dev/null +++ b/common/src/main/java/jp/co/soramitsu/common/utils/SolanaTransactionSigner.kt @@ -0,0 +1,366 @@ +package jp.co.soramitsu.common.utils + +import jp.co.soramitsu.common.model.UniversalWalletDerivationPaths +import org.bouncycastle.util.encoders.Base64 +import java.math.BigInteger + +object SolanaTransactionSigner { + private const val SIGNATURE_BYTES = 64 + private const val PUBLIC_KEY_BYTES = 32 + private const val VERSION_PREFIX_MASK = 0x80 + private const val VERSION_VALUE_MASK = 0x7f + private const val MAX_COMPACT_U16_BYTES = 3 + private val BASE58_ALPHABET = "123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz".toCharArray() + private val BASE64_TRANSACTION = Regex("^(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=)?$") + + fun parseSerializedTransaction(transaction: ByteArray): ParsedSolanaTransaction { + requireTransaction(transaction) + + val signatureCount = readCompactU16(transaction, 0, ErrorCode.INVALID_SIGNATURE_COUNT) + val signaturesOffset = signatureCount.offset + val messageOffset = signaturesOffset + signatureCount.value * SIGNATURE_BYTES + + if (signatureCount.value < 1) { + throw SolanaTransactionException(ErrorCode.MISSING_SIGNATURE_SLOT) + } + + ensureAvailable(transaction, signaturesOffset, signatureCount.value * SIGNATURE_BYTES, ErrorCode.TRUNCATED_SIGNATURES) + ensureAvailable(transaction, messageOffset, 1, ErrorCode.MISSING_MESSAGE) + + val messageBytes = transaction.copyOfRange(messageOffset, transaction.size) + val message = parseMessage(messageBytes) + + if (signatureCount.value < message.requiredSignatures) { + throw SolanaTransactionException(ErrorCode.MISSING_REQUIRED_SIGNATURE_SLOTS) + } + + return message.copy( + messageBytes = messageBytes, + messageOffset = messageOffset, + signatureCount = signatureCount.value, + signaturesOffset = signaturesOffset + ) + } + + fun parseSerializedTransaction(transactionBase64: String): ParsedSolanaTransaction { + return parseSerializedTransaction(decodeBase64Transaction(transactionBase64)) + } + + fun signSerializedTransaction( + mnemonic: String, + transaction: ByteArray, + expectedSigner: String? = null, + derivationPath: String = UniversalWalletDerivationPaths.SOLANA_DEFAULT, + passphrase: String = "" + ): SignedSolanaTransaction { + val parsed = parseSerializedTransaction(transaction) + val account = SolanaKeyDerivation.deriveAccount( + mnemonic = mnemonic, + passphrase = passphrase, + derivationPath = derivationPath + ) + + if (expectedSigner != null && expectedSigner != account.address) { + throw SolanaTransactionException(ErrorCode.SIGNER_MISMATCH) + } + + val signerIndex = parsed.accountKeys.indexOf(account.address) + + if (signerIndex < 0) { + throw SolanaTransactionException(ErrorCode.SIGNER_NOT_FOUND) + } + if (signerIndex >= parsed.requiredSignatures) { + throw SolanaTransactionException(ErrorCode.SIGNER_NOT_REQUIRED) + } + if (signerIndex >= parsed.signatureCount) { + throw SolanaTransactionException(ErrorCode.MISSING_SIGNATURE_SLOT) + } + + val signature = SolanaSigner.signMessage(account.privateKey, parsed.messageBytes) + val signedTransaction = transaction.copyOf() + signature.copyInto(signedTransaction, parsed.signaturesOffset + signerIndex * SIGNATURE_BYTES) + + return SignedSolanaTransaction( + requiredSignatures = parsed.requiredSignatures, + signedTransaction = signedTransaction, + signedTransactionBase64 = Base64.toBase64String(signedTransaction), + signatureBase58 = base58Encode(signature), + signer = account.address, + version = parsed.version + ) + } + + fun signSerializedTransaction( + mnemonic: String, + transactionBase64: String, + expectedSigner: String? = null, + derivationPath: String = UniversalWalletDerivationPaths.SOLANA_DEFAULT, + passphrase: String = "" + ): SignedSolanaTransaction { + return signSerializedTransaction( + mnemonic = mnemonic, + transaction = decodeBase64Transaction(transactionBase64), + expectedSigner = expectedSigner, + derivationPath = derivationPath, + passphrase = passphrase + ) + } + + private fun parseMessage(message: ByteArray): ParsedSolanaTransaction { + var offset = 0 + var version = SolanaTransactionVersion.Legacy + + if ((message[offset].toInt() and VERSION_PREFIX_MASK) != 0) { + val versionValue = message[offset].toInt() and VERSION_VALUE_MASK + if (versionValue != 0) { + throw SolanaTransactionException(ErrorCode.UNSUPPORTED_TRANSACTION_VERSION) + } + + version = SolanaTransactionVersion.V0 + offset += 1 + } + + ensureAvailable(message, offset, 3, ErrorCode.MALFORMED_MESSAGE_HEADER) + + val requiredSignatures = message[offset].toInt() and 0xff + val readonlySignedAccounts = message[offset + 1].toInt() and 0xff + val readonlyUnsignedAccounts = message[offset + 2].toInt() and 0xff + + offset += 3 + + val accountCount = readCompactU16(message, offset, ErrorCode.INVALID_ACCOUNT_KEYS) + offset = accountCount.offset + + if (accountCount.value < requiredSignatures) { + throw SolanaTransactionException(ErrorCode.INVALID_REQUIRED_SIGNATURE_COUNT) + } + if (readonlySignedAccounts > requiredSignatures) { + throw SolanaTransactionException(ErrorCode.INVALID_REQUIRED_SIGNATURE_COUNT) + } + if (readonlyUnsignedAccounts > accountCount.value - requiredSignatures) { + throw SolanaTransactionException(ErrorCode.INVALID_REQUIRED_SIGNATURE_COUNT) + } + + ensureAvailable(message, offset, accountCount.value * PUBLIC_KEY_BYTES, ErrorCode.TRUNCATED_ACCOUNT_KEYS) + + val accountKeys = mutableListOf() + repeat(accountCount.value) { + accountKeys.add(base58Encode(message.copyOfRange(offset, offset + PUBLIC_KEY_BYTES))) + offset += PUBLIC_KEY_BYTES + } + + ensureAvailable(message, offset, PUBLIC_KEY_BYTES, ErrorCode.TRUNCATED_RECENT_BLOCKHASH) + val recentBlockhash = base58Encode(message.copyOfRange(offset, offset + PUBLIC_KEY_BYTES)) + offset += PUBLIC_KEY_BYTES + + val instructions = skipCompiledInstructions(message, offset) + offset = instructions.offset + + var addressTableLookupCount = 0 + if (version == SolanaTransactionVersion.V0) { + val addressTableLookups = skipAddressTableLookups(message, offset) + addressTableLookupCount = addressTableLookups.count + offset = addressTableLookups.offset + } + + if (offset != message.size) { + throw SolanaTransactionException(ErrorCode.TRAILING_MESSAGE_BYTES) + } + + return ParsedSolanaTransaction( + accountKeys = accountKeys, + addressTableLookupCount = addressTableLookupCount, + instructionCount = instructions.count, + messageBytes = ByteArray(0), + messageOffset = 0, + readonlySignedAccounts = readonlySignedAccounts, + readonlyUnsignedAccounts = readonlyUnsignedAccounts, + recentBlockhash = recentBlockhash, + requiredSignatures = requiredSignatures, + signatureCount = 0, + signaturesOffset = 0, + version = version + ) + } + + private fun skipCompiledInstructions(message: ByteArray, offset: Int): CountAndOffset { + var nextOffset = offset + val instructionCount = readCompactU16(message, nextOffset, ErrorCode.INVALID_INSTRUCTION_COUNT) + nextOffset = instructionCount.offset + + repeat(instructionCount.value) { + ensureAvailable(message, nextOffset, 1, ErrorCode.TRUNCATED_INSTRUCTION_PROGRAM) + nextOffset += 1 + + val accountIndexCount = readCompactU16(message, nextOffset, ErrorCode.INVALID_INSTRUCTION_ACCOUNTS) + nextOffset = accountIndexCount.offset + ensureAvailable(message, nextOffset, accountIndexCount.value, ErrorCode.TRUNCATED_INSTRUCTION_ACCOUNTS) + nextOffset += accountIndexCount.value + + val dataLength = readCompactU16(message, nextOffset, ErrorCode.INVALID_INSTRUCTION_DATA) + nextOffset = dataLength.offset + ensureAvailable(message, nextOffset, dataLength.value, ErrorCode.TRUNCATED_INSTRUCTION_DATA) + nextOffset += dataLength.value + } + + return CountAndOffset(instructionCount.value, nextOffset) + } + + private fun skipAddressTableLookups(message: ByteArray, offset: Int): CountAndOffset { + var nextOffset = offset + val lookupCount = readCompactU16(message, nextOffset, ErrorCode.INVALID_ADDRESS_TABLE_LOOKUPS) + nextOffset = lookupCount.offset + + repeat(lookupCount.value) { + ensureAvailable(message, nextOffset, PUBLIC_KEY_BYTES, ErrorCode.TRUNCATED_ADDRESS_TABLE_LOOKUP) + nextOffset += PUBLIC_KEY_BYTES + + val writableCount = readCompactU16(message, nextOffset, ErrorCode.INVALID_ADDRESS_TABLE_WRITABLE_INDEXES) + nextOffset = writableCount.offset + ensureAvailable(message, nextOffset, writableCount.value, ErrorCode.TRUNCATED_ADDRESS_TABLE_WRITABLE_INDEXES) + nextOffset += writableCount.value + + val readonlyCount = readCompactU16(message, nextOffset, ErrorCode.INVALID_ADDRESS_TABLE_READONLY_INDEXES) + nextOffset = readonlyCount.offset + ensureAvailable(message, nextOffset, readonlyCount.value, ErrorCode.TRUNCATED_ADDRESS_TABLE_READONLY_INDEXES) + nextOffset += readonlyCount.value + } + + return CountAndOffset(lookupCount.value, nextOffset) + } + + private fun readCompactU16(bytes: ByteArray, offset: Int, code: ErrorCode): CountAndOffset { + var value = 0 + var shift = 0 + var nextOffset = offset + + repeat(MAX_COMPACT_U16_BYTES) { + ensureAvailable(bytes, nextOffset, 1, code) + + val byte = bytes[nextOffset].toInt() and 0xff + nextOffset += 1 + value = value or ((byte and 0x7f) shl shift) + + if ((byte and 0x80) == 0) { + return CountAndOffset(value, nextOffset) + } + + shift += 7 + } + + throw SolanaTransactionException(code) + } + + private fun ensureAvailable(bytes: ByteArray, offset: Int, length: Int, code: ErrorCode) { + if (offset < 0 || length < 0 || offset + length > bytes.size) { + throw SolanaTransactionException(code) + } + } + + private fun requireTransaction(transaction: ByteArray) { + if (transaction.isEmpty()) { + throw SolanaTransactionException(ErrorCode.EMPTY_TRANSACTION) + } + } + + private fun decodeBase64Transaction(transactionBase64: String): ByteArray { + if (transactionBase64.isEmpty()) { + throw SolanaTransactionException(ErrorCode.EMPTY_TRANSACTION) + } + if (!BASE64_TRANSACTION.matches(transactionBase64)) { + throw SolanaTransactionException(ErrorCode.INVALID_TRANSACTION_BASE64) + } + + return Base64.decode(transactionBase64) + } + + private fun base58Encode(bytes: ByteArray): String { + if (bytes.isEmpty()) { + return "" + } + + var value = BigInteger(1, bytes) + val output = StringBuilder() + val radix = BigInteger.valueOf(BASE58_ALPHABET.size.toLong()) + + while (value > BigInteger.ZERO) { + val divRem = value.divideAndRemainder(radix) + output.append(BASE58_ALPHABET[divRem[1].toInt()]) + value = divRem[0] + } + + bytes.takeWhile { it == 0.toByte() }.forEach { _ -> + output.append(BASE58_ALPHABET[0]) + } + + return output.reverse().toString() + } + + data class ParsedSolanaTransaction( + val accountKeys: List, + val addressTableLookupCount: Int, + val instructionCount: Int, + val messageBytes: ByteArray, + val messageOffset: Int, + val readonlySignedAccounts: Int, + val readonlyUnsignedAccounts: Int, + val recentBlockhash: String, + val requiredSignatures: Int, + val signatureCount: Int, + val signaturesOffset: Int, + val version: SolanaTransactionVersion + ) + + data class SignedSolanaTransaction( + val requiredSignatures: Int, + val signedTransaction: ByteArray, + val signedTransactionBase64: String, + val signatureBase58: String, + val signer: String, + val version: SolanaTransactionVersion + ) + + class SolanaTransactionException(val code: ErrorCode) : IllegalArgumentException(code.name) + + enum class SolanaTransactionVersion { + Legacy, + V0 + } + + enum class ErrorCode { + EMPTY_TRANSACTION, + INVALID_TRANSACTION_BASE64, + INVALID_SIGNATURE_COUNT, + MISSING_SIGNATURE_SLOT, + TRUNCATED_SIGNATURES, + MISSING_MESSAGE, + UNSUPPORTED_TRANSACTION_VERSION, + MALFORMED_MESSAGE_HEADER, + INVALID_ACCOUNT_KEYS, + INVALID_REQUIRED_SIGNATURE_COUNT, + TRUNCATED_ACCOUNT_KEYS, + TRUNCATED_RECENT_BLOCKHASH, + INVALID_INSTRUCTION_COUNT, + TRUNCATED_INSTRUCTION_PROGRAM, + INVALID_INSTRUCTION_ACCOUNTS, + TRUNCATED_INSTRUCTION_ACCOUNTS, + INVALID_INSTRUCTION_DATA, + TRUNCATED_INSTRUCTION_DATA, + INVALID_ADDRESS_TABLE_LOOKUPS, + TRUNCATED_ADDRESS_TABLE_LOOKUP, + INVALID_ADDRESS_TABLE_WRITABLE_INDEXES, + TRUNCATED_ADDRESS_TABLE_WRITABLE_INDEXES, + INVALID_ADDRESS_TABLE_READONLY_INDEXES, + TRUNCATED_ADDRESS_TABLE_READONLY_INDEXES, + TRAILING_MESSAGE_BYTES, + MISSING_REQUIRED_SIGNATURE_SLOTS, + SIGNER_MISMATCH, + SIGNER_NOT_FOUND, + SIGNER_NOT_REQUIRED + } + + private data class CountAndOffset(val value: Int, val offset: Int) { + val count: Int + get() = value + } +} diff --git a/common/src/main/java/jp/co/soramitsu/common/utils/TonKeyDerivation.kt b/common/src/main/java/jp/co/soramitsu/common/utils/TonKeyDerivation.kt new file mode 100644 index 0000000000..92ddba7cee --- /dev/null +++ b/common/src/main/java/jp/co/soramitsu/common/utils/TonKeyDerivation.kt @@ -0,0 +1,59 @@ +package jp.co.soramitsu.common.utils + +import jp.co.soramitsu.common.model.UniversalWalletDerivationPaths + +object TonKeyDerivation { + + fun deriveAccount( + mnemonic: String, + passphrase: String = "", + derivationPath: String = UniversalWalletDerivationPaths.TON_DEFAULT + ): TonAccount { + val ed25519 = SolanaKeyDerivation.deriveAccount( + mnemonic = mnemonic, + passphrase = passphrase, + derivationPath = derivationPath + ) + val publicKeyHex = ed25519.publicKey.toHex() + + return TonAccount( + derivationPath = derivationPath, + privateKey = ed25519.privateKey, + chainCode = ed25519.chainCode, + publicKey = ed25519.publicKey, + publicKeyHex = publicKeyHex, + addressBounceable = ed25519.publicKey.v4r2tonAddress(isTestnet = false, bounceable = true), + addressNonBounceable = ed25519.publicKey.v4r2tonAddress(isTestnet = false), + testnetNonBounceable = ed25519.publicKey.v4r2tonAddress(isTestnet = true), + accountId = ed25519.publicKey.tonAccountId(isTestnet = false) + ) + } + + fun addressFromPublicKey( + publicKey: ByteArray, + isTestnet: Boolean = false, + bounceable: Boolean = false + ): String { + require(publicKey.size == PUBLIC_KEY_LENGTH) { "TON public key must be 32 bytes" } + + return publicKey.v4r2tonAddress(isTestnet = isTestnet, bounceable = bounceable) + } + + private fun ByteArray.toHex(): String = joinToString("") { byte -> + (byte.toInt() and 0xff).toString(16).padStart(2, '0') + } + + data class TonAccount( + val derivationPath: String, + val privateKey: ByteArray, + val chainCode: ByteArray, + val publicKey: ByteArray, + val publicKeyHex: String, + val addressBounceable: String, + val addressNonBounceable: String, + val testnetNonBounceable: String, + val accountId: String + ) + + private const val PUBLIC_KEY_LENGTH = 32 +} diff --git a/common/src/main/java/jp/co/soramitsu/common/utils/TonUtils.kt b/common/src/main/java/jp/co/soramitsu/common/utils/TonUtils.kt index fcec33b9a1..e20d77bf13 100644 --- a/common/src/main/java/jp/co/soramitsu/common/utils/TonUtils.kt +++ b/common/src/main/java/jp/co/soramitsu/common/utils/TonUtils.kt @@ -38,10 +38,10 @@ private fun v4r2SmartContractAddress(publicKey: ByteArray): AddrStd { return AddrStd(workchain, hash) } -fun ByteArray.v4r2tonAddress(isTestnet: Boolean): String { +fun ByteArray.v4r2tonAddress(isTestnet: Boolean, bounceable: Boolean = false): String { val contractAddress = v4r2SmartContractAddress(this) - return contractAddress.toWalletAddress(isTestnet) + return contractAddress.toWalletAddress(testnet = isTestnet, bounceable = bounceable) } // Attention!!! Use the result of this function only with api requests. For internal fearless wallet purposes use tonPublicKey as accountId (MetaAccount.accountId(chain: IChain): ByteArray? function) @@ -54,10 +54,10 @@ fun ByteArray.tonAccountId(isTestnet: Boolean): String { ).lowercase() } -fun AddrStd.toWalletAddress(testnet: Boolean): String { +fun AddrStd.toWalletAddress(testnet: Boolean, bounceable: Boolean = false): String { return toString( userFriendly = true, - bounceable = false, + bounceable = bounceable, testOnly = testnet ) } diff --git a/common/src/main/res/drawable/ic_cross_24.xml b/common/src/main/res/drawable/ic_cross_24.xml new file mode 100644 index 0000000000..e492a6700e --- /dev/null +++ b/common/src/main/res/drawable/ic_cross_24.xml @@ -0,0 +1,9 @@ + + + diff --git a/common/src/main/res/drawable/ic_favorite_enabled.xml b/common/src/main/res/drawable/ic_favorite_enabled.xml new file mode 100644 index 0000000000..77ea9b4e07 --- /dev/null +++ b/common/src/main/res/drawable/ic_favorite_enabled.xml @@ -0,0 +1,9 @@ + + + diff --git a/common/src/main/res/values/strings.xml b/common/src/main/res/values/strings.xml index 7ee2632005..88b303eaf5 100644 --- a/common/src/main/res/values/strings.xml +++ b/common/src/main/res/values/strings.xml @@ -358,6 +358,9 @@ EVM chain accounts Substrate chain accounts TON chain accounts + Bitcoin chain accounts + Solana chain accounts + Iroha chain accounts Cannot proceed: Your wallet does not have the necessary accounts. Please make sure you have the required accounts set up before continuing The node has been added previously. Please try another node. Can\'t establish connection with the node. Please try another one. diff --git a/common/src/test/java/jp/co/soramitsu/common/data/secrets/v2/SecretStoreV2Test.kt b/common/src/test/java/jp/co/soramitsu/common/data/secrets/v2/SecretStoreV2Test.kt index c5f8efee50..2b9f5049e1 100644 --- a/common/src/test/java/jp/co/soramitsu/common/data/secrets/v2/SecretStoreV2Test.kt +++ b/common/src/test/java/jp/co/soramitsu/common/data/secrets/v2/SecretStoreV2Test.kt @@ -4,7 +4,7 @@ import jp.co.soramitsu.common.data.secrets.v1.Keypair import jp.co.soramitsu.common.data.secrets.v2.KeyPairSchema.PrivateKey import jp.co.soramitsu.common.data.secrets.v2.MetaAccountSecrets.SubstrateDerivationPath import jp.co.soramitsu.common.data.secrets.v2.MetaAccountSecrets.SubstrateKeypair -import jp.co.soramitsu.shared_utils.scale.EncodableStruct +import jp.co.soramitsu.fearless_utils.scale.EncodableStruct import jp.co.soramitsu.testshared.HashMapEncryptedPreferences import kotlinx.coroutines.runBlocking import org.junit.Assert.assertArrayEquals diff --git a/common/src/test/java/jp/co/soramitsu/common/utils/CryptoUtilsKtTest.kt b/common/src/test/java/jp/co/soramitsu/common/utils/CryptoUtilsKtTest.kt index df38892b56..056013cba6 100644 --- a/common/src/test/java/jp/co/soramitsu/common/utils/CryptoUtilsKtTest.kt +++ b/common/src/test/java/jp/co/soramitsu/common/utils/CryptoUtilsKtTest.kt @@ -1,6 +1,6 @@ package jp.co.soramitsu.common.utils -import jp.co.soramitsu.shared_utils.extensions.fromHex +import jp.co.soramitsu.fearless_utils.extensions.fromHex import org.junit.Assert.assertArrayEquals import org.junit.Test diff --git a/common/src/test/java/jp/co/soramitsu/common/wallet/BitcoinBalanceSyncTest.kt b/common/src/test/java/jp/co/soramitsu/common/wallet/BitcoinBalanceSyncTest.kt new file mode 100644 index 0000000000..d5e7d57133 --- /dev/null +++ b/common/src/test/java/jp/co/soramitsu/common/wallet/BitcoinBalanceSyncTest.kt @@ -0,0 +1,162 @@ +package jp.co.soramitsu.common.wallet + +import jp.co.soramitsu.common.data.network.bitcoin.BitcoinBalanceSync +import jp.co.soramitsu.common.data.network.bitcoin.BitcoinBalanceSyncException +import jp.co.soramitsu.common.data.network.bitcoin.BitcoinEsploraAddress +import jp.co.soramitsu.common.data.network.bitcoin.BitcoinEsploraStats +import jp.co.soramitsu.common.data.network.bitcoin.BitcoinEsploraTransaction +import jp.co.soramitsu.common.data.network.bitcoin.BitcoinEsploraUtxo +import jp.co.soramitsu.common.data.network.bitcoin.BitcoinIndexerClient +import jp.co.soramitsu.common.data.network.bitcoin.BitcoinIndexerRoutes +import jp.co.soramitsu.common.data.network.bitcoin.BitcoinReceiveDiscovery +import jp.co.soramitsu.common.utils.BitcoinKeyDerivation +import kotlinx.coroutines.runBlocking +import org.junit.Assert.assertEquals +import org.junit.Assert.assertThrows +import org.junit.Test + +class BitcoinBalanceSyncTest { + + @Test + fun `sums confirmed and mempool balances from discovered receive addresses`() = runBlocking { + val balances = balancesByAddress( + 0 to AddressBalance(confirmedSats = 10, mempoolSats = 2), + 1 to AddressBalance(confirmedSats = 3, mempoolSats = 5) + ) + val client = FakeBitcoinIndexerClient(balances) + val balanceSync = BitcoinBalanceSync(BitcoinReceiveDiscovery(client)) + + val result = balanceSync.balance( + mnemonic = MNEMONIC, + gapLimit = 2, + maxLookahead = 5 + ) + + assertEquals(13L, result.confirmedSats) + assertEquals(7L, result.mempoolSats) + assertEquals(20L, result.totalSats) + assertEquals(listOf(0, 1), result.usedAddresses.map { it.index }) + assertEquals(4, client.addressCalls.size) + } + + @Test + fun `rejects balance overflow across discovered addresses`() { + val balances = balancesByAddress( + 0 to AddressBalance(confirmedSats = Long.MAX_VALUE, mempoolSats = 0), + 1 to AddressBalance(confirmedSats = Long.MAX_VALUE, mempoolSats = 0) + ) + val client = FakeBitcoinIndexerClient(balances) + val balanceSync = BitcoinBalanceSync(BitcoinReceiveDiscovery(client)) + + val error = assertThrows(BitcoinBalanceSyncException::class.java) { + runBlocking { + balanceSync.balance( + mnemonic = MNEMONIC, + gapLimit = 1, + maxLookahead = 4 + ) + } + } + + assertEquals(BitcoinBalanceSyncException.Code.BALANCE_OVERFLOW, error.code) + } + + private class FakeBitcoinIndexerClient( + private val balances: Map + ) : BitcoinIndexerClient { + val addressCalls = mutableListOf() + + override suspend fun address( + address: String, + network: BitcoinIndexerRoutes.Network, + baseUrl: String? + ): BitcoinEsploraAddress { + addressCalls += address + val balance = balances[address] ?: AddressBalance() + + return addressStats( + address = address, + confirmedSats = balance.confirmedSats, + mempoolSats = balance.mempoolSats, + txCount = if (balance.confirmedSats > 0 || balance.mempoolSats > 0) 1 else 0 + ) + } + + override suspend fun utxos( + address: String, + network: BitcoinIndexerRoutes.Network, + baseUrl: String? + ): List { + error("Unexpected Bitcoin UTXO call") + } + + override suspend fun transactions( + address: String, + network: BitcoinIndexerRoutes.Network, + baseUrl: String?, + lastSeenTxid: String?, + mempool: Boolean + ): List { + error("Unexpected Bitcoin transaction call") + } + + override suspend fun feeEstimates( + network: BitcoinIndexerRoutes.Network, + baseUrl: String? + ): Map { + error("Unexpected Bitcoin fee-estimates call") + } + + override suspend fun broadcastTransaction( + txHex: String, + network: BitcoinIndexerRoutes.Network, + baseUrl: String? + ): String { + error("Unexpected Bitcoin broadcast call") + } + } + + private data class AddressBalance( + val confirmedSats: Long = 0, + val mempoolSats: Long = 0 + ) + + private companion object { + const val MNEMONIC = "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about" + + fun balancesByAddress(vararg balances: Pair): Map { + return balances.associate { (index, balance) -> + val address = BitcoinKeyDerivation.deriveKey( + mnemonic = MNEMONIC, + derivationPath = BitcoinKeyDerivation.getReceivePath(index = index.toLong()) + ).address + address to balance + } + } + + fun addressStats( + address: String, + confirmedSats: Long, + mempoolSats: Long, + txCount: Int + ): BitcoinEsploraAddress { + return BitcoinEsploraAddress( + address = address, + chainStats = BitcoinEsploraStats( + fundedTxoCount = txCount, + fundedTxoSum = confirmedSats, + spentTxoCount = 0, + spentTxoSum = 0, + txCount = txCount + ), + mempoolStats = BitcoinEsploraStats( + fundedTxoCount = if (mempoolSats > 0) 1 else 0, + fundedTxoSum = mempoolSats, + spentTxoCount = 0, + spentTxoSum = 0, + txCount = if (mempoolSats > 0) 1 else 0 + ) + ) + } + } +} diff --git a/common/src/test/java/jp/co/soramitsu/common/wallet/BitcoinFeeEstimatorTest.kt b/common/src/test/java/jp/co/soramitsu/common/wallet/BitcoinFeeEstimatorTest.kt new file mode 100644 index 0000000000..320135ed66 --- /dev/null +++ b/common/src/test/java/jp/co/soramitsu/common/wallet/BitcoinFeeEstimatorTest.kt @@ -0,0 +1,118 @@ +package jp.co.soramitsu.common.wallet + +import jp.co.soramitsu.common.data.network.bitcoin.BitcoinEsploraAddress +import jp.co.soramitsu.common.data.network.bitcoin.BitcoinEsploraTransaction +import jp.co.soramitsu.common.data.network.bitcoin.BitcoinEsploraUtxo +import jp.co.soramitsu.common.data.network.bitcoin.BitcoinFeeEstimator +import jp.co.soramitsu.common.data.network.bitcoin.BitcoinFeeEstimatorException +import jp.co.soramitsu.common.data.network.bitcoin.BitcoinIndexerClient +import jp.co.soramitsu.common.data.network.bitcoin.BitcoinIndexerRoutes +import kotlinx.coroutines.runBlocking +import org.junit.Assert.assertEquals +import org.junit.Assert.assertThrows +import org.junit.Test + +class BitcoinFeeEstimatorTest { + + @Test + fun `selects exact next slower or slowest available fee estimate`() { + val estimator = BitcoinFeeEstimator(FakeBitcoinIndexerClient()) + + assertEquals(4.0, estimator.select(mapOf("1" to 9.0, "2" to 4.0, "6" to 2.0), 2).feeRateSatPerVbyte, 0.0) + assertEquals(2.0, estimator.select(mapOf("1" to 9.0, "6" to 2.0), 2).feeRateSatPerVbyte, 0.0) + assertEquals(2.0, estimator.select(mapOf("1" to 9.0, "6" to 2.0), 10).feeRateSatPerVbyte, 0.0) + } + + @Test + fun `rejects unsafe fee estimates and targets`() { + val estimator = BitcoinFeeEstimator(FakeBitcoinIndexerClient()) + + assertFeeError(BitcoinFeeEstimatorException.Code.FEE_ESTIMATES_UNAVAILABLE) { + estimator.select(emptyMap(), 2) + } + assertFeeError(BitcoinFeeEstimatorException.Code.INVALID_FEE_TARGET) { + estimator.select(mapOf("2" to 1.0), 0) + } + assertFeeError(BitcoinFeeEstimatorException.Code.INVALID_FEE_RATE) { + estimator.select(mapOf("2" to 10_001.0), 2) + } + } + + @Test + fun `fetches estimates through selected bitcoin indexer network`() = runBlocking { + val client = FakeBitcoinIndexerClient(estimates = mapOf("3" to 5.0)) + val estimator = BitcoinFeeEstimator(client) + + val result = estimator.estimate( + network = BitcoinIndexerRoutes.Network.Testnet, + baseUrl = "https://bitcoin.example/api", + targetBlocks = 2 + ) + + assertEquals(5.0, result.feeRateSatPerVbyte, 0.0) + assertEquals(2, result.requestedTargetBlocks) + assertEquals(3, result.selectedTargetBlocks) + assertEquals(BitcoinIndexerRoutes.Network.Testnet, client.lastNetwork) + assertEquals("https://bitcoin.example/api", client.lastBaseUrl) + } + + private fun assertFeeError( + expected: BitcoinFeeEstimatorException.Code, + block: () -> Unit + ) { + val error = assertThrows(BitcoinFeeEstimatorException::class.java) { block() } + assertEquals(expected, error.code) + } + + private class FakeBitcoinIndexerClient( + private val estimates: Map = emptyMap() + ) : BitcoinIndexerClient { + var lastNetwork: BitcoinIndexerRoutes.Network? = null + private set + var lastBaseUrl: String? = null + private set + + override suspend fun feeEstimates( + network: BitcoinIndexerRoutes.Network, + baseUrl: String? + ): Map { + lastNetwork = network + lastBaseUrl = baseUrl + return estimates + } + + override suspend fun address( + address: String, + network: BitcoinIndexerRoutes.Network, + baseUrl: String? + ): BitcoinEsploraAddress { + error("Unexpected Bitcoin address call") + } + + override suspend fun utxos( + address: String, + network: BitcoinIndexerRoutes.Network, + baseUrl: String? + ): List { + error("Unexpected Bitcoin UTXO call") + } + + override suspend fun transactions( + address: String, + network: BitcoinIndexerRoutes.Network, + baseUrl: String?, + lastSeenTxid: String?, + mempool: Boolean + ): List { + error("Unexpected Bitcoin transaction call") + } + + override suspend fun broadcastTransaction( + txHex: String, + network: BitcoinIndexerRoutes.Network, + baseUrl: String? + ): String { + error("Unexpected Bitcoin broadcast call") + } + } +} diff --git a/common/src/test/java/jp/co/soramitsu/common/wallet/BitcoinIndexerClientTest.kt b/common/src/test/java/jp/co/soramitsu/common/wallet/BitcoinIndexerClientTest.kt new file mode 100644 index 0000000000..162e61ab22 --- /dev/null +++ b/common/src/test/java/jp/co/soramitsu/common/wallet/BitcoinIndexerClientTest.kt @@ -0,0 +1,121 @@ +package jp.co.soramitsu.common.wallet + +import jp.co.soramitsu.common.data.network.bitcoin.BitcoinEsploraAddress +import jp.co.soramitsu.common.data.network.bitcoin.BitcoinEsploraStats +import jp.co.soramitsu.common.data.network.bitcoin.BitcoinEsploraTransaction +import jp.co.soramitsu.common.data.network.bitcoin.BitcoinEsploraTxStatus +import jp.co.soramitsu.common.data.network.bitcoin.BitcoinEsploraUtxo +import jp.co.soramitsu.common.data.network.bitcoin.BitcoinIndexerApi +import jp.co.soramitsu.common.data.network.bitcoin.BitcoinIndexerRoutes +import jp.co.soramitsu.common.data.network.bitcoin.RetrofitBitcoinIndexerClient +import kotlinx.coroutines.runBlocking +import okhttp3.RequestBody +import okio.Buffer +import org.junit.Assert.assertEquals +import org.junit.Assert.assertThrows +import org.junit.Test + +class BitcoinIndexerClientTest { + + @Test + fun `delegates esplora read endpoints through validated routes`() = runBlocking { + val api = FakeBitcoinIndexerApi() + val client = RetrofitBitcoinIndexerClient(api) + + client.address(MAINNET_ADDRESS) + assertEquals("https://blockstream.info/api/address/$MAINNET_ADDRESS", api.lastUrl) + + client.utxos(TESTNET_ADDRESS, BitcoinIndexerRoutes.Network.Testnet) + assertEquals("https://blockstream.info/testnet/api/address/$TESTNET_ADDRESS/utxo", api.lastUrl) + + client.transactions(MAINNET_ADDRESS, lastSeenTxid = TXID.uppercase()) + assertEquals("https://blockstream.info/api/address/$MAINNET_ADDRESS/txs/chain/$TXID", api.lastUrl) + + client.transactions(MAINNET_ADDRESS, mempool = true) + assertEquals("https://blockstream.info/api/address/$MAINNET_ADDRESS/txs/mempool", api.lastUrl) + } + + @Test + fun `delegates broadcast with normalized text transaction body`() { + runBlocking { + val api = FakeBitcoinIndexerApi() + val client = RetrofitBitcoinIndexerClient(api) + + client.broadcastTransaction(" 00AA ", BitcoinIndexerRoutes.Network.Testnet) + + assertEquals("https://blockstream.info/testnet/api/tx", api.lastUrl) + assertEquals("text/plain", api.lastRequestBody?.contentType().toString()) + assertEquals("00aa", api.lastRequestBody?.readUtf8()) + + assertThrows(BitcoinIndexerRoutes.BitcoinIndexerRouteException::class.java) { + runBlocking { + client.broadcastTransaction("00gg") + } + } + } + } + + private class FakeBitcoinIndexerApi : BitcoinIndexerApi { + var lastUrl: String? = null + private set + var lastRequestBody: RequestBody? = null + private set + + override suspend fun getAddress(url: String): BitcoinEsploraAddress { + lastUrl = url + return BitcoinEsploraAddress( + address = MAINNET_ADDRESS, + chainStats = BitcoinEsploraStats(0, 0, 0, 0, 0), + mempoolStats = BitcoinEsploraStats(0, 0, 0, 0, 0) + ) + } + + override suspend fun getUtxos(url: String): List { + lastUrl = url + return listOf( + BitcoinEsploraUtxo( + txid = TXID, + vout = 0, + value = 1, + status = BitcoinEsploraTxStatus(confirmed = true) + ) + ) + } + + override suspend fun getTransactions(url: String): List { + lastUrl = url + return listOf( + BitcoinEsploraTransaction( + txid = TXID, + status = BitcoinEsploraTxStatus(confirmed = true) + ) + ) + } + + override suspend fun getFeeEstimates(url: String): Map { + lastUrl = url + return mapOf("1" to 1.0) + } + + override suspend fun broadcastTransaction( + url: String, + body: RequestBody + ): String { + lastUrl = url + lastRequestBody = body + return TXID + } + } + + private fun RequestBody.readUtf8(): String { + val buffer = Buffer() + writeTo(buffer) + return buffer.readUtf8() + } + + private companion object { + const val MAINNET_ADDRESS = "bc1qcr8te4kr609gcawutmrza0j4xv80jy8z306fyu" + const val TESTNET_ADDRESS = "tb1q6rz28mcfaxtmd6v789l9rrlrusdprr9pqcpvkl" + val TXID = "11".repeat(32) + } +} diff --git a/common/src/test/java/jp/co/soramitsu/common/wallet/BitcoinIndexerRoutesTest.kt b/common/src/test/java/jp/co/soramitsu/common/wallet/BitcoinIndexerRoutesTest.kt new file mode 100644 index 0000000000..48318f4190 --- /dev/null +++ b/common/src/test/java/jp/co/soramitsu/common/wallet/BitcoinIndexerRoutesTest.kt @@ -0,0 +1,158 @@ +package jp.co.soramitsu.common.wallet + +import com.google.gson.Gson +import jp.co.soramitsu.common.data.network.bitcoin.BitcoinEsploraAddress +import jp.co.soramitsu.common.data.network.bitcoin.BitcoinEsploraTransaction +import jp.co.soramitsu.common.data.network.bitcoin.BitcoinIndexerRoutes +import jp.co.soramitsu.common.data.network.bitcoin.BitcoinIndexerRoutes.BitcoinIndexerRouteException +import jp.co.soramitsu.common.data.network.bitcoin.BitcoinIndexerRoutes.ErrorCode +import jp.co.soramitsu.common.data.network.bitcoin.BitcoinIndexerRoutes.Network +import jp.co.soramitsu.common.model.UniversalWalletRegistry +import org.junit.Assert.assertEquals +import org.junit.Assert.assertThrows +import org.junit.Test + +class BitcoinIndexerRoutesTest { + + @Test + fun `builds bitcoin esplora endpoint urls with validated parameters`() { + assertEquals( + "${UniversalWalletRegistry.BITCOIN_MAINNET_INDEXER_BASE_URL}/address/$MAINNET_ADDRESS", + BitcoinIndexerRoutes.addressUrl(MAINNET_ADDRESS) + ) + assertEquals( + "https://blockstream.info/testnet/api/address/$TESTNET_ADDRESS/utxo", + BitcoinIndexerRoutes.utxosUrl(TESTNET_ADDRESS, Network.Testnet) + ) + assertEquals( + "https://bitcoin.example/api/address/$MAINNET_ADDRESS/txs", + BitcoinIndexerRoutes.transactionsUrl(MAINNET_ADDRESS, baseUrl = "https://bitcoin.example/api/") + ) + assertEquals( + "https://bitcoin.example/api/address/$MAINNET_ADDRESS/txs/chain/$TXID", + BitcoinIndexerRoutes.transactionsUrl(MAINNET_ADDRESS, baseUrl = "https://bitcoin.example/api/", lastSeenTxid = TXID.uppercase()) + ) + assertEquals( + "https://bitcoin.example/api/address/$MAINNET_ADDRESS/txs/mempool", + BitcoinIndexerRoutes.transactionsUrl(MAINNET_ADDRESS, baseUrl = "https://bitcoin.example/api/", mempool = true) + ) + assertEquals( + "https://blockstream.info/api/fee-estimates", + BitcoinIndexerRoutes.feeEstimatesUrl() + ) + assertEquals( + "https://blockstream.info/testnet/api/tx", + BitcoinIndexerRoutes.broadcastTransactionUrl(Network.Testnet) + ) + assertEquals("00aa", BitcoinIndexerRoutes.normalizeBroadcastTransactionBody(" 00AA ")) + } + + @Test + fun `allows local http base urls but rejects nonlocal insecure base urls`() { + assertEquals("http://localhost:3000/api", BitcoinIndexerRoutes.normalizeBaseUrl("http://localhost:3000/api/")) + assertEquals("http://127.0.0.1:3000/api", BitcoinIndexerRoutes.normalizeBaseUrl("http://127.0.0.1:3000/api/")) + + assertRouteError(ErrorCode.INVALID_BASE_URL) { + BitcoinIndexerRoutes.normalizeBaseUrl("http://blockstream.info/api") + } + assertRouteError(ErrorCode.INVALID_BASE_URL) { + BitcoinIndexerRoutes.normalizeBaseUrl("not a url") + } + } + + @Test + fun `rejects malformed bitcoin route inputs before network calls`() { + assertRouteError(ErrorCode.INVALID_ADDRESS) { + BitcoinIndexerRoutes.addressUrl("../bad") + } + assertRouteError(ErrorCode.INVALID_ADDRESS) { + BitcoinIndexerRoutes.addressUrl(TESTNET_ADDRESS, Network.Mainnet) + } + assertRouteError(ErrorCode.INVALID_TXID) { + BitcoinIndexerRoutes.transactionsUrl(MAINNET_ADDRESS, lastSeenTxid = "../../../bad") + } + assertRouteError(ErrorCode.INVALID_TX_HEX) { + BitcoinIndexerRoutes.normalizeBroadcastTransactionBody("abc") + } + assertRouteError(ErrorCode.INVALID_TX_HEX) { + BitcoinIndexerRoutes.normalizeBroadcastTransactionBody("00gg") + } + assertRouteError(ErrorCode.INVALID_TX_HEX) { + BitcoinIndexerRoutes.normalizeBroadcastTransactionBody("00".repeat(BitcoinIndexerRoutes.MAX_TX_HEX_LENGTH / 2 + 1)) + } + } + + @Test + fun `parses esplora address stats without losing satoshi precision`() { + val response = Gson().fromJson( + """ + { + "address": "$MAINNET_ADDRESS", + "chain_stats": { + "funded_txo_count": 2, + "funded_txo_sum": 2100000000000000, + "spent_txo_count": 1, + "spent_txo_sum": 123456789, + "tx_count": 3 + }, + "mempool_stats": { + "funded_txo_count": 1, + "funded_txo_sum": 5000, + "spent_txo_count": 0, + "spent_txo_sum": 0, + "tx_count": 1 + } + } + """.trimIndent(), + BitcoinEsploraAddress::class.java + ) + + assertEquals(MAINNET_ADDRESS, response.address) + assertEquals(2_099_999_876_543_211L, response.confirmedSats) + assertEquals(5_000L, response.mempoolSats) + assertEquals(2_099_999_876_548_211L, response.totalSats) + } + + @Test + fun `parses esplora transaction movement outputs without coercing string amounts`() { + val response = Gson().fromJson( + """ + { + "txid": "$TXID", + "status": { + "confirmed": true + }, + "vin": [ + { + "prevout": { + "scriptpubkey_address": "$MAINNET_ADDRESS", + "value": 1000 + } + } + ], + "vout": [ + { + "scriptpubkey_address": "$MAINNET_ADDRESS", + "value": "900" + } + ] + } + """.trimIndent(), + BitcoinEsploraTransaction::class.java + ) + + assertEquals(1_000L, response.vin.orEmpty().single().prevout?.valueSatsOrNull()) + assertEquals(null, response.vout.orEmpty().single().valueSatsOrNull()) + } + + private fun assertRouteError(expected: ErrorCode, block: () -> Unit) { + val error = assertThrows(BitcoinIndexerRouteException::class.java) { block() } + assertEquals(expected, error.code) + } + + private companion object { + const val MAINNET_ADDRESS = "bc1qcr8te4kr609gcawutmrza0j4xv80jy8z306fyu" + const val TESTNET_ADDRESS = "tb1q6rz28mcfaxtmd6v789l9rrlrusdprr9pqcpvkl" + val TXID = "11".repeat(32) + } +} diff --git a/common/src/test/java/jp/co/soramitsu/common/wallet/BitcoinKeyDerivationTest.kt b/common/src/test/java/jp/co/soramitsu/common/wallet/BitcoinKeyDerivationTest.kt new file mode 100644 index 0000000000..e457a0e8b8 --- /dev/null +++ b/common/src/test/java/jp/co/soramitsu/common/wallet/BitcoinKeyDerivationTest.kt @@ -0,0 +1,142 @@ +package jp.co.soramitsu.common.wallet + +import com.google.gson.JsonObject +import com.google.gson.JsonParser +import jp.co.soramitsu.common.model.UniversalWalletDerivationPaths +import jp.co.soramitsu.common.utils.BitcoinKeyDerivation +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNotEquals +import org.junit.Assert.assertThrows +import org.junit.Assert.assertTrue +import org.junit.Test +import java.io.InputStreamReader + +class BitcoinKeyDerivationTest { + + @Test + fun `derives universal wallet v2 bitcoin vectors`() { + loadFixture().getAsJsonArray("vectors").forEach { element -> + val vector = element.asJsonObject + val expected = vector.getAsJsonObject("expected").getAsJsonObject("bitcoin") + val expectedMainnet = expected.getAsJsonObject("mainnet") + val expectedTestnet = expected.getAsJsonObject("testnet") + + val mainnet = BitcoinKeyDerivation.deriveAccount(vector["mnemonic"].asString, network = BitcoinKeyDerivation.Network.Mainnet) + val testnet = BitcoinKeyDerivation.deriveAccount(vector["mnemonic"].asString, network = BitcoinKeyDerivation.Network.Testnet) + + assertEquals(UniversalWalletDerivationPaths.BITCOIN_MAINNET_ACCOUNT, mainnet.accountPath) + assertEquals(UniversalWalletDerivationPaths.BITCOIN_MAINNET_FIRST_RECEIVE, mainnet.firstReceivePath) + assertEquals(expectedMainnet["accountXpub"].asString, mainnet.accountXpub) + assertEquals(expectedMainnet["firstReceiveAddress"].asString, mainnet.firstReceiveAddress) + assertEquals(32, mainnet.privateKey.size) + assertEquals(32, mainnet.chainCode.size) + assertEquals(33, mainnet.publicKey.size) + + assertEquals(UniversalWalletDerivationPaths.BITCOIN_TESTNET_ACCOUNT, testnet.accountPath) + assertEquals(UniversalWalletDerivationPaths.BITCOIN_TESTNET_FIRST_RECEIVE, testnet.firstReceivePath) + assertEquals(expectedTestnet["accountXpub"].asString, testnet.accountXpub) + assertEquals(expectedTestnet["firstReceiveAddress"].asString, testnet.firstReceiveAddress) + assertEquals(32, testnet.privateKey.size) + assertEquals(32, testnet.chainCode.size) + assertEquals(33, testnet.publicKey.size) + } + } + + @Test + fun `normalizes mnemonic whitespace before bitcoin derivation`() { + val vector = loadFixture().getAsJsonArray("vectors").first().asJsonObject + val expected = vector.getAsJsonObject("expected").getAsJsonObject("bitcoin").getAsJsonObject("mainnet") + val padded = " ${vector["mnemonic"].asString.replace(" ", " \n\t")} " + + val account = BitcoinKeyDerivation.deriveAccount(padded, network = BitcoinKeyDerivation.Network.Mainnet) + + assertEquals(expected["accountXpub"].asString, account.accountXpub) + assertEquals(expected["firstReceiveAddress"].asString, account.firstReceiveAddress) + } + + @Test + fun `different bitcoin paths and passphrases cannot reuse vector addresses`() { + val vector = loadFixture().getAsJsonArray("vectors").first().asJsonObject + val expected = vector.getAsJsonObject("expected").getAsJsonObject("bitcoin").getAsJsonObject("mainnet") + val mnemonic = vector["mnemonic"].asString + val wrongPurpose = BitcoinKeyDerivation.deriveKey( + mnemonic = mnemonic, + derivationPath = "m/44'/0'/0'/0/0", + network = BitcoinKeyDerivation.Network.Mainnet + ) + val withPassphrase = BitcoinKeyDerivation.deriveAccount( + mnemonic = mnemonic, + passphrase = "fearless", + network = BitcoinKeyDerivation.Network.Mainnet + ) + + assertNotEquals(expected["firstReceiveAddress"].asString, wrongPurpose.address) + assertNotEquals(expected["accountXpub"].asString, withPassphrase.accountXpub) + assertNotEquals(expected["firstReceiveAddress"].asString, withPassphrase.firstReceiveAddress) + } + + @Test + fun `builds bitcoin receive paths safely`() { + assertEquals("m/84'/0'/0'/0/7", BitcoinKeyDerivation.getReceivePath(BitcoinKeyDerivation.Network.Mainnet, 7)) + assertEquals("m/84'/1'/0'/0/7", BitcoinKeyDerivation.getReceivePath(BitcoinKeyDerivation.Network.Testnet, 7)) + assertEquals("m/84'/0'/0'/1/2", BitcoinKeyDerivation.getReceivePath(BitcoinKeyDerivation.Network.Mainnet, 2, 1)) + + assertThrows(IllegalArgumentException::class.java) { + BitcoinKeyDerivation.getReceivePath(BitcoinKeyDerivation.Network.Mainnet, -1) + } + assertThrows(IllegalArgumentException::class.java) { + BitcoinKeyDerivation.getReceivePath(BitcoinKeyDerivation.Network.Mainnet, 0, 2) + } + } + + @Test + fun `rejects malformed or unsafe bitcoin derivation inputs`() { + val seed = ByteArray(64) { 1 } + + assertThrows(IllegalArgumentException::class.java) { + BitcoinKeyDerivation.deriveAccount("") + } + assertThrows(IllegalArgumentException::class.java) { + BitcoinKeyDerivation.derivePrivateKey(ByteArray(0), UniversalWalletDerivationPaths.BITCOIN_MAINNET_FIRST_RECEIVE) + } + assertThrows(IllegalArgumentException::class.java) { + BitcoinKeyDerivation.derivePrivateKey(seed, "") + } + assertThrows(IllegalArgumentException::class.java) { + BitcoinKeyDerivation.derivePrivateKey(seed, "84'/0'/0'/0/0") + } + assertThrows(IllegalArgumentException::class.java) { + BitcoinKeyDerivation.derivePrivateKey(seed, "m/84'//0'") + } + assertThrows(IllegalArgumentException::class.java) { + BitcoinKeyDerivation.derivePrivateKey(seed, "m/84'/x'/0'") + } + assertThrows(IllegalArgumentException::class.java) { + BitcoinKeyDerivation.derivePrivateKey(seed, "m/84'/2147483648'/0'") + } + } + + @Test + fun `rejects invalid bitcoin public and private key lengths`() { + assertThrows(IllegalArgumentException::class.java) { + BitcoinKeyDerivation.publicKeyFromPrivateKey(ByteArray(31)) + } + assertThrows(IllegalArgumentException::class.java) { + BitcoinKeyDerivation.addressFromPublicKey(ByteArray(32), BitcoinKeyDerivation.Network.Mainnet) + } + + val account = BitcoinKeyDerivation.deriveAccount(loadFixture().getAsJsonArray("vectors").first().asJsonObject["mnemonic"].asString) + assertTrue(account.firstReceiveAddress.startsWith("bc1q")) + assertFalse(account.firstReceiveAddress.startsWith("tb1q")) + } + + private fun loadFixture(): JsonObject { + val stream = javaClass.classLoader?.getResourceAsStream("universal-wallet-v2-vectors.json") + ?: error("universal-wallet-v2-vectors.json is missing from test resources") + + return stream.use { + JsonParser.parseReader(InputStreamReader(it)).asJsonObject + } + } +} diff --git a/common/src/test/java/jp/co/soramitsu/common/wallet/BitcoinReceiveDiscoveryTest.kt b/common/src/test/java/jp/co/soramitsu/common/wallet/BitcoinReceiveDiscoveryTest.kt new file mode 100644 index 0000000000..ba1b4f62e7 --- /dev/null +++ b/common/src/test/java/jp/co/soramitsu/common/wallet/BitcoinReceiveDiscoveryTest.kt @@ -0,0 +1,212 @@ +package jp.co.soramitsu.common.wallet + +import jp.co.soramitsu.common.data.network.bitcoin.BitcoinEsploraAddress +import jp.co.soramitsu.common.data.network.bitcoin.BitcoinEsploraStats +import jp.co.soramitsu.common.data.network.bitcoin.BitcoinEsploraTransaction +import jp.co.soramitsu.common.data.network.bitcoin.BitcoinEsploraUtxo +import jp.co.soramitsu.common.data.network.bitcoin.BitcoinIndexerClient +import jp.co.soramitsu.common.data.network.bitcoin.BitcoinIndexerRoutes +import jp.co.soramitsu.common.data.network.bitcoin.BitcoinReceiveDiscovery +import jp.co.soramitsu.common.data.network.bitcoin.BitcoinReceiveDiscoveryException +import jp.co.soramitsu.common.model.UniversalWalletRegistry +import jp.co.soramitsu.common.utils.BitcoinKeyDerivation +import kotlinx.coroutines.runBlocking +import org.junit.Assert.assertEquals +import org.junit.Assert.assertThrows +import org.junit.Assert.assertTrue +import org.junit.Test + +class BitcoinReceiveDiscoveryTest { + + @Test + fun `scans until configured unused gap after last used receive address`() = runBlocking { + val client = FakeBitcoinIndexerClient(usedAddresses = usedAddresses(0, 2)) + val discovery = BitcoinReceiveDiscovery(client) + + val result = discovery.discover( + mnemonic = MNEMONIC, + gapLimit = 3, + maxLookahead = 20 + ) + + assertEquals(3, result.gapLimit) + assertEquals(2, result.lastUsedIndex) + assertEquals(3, result.nextReceiveIndex) + assertEquals(listOf(0, 2), result.usedAddresses.map { it.index }) + assertEquals(6, client.addressCalls.size) + } + + @Test + fun `uses registry gap limit when no override is provided`() = runBlocking { + val client = FakeBitcoinIndexerClient() + val discovery = BitcoinReceiveDiscovery(client) + + val result = discovery.discover(mnemonic = MNEMONIC) + + assertEquals(UniversalWalletRegistry.bitcoinMainnet.defaultGapLimit, result.gapLimit) + assertEquals(UniversalWalletRegistry.bitcoinMainnet.defaultGapLimit, client.addressCalls.size) + } + + @Test + fun `derives testnet receive addresses through testnet indexer network`() = runBlocking { + val client = FakeBitcoinIndexerClient() + val discovery = BitcoinReceiveDiscovery(client) + + val result = discovery.discover( + mnemonic = MNEMONIC, + network = BitcoinKeyDerivation.Network.Testnet, + gapLimit = 1 + ) + + assertTrue(result.addresses.first().address.startsWith("tb1q")) + assertEquals(listOf(BitcoinIndexerRoutes.Network.Testnet), client.networkCalls) + } + + @Test + fun `rejects unsafe discovery parameters before indexer calls`() { + val client = FakeBitcoinIndexerClient() + val discovery = BitcoinReceiveDiscovery(client) + + assertThrows(BitcoinReceiveDiscoveryException::class.java) { + runBlocking { + discovery.discover(mnemonic = "", gapLimit = 2) + } + } + assertThrows(BitcoinReceiveDiscoveryException::class.java) { + runBlocking { + discovery.discover(mnemonic = MNEMONIC, gapLimit = 0) + } + } + assertThrows(BitcoinReceiveDiscoveryException::class.java) { + runBlocking { + discovery.discover(mnemonic = MNEMONIC, gapLimit = 101) + } + } + assertThrows(BitcoinReceiveDiscoveryException::class.java) { + runBlocking { + discovery.discover(mnemonic = MNEMONIC, gapLimit = 5, maxLookahead = 4) + } + } + assertEquals(emptyList(), client.addressCalls) + } + + @Test + fun `fails when max lookahead is exhausted before the unused gap is reached`() { + val client = FakeBitcoinIndexerClient(usedAddresses = usedAddresses(0)) + val discovery = BitcoinReceiveDiscovery(client) + + val error = assertThrows(BitcoinReceiveDiscoveryException::class.java) { + runBlocking { + discovery.discover(mnemonic = MNEMONIC, gapLimit = 3, maxLookahead = 3) + } + } + + assertEquals(BitcoinReceiveDiscoveryException.Code.LOOKAHEAD_EXHAUSTED, error.code) + } + + @Test + fun `rejects impossible transaction counts from indexer responses`() { + val client = FakeBitcoinIndexerClient { address -> + addressStats(address, chainTxCount = Int.MAX_VALUE, mempoolTxCount = 1) + } + val discovery = BitcoinReceiveDiscovery(client) + + val error = assertThrows(BitcoinReceiveDiscoveryException::class.java) { + runBlocking { + discovery.discover(mnemonic = MNEMONIC, gapLimit = 1) + } + } + + assertEquals(BitcoinReceiveDiscoveryException.Code.INVALID_TRANSACTION_COUNT, error.code) + } + + private class FakeBitcoinIndexerClient( + private val usedAddresses: Set = emptySet(), + private val response: (String) -> BitcoinEsploraAddress = { address -> + addressStats(address, chainTxCount = if (address in usedAddresses) 1 else 0) + } + ) : BitcoinIndexerClient { + val addressCalls = mutableListOf() + val networkCalls = mutableListOf() + + override suspend fun address( + address: String, + network: BitcoinIndexerRoutes.Network, + baseUrl: String? + ): BitcoinEsploraAddress { + addressCalls += address + networkCalls += network + return response(address) + } + + override suspend fun utxos( + address: String, + network: BitcoinIndexerRoutes.Network, + baseUrl: String? + ): List { + error("Unexpected Bitcoin UTXO call") + } + + override suspend fun transactions( + address: String, + network: BitcoinIndexerRoutes.Network, + baseUrl: String?, + lastSeenTxid: String?, + mempool: Boolean + ): List { + error("Unexpected Bitcoin transaction call") + } + + override suspend fun feeEstimates( + network: BitcoinIndexerRoutes.Network, + baseUrl: String? + ): Map { + error("Unexpected Bitcoin fee-estimates call") + } + + override suspend fun broadcastTransaction( + txHex: String, + network: BitcoinIndexerRoutes.Network, + baseUrl: String? + ): String { + error("Unexpected Bitcoin broadcast call") + } + } + + private companion object { + const val MNEMONIC = "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about" + + fun usedAddresses(vararg indexes: Int): Set { + return indexes.map { index -> + BitcoinKeyDerivation.deriveKey( + mnemonic = MNEMONIC, + derivationPath = BitcoinKeyDerivation.getReceivePath(index = index.toLong()) + ).address + }.toSet() + } + + fun addressStats( + address: String, + chainTxCount: Int = 0, + mempoolTxCount: Int = 0 + ): BitcoinEsploraAddress { + return BitcoinEsploraAddress( + address = address, + chainStats = BitcoinEsploraStats( + fundedTxoCount = chainTxCount, + fundedTxoSum = 0, + spentTxoCount = 0, + spentTxoSum = 0, + txCount = chainTxCount + ), + mempoolStats = BitcoinEsploraStats( + fundedTxoCount = mempoolTxCount, + fundedTxoSum = 0, + spentTxoCount = 0, + spentTxoSum = 0, + txCount = mempoolTxCount + ) + ) + } + } +} diff --git a/common/src/test/java/jp/co/soramitsu/common/wallet/BitcoinSendPlannerTest.kt b/common/src/test/java/jp/co/soramitsu/common/wallet/BitcoinSendPlannerTest.kt new file mode 100644 index 0000000000..74049e74ba --- /dev/null +++ b/common/src/test/java/jp/co/soramitsu/common/wallet/BitcoinSendPlannerTest.kt @@ -0,0 +1,267 @@ +package jp.co.soramitsu.common.wallet + +import jp.co.soramitsu.common.data.network.bitcoin.BitcoinEsploraAddress +import jp.co.soramitsu.common.data.network.bitcoin.BitcoinEsploraTransaction +import jp.co.soramitsu.common.data.network.bitcoin.BitcoinEsploraTxStatus +import jp.co.soramitsu.common.data.network.bitcoin.BitcoinEsploraUtxo +import jp.co.soramitsu.common.data.network.bitcoin.BitcoinIndexerClient +import jp.co.soramitsu.common.data.network.bitcoin.BitcoinIndexerRoutes +import jp.co.soramitsu.common.data.network.bitcoin.BitcoinSendPlanner +import jp.co.soramitsu.common.data.network.bitcoin.BitcoinSendPlannerException +import jp.co.soramitsu.common.data.network.bitcoin.BitcoinUtxoSelectionException +import jp.co.soramitsu.common.data.network.bitcoin.BitcoinUtxoSource +import kotlinx.coroutines.runBlocking +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Assert.assertThrows +import org.junit.Test + +class BitcoinSendPlannerTest { + + @Test + fun `plans unsigned spend with estimated fee and confirmed utxos`() = runBlocking { + val client = FakeBitcoinIndexerClient( + estimates = mapOf("2" to 2.0), + utxosByAddress = mapOf(MAINNET_ADDRESS to listOf(utxo(valueSats = 100_000))) + ) + val planner = BitcoinSendPlanner(client) + + val plan = planner.plan( + amountSats = 50_000, + sources = listOf(BitcoinUtxoSource(MAINNET_ADDRESS, "m/84'/0'/0'/0/0")), + recipientAddress = RECIPIENT_ADDRESS + ) + + assertEquals(50_000L, plan.amountSats) + assertEquals(RECIPIENT_ADDRESS, plan.recipientAddress) + assertEquals(MAINNET_ADDRESS, plan.changeAddress) + assertEquals(2.0, plan.feeRateSatPerVbyte, 0.0) + assertEquals(2, plan.feeTargetBlocks) + assertEquals(listOf(MAINNET_ADDRESS), plan.sourceAddresses) + assertEquals(1, plan.selectedUtxos.size) + assertEquals(282L, plan.feeSats) + assertEquals(49_718L, plan.changeSats) + assertEquals(0L, plan.absorbedDustSats) + assertEquals(listOf(BitcoinIndexerRoutes.Network.Mainnet), client.feeEstimateNetworks) + assertEquals(listOf(MAINNET_ADDRESS), client.utxoAddressCalls) + } + + @Test + fun `uses manual fee rate without fetching fee estimates`() = runBlocking { + val client = FakeBitcoinIndexerClient( + utxosByAddress = mapOf(MAINNET_ADDRESS to listOf(utxo(valueSats = 50_110))) + ) + val planner = BitcoinSendPlanner(client) + + val plan = planner.plan( + amountSats = 50_000, + sources = listOf(BitcoinUtxoSource(MAINNET_ADDRESS)), + recipientAddress = RECIPIENT_ADDRESS, + feeRateSatPerVbyte = 1.0 + ) + + assertEquals(1.0, plan.feeRateSatPerVbyte, 0.0) + assertNull(plan.feeTargetBlocks) + assertEquals(110L, plan.feeSats) + assertEquals(emptyList(), client.feeEstimateNetworks) + } + + @Test + fun `requires explicit opt in for unconfirmed utxos`() = runBlocking { + val client = FakeBitcoinIndexerClient( + utxosByAddress = mapOf(MAINNET_ADDRESS to listOf(utxo(valueSats = 100_000, confirmed = false))) + ) + val planner = BitcoinSendPlanner(client) + + assertPlannerError(BitcoinSendPlannerException.Code.NO_SPENDABLE_UTXOS) { + runBlocking { + planner.plan( + amountSats = 50_000, + sources = listOf(BitcoinUtxoSource(MAINNET_ADDRESS)), + recipientAddress = RECIPIENT_ADDRESS, + feeRateSatPerVbyte = 1.0 + ) + } + } + + val plan = planner.plan( + amountSats = 50_000, + sources = listOf(BitcoinUtxoSource(MAINNET_ADDRESS)), + recipientAddress = RECIPIENT_ADDRESS, + feeRateSatPerVbyte = 1.0, + includeUnconfirmed = true + ) + + assertEquals(true, plan.includeUnconfirmed) + assertEquals(1, plan.selectedUtxos.size) + } + + @Test + fun `rejects invalid planner inputs before network calls`() { + val client = FakeBitcoinIndexerClient() + val planner = BitcoinSendPlanner(client) + + assertPlannerError(BitcoinSendPlannerException.Code.SOURCES_REQUIRED) { + runBlocking { + planner.plan(1_000, emptyList(), RECIPIENT_ADDRESS, feeRateSatPerVbyte = 1.0) + } + } + assertPlannerError(BitcoinSendPlannerException.Code.TOO_MANY_SOURCES) { + runBlocking { + planner.plan( + amountSats = 1_000, + sources = (0..100).map { BitcoinUtxoSource("bc1q${it.toString().padStart(3, '0')}aaaaaaaaaaaaaa") }, + recipientAddress = RECIPIENT_ADDRESS, + feeRateSatPerVbyte = 1.0 + ) + } + } + assertPlannerError(BitcoinSendPlannerException.Code.INVALID_SOURCE_ADDRESS) { + runBlocking { + planner.plan(1_000, listOf(BitcoinUtxoSource(TESTNET_ADDRESS)), RECIPIENT_ADDRESS, feeRateSatPerVbyte = 1.0) + } + } + assertPlannerError(BitcoinSendPlannerException.Code.DUPLICATE_SOURCE_ADDRESS) { + runBlocking { + planner.plan( + 1_000, + listOf(BitcoinUtxoSource(MAINNET_ADDRESS), BitcoinUtxoSource(MAINNET_ADDRESS.uppercase())), + RECIPIENT_ADDRESS, + feeRateSatPerVbyte = 1.0 + ) + } + } + assertPlannerError(BitcoinSendPlannerException.Code.INVALID_DERIVATION_PATH) { + runBlocking { + planner.plan( + 1_000, + listOf(BitcoinUtxoSource(MAINNET_ADDRESS, "../bad")), + RECIPIENT_ADDRESS, + feeRateSatPerVbyte = 1.0 + ) + } + } + assertPlannerError(BitcoinSendPlannerException.Code.INVALID_RECIPIENT_ADDRESS) { + runBlocking { + planner.plan(1_000, listOf(BitcoinUtxoSource(MAINNET_ADDRESS)), TESTNET_ADDRESS, feeRateSatPerVbyte = 1.0) + } + } + assertPlannerError(BitcoinSendPlannerException.Code.INVALID_CHANGE_ADDRESS) { + runBlocking { + planner.plan( + 1_000, + listOf(BitcoinUtxoSource(MAINNET_ADDRESS)), + RECIPIENT_ADDRESS, + changeAddress = TESTNET_ADDRESS, + feeRateSatPerVbyte = 1.0 + ) + } + } + assertPlannerError(BitcoinSendPlannerException.Code.INVALID_FEE_RATE) { + runBlocking { + planner.plan(1_000, listOf(BitcoinUtxoSource(MAINNET_ADDRESS)), RECIPIENT_ADDRESS, feeRateSatPerVbyte = 0.0) + } + } + assertEquals(emptyList(), client.utxoAddressCalls) + assertEquals(emptyList(), client.feeEstimateNetworks) + } + + @Test + fun `propagates selection failures from spend planning`() { + val client = FakeBitcoinIndexerClient( + utxosByAddress = mapOf(MAINNET_ADDRESS to listOf(utxo(valueSats = 30_000))) + ) + val planner = BitcoinSendPlanner(client) + + val error = assertThrows(BitcoinUtxoSelectionException::class.java) { + runBlocking { + planner.plan( + amountSats = 50_000, + sources = listOf(BitcoinUtxoSource(MAINNET_ADDRESS)), + recipientAddress = RECIPIENT_ADDRESS, + feeRateSatPerVbyte = 1.0 + ) + } + } + + assertEquals(BitcoinUtxoSelectionException.Code.INSUFFICIENT_FUNDS, error.code) + } + + private fun assertPlannerError( + expected: BitcoinSendPlannerException.Code, + block: () -> Unit + ) { + val error = assertThrows(BitcoinSendPlannerException::class.java) { block() } + assertEquals(expected, error.code) + } + + private class FakeBitcoinIndexerClient( + private val estimates: Map = emptyMap(), + private val utxosByAddress: Map> = emptyMap() + ) : BitcoinIndexerClient { + val feeEstimateNetworks = mutableListOf() + val utxoAddressCalls = mutableListOf() + + override suspend fun feeEstimates( + network: BitcoinIndexerRoutes.Network, + baseUrl: String? + ): Map { + feeEstimateNetworks += network + return estimates + } + + override suspend fun utxos( + address: String, + network: BitcoinIndexerRoutes.Network, + baseUrl: String? + ): List { + utxoAddressCalls += address + return utxosByAddress[address].orEmpty() + } + + override suspend fun address( + address: String, + network: BitcoinIndexerRoutes.Network, + baseUrl: String? + ): BitcoinEsploraAddress { + error("Unexpected Bitcoin address call") + } + + override suspend fun transactions( + address: String, + network: BitcoinIndexerRoutes.Network, + baseUrl: String?, + lastSeenTxid: String?, + mempool: Boolean + ): List { + error("Unexpected Bitcoin transaction call") + } + + override suspend fun broadcastTransaction( + txHex: String, + network: BitcoinIndexerRoutes.Network, + baseUrl: String? + ): String { + error("Unexpected Bitcoin broadcast call") + } + } + + private companion object { + const val MAINNET_ADDRESS = "bc1qcr8te4kr609gcawutmrza0j4xv80jy8z306fyu" + const val RECIPIENT_ADDRESS = "bc1q6rz28mcfaxtmd6v789l9rrlrusdprr9pqcpvkl" + const val TESTNET_ADDRESS = "tb1q6rz28mcfaxtmd6v789l9rrlrusdprr9pqcpvkl" + + fun utxo( + valueSats: Long, + confirmed: Boolean = true, + txid: String = "11".repeat(32) + ): BitcoinEsploraUtxo { + return BitcoinEsploraUtxo( + txid = txid, + vout = 0, + value = valueSats, + status = BitcoinEsploraTxStatus(confirmed = confirmed) + ) + } + } +} diff --git a/common/src/test/java/jp/co/soramitsu/common/wallet/BitcoinSendServiceTest.kt b/common/src/test/java/jp/co/soramitsu/common/wallet/BitcoinSendServiceTest.kt new file mode 100644 index 0000000000..af137dd833 --- /dev/null +++ b/common/src/test/java/jp/co/soramitsu/common/wallet/BitcoinSendServiceTest.kt @@ -0,0 +1,142 @@ +package jp.co.soramitsu.common.wallet + +import jp.co.soramitsu.common.data.network.bitcoin.BitcoinEsploraAddress +import jp.co.soramitsu.common.data.network.bitcoin.BitcoinEsploraStats +import jp.co.soramitsu.common.data.network.bitcoin.BitcoinEsploraTransaction +import jp.co.soramitsu.common.data.network.bitcoin.BitcoinEsploraTxStatus +import jp.co.soramitsu.common.data.network.bitcoin.BitcoinEsploraUtxo +import jp.co.soramitsu.common.data.network.bitcoin.BitcoinIndexerClient +import jp.co.soramitsu.common.data.network.bitcoin.BitcoinIndexerRoutes +import jp.co.soramitsu.common.data.network.bitcoin.BitcoinSendRequest +import jp.co.soramitsu.common.data.network.bitcoin.BitcoinSendService +import jp.co.soramitsu.common.data.network.bitcoin.BitcoinSendServiceException +import jp.co.soramitsu.common.data.network.bitcoin.BitcoinUtxoSource +import kotlinx.coroutines.runBlocking +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Assert.assertThrows +import org.junit.Test + +class BitcoinSendServiceTest { + + @Test + fun `prepares signed transaction from selected utxos without broadcasting`() = runBlocking { + val client = FakeBitcoinIndexerClient() + val prepared = BitcoinSendService(client).prepare(request()) + + assertEquals(282L, prepared.plan.feeSats) + assertEquals(49_718L, prepared.plan.changeSats) + assertEquals(EXPECTED_TXID, prepared.transaction.txid) + assertEquals(EXPECTED_TX_HEX, prepared.transaction.txHex) + assertNull(client.lastBroadcastTxHex) + } + + @Test + fun `sends signed transaction and requires matching broadcast txid`() = runBlocking { + val client = FakeBitcoinIndexerClient(broadcastResponse = EXPECTED_TXID.uppercase()) + val result = BitcoinSendService(client).send(request(baseUrl = "https://bitcoin.example/api")) + + assertEquals(EXPECTED_TXID, result.broadcastTxid) + assertEquals(EXPECTED_TXID, result.prepared.transaction.txid) + assertEquals(EXPECTED_TX_HEX, client.lastBroadcastTxHex) + assertEquals(BitcoinIndexerRoutes.Network.Mainnet, client.lastBroadcastNetwork) + assertEquals("https://bitcoin.example/api", client.lastBroadcastBaseUrl) + } + + @Test + fun `rejects broadcast txid mismatches`() { + val client = FakeBitcoinIndexerClient(broadcastResponse = "22".repeat(32)) + + val error = assertThrows(BitcoinSendServiceException::class.java) { + runBlocking { + BitcoinSendService(client).send(request()) + } + } + + assertEquals(BitcoinSendServiceException.Code.BROADCAST_TXID_MISMATCH, error.code) + } + + private class FakeBitcoinIndexerClient( + private val broadcastResponse: String = EXPECTED_TXID + ) : BitcoinIndexerClient { + var lastBroadcastTxHex: String? = null + private set + var lastBroadcastNetwork: BitcoinIndexerRoutes.Network? = null + private set + var lastBroadcastBaseUrl: String? = null + private set + + override suspend fun address( + address: String, + network: BitcoinIndexerRoutes.Network, + baseUrl: String? + ): BitcoinEsploraAddress { + return BitcoinEsploraAddress( + address = address, + chainStats = BitcoinEsploraStats(0, 0, 0, 0, 0), + mempoolStats = BitcoinEsploraStats(0, 0, 0, 0, 0) + ) + } + + override suspend fun utxos( + address: String, + network: BitcoinIndexerRoutes.Network, + baseUrl: String? + ): List { + return listOf( + BitcoinEsploraUtxo( + txid = TXID, + vout = 1, + value = 100_000, + status = BitcoinEsploraTxStatus(confirmed = true) + ) + ) + } + + override suspend fun transactions( + address: String, + network: BitcoinIndexerRoutes.Network, + baseUrl: String?, + lastSeenTxid: String?, + mempool: Boolean + ): List = emptyList() + + override suspend fun feeEstimates( + network: BitcoinIndexerRoutes.Network, + baseUrl: String? + ): Map = emptyMap() + + override suspend fun broadcastTransaction( + txHex: String, + network: BitcoinIndexerRoutes.Network, + baseUrl: String? + ): String { + lastBroadcastTxHex = txHex + lastBroadcastNetwork = network + lastBroadcastBaseUrl = baseUrl + + return broadcastResponse + } + } + + private companion object { + const val MNEMONIC = "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about" + const val MAINNET_ADDRESS = "bc1qcr8te4kr609gcawutmrza0j4xv80jy8z306fyu" + const val MAINNET_RECIPIENT = "bc1qslk39wvggqa0vl8nd6jckaz54dw3vk45c5w60m" + val TXID = "11".repeat(32) + const val EXPECTED_TXID = "94c9b9d5070f24e06725b1000d9b1a0d46473d07b59088aca35e3d3da345023d" + const val EXPECTED_TX_HEX = "0200000000010111111111111111111111111111111111111111111111111111111111111111110100000000ffffffff0250c300000000000016001487ed12b988403af67cf36ea58b7454ab5d165ab436c2000000000000160014c0cebcd6c3d3ca8c75dc5ec62ebe55330ef910e202473044022009ec0c24a20c4346c6516065723e2e83e6a7e4dd278fb66e27f36d9108d7ef4c022077f115bfbd68a2bc7a4c100766d9cceaf5300bcfcdc775be0248e7fb5a58216801210330d54fd0dd420a6e5f8d3624f5f3482cae350f79d5f0753bf5beef9c2d91af3c00000000" + + fun request(baseUrl: String? = null): BitcoinSendRequest { + return BitcoinSendRequest( + mnemonic = MNEMONIC, + amountSats = 50_000, + sources = listOf(BitcoinUtxoSource(MAINNET_ADDRESS)), + recipientAddress = MAINNET_RECIPIENT, + changeAddress = MAINNET_ADDRESS, + feeRateSatPerVbyte = 2.0, + baseUrl = baseUrl + ) + } + } +} diff --git a/common/src/test/java/jp/co/soramitsu/common/wallet/BitcoinTransactionBroadcasterTest.kt b/common/src/test/java/jp/co/soramitsu/common/wallet/BitcoinTransactionBroadcasterTest.kt new file mode 100644 index 0000000000..d79db5dc9e --- /dev/null +++ b/common/src/test/java/jp/co/soramitsu/common/wallet/BitcoinTransactionBroadcasterTest.kt @@ -0,0 +1,120 @@ +package jp.co.soramitsu.common.wallet + +import jp.co.soramitsu.common.data.network.bitcoin.BitcoinBroadcastException +import jp.co.soramitsu.common.data.network.bitcoin.BitcoinEsploraAddress +import jp.co.soramitsu.common.data.network.bitcoin.BitcoinEsploraTransaction +import jp.co.soramitsu.common.data.network.bitcoin.BitcoinEsploraUtxo +import jp.co.soramitsu.common.data.network.bitcoin.BitcoinIndexerClient +import jp.co.soramitsu.common.data.network.bitcoin.BitcoinIndexerRoutes +import jp.co.soramitsu.common.data.network.bitcoin.BitcoinTransactionBroadcaster +import kotlinx.coroutines.runBlocking +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Assert.assertThrows +import org.junit.Test + +class BitcoinTransactionBroadcasterTest { + + @Test + fun `normalizes transaction hex delegates selected network and validates txid response`() = runBlocking { + val client = FakeBitcoinIndexerClient(TXID.uppercase()) + val broadcaster = BitcoinTransactionBroadcaster(client) + + val result = broadcaster.broadcast( + txHex = " 00AA ", + network = BitcoinIndexerRoutes.Network.Testnet, + baseUrl = "https://bitcoin.example/api" + ) + + assertEquals(BitcoinIndexerRoutes.Network.Testnet, result.network) + assertEquals("00aa", result.txHex) + assertEquals(TXID, result.txid) + assertEquals("00aa", client.lastTxHex) + assertEquals(BitcoinIndexerRoutes.Network.Testnet, client.lastNetwork) + assertEquals("https://bitcoin.example/api", client.lastBaseUrl) + } + + @Test + fun `rejects invalid transaction hex before calling indexer`() { + val client = FakeBitcoinIndexerClient(TXID) + val broadcaster = BitcoinTransactionBroadcaster(client) + + assertBroadcastError(BitcoinBroadcastException.Code.INVALID_TX_HEX) { + broadcaster.broadcast("00gg") + } + assertNull(client.lastTxHex) + } + + @Test + fun `rejects malformed txid returned by indexer`() { + val broadcaster = BitcoinTransactionBroadcaster(FakeBitcoinIndexerClient("not-a-txid")) + + assertBroadcastError(BitcoinBroadcastException.Code.INVALID_TXID_RESPONSE) { + broadcaster.broadcast("00aa") + } + } + + private fun assertBroadcastError( + expected: BitcoinBroadcastException.Code, + block: suspend () -> Unit + ) { + val error = assertThrows(BitcoinBroadcastException::class.java) { + runBlocking { + block() + } + } + assertEquals(expected, error.code) + } + + private class FakeBitcoinIndexerClient( + private val broadcastResponse: String + ) : BitcoinIndexerClient { + var lastTxHex: String? = null + private set + var lastNetwork: BitcoinIndexerRoutes.Network? = null + private set + var lastBaseUrl: String? = null + private set + + override suspend fun address( + address: String, + network: BitcoinIndexerRoutes.Network, + baseUrl: String? + ): BitcoinEsploraAddress = error("Not used") + + override suspend fun utxos( + address: String, + network: BitcoinIndexerRoutes.Network, + baseUrl: String? + ): List = error("Not used") + + override suspend fun transactions( + address: String, + network: BitcoinIndexerRoutes.Network, + baseUrl: String?, + lastSeenTxid: String?, + mempool: Boolean + ): List = error("Not used") + + override suspend fun feeEstimates( + network: BitcoinIndexerRoutes.Network, + baseUrl: String? + ): Map = error("Not used") + + override suspend fun broadcastTransaction( + txHex: String, + network: BitcoinIndexerRoutes.Network, + baseUrl: String? + ): String { + lastTxHex = txHex + lastNetwork = network + lastBaseUrl = baseUrl + + return broadcastResponse + } + } + + private companion object { + val TXID = "11".repeat(32) + } +} diff --git a/common/src/test/java/jp/co/soramitsu/common/wallet/BitcoinTransactionBuilderTest.kt b/common/src/test/java/jp/co/soramitsu/common/wallet/BitcoinTransactionBuilderTest.kt new file mode 100644 index 0000000000..eeebe52623 --- /dev/null +++ b/common/src/test/java/jp/co/soramitsu/common/wallet/BitcoinTransactionBuilderTest.kt @@ -0,0 +1,240 @@ +package jp.co.soramitsu.common.wallet + +import jp.co.soramitsu.common.data.network.bitcoin.BitcoinPaymentOutput +import jp.co.soramitsu.common.data.network.bitcoin.BitcoinSpendableUtxo +import jp.co.soramitsu.common.data.network.bitcoin.BitcoinTransactionBuilder +import jp.co.soramitsu.common.data.network.bitcoin.BitcoinTransactionException +import jp.co.soramitsu.common.data.network.bitcoin.BitcoinUtxoSelector +import org.junit.Assert.assertEquals +import org.junit.Assert.assertThrows +import org.junit.Test + +class BitcoinTransactionBuilderTest { + + @Test + fun `builds and signs mainnet p2wpkh transaction matching web vector`() { + val result = BitcoinTransactionBuilder.buildP2wpkhTransaction( + mnemonic = MNEMONIC, + inputs = listOf( + BitcoinSpendableUtxo( + address = MAINNET_ADDRESS, + txid = TXID, + valueSats = 100_000, + vout = 1 + ) + ), + outputs = listOf(BitcoinPaymentOutput(MAINNET_RECIPIENT, 50_000)), + changeAddress = MAINNET_ADDRESS, + feeRateSatPerVbyte = 2.0 + ) + + assertEquals(282L, result.feeSats) + assertEquals(49_718L, result.changeSats) + assertEquals(100_000L, result.inputTotalSats) + assertEquals(50_000L, result.outputTotalSats) + assertEquals(141, result.vsize) + assertEquals(EXPECTED_TXID, result.txid) + assertEquals(EXPECTED_TX_HEX, result.txHex) + } + + @Test + fun `supports explicit fee spends with no change output`() { + val result = BitcoinTransactionBuilder.buildP2wpkhTransaction( + mnemonic = MNEMONIC, + inputs = listOf( + BitcoinSpendableUtxo( + txid = "22".repeat(32), + valueSats = 51_000, + vout = 0 + ) + ), + outputs = listOf(BitcoinPaymentOutput(MAINNET_RECIPIENT, 50_000)), + feeSats = 1_000 + ) + + assertEquals(0L, result.changeSats) + assertEquals(1_000L, result.feeSats) + assertEquals(50_000L, result.outputTotalSats) + } + + @Test + fun `rejects unsafe utxos outputs fees and change handling before signing`() { + val baseInput = BitcoinSpendableUtxo(txid = TXID, valueSats = 100_000, vout = 0) + val baseOutput = BitcoinPaymentOutput(MAINNET_RECIPIENT, 50_000) + + assertTransactionError(BitcoinTransactionException.Code.INPUTS_REQUIRED) { + BitcoinTransactionBuilder.buildP2wpkhTransaction(MNEMONIC, inputs = emptyList(), outputs = listOf(baseOutput), feeSats = 1_000) + } + assertTransactionError(BitcoinTransactionException.Code.INVALID_TXID) { + BitcoinTransactionBuilder.buildP2wpkhTransaction( + MNEMONIC, + inputs = listOf(baseInput.copy(txid = "zz")), + outputs = listOf(baseOutput), + feeSats = 1_000 + ) + } + assertTransactionError(BitcoinTransactionException.Code.DUPLICATE_UTXO) { + BitcoinTransactionBuilder.buildP2wpkhTransaction( + MNEMONIC, + inputs = listOf(baseInput, baseInput), + outputs = listOf(baseOutput), + feeSats = 1_000 + ) + } + assertTransactionError(BitcoinTransactionException.Code.SATOSHI_OVERFLOW) { + BitcoinTransactionBuilder.buildP2wpkhTransaction( + MNEMONIC, + inputs = listOf( + baseInput.copy(valueSats = BitcoinUtxoSelector.MAX_SATOSHI), + baseInput.copy(txid = "22".repeat(32), valueSats = 1) + ), + outputs = listOf(baseOutput), + feeSats = 1_000 + ) + } + assertTransactionError(BitcoinTransactionException.Code.INVALID_OUTPUT_ADDRESS) { + BitcoinTransactionBuilder.buildP2wpkhTransaction( + MNEMONIC, + inputs = listOf(baseInput), + outputs = listOf(BitcoinPaymentOutput(TESTNET_ADDRESS, 50_000)), + feeSats = 1_000 + ) + } + assertTransactionError(BitcoinTransactionException.Code.SATOSHI_OVERFLOW) { + BitcoinTransactionBuilder.buildP2wpkhTransaction( + MNEMONIC, + inputs = listOf(baseInput.copy(valueSats = BitcoinUtxoSelector.MAX_SATOSHI)), + outputs = listOf( + BitcoinPaymentOutput(MAINNET_RECIPIENT, BitcoinUtxoSelector.MAX_SATOSHI), + BitcoinPaymentOutput(MAINNET_ADDRESS, BitcoinUtxoSelector.BITCOIN_P2WPKH_DUST_SATS) + ), + feeSats = 1_000 + ) + } + assertTransactionError(BitcoinTransactionException.Code.INVALID_OUTPUT_VALUE) { + BitcoinTransactionBuilder.buildP2wpkhTransaction( + MNEMONIC, + inputs = listOf(baseInput), + outputs = listOf(BitcoinPaymentOutput(MAINNET_RECIPIENT, BitcoinUtxoSelector.BITCOIN_P2WPKH_DUST_SATS - 1)), + feeSats = 1_000 + ) + } + assertTransactionError(BitcoinTransactionException.Code.INVALID_FEE) { + BitcoinTransactionBuilder.buildP2wpkhTransaction(MNEMONIC, inputs = listOf(baseInput), outputs = listOf(baseOutput), feeSats = 0) + } + assertTransactionError(BitcoinTransactionException.Code.INVALID_FEE_RATE) { + BitcoinTransactionBuilder.buildP2wpkhTransaction( + MNEMONIC, + inputs = listOf(baseInput), + outputs = listOf(baseOutput), + feeRateSatPerVbyte = 0.0 + ) + } + assertTransactionError(BitcoinTransactionException.Code.INSUFFICIENT_FUNDS) { + BitcoinTransactionBuilder.buildP2wpkhTransaction( + MNEMONIC, + inputs = listOf(baseInput.copy(valueSats = 50_500)), + outputs = listOf(baseOutput), + feeSats = 1_000 + ) + } + assertTransactionError(BitcoinTransactionException.Code.CHANGE_ADDRESS_REQUIRED) { + BitcoinTransactionBuilder.buildP2wpkhTransaction( + MNEMONIC, + inputs = listOf(baseInput.copy(valueSats = 52_000)), + outputs = listOf(baseOutput), + feeSats = 1_000 + ) + } + assertTransactionError(BitcoinTransactionException.Code.CHANGE_BELOW_DUST) { + BitcoinTransactionBuilder.buildP2wpkhTransaction( + MNEMONIC, + inputs = listOf(baseInput.copy(valueSats = 50_900)), + outputs = listOf(baseOutput), + changeAddress = MAINNET_ADDRESS, + feeSats = 800 + ) + } + } + + @Test + fun `rejects utxos that do not match derived bip84 key or witness script`() { + assertTransactionError(BitcoinTransactionException.Code.UTXO_ADDRESS_MISMATCH) { + BitcoinTransactionBuilder.buildP2wpkhTransaction( + mnemonic = MNEMONIC, + inputs = listOf( + BitcoinSpendableUtxo( + address = MAINNET_RECIPIENT, + txid = TXID, + valueSats = 51_000, + vout = 0 + ) + ), + outputs = listOf(BitcoinPaymentOutput(MAINNET_RECIPIENT, 50_000)), + feeSats = 1_000 + ) + } + + assertTransactionError(BitcoinTransactionException.Code.UTXO_SCRIPT_MISMATCH) { + BitcoinTransactionBuilder.buildP2wpkhTransaction( + mnemonic = MNEMONIC, + inputs = listOf( + BitcoinSpendableUtxo( + scriptPubKey = "0014${"00".repeat(20)}", + txid = TXID, + valueSats = 51_000, + vout = 0 + ) + ), + outputs = listOf(BitcoinPaymentOutput(MAINNET_RECIPIENT, 50_000)), + feeSats = 1_000 + ) + } + + assertTransactionError(BitcoinTransactionException.Code.INVALID_DERIVATION_PATH) { + BitcoinTransactionBuilder.buildP2wpkhTransaction( + mnemonic = MNEMONIC, + inputs = listOf( + BitcoinSpendableUtxo( + derivationPath = "m/84'/2147483648'/0'/0/0", + txid = TXID, + valueSats = 51_000, + vout = 0 + ) + ), + outputs = listOf(BitcoinPaymentOutput(MAINNET_RECIPIENT, 50_000)), + feeSats = 1_000 + ) + } + } + + @Test + fun `rejects empty mnemonic before signing`() { + assertTransactionError(BitcoinTransactionException.Code.INVALID_MNEMONIC) { + BitcoinTransactionBuilder.buildP2wpkhTransaction( + mnemonic = "", + inputs = listOf(BitcoinSpendableUtxo(txid = TXID, valueSats = 51_000, vout = 0)), + outputs = listOf(BitcoinPaymentOutput(MAINNET_RECIPIENT, 50_000)), + feeSats = 1_000 + ) + } + } + + private fun assertTransactionError( + expected: BitcoinTransactionException.Code, + block: () -> Unit + ) { + val error = assertThrows(BitcoinTransactionException::class.java) { block() } + assertEquals(expected, error.code) + } + + private companion object { + const val MNEMONIC = "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about" + const val MAINNET_ADDRESS = "bc1qcr8te4kr609gcawutmrza0j4xv80jy8z306fyu" + const val MAINNET_RECIPIENT = "bc1qslk39wvggqa0vl8nd6jckaz54dw3vk45c5w60m" + const val TESTNET_ADDRESS = "tb1q6rz28mcfaxtmd6v789l9rrlrusdprr9pqcpvkl" + val TXID = "11".repeat(32) + const val EXPECTED_TXID = "94c9b9d5070f24e06725b1000d9b1a0d46473d07b59088aca35e3d3da345023d" + const val EXPECTED_TX_HEX = "0200000000010111111111111111111111111111111111111111111111111111111111111111110100000000ffffffff0250c300000000000016001487ed12b988403af67cf36ea58b7454ab5d165ab436c2000000000000160014c0cebcd6c3d3ca8c75dc5ec62ebe55330ef910e202473044022009ec0c24a20c4346c6516065723e2e83e6a7e4dd278fb66e27f36d9108d7ef4c022077f115bfbd68a2bc7a4c100766d9cceaf5300bcfcdc775be0248e7fb5a58216801210330d54fd0dd420a6e5f8d3624f5f3482cae350f79d5f0753bf5beef9c2d91af3c00000000" + } +} diff --git a/common/src/test/java/jp/co/soramitsu/common/wallet/BitcoinTransactionHistorySyncTest.kt b/common/src/test/java/jp/co/soramitsu/common/wallet/BitcoinTransactionHistorySyncTest.kt new file mode 100644 index 0000000000..51a30bc378 --- /dev/null +++ b/common/src/test/java/jp/co/soramitsu/common/wallet/BitcoinTransactionHistorySyncTest.kt @@ -0,0 +1,321 @@ +package jp.co.soramitsu.common.wallet + +import com.google.gson.JsonPrimitive +import jp.co.soramitsu.common.data.network.bitcoin.BitcoinEsploraAddress +import jp.co.soramitsu.common.data.network.bitcoin.BitcoinEsploraStats +import jp.co.soramitsu.common.data.network.bitcoin.BitcoinEsploraTransaction +import jp.co.soramitsu.common.data.network.bitcoin.BitcoinEsploraTransactionInput +import jp.co.soramitsu.common.data.network.bitcoin.BitcoinEsploraTransactionOutput +import jp.co.soramitsu.common.data.network.bitcoin.BitcoinEsploraTxStatus +import jp.co.soramitsu.common.data.network.bitcoin.BitcoinEsploraUtxo +import jp.co.soramitsu.common.data.network.bitcoin.BitcoinIndexerClient +import jp.co.soramitsu.common.data.network.bitcoin.BitcoinIndexerRoutes +import jp.co.soramitsu.common.data.network.bitcoin.BitcoinTransactionHistoryException +import jp.co.soramitsu.common.data.network.bitcoin.BitcoinTransactionHistorySync +import kotlinx.coroutines.runBlocking +import org.junit.Assert.assertEquals +import org.junit.Assert.assertThrows +import org.junit.Test + +class BitcoinTransactionHistorySyncTest { + + @Test + fun `normalizes outgoing and incoming transactions from Esplora`() = runBlocking { + val client = FakeBitcoinIndexerClient( + transactions = listOf( + transaction( + txid = TXID_1, + fee = 141, + status = BitcoinEsploraTxStatus( + blockHash = BLOCK_HASH, + blockHeight = 100, + blockTime = 1_710_000_000, + confirmed = true + ), + vin = listOf(input(MAINNET_ADDRESS, 100_000)), + vout = listOf(output(COUNTERPARTY, 60_000), output(MAINNET_ADDRESS, 39_859)) + ), + transaction( + txid = TXID_2, + fee = 200, + status = BitcoinEsploraTxStatus(confirmed = false), + vin = listOf(input(COUNTERPARTY, 75_200)), + vout = listOf(output(MAINNET_ADDRESS, 75_000)) + ), + transaction( + txid = TXID_3, + vin = listOf(input(COUNTERPARTY, 10_000)), + vout = listOf(output(COUNTERPARTY, 9_900)) + ) + ) + ) + val history = BitcoinTransactionHistorySync(client).history( + address = MAINNET_ADDRESS, + baseUrl = "https://bitcoin.example/api", + lastSeenTxid = TXID_0.uppercase() + ) + + assertEquals(MAINNET_ADDRESS, history.address) + assertEquals(BitcoinIndexerRoutes.Network.Mainnet, history.network) + assertEquals(TXID_3, history.nextLastSeenTxid) + assertEquals(MAINNET_ADDRESS, client.lastAddress) + assertEquals("https://bitcoin.example/api", client.lastBaseUrl) + assertEquals(TXID_0.uppercase(), client.lastSeenTxid) + assertEquals(2, history.entries.size) + + val outgoing = history.entries[0] + assertEquals(TXID_1, outgoing.txid) + assertEquals(BLOCK_HASH, outgoing.blockHash) + assertEquals(100L, outgoing.blockHeight) + assertEquals(1_710_000_000L, outgoing.timestamp) + assertEquals(60_000L, outgoing.amountSats) + assertEquals(141L, outgoing.feeSats) + assertEquals(MAINNET_ADDRESS, outgoing.from) + assertEquals(COUNTERPARTY, outgoing.to) + assertEquals(true, outgoing.outgoing) + assertEquals(true, outgoing.confirmed) + + val incoming = history.entries[1] + assertEquals(TXID_2, incoming.txid) + assertEquals(TXID_2, incoming.blockHash) + assertEquals(75_000L, incoming.amountSats) + assertEquals(0L, incoming.feeSats) + assertEquals(COUNTERPARTY, incoming.from) + assertEquals(MAINNET_ADDRESS, incoming.to) + assertEquals(false, incoming.outgoing) + assertEquals(false, incoming.confirmed) + } + + @Test + fun `filters malformed no movement and non positive amount transactions`() = runBlocking { + val client = FakeBitcoinIndexerClient( + transactions = listOf( + transaction( + txid = TXID_1, + vin = listOf(input(MAINNET_ADDRESS, JsonPrimitive("1000"))), + vout = listOf(output(MAINNET_ADDRESS, 900)) + ), + transaction( + txid = TXID_2, + fee = 141, + vin = listOf(input(MAINNET_ADDRESS, 1_000)), + vout = listOf(output(MAINNET_ADDRESS, 859)) + ), + transaction( + txid = "not-a-txid", + vin = listOf(input(COUNTERPARTY, 2_000)), + vout = listOf(output(MAINNET_ADDRESS, 1_000)) + ) + ) + ) + + val history = BitcoinTransactionHistorySync(client).history(MAINNET_ADDRESS) + + assertEquals(0, history.entries.size) + assertEquals(TXID_2, history.nextLastSeenTxid) + } + + @Test + fun `rejects wrong network address before fetching history`() { + val client = FakeBitcoinIndexerClient(emptyList()) + + val error = assertThrows(BitcoinTransactionHistoryException::class.java) { + runBlocking { + BitcoinTransactionHistorySync(client).history(TESTNET_ADDRESS) + } + } + + assertEquals(BitcoinTransactionHistoryException.Code.INVALID_ADDRESS, error.code) + assertEquals(0, client.calls) + + val windowClient = FakeBitcoinIndexerClient() + val windowError = assertThrows(BitcoinTransactionHistoryException::class.java) { + runBlocking { + BitcoinTransactionHistorySync(windowClient).historyWindow(TESTNET_ADDRESS) + } + } + + assertEquals(BitcoinTransactionHistoryException.Code.INVALID_ADDRESS, windowError.code) + assertEquals(0, windowClient.calls) + } + + @Test + fun `history window follows esplora chain pages until a short page`() = runBlocking { + val transactions = (1..27).map { index -> outgoingTransaction(txidAt(index)) } + val pageOneCursor = transactions[24].txid + val client = FakeBitcoinIndexerClient( + transactionProvider = { cursor -> + when (cursor) { + null -> transactions.take(25) + pageOneCursor -> transactions.drop(25) + else -> emptyList() + } + } + ) + + val history = BitcoinTransactionHistorySync(client).historyWindow( + address = MAINNET_ADDRESS, + baseUrl = "https://bitcoin.example/api" + ) + + assertEquals(2, client.calls) + assertEquals(listOf(null, pageOneCursor), client.lastSeenTxids) + assertEquals(27, history.entries.size) + assertEquals(transactions.last().txid, history.nextLastSeenTxid) + } + + @Test + fun `history window stops when a page repeats the same cursor and entries`() = runBlocking { + val transactions = (1..25).map { index -> outgoingTransaction(txidAt(index)) } + val repeatedCursor = transactions.last().txid + val client = FakeBitcoinIndexerClient( + transactionProvider = { cursor -> + when (cursor) { + null -> transactions + repeatedCursor -> transactions + else -> emptyList() + } + } + ) + + val history = BitcoinTransactionHistorySync(client).historyWindow(MAINNET_ADDRESS) + + assertEquals(2, client.calls) + assertEquals(listOf(null, repeatedCursor), client.lastSeenTxids) + assertEquals(25, history.entries.size) + assertEquals(repeatedCursor, history.nextLastSeenTxid) + } + + @Test + fun `history window caps pagination before unbounded esplora traversal`() = runBlocking { + val transactions = (1..325).map { index -> outgoingTransaction(txidAt(index)) } + val client = FakeBitcoinIndexerClient( + transactionProvider = { cursor -> + val startIndex = cursor?.let { previous -> + transactions.indexOfFirst { it.txid == previous } + 1 + } ?: 0 + + transactions.drop(startIndex).take(25) + } + ) + + val history = BitcoinTransactionHistorySync(client).historyWindow(MAINNET_ADDRESS) + + assertEquals(12, client.calls) + assertEquals(300, history.entries.size) + assertEquals(transactions[299].txid, history.nextLastSeenTxid) + } + + private class FakeBitcoinIndexerClient( + private val transactions: List = emptyList(), + private val transactionProvider: ((String?) -> List)? = null + ) : BitcoinIndexerClient { + var calls = 0 + private set + var lastAddress: String? = null + private set + var lastBaseUrl: String? = null + private set + var lastSeenTxid: String? = null + private set + val lastSeenTxids = mutableListOf() + + override suspend fun address( + address: String, + network: BitcoinIndexerRoutes.Network, + baseUrl: String? + ): BitcoinEsploraAddress { + return BitcoinEsploraAddress( + address = address, + chainStats = BitcoinEsploraStats(0, 0, 0, 0, 0), + mempoolStats = BitcoinEsploraStats(0, 0, 0, 0, 0) + ) + } + + override suspend fun utxos( + address: String, + network: BitcoinIndexerRoutes.Network, + baseUrl: String? + ): List = emptyList() + + override suspend fun transactions( + address: String, + network: BitcoinIndexerRoutes.Network, + baseUrl: String?, + lastSeenTxid: String?, + mempool: Boolean + ): List { + calls += 1 + lastAddress = address + lastBaseUrl = baseUrl + this.lastSeenTxid = lastSeenTxid + lastSeenTxids.add(lastSeenTxid) + + return transactionProvider?.invoke(lastSeenTxid) ?: transactions + } + + override suspend fun feeEstimates( + network: BitcoinIndexerRoutes.Network, + baseUrl: String? + ): Map = emptyMap() + + override suspend fun broadcastTransaction( + txHex: String, + network: BitcoinIndexerRoutes.Network, + baseUrl: String? + ): String = TXID_1 + } + + private companion object { + const val MAINNET_ADDRESS = "bc1qcr8te4kr609gcawutmrza0j4xv80jy8z306fyu" + const val TESTNET_ADDRESS = "tb1q6rz28mcfaxtmd6v789l9rrlrusdprr9pqcpvkl" + const val COUNTERPARTY = "bc1q6rz28mcfaxtmd6v789l9rrlrusdprr9pkv76kj" + val BLOCK_HASH = "aa".repeat(32) + val TXID_0 = "00".repeat(32) + val TXID_1 = "11".repeat(32) + val TXID_2 = "22".repeat(32) + val TXID_3 = "33".repeat(32) + + fun txidAt(index: Int): String = index.toString(16).padStart(64, '0') + + fun outgoingTransaction(txid: String): BitcoinEsploraTransaction { + return transaction( + txid = txid, + fee = 100, + vin = listOf(input(MAINNET_ADDRESS, 1_000)), + vout = listOf(output(COUNTERPARTY, 800), output(MAINNET_ADDRESS, 100)) + ) + } + + fun transaction( + txid: String, + fee: Long? = null, + status: BitcoinEsploraTxStatus = BitcoinEsploraTxStatus(confirmed = true), + vin: List, + vout: List + ): BitcoinEsploraTransaction { + return BitcoinEsploraTransaction( + txid = txid, + status = status, + fee = fee, + vin = vin, + vout = vout + ) + } + + fun input(address: String, value: Long): BitcoinEsploraTransactionInput = input(address, JsonPrimitive(value)) + + fun input(address: String, value: JsonPrimitive): BitcoinEsploraTransactionInput { + return BitcoinEsploraTransactionInput(output(address, value)) + } + + fun output(address: String, value: Long): BitcoinEsploraTransactionOutput = output(address, JsonPrimitive(value)) + + fun output(address: String, value: JsonPrimitive): BitcoinEsploraTransactionOutput { + return BitcoinEsploraTransactionOutput( + scriptPubKeyAddress = address, + value = value + ) + } + } +} diff --git a/common/src/test/java/jp/co/soramitsu/common/wallet/BitcoinUtxoSelectorTest.kt b/common/src/test/java/jp/co/soramitsu/common/wallet/BitcoinUtxoSelectorTest.kt new file mode 100644 index 0000000000..be9229485b --- /dev/null +++ b/common/src/test/java/jp/co/soramitsu/common/wallet/BitcoinUtxoSelectorTest.kt @@ -0,0 +1,205 @@ +package jp.co.soramitsu.common.wallet + +import jp.co.soramitsu.common.data.network.bitcoin.BitcoinEsploraTxStatus +import jp.co.soramitsu.common.data.network.bitcoin.BitcoinEsploraUtxo +import jp.co.soramitsu.common.data.network.bitcoin.BitcoinSpendableUtxo +import jp.co.soramitsu.common.data.network.bitcoin.BitcoinUtxoSelectionException +import jp.co.soramitsu.common.data.network.bitcoin.BitcoinUtxoSelector +import jp.co.soramitsu.common.data.network.bitcoin.BitcoinUtxoSource +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Assert.assertThrows +import org.junit.Test + +class BitcoinUtxoSelectorTest { + + @Test + fun `selects deterministic highest value utxos and computes change fee`() { + val result = selector.select( + amountSats = 50_000, + changeAddress = MAINNET_ADDRESS, + feeRateSatPerVbyte = 2.0, + utxos = listOf( + spendable(txid = "55".repeat(32), valueSats = 80_000, vout = 1), + spendable(txid = "11".repeat(32), valueSats = 100_000, vout = 0) + ) + ) + + assertEquals(listOf("11".repeat(32)), result.selectedUtxos.map { it.txid }) + assertEquals(100_000L, result.inputTotalSats) + assertEquals(282L, result.feeSats) + assertEquals(MAINNET_ADDRESS, result.changeAddress) + assertEquals(49_718L, result.changeSats) + assertEquals(0L, result.absorbedDustSats) + } + + @Test + fun `absorbs uneconomical dust remainder into fee`() { + val result = selector.select( + amountSats = 50_000, + changeAddress = MAINNET_ADDRESS, + feeRateSatPerVbyte = 1.0, + utxos = listOf(spendable(valueSats = 50_210)) + ) + + assertEquals(210L, result.feeSats) + assertEquals(100L, result.absorbedDustSats) + assertEquals(0L, result.changeSats) + assertNull(result.changeAddress) + } + + @Test + fun `filters unconfirmed esplora utxos unless explicitly included`() { + val utxos = listOf( + esploraUtxo(valueSats = 1_000, confirmed = true), + esploraUtxo(valueSats = 2_000, confirmed = false, txid = "22".repeat(32)) + ) + + assertEquals( + 1, + selector.spendableUtxos(BitcoinUtxoSource(MAINNET_ADDRESS, "m/84'/0'/0'/0/0"), utxos).size + ) + assertEquals( + 2, + selector.spendableUtxos( + BitcoinUtxoSource(MAINNET_ADDRESS, "m/84'/0'/0'/0/0"), + utxos, + includeUnconfirmed = true + ).size + ) + } + + @Test + fun `rejects insufficient funds and too many required inputs`() { + assertSelectionError(BitcoinUtxoSelectionException.Code.INSUFFICIENT_FUNDS) { + selector.select( + amountSats = 50_000, + changeAddress = MAINNET_ADDRESS, + feeRateSatPerVbyte = 1.0, + utxos = listOf(spendable(valueSats = 30_000)) + ) + } + assertSelectionError(BitcoinUtxoSelectionException.Code.TOO_MANY_INPUTS_REQUIRED) { + selector.select( + amountSats = 90_000, + changeAddress = MAINNET_ADDRESS, + feeRateSatPerVbyte = 1.0, + maxInputs = 1, + utxos = listOf( + spendable(valueSats = 40_000, txid = "11".repeat(32), vout = 0), + spendable(valueSats = 40_000, txid = "22".repeat(32), vout = 1), + spendable(valueSats = 40_000, txid = "33".repeat(32), vout = 2) + ) + ) + } + } + + @Test + fun `rejects malformed send selection inputs before returning a plan`() { + assertSelectionError(BitcoinUtxoSelectionException.Code.AMOUNT_BELOW_DUST) { + selector.select(1, MAINNET_ADDRESS, 1.0, utxos = listOf(spendable(valueSats = 1_000))) + } + assertSelectionError(BitcoinUtxoSelectionException.Code.INVALID_CHANGE_ADDRESS) { + selector.select(1_000, TESTNET_ADDRESS, 1.0, utxos = listOf(spendable(valueSats = 2_000))) + } + assertSelectionError(BitcoinUtxoSelectionException.Code.INVALID_FEE_RATE) { + selector.select(1_000, MAINNET_ADDRESS, 0.0, utxos = listOf(spendable(valueSats = 2_000))) + } + assertSelectionError(BitcoinUtxoSelectionException.Code.INVALID_MAX_INPUTS) { + selector.select(1_000, MAINNET_ADDRESS, 1.0, maxInputs = 0, utxos = listOf(spendable(valueSats = 2_000))) + } + assertSelectionError(BitcoinUtxoSelectionException.Code.UTXOS_REQUIRED) { + selector.select(1_000, MAINNET_ADDRESS, 1.0, utxos = emptyList()) + } + } + + @Test + fun `rejects adversarial utxo payloads`() { + assertSelectionError(BitcoinUtxoSelectionException.Code.INVALID_TXID) { + selectSingle(spendable(txid = "../bad", valueSats = 2_000)) + } + assertSelectionError(BitcoinUtxoSelectionException.Code.INVALID_VOUT) { + selectSingle(spendable(valueSats = 2_000, vout = -1)) + } + assertSelectionError(BitcoinUtxoSelectionException.Code.INVALID_UTXO_VALUE) { + selectSingle(spendable(valueSats = 0)) + } + assertSelectionError(BitcoinUtxoSelectionException.Code.INVALID_SCRIPT_PUBKEY) { + selectSingle(spendable(valueSats = 2_000, scriptPubKey = "00gg")) + } + assertSelectionError(BitcoinUtxoSelectionException.Code.INVALID_DERIVATION_PATH) { + selectSingle(spendable(valueSats = 2_000, derivationPath = "../bad")) + } + assertSelectionError(BitcoinUtxoSelectionException.Code.DUPLICATE_UTXO) { + selector.select( + amountSats = 1_000, + changeAddress = MAINNET_ADDRESS, + feeRateSatPerVbyte = 1.0, + utxos = listOf(spendable(valueSats = 2_000), spendable(valueSats = 2_000)) + ) + } + } + + @Test + fun `rejects malformed source addresses and paths`() { + assertSelectionError(BitcoinUtxoSelectionException.Code.INVALID_SOURCE_ADDRESS) { + selector.spendableUtxos(BitcoinUtxoSource(TESTNET_ADDRESS), listOf(esploraUtxo())) + } + assertSelectionError(BitcoinUtxoSelectionException.Code.INVALID_DERIVATION_PATH) { + selector.spendableUtxos(BitcoinUtxoSource(MAINNET_ADDRESS, "../bad"), listOf(esploraUtxo())) + } + } + + private fun selectSingle(utxo: BitcoinSpendableUtxo) { + selector.select( + amountSats = 1_000, + changeAddress = MAINNET_ADDRESS, + feeRateSatPerVbyte = 1.0, + utxos = listOf(utxo) + ) + } + + private fun assertSelectionError( + expected: BitcoinUtxoSelectionException.Code, + block: () -> Unit + ) { + val error = assertThrows(BitcoinUtxoSelectionException::class.java) { block() } + assertEquals(expected, error.code) + } + + private companion object { + val selector = BitcoinUtxoSelector() + const val MAINNET_ADDRESS = "bc1qcr8te4kr609gcawutmrza0j4xv80jy8z306fyu" + const val TESTNET_ADDRESS = "tb1q6rz28mcfaxtmd6v789l9rrlrusdprr9pqcpvkl" + + fun spendable( + txid: String = "11".repeat(32), + valueSats: Long, + vout: Long = 0, + derivationPath: String? = null, + scriptPubKey: String? = null + ): BitcoinSpendableUtxo { + return BitcoinSpendableUtxo( + address = MAINNET_ADDRESS, + derivationPath = derivationPath, + scriptPubKey = scriptPubKey, + txid = txid, + valueSats = valueSats, + vout = vout + ) + } + + fun esploraUtxo( + valueSats: Long = 1_000, + confirmed: Boolean = true, + txid: String = "11".repeat(32) + ): BitcoinEsploraUtxo { + return BitcoinEsploraUtxo( + txid = txid, + vout = 0, + value = valueSats, + status = BitcoinEsploraTxStatus(confirmed = confirmed) + ) + } + } +} diff --git a/common/src/test/java/jp/co/soramitsu/common/wallet/IrohaAddressCodecTest.kt b/common/src/test/java/jp/co/soramitsu/common/wallet/IrohaAddressCodecTest.kt new file mode 100644 index 0000000000..f8b2255743 --- /dev/null +++ b/common/src/test/java/jp/co/soramitsu/common/wallet/IrohaAddressCodecTest.kt @@ -0,0 +1,172 @@ +package jp.co.soramitsu.common.wallet + +import com.google.gson.JsonObject +import com.google.gson.JsonParser +import jp.co.soramitsu.common.model.UniversalWalletRegistry +import jp.co.soramitsu.common.utils.IrohaAddressCodec +import jp.co.soramitsu.common.utils.IrohaAddressCodec.ErrorCode +import jp.co.soramitsu.common.utils.IrohaAddressCodec.IrohaAddressException +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertThrows +import org.junit.Assert.assertTrue +import org.junit.Test +import java.io.InputStreamReader + +class IrohaAddressCodecTest { + + @Test + fun `encodes and parses Taira and Nexus golden vectors`() { + loadVectors().forEach { vector -> + val iroha = vector.getAsJsonObject("expected").getAsJsonObject("iroha") + val taira = iroha.getAsJsonObject("taira") + val nexus = iroha.getAsJsonObject("nexus") + + assertEquals(taira["canonicalHex"].asString, IrohaAddressCodec.canonicalHex(taira["publicKeyHex"].asString)) + assertEquals(nexus["canonicalHex"].asString, IrohaAddressCodec.canonicalHex(nexus["publicKeyHex"].asString)) + assertEquals( + taira["i105"].asString, + IrohaAddressCodec.encode(taira["publicKeyHex"].asString, UniversalWalletRegistry.taira.chainDiscriminant) + ) + assertEquals( + nexus["i105"].asString, + IrohaAddressCodec.encode(nexus["publicKeyHex"].asString, UniversalWalletRegistry.nexus.chainDiscriminant) + ) + + assertEquals( + IrohaAddressCodec.Details( + chainDiscriminant = UniversalWalletRegistry.taira.chainDiscriminant, + network = IrohaAddressCodec.NetworkKind.TAIRA, + canonicalHex = taira["canonicalHex"].asString, + publicKeyHex = taira["publicKeyHex"].asString, + i105 = taira["i105"].asString + ), + IrohaAddressCodec.parse(taira["i105"].asString, UniversalWalletRegistry.taira.chainDiscriminant) + ) + assertEquals( + IrohaAddressCodec.Details( + chainDiscriminant = UniversalWalletRegistry.nexus.chainDiscriminant, + network = IrohaAddressCodec.NetworkKind.NEXUS, + canonicalHex = nexus["canonicalHex"].asString, + publicKeyHex = nexus["publicKeyHex"].asString, + i105 = nexus["i105"].asString + ), + IrohaAddressCodec.parse(nexus["i105"].asString, UniversalWalletRegistry.nexus.chainDiscriminant) + ) + } + } + + @Test + fun `rejects network mismatches and malformed I105 literals`() { + val iroha = loadVectors().first().getAsJsonObject("expected").getAsJsonObject("iroha") + val taira = iroha.getAsJsonObject("taira") + val nexus = iroha.getAsJsonObject("nexus") + val tairaAddress = taira["i105"].asString + val nexusAddress = nexus["i105"].asString + + assertError(ErrorCode.ERR_UNEXPECTED_NETWORK_PREFIX) { + IrohaAddressCodec.parse(tairaAddress, UniversalWalletRegistry.nexus.chainDiscriminant) + } + assertError(ErrorCode.ERR_UNEXPECTED_NETWORK_PREFIX) { + IrohaAddressCodec.parse(nexusAddress, UniversalWalletRegistry.taira.chainDiscriminant) + } + assertError(ErrorCode.ERR_CHECKSUM_MISMATCH) { + IrohaAddressCodec.parse(tamperLastSymbol(tairaAddress), UniversalWalletRegistry.taira.chainDiscriminant) + } + assertError(ErrorCode.ERR_INVALID_I105_CHAR) { + IrohaAddressCodec.parse("${tairaAddress.take(8)}!${tairaAddress.drop(9)}") + } + assertError(ErrorCode.ERR_UNSUPPORTED_ADDRESS_FORMAT) { + IrohaAddressCodec.parse(" $tairaAddress") + } + assertError(ErrorCode.ERR_UNSUPPORTED_ADDRESS_FORMAT) { + IrohaAddressCodec.parse(taira["canonicalHex"].asString) + } + assertError(ErrorCode.ERR_MISSING_I105_SENTINEL) { + IrohaAddressCodec.parse(nexusAddress.replaceFirst("sora", "\uff53\uff4f\uff52\uff41")) + } + assertError(ErrorCode.ERR_INVALID_I105_CHAR) { + IrohaAddressCodec.parse(nexusAddress.replaceFirst("\uff9b", "\u30ed")) + } + + assertFalse(IrohaAddressCodec.isValid(tamperLastSymbol(tairaAddress), UniversalWalletRegistry.taira.chainDiscriminant)) + assertEquals(null, IrohaAddressCodec.networkKind(taira["canonicalHex"].asString)) + } + + @Test + fun `supports canonical custom numeric prefixes only`() { + val publicKeyHex = loadVectors().first().getAsJsonObject("expected").getAsJsonObject("iroha").getAsJsonObject("taira")["publicKeyHex"].asString + val custom = IrohaAddressCodec.encode(publicKeyHex, 42) + + assertTrue(custom.startsWith("n42")) + assertEquals(IrohaAddressCodec.NetworkKind.CUSTOM, IrohaAddressCodec.parse(custom, 42).network) + assertError(ErrorCode.ERR_UNEXPECTED_NETWORK_PREFIX) { + IrohaAddressCodec.parse(custom, UniversalWalletRegistry.taira.chainDiscriminant) + } + assertError(ErrorCode.ERR_UNSUPPORTED_ADDRESS_FORMAT) { + IrohaAddressCodec.parse(custom.replaceFirst(Regex("^n42"), "n00042"), 42) + } + } + + @Test + fun `rejects invalid public keys and discriminants before encoding`() { + val publicKeyHex = loadVectors().first().getAsJsonObject("expected").getAsJsonObject("iroha").getAsJsonObject("taira")["publicKeyHex"].asString + + assertError(ErrorCode.ERR_INVALID_LENGTH) { IrohaAddressCodec.encode("abcd", UniversalWalletRegistry.taira.chainDiscriminant) } + assertError(ErrorCode.ERR_INVALID_HEX_ADDRESS) { + IrohaAddressCodec.encode("${publicKeyHex.dropLast(1)}z", UniversalWalletRegistry.taira.chainDiscriminant) + } + assertError(ErrorCode.ERR_INVALID_I105_PREFIX) { IrohaAddressCodec.encode(publicKeyHex, -1) } + assertError(ErrorCode.ERR_INVALID_I105_PREFIX) { IrohaAddressCodec.encode(publicKeyHex, 0x4000) } + } + + @Test + fun `rejects checksum-valid payloads outside single-key Ed25519 shape`() { + val canonicalHex = loadVectors().first().getAsJsonObject("expected").getAsJsonObject("iroha").getAsJsonObject("taira")["canonicalHex"].asString + val tairaDiscriminant = UniversalWalletRegistry.taira.chainDiscriminant + + assertError(ErrorCode.ERR_INVALID_HEADER_VERSION) { + IrohaAddressCodec.parse(IrohaAddressCodec.encodeCanonicalHex("0x22${canonicalHex.drop(4)}", tairaDiscriminant)) + } + assertError(ErrorCode.ERR_INVALID_NORM_VERSION) { + IrohaAddressCodec.parse(IrohaAddressCodec.encodeCanonicalHex("0x00${canonicalHex.drop(4)}", tairaDiscriminant)) + } + assertError(ErrorCode.ERR_UNKNOWN_ADDRESS_CLASS) { + IrohaAddressCodec.parse(IrohaAddressCodec.encodeCanonicalHex("0x12${canonicalHex.drop(4)}", tairaDiscriminant)) + } + assertError(ErrorCode.ERR_UNEXPECTED_EXTENSION_FLAG) { + IrohaAddressCodec.parse(IrohaAddressCodec.encodeCanonicalHex("0x03${canonicalHex.drop(4)}", tairaDiscriminant)) + } + assertError(ErrorCode.ERR_UNKNOWN_CONTROLLER_TAG) { + IrohaAddressCodec.parse(IrohaAddressCodec.encodeCanonicalHex("${canonicalHex.take(4)}01${canonicalHex.drop(6)}", tairaDiscriminant)) + } + assertError(ErrorCode.ERR_UNKNOWN_CURVE) { + IrohaAddressCodec.parse(IrohaAddressCodec.encodeCanonicalHex("${canonicalHex.take(6)}02${canonicalHex.drop(8)}", tairaDiscriminant)) + } + assertError(ErrorCode.ERR_INVALID_LENGTH) { + IrohaAddressCodec.parse(IrohaAddressCodec.encodeCanonicalHex("${canonicalHex.take(8)}1f${canonicalHex.drop(10)}", tairaDiscriminant)) + } + assertError(ErrorCode.ERR_UNEXPECTED_TRAILING_BYTES) { + IrohaAddressCodec.parse(IrohaAddressCodec.encodeCanonicalHex("${canonicalHex}00", tairaDiscriminant)) + } + } + + private fun loadVectors(): List { + val stream = javaClass.classLoader?.getResourceAsStream("universal-wallet-v2-vectors.json") + ?: error("universal-wallet-v2-vectors.json is missing from test resources") + + return stream.use { + JsonParser.parseReader(InputStreamReader(it)).asJsonObject.getAsJsonArray("vectors").map { vector -> vector.asJsonObject } + } + } + + private fun assertError(expected: ErrorCode, block: () -> Unit) { + val error = assertThrows(IrohaAddressException::class.java) { block() } + assertEquals(expected, error.code) + } + + private fun tamperLastSymbol(address: String): String { + val replacement = if (address.endsWith("1")) "2" else "1" + return address.dropLast(1) + replacement + } +} diff --git a/common/src/test/java/jp/co/soramitsu/common/wallet/IrohaKeyDerivationTest.kt b/common/src/test/java/jp/co/soramitsu/common/wallet/IrohaKeyDerivationTest.kt new file mode 100644 index 0000000000..da1f7175e3 --- /dev/null +++ b/common/src/test/java/jp/co/soramitsu/common/wallet/IrohaKeyDerivationTest.kt @@ -0,0 +1,94 @@ +package jp.co.soramitsu.common.wallet + +import com.google.gson.JsonObject +import com.google.gson.JsonParser +import jp.co.soramitsu.common.model.UniversalWalletDerivationPaths +import jp.co.soramitsu.common.model.UniversalWalletRegistry +import jp.co.soramitsu.common.utils.IrohaKeyDerivation +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNotEquals +import org.junit.Assert.assertThrows +import org.junit.Test +import java.io.InputStreamReader + +class IrohaKeyDerivationTest { + + @Test + fun `derives universal wallet v2 iroha vectors`() { + loadVectors().forEach { vector -> + val iroha = vector.getAsJsonObject("expected").getAsJsonObject("iroha") + val taira = iroha.getAsJsonObject("taira") + val nexus = iroha.getAsJsonObject("nexus") + + val account = IrohaKeyDerivation.deriveAccount(vector["mnemonic"].asString) + val tairaAddress = IrohaKeyDerivation.deriveAddress( + mnemonic = vector["mnemonic"].asString, + chainDiscriminant = UniversalWalletRegistry.taira.chainDiscriminant + ) + val nexusAddress = IrohaKeyDerivation.deriveAddress( + mnemonic = vector["mnemonic"].asString, + chainDiscriminant = UniversalWalletRegistry.nexus.chainDiscriminant + ) + + assertEquals(UniversalWalletDerivationPaths.IROHA_DEFAULT, account.derivationPath) + assertEquals(iroha["derivationPath"].asString, account.derivationPath) + assertEquals(taira["publicKeyHex"].asString, account.publicKeyHex) + assertEquals(taira["canonicalHex"].asString, account.canonicalHex) + assertEquals(taira["i105"].asString, tairaAddress.i105) + assertEquals(nexus["i105"].asString, nexusAddress.i105) + assertEquals(taira["publicKeyHex"].asString, nexus["publicKeyHex"].asString) + assertEquals(taira["canonicalHex"].asString, nexus["canonicalHex"].asString) + } + } + + @Test + fun `does not reuse iroha vectors across paths or passphrases`() { + val vector = loadVectors().first() + val iroha = vector.getAsJsonObject("expected").getAsJsonObject("iroha") + val taira = iroha.getAsJsonObject("taira") + + val wrongPath = IrohaKeyDerivation.deriveAccount( + mnemonic = vector["mnemonic"].asString, + derivationPath = "m/44'/617'/1'/0'" + ) + val withPassphrase = IrohaKeyDerivation.deriveAccount( + mnemonic = vector["mnemonic"].asString, + passphrase = "fearless" + ) + + assertNotEquals(taira["publicKeyHex"].asString, wrongPath.publicKeyHex) + assertNotEquals(taira["canonicalHex"].asString, wrongPath.canonicalHex) + assertNotEquals(taira["publicKeyHex"].asString, withPassphrase.publicKeyHex) + assertNotEquals(taira["canonicalHex"].asString, withPassphrase.canonicalHex) + } + + @Test + fun `rejects malformed iroha derivation inputs`() { + val mnemonic = loadVectors().first()["mnemonic"].asString + + assertThrows(IllegalArgumentException::class.java) { + IrohaKeyDerivation.deriveAccount("") + } + assertThrows(IllegalArgumentException::class.java) { + IrohaKeyDerivation.deriveAccount(mnemonic, derivationPath = "") + } + assertThrows(IllegalArgumentException::class.java) { + IrohaKeyDerivation.deriveAccount(mnemonic, derivationPath = "44'/617'/0'/0'") + } + assertThrows(IllegalArgumentException::class.java) { + IrohaKeyDerivation.deriveAccount(mnemonic, derivationPath = "m/44'/617/0'/0'") + } + assertThrows(IllegalArgumentException::class.java) { + IrohaKeyDerivation.deriveAddress(mnemonic, chainDiscriminant = -1) + } + } + + private fun loadVectors(): List { + val stream = javaClass.classLoader?.getResourceAsStream("universal-wallet-v2-vectors.json") + ?: error("universal-wallet-v2-vectors.json is missing from test resources") + + return stream.use { + JsonParser.parseReader(InputStreamReader(it)).asJsonObject.getAsJsonArray("vectors").map { vector -> vector.asJsonObject } + } + } +} diff --git a/common/src/test/java/jp/co/soramitsu/common/wallet/IrohaToriiClientTest.kt b/common/src/test/java/jp/co/soramitsu/common/wallet/IrohaToriiClientTest.kt new file mode 100644 index 0000000000..931bc68378 --- /dev/null +++ b/common/src/test/java/jp/co/soramitsu/common/wallet/IrohaToriiClientTest.kt @@ -0,0 +1,215 @@ +package jp.co.soramitsu.common.wallet + +import jp.co.soramitsu.common.data.network.iroha.IrohaAccountAssetListResponse +import jp.co.soramitsu.common.data.network.iroha.IrohaAccountAssetListItem +import jp.co.soramitsu.common.data.network.iroha.IrohaAccountListItem +import jp.co.soramitsu.common.data.network.iroha.IrohaAccountListResponse +import jp.co.soramitsu.common.data.network.iroha.IrohaAssetDefinitionListResponse +import jp.co.soramitsu.common.data.network.iroha.IrohaMcpJsonRpcRequest +import jp.co.soramitsu.common.data.network.iroha.IrohaMcpJsonRpcResponse +import jp.co.soramitsu.common.data.network.iroha.IrohaPipelineTransactionStatus +import jp.co.soramitsu.common.data.network.iroha.IrohaPipelineTransactionStatusResponse +import jp.co.soramitsu.common.data.network.iroha.IrohaToriiApi +import jp.co.soramitsu.common.data.network.iroha.IrohaToriiRoutes +import jp.co.soramitsu.common.data.network.iroha.IrohaTransactionSubmissionPayload +import jp.co.soramitsu.common.data.network.iroha.IrohaTransactionSubmissionReceipt +import jp.co.soramitsu.common.data.network.iroha.RetrofitIrohaToriiClient +import jp.co.soramitsu.common.model.UniversalWalletRegistry +import jp.co.soramitsu.common.utils.IrohaAddressCodec.IrohaAddressException +import kotlinx.coroutines.runBlocking +import okhttp3.RequestBody +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertThrows +import org.junit.Test + +class IrohaToriiClientTest { + + @Test + fun `delegates read endpoints through validated torii routes`() = runBlocking { + val api = FakeIrohaToriiApi() + val client = RetrofitIrohaToriiClient(api) + + client.accounts(limit = 50, offset = 10, countMode = IrohaToriiRoutes.CountMode.Exact) + assertEquals( + "https://taira.sora.org/v1/accounts?limit=50&offset=10&count_mode=exact", + api.lastUrl + ) + + client.accountAssets( + accountId = ACCOUNT, + limit = 25, + countMode = IrohaToriiRoutes.CountMode.Bounded, + asset = "xor#sora", + scope = "global" + ) + assertEquals( + "https://taira.sora.org/v1/accounts/$ENCODED_ACCOUNT/assets?limit=25&count_mode=bounded&asset=xor%23sora&scope=global", + api.lastUrl + ) + + client.transactionStatus(HASH, scope = IrohaToriiRoutes.TransactionStatusScope.Global) + assertEquals( + "https://taira.sora.org/v1/pipeline/transactions/status?hash=$HASH&scope=global", + api.lastUrl + ) + } + + @Test + fun `delegates mcp and transaction writes with explicit transport contracts`() = runBlocking { + val api = FakeIrohaToriiApi() + val client = RetrofitIrohaToriiClient(api) + + client.submitTransaction(byteArrayOf(1, 2, 3)) + assertEquals("https://taira.sora.org/v1/pipeline/transactions", api.lastUrl) + assertEquals("application/x-norito", api.lastRequestBody?.contentType().toString()) + + client.mcpJsonRpc(IrohaToriiRoutes.mcpJsonRpcRequest("tools/list", "1")) + assertEquals("https://taira.sora.org/v1/mcp", api.lastUrl) + assertEquals("tools/list", api.lastJsonRpcRequest?.method) + } + + @Test + fun `routes nexus through registry torii url while allowing runtime override`() { + val api = FakeIrohaToriiApi() + val client = RetrofitIrohaToriiClient(api) + + assertFalse(UniversalWalletRegistry.nexus.enabledByDefault) + + runBlocking { + client.mcpCapabilities(network = UniversalWalletRegistry.nexus) + } + assertEquals("https://minamoto.sora.org/v1/mcp", api.lastUrl) + + runBlocking { + client.mcpCapabilities( + network = UniversalWalletRegistry.nexus, + baseUrl = "https://nexus.example" + ) + } + assertEquals("https://nexus.example/v1/mcp", api.lastUrl) + } + + @Test + fun `rejects wrong-network iroha account ids before fetch`() { + val api = FakeIrohaToriiApi() + val client = RetrofitIrohaToriiClient(api) + + assertThrows(IrohaAddressException::class.java) { + runBlocking { + client.account(NEXUS_ACCOUNT) + } + } + assertEquals(null, api.lastUrl) + + runBlocking { + client.account( + accountId = NEXUS_ACCOUNT, + baseUrl = "https://nexus.example", + network = UniversalWalletRegistry.nexus + ) + } + assertEquals("https://nexus.example/v1/accounts/$ENCODED_NEXUS_ACCOUNT", api.lastUrl) + } + + private class FakeIrohaToriiApi : IrohaToriiApi { + var lastUrl: String? = null + private set + var lastRequestBody: RequestBody? = null + private set + var lastJsonRpcRequest: IrohaMcpJsonRpcRequest? = null + private set + + override suspend fun health(url: String): String { + lastUrl = url + return "ok" + } + + override suspend fun accounts(url: String): IrohaAccountListResponse { + lastUrl = url + return IrohaAccountListResponse( + items = listOf(IrohaAccountListItem(id = ACCOUNT)), + hasMore = false, + countMode = "bounded" + ) + } + + override suspend fun account(url: String): IrohaAccountListItem { + lastUrl = url + return IrohaAccountListItem(id = ACCOUNT) + } + + override suspend fun accountAssets(url: String): IrohaAccountAssetListResponse { + lastUrl = url + return IrohaAccountAssetListResponse( + items = listOf( + IrohaAccountAssetListItem( + accountId = ACCOUNT, + asset = "xor#sora", + quantity = "1", + scope = "global" + ) + ), + hasMore = false, + countMode = "bounded" + ) + } + + override suspend fun assetDefinitions(url: String): IrohaAssetDefinitionListResponse { + lastUrl = url + return IrohaAssetDefinitionListResponse( + items = emptyList(), + hasMore = false, + countMode = "bounded" + ) + } + + override suspend fun transactionStatus(url: String): IrohaPipelineTransactionStatusResponse { + lastUrl = url + return IrohaPipelineTransactionStatusResponse( + hash = HASH, + status = IrohaPipelineTransactionStatus(kind = "committed"), + scope = "global", + resolvedFrom = "pipeline" + ) + } + + override suspend fun submitTransaction( + url: String, + body: RequestBody + ): IrohaTransactionSubmissionReceipt { + lastUrl = url + lastRequestBody = body + return IrohaTransactionSubmissionReceipt( + payload = IrohaTransactionSubmissionPayload( + txHash = HASH, + entrypointHash = HASH, + submittedAtMs = 1L, + submittedAtHeight = 1L + ) + ) + } + + override suspend fun mcpCapabilities(url: String): Map { + lastUrl = url + return mapOf("ok" to true) + } + + override suspend fun mcpJsonRpc( + url: String, + body: IrohaMcpJsonRpcRequest + ): IrohaMcpJsonRpcResponse { + lastUrl = url + lastJsonRpcRequest = body + return IrohaMcpJsonRpcResponse(id = body.id, result = mapOf("ok" to true)) + } + } + + private companion object { + const val ACCOUNT = "testuロ1Pcナ2ラtノaリLユス2MヱミホウモヱキニイMメSマアヱキJヱFmJヌMs6YN687Y" + const val ENCODED_ACCOUNT = "testu%EF%BE%9B1Pc%EF%BE%852%EF%BE%97t%EF%BE%89a%EF%BE%98L%EF%BE%95%EF%BD%BD2M%E3%83%B1%EF%BE%90%EF%BE%8E%EF%BD%B3%EF%BE%93%E3%83%B1%EF%BD%B7%EF%BE%86%EF%BD%B2M%EF%BE%92S%EF%BE%8F%EF%BD%B1%E3%83%B1%EF%BD%B7J%E3%83%B1FmJ%EF%BE%87Ms6YN687Y" + const val NEXUS_ACCOUNT = "sorauロ1Pcナ2ラtノaリLユス2MヱミホウモヱキニイMメSマアヱキJヱFmJヌMs6YN687Y" + const val ENCODED_NEXUS_ACCOUNT = "sorau%EF%BE%9B1Pc%EF%BE%852%EF%BE%97t%EF%BE%89a%EF%BE%98L%EF%BE%95%EF%BD%BD2M%E3%83%B1%EF%BE%90%EF%BE%8E%EF%BD%B3%EF%BE%93%E3%83%B1%EF%BD%B7%EF%BE%86%EF%BD%B2M%EF%BE%92S%EF%BE%8F%EF%BD%B1%E3%83%B1%EF%BD%B7J%E3%83%B1FmJ%EF%BE%87Ms6YN687Y" + val HASH = "a".repeat(64) + } +} diff --git a/common/src/test/java/jp/co/soramitsu/common/wallet/IrohaToriiRoutesTest.kt b/common/src/test/java/jp/co/soramitsu/common/wallet/IrohaToriiRoutesTest.kt new file mode 100644 index 0000000000..ec95e4c6cb --- /dev/null +++ b/common/src/test/java/jp/co/soramitsu/common/wallet/IrohaToriiRoutesTest.kt @@ -0,0 +1,132 @@ +package jp.co.soramitsu.common.wallet + +import com.google.gson.Gson +import jp.co.soramitsu.common.data.network.iroha.IrohaAccountAssetListResponse +import jp.co.soramitsu.common.data.network.iroha.IrohaToriiRoutes +import jp.co.soramitsu.common.data.network.iroha.IrohaToriiRoutes.CountMode +import jp.co.soramitsu.common.data.network.iroha.IrohaToriiRoutes.ErrorCode +import jp.co.soramitsu.common.data.network.iroha.IrohaToriiRoutes.IrohaToriiRouteException +import jp.co.soramitsu.common.data.network.iroha.IrohaToriiRoutes.TransactionStatusScope +import jp.co.soramitsu.common.model.UniversalWalletRegistry +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertThrows +import org.junit.Test + +class IrohaToriiRoutesTest { + + @Test + fun `builds taira torii endpoint urls with validated parameters`() { + assertEquals("https://taira.sora.org/health", IrohaToriiRoutes.healthUrl()) + assertEquals("https://taira.sora.org/v1/mcp", IrohaToriiRoutes.mcpUrl()) + assertEquals( + "https://taira.sora.org/v1/accounts?limit=50&offset=10&count_mode=exact", + IrohaToriiRoutes.accountsUrl(limit = 50, offset = 10, countMode = CountMode.Exact) + ) + assertEquals( + "https://taira.sora.org/v1/accounts/$ENCODED_ACCOUNT", + IrohaToriiRoutes.accountUrl(ACCOUNT) + ) + assertEquals( + "https://taira.sora.org/v1/accounts/$ENCODED_ACCOUNT/assets?limit=25&count_mode=bounded&asset=xor%23sora&scope=global", + IrohaToriiRoutes.accountAssetsUrl( + accountId = ACCOUNT, + limit = 25, + countMode = CountMode.Bounded, + asset = "xor#sora", + scope = "global" + ) + ) + assertEquals("https://taira.sora.org/v1/assets/definitions", IrohaToriiRoutes.assetDefinitionsUrl()) + assertEquals("https://taira.sora.org/v1/pipeline/transactions", IrohaToriiRoutes.submitTransactionUrl()) + assertEquals( + "https://taira.sora.org/v1/pipeline/transactions/status?hash=$HASH&scope=global", + IrohaToriiRoutes.transactionStatusUrl("0x$HASH", scope = TransactionStatusScope.Global) + ) + assertEquals("tools/list", IrohaToriiRoutes.mcpJsonRpcRequest(" tools/list ", "1").method) + } + + @Test + fun `allows local http base urls but rejects nonlocal insecure base urls`() { + assertEquals("http://localhost:8080", IrohaToriiRoutes.normalizeBaseUrl("http://localhost:8080/")) + assertEquals("http://127.0.0.1:8080", IrohaToriiRoutes.normalizeBaseUrl("http://127.0.0.1:8080/")) + + assertRouteError(ErrorCode.INVALID_BASE_URL) { + IrohaToriiRoutes.normalizeBaseUrl("http://taira.sora.org") + } + assertRouteError(ErrorCode.INVALID_BASE_URL) { + IrohaToriiRoutes.normalizeBaseUrl("not a url") + } + assertEquals("https://minamoto.sora.org", IrohaToriiRoutes.requireToriiBaseUrl(UniversalWalletRegistry.nexus)) + assertEquals("https://minamoto.sora.org/v1/mcp", IrohaToriiRoutes.mcpUrl(UniversalWalletRegistry.nexus)) + assertFalse(UniversalWalletRegistry.nexus.enabledByDefault) + } + + @Test + fun `rejects malformed iroha route inputs before network calls`() { + assertRouteError(ErrorCode.INVALID_ACCOUNT_ID) { + IrohaToriiRoutes.accountUrl("../bad") + } + assertRouteError(ErrorCode.INVALID_ASSET) { + IrohaToriiRoutes.accountAssetsUrl(ACCOUNT, asset = "../bad") + } + assertRouteError(ErrorCode.INVALID_SCOPE) { + IrohaToriiRoutes.accountAssetsUrl(ACCOUNT, scope = "bad/scope") + } + assertRouteError(ErrorCode.INVALID_LIMIT) { + IrohaToriiRoutes.accountsUrl(limit = 0) + } + assertRouteError(ErrorCode.INVALID_LIMIT) { + IrohaToriiRoutes.accountsUrl(limit = 501) + } + assertRouteError(ErrorCode.INVALID_OFFSET) { + IrohaToriiRoutes.accountsUrl(offset = -1) + } + assertRouteError(ErrorCode.INVALID_HASH) { + IrohaToriiRoutes.transactionStatusUrl("not-a-hash") + } + assertRouteError(ErrorCode.INVALID_JSON_RPC_ID) { + IrohaToriiRoutes.mcpJsonRpcRequest("tools/list", "../bad") + } + assertRouteError(ErrorCode.INVALID_MCP_METHOD) { + IrohaToriiRoutes.mcpJsonRpcRequest("../bad", "1") + } + } + + @Test + fun `parses account asset quantities as strings`() { + val response = Gson().fromJson( + """ + { + "items": [ + { + "account_id": "$ACCOUNT", + "asset": "xor#sora", + "quantity": "340282366920938463463374607431768211455", + "scope": "global" + } + ], + "has_more": false, + "count_mode": "exact", + "total": 1 + } + """.trimIndent(), + IrohaAccountAssetListResponse::class.java + ) + + assertEquals("340282366920938463463374607431768211455", response.items.single().quantity) + assertEquals("exact", response.countMode) + assertEquals(1L, response.total) + } + + private fun assertRouteError(expected: ErrorCode, block: () -> Unit) { + val error = assertThrows(IrohaToriiRouteException::class.java) { block() } + assertEquals(expected, error.code) + } + + private companion object { + const val ACCOUNT = "testuロ1Pcナ2ラtノaリLユス2MヱミホウモヱヌニイMメSマムヱヌJヱFmJヌMs6YN687Y" + const val ENCODED_ACCOUNT = "testu%EF%BE%9B1Pc%EF%BE%852%EF%BE%97t%EF%BE%89a%EF%BE%98L%EF%BE%95%EF%BD%BD2M%E3%83%B1%EF%BE%90%EF%BE%8E%EF%BD%B3%EF%BE%93%E3%83%B1%EF%BE%87%EF%BE%86%EF%BD%B2M%EF%BE%92S%EF%BE%8F%EF%BE%91%E3%83%B1%EF%BE%87J%E3%83%B1FmJ%EF%BE%87Ms6YN687Y" + val HASH = "a".repeat(64) + } +} diff --git a/common/src/test/java/jp/co/soramitsu/common/wallet/SolanaBalanceSyncTest.kt b/common/src/test/java/jp/co/soramitsu/common/wallet/SolanaBalanceSyncTest.kt new file mode 100644 index 0000000000..fb8084eb15 --- /dev/null +++ b/common/src/test/java/jp/co/soramitsu/common/wallet/SolanaBalanceSyncTest.kt @@ -0,0 +1,280 @@ +package jp.co.soramitsu.common.wallet + +import jp.co.soramitsu.common.data.network.solana.SolanaBalanceSync +import jp.co.soramitsu.common.data.network.solana.SolanaBalanceSyncException +import jp.co.soramitsu.common.data.network.solana.SolanaIndexerClient +import jp.co.soramitsu.common.data.network.solana.SolanaIndexerRoutes +import jp.co.soramitsu.common.data.network.solana.SolanaIndexerServiceInfo +import jp.co.soramitsu.common.data.network.solana.SolanaNativeBalance +import jp.co.soramitsu.common.data.network.solana.SolanaTokenBalance +import jp.co.soramitsu.common.data.network.solana.SolanaTokenMetadata +import jp.co.soramitsu.common.data.network.solana.SolanaTokenMetadataBatchResponse +import jp.co.soramitsu.common.data.network.solana.SolanaWalletAssetsResponse +import jp.co.soramitsu.common.data.network.solana.SolanaWalletBalancesResponse +import jp.co.soramitsu.common.data.network.solana.SolanaWalletStateResponse +import jp.co.soramitsu.common.data.network.solana.SolanaWalletTransactionsResponse +import jp.co.soramitsu.common.model.UniversalWalletRegistry +import kotlinx.coroutines.runBlocking +import org.junit.Assert.assertEquals +import org.junit.Assert.assertThrows +import org.junit.Test + +class SolanaBalanceSyncTest { + + @Test + fun `normalizes native SOL and token balances from SI into shared asset balances`() = runBlocking { + val client = FakeSolanaIndexerClient( + response = balancesResponse( + lamports = "2500000000", + uiAmountString = "2.5", + tokens = listOf( + tokenBalance(SPL_MINT, TOKEN_ACCOUNT, "12500000", "12.5", 6, "spl-token"), + tokenBalance(TOKEN_2022_MINT, TOKEN_2022_ACCOUNT, "3000000001", "3.000000001", 9, "token-2022") + ) + ), + metadata = listOf( + tokenMetadata(SPL_MINT, "USDC", "USD Coin", "spl-token"), + tokenMetadata(TOKEN_2022_MINT, "T22", "Token 2022 Asset", "token-2022") + ) + ) + val result = SolanaBalanceSync(client).balances(WALLET) + + assertEquals("https://si.soramitsu.io", client.verifiedBaseUrls.single()) + assertEquals("solana-mainnet", result.networkId) + assertEquals("solana:mainnet", result.chainId) + assertEquals(3, result.balances.size) + assertEquals("solana-mainnet", result.nativeBalance.accountId) + assertEquals("SOL", result.nativeBalance.assetId) + assertEquals("2500000000", result.nativeBalance.amount) + assertEquals("2.5", result.nativeBalance.uiAmountString) + assertEquals(true, result.nativeBalance.isNative) + + val spl = result.tokenBalances[0] + assertEquals("solana-mainnet", spl.accountId) + assertEquals(SPL_MINT, spl.assetId) + assertEquals("12500000", spl.amount) + assertEquals("12.5", spl.uiAmountString) + assertEquals(6, spl.decimals) + assertEquals("USDC", spl.symbol) + assertEquals("USD Coin", spl.name) + assertEquals(TOKEN_ACCOUNT, spl.tokenAccountId) + assertEquals(SPL_MINT, spl.contractAddress) + assertEquals("spl-token", spl.tokenProgram) + + val token2022 = result.tokenBalances[1] + assertEquals(TOKEN_2022_MINT, token2022.assetId) + assertEquals("T22", token2022.symbol) + assertEquals("Token 2022 Asset", token2022.name) + assertEquals("token-2022", token2022.tokenProgram) + assertEquals(listOf(listOf(SPL_MINT, TOKEN_2022_MINT)), client.metadataBatchCalls) + } + + @Test + fun `keeps token balances when metadata lookup fails`() = runBlocking { + val client = FakeSolanaIndexerClient( + response = balancesResponse( + lamports = "1", + uiAmountString = "0.000000001", + tokens = listOf(tokenBalance(SPL_MINT, TOKEN_ACCOUNT, "7", "7", 6, "spl-token")) + ), + metadataError = IllegalStateException("metadata unavailable") + ) + + val result = SolanaBalanceSync(client).balances(WALLET) + + assertEquals(1, result.tokenBalances.size) + assertEquals("So11...1112", result.tokenBalances.single().symbol) + assertEquals(SPL_MINT, result.tokenBalances.single().name) + } + + @Test + fun `skips malformed token balances without hiding native SOL`() = runBlocking { + val client = FakeSolanaIndexerClient( + response = balancesResponse( + lamports = "1", + uiAmountString = "0.000000001", + tokens = listOf( + tokenBalance(SPL_MINT, TOKEN_ACCOUNT, "7", "7", 6, "spl-token").copy(owner = "11111111111111111111111111111111"), + tokenBalance(TOKEN_2022_MINT, TOKEN_2022_ACCOUNT, "3", "3", 9, "token-2022") + ) + ) + ) + + val result = SolanaBalanceSync(client).balances(WALLET) + + assertEquals("1", result.nativeBalance.amount) + assertEquals(listOf(TOKEN_2022_MINT), result.tokenBalances.map { it.assetId }) + } + + @Test + fun `rejects mismatched wallet and malformed native balances`() { + val walletMismatch = assertThrows(SolanaBalanceSyncException::class.java) { + runBlocking { + SolanaBalanceSync( + FakeSolanaIndexerClient( + response = balancesResponse(wallet = "11111111111111111111111111111111") + ) + ).balances(WALLET) + } + } + assertEquals(SolanaBalanceSyncException.Code.WALLET_MISMATCH, walletMismatch.code) + + val malformedNative = assertThrows(SolanaBalanceSyncException::class.java) { + runBlocking { + SolanaBalanceSync( + FakeSolanaIndexerClient( + response = balancesResponse(lamports = "-1") + ) + ).balances(WALLET) + } + } + assertEquals(SolanaBalanceSyncException.Code.INVALID_NATIVE_BALANCE, malformedNative.code) + } + + @Test + fun `rejects malformed wallet before network calls`() { + val client = FakeSolanaIndexerClient() + + assertThrows(SolanaIndexerRoutes.SolanaIndexerRouteException::class.java) { + runBlocking { + SolanaBalanceSync(client).balances("../bad") + } + } + + assertEquals(emptyList(), client.verifiedBaseUrls) + } + + private class FakeSolanaIndexerClient( + private val response: SolanaWalletBalancesResponse = balancesResponse(), + private val metadata: List = emptyList(), + private val metadataError: Exception? = null + ) : SolanaIndexerClient { + val verifiedBaseUrls = mutableListOf() + val metadataBatchCalls = mutableListOf>() + + override suspend fun serviceInfo(baseUrl: String?): SolanaIndexerServiceInfo { + error("Unexpected Solana service-info call") + } + + override suspend fun verifyServiceInfo(baseUrl: String?): SolanaIndexerServiceInfo { + verifiedBaseUrls += baseUrl.orEmpty() + return SolanaIndexerServiceInfo( + schemaVersion = 1, + serviceId = "si.soramitsu.io", + serviceName = "Solswap Indexer", + ecosystem = "solana", + chainId = "solana:mainnet", + network = "mainnet", + publicBaseUrl = "https://si.soramitsu.io", + readOnly = true + ) + } + + override suspend fun balances(wallet: String, baseUrl: String?): SolanaWalletBalancesResponse { + assertEquals(WALLET, wallet) + assertEquals(UniversalWalletRegistry.SOLANA_INDEXER_BASE_URL, baseUrl) + return response + } + + override suspend fun assets(wallet: String, baseUrl: String?): SolanaWalletAssetsResponse { + error("Unexpected Solana assets call") + } + + override suspend fun state(wallet: String, baseUrl: String?): SolanaWalletStateResponse { + error("Unexpected Solana state call") + } + + override suspend fun transactions( + wallet: String, + baseUrl: String?, + before: String?, + limit: Int + ): SolanaWalletTransactionsResponse { + error("Unexpected Solana transactions call") + } + + override suspend fun tokenMetadata(mint: String, baseUrl: String?): SolanaTokenMetadata { + error("Unexpected Solana single metadata call") + } + + override suspend fun tokenMetadataBatch( + mints: List, + baseUrl: String? + ): SolanaTokenMetadataBatchResponse { + metadataError?.let { throw it } + metadataBatchCalls += mints + return SolanaTokenMetadataBatchResponse( + total = metadata.size, + syncedAt = 1L, + tokens = metadata.filter { it.mint in mints } + ) + } + } + + private companion object { + const val WALLET = "HAgk14JpMQLgt6rVgv7cBQFJWFto5Dqxi472uT3DKpqk" + const val SPL_MINT = "So11111111111111111111111111111111111111112" + const val TOKEN_2022_MINT = "5Pobwp6d9ihN9Nz38f87gVCEBFMgipFiSM2VtUhVit6w" + const val TOKEN_ACCOUNT = "9xQeWvG816bUx9EPfQ4vF5xXw4wa9VFeTuzA7h4sFnH" + const val TOKEN_2022_ACCOUNT = "7YttLkHDoYJk5HWB7wZWTsxjZCmMCbU6Uf17dYxcqYND" + + fun balancesResponse( + wallet: String = WALLET, + lamports: String = "0", + uiAmountString: String = "0", + tokens: List = emptyList() + ): SolanaWalletBalancesResponse { + return SolanaWalletBalancesResponse( + wallet = wallet, + native = SolanaNativeBalance( + lamports = lamports, + uiAmountString = uiAmountString + ), + tokens = tokens, + total = tokens.size + 1, + syncedAt = 1710000000000L + ) + } + + fun tokenBalance( + mint: String, + accountAddress: String, + amount: String, + uiAmountString: String, + decimals: Int, + program: String + ): SolanaTokenBalance { + return SolanaTokenBalance( + accountAddress = accountAddress, + mint = mint, + owner = WALLET, + program = program, + programId = if (program == "token-2022") { + "TokenzQdY11111111111111111111111111111111111" + } else { + "TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA" + }, + amount = amount, + decimals = decimals, + uiAmountString = uiAmountString + ) + } + + fun tokenMetadata( + mint: String, + symbol: String, + name: String, + program: String + ): SolanaTokenMetadata { + return SolanaTokenMetadata( + mint = mint, + exists = true, + program = program, + decimals = 6, + name = symbol, + symbol = symbol, + syncedAt = 1L + ).copy(name = name) + } + } +} diff --git a/common/src/test/java/jp/co/soramitsu/common/wallet/SolanaIndexerClientTest.kt b/common/src/test/java/jp/co/soramitsu/common/wallet/SolanaIndexerClientTest.kt new file mode 100644 index 0000000000..efd0aafca2 --- /dev/null +++ b/common/src/test/java/jp/co/soramitsu/common/wallet/SolanaIndexerClientTest.kt @@ -0,0 +1,177 @@ +package jp.co.soramitsu.common.wallet + +import jp.co.soramitsu.common.data.network.solana.RetrofitSolanaIndexerClient +import jp.co.soramitsu.common.data.network.solana.SolanaIndexerApi +import jp.co.soramitsu.common.data.network.solana.SolanaIndexerIdentityException +import jp.co.soramitsu.common.data.network.solana.SolanaIndexerRoutes +import jp.co.soramitsu.common.data.network.solana.SolanaIndexerServiceInfo +import jp.co.soramitsu.common.data.network.solana.SolanaNativeBalance +import jp.co.soramitsu.common.data.network.solana.SolanaTokenMetadata +import jp.co.soramitsu.common.data.network.solana.SolanaTokenMetadataBatchRequest +import jp.co.soramitsu.common.data.network.solana.SolanaTokenMetadataBatchResponse +import jp.co.soramitsu.common.data.network.solana.SolanaTokenTransferFeeConfig +import jp.co.soramitsu.common.data.network.solana.SolanaTokenTransferHook +import jp.co.soramitsu.common.data.network.solana.SolanaWalletAssetsResponse +import jp.co.soramitsu.common.data.network.solana.SolanaWalletBalancesResponse +import jp.co.soramitsu.common.data.network.solana.SolanaWalletStateResponse +import jp.co.soramitsu.common.data.network.solana.SolanaWalletTransactionsResponse +import kotlinx.coroutines.runBlocking +import org.junit.Assert.assertEquals +import org.junit.Assert.assertThrows +import org.junit.Test + +class SolanaIndexerClientTest { + + @Test + fun `delegates wallet endpoints through validated si routes`() = runBlocking { + val api = FakeSolanaIndexerApi() + val client = RetrofitSolanaIndexerClient(api) + + client.verifyServiceInfo() + assertEquals("https://si.soramitsu.io/api/indexer/v1/service-info", api.lastUrl) + + client.balances(WALLET) + assertEquals("https://si.soramitsu.io/api/indexer/v1/accounts/$WALLET/balances", api.lastUrl) + + client.transactions(WALLET, before = SIGNATURE, limit = 25) + assertEquals( + "https://si.soramitsu.io/api/indexer/v1/accounts/$WALLET/txs?limit=25&before=$SIGNATURE", + api.lastUrl + ) + + val metadata = client.tokenMetadata(MINT) + assertEquals("https://si.soramitsu.io/api/indexer/v1/tokens/$MINT/metadata", api.lastUrl) + assertEquals(listOf("transferFeeConfig", "transferHook"), metadata.extensions) + assertEquals("0", metadata.transferFeeConfig?.withheldAmount) + assertEquals(WALLET, metadata.transferHook?.programId) + assertEquals(WALLET, metadata.transferHook?.extraAccountMetasAddress) + } + + @Test + fun `delegates metadata batch with normalized request body`() { + runBlocking { + val api = FakeSolanaIndexerApi() + val client = RetrofitSolanaIndexerClient(api) + + client.tokenMetadataBatch(listOf(MINT)) + assertEquals("https://si.soramitsu.io/api/indexer/v1/tokens/metadata", api.lastUrl) + assertEquals(listOf(MINT), api.lastMetadataBatchRequest?.mints) + + assertThrows(SolanaIndexerRoutes.SolanaIndexerRouteException::class.java) { + runBlocking { + client.tokenMetadataBatch(emptyList()) + } + } + } + } + + @Test + fun `verify service info rejects misrouted solana indexer`() = runBlocking { + val api = FakeSolanaIndexerApi() + val client = RetrofitSolanaIndexerClient(api) + + api.serviceInfoResponse = api.serviceInfoResponse.copy(serviceId = "ti.soramitsu.io") + + val error = assertThrows(SolanaIndexerIdentityException::class.java) { + runBlocking { + client.verifyServiceInfo() + } + } + assertEquals("ti.soramitsu.io", error.serviceInfo.serviceId) + assertEquals("https://si.soramitsu.io/api/indexer/v1/service-info", api.lastUrl) + } + + private class FakeSolanaIndexerApi : SolanaIndexerApi { + var lastUrl: String? = null + private set + var lastMetadataBatchRequest: SolanaTokenMetadataBatchRequest? = null + private set + var serviceInfoResponse = SolanaIndexerServiceInfo( + schemaVersion = 1, + serviceId = "si.soramitsu.io", + serviceName = "Solswap Indexer", + ecosystem = "solana", + chainId = "solana:mainnet", + network = "mainnet", + publicBaseUrl = "https://si.soramitsu.io", + readOnly = true + ) + + override suspend fun getServiceInfo(url: String): SolanaIndexerServiceInfo { + lastUrl = url + return serviceInfoResponse + } + + override suspend fun getBalances(url: String): SolanaWalletBalancesResponse { + lastUrl = url + return SolanaWalletBalancesResponse( + wallet = WALLET, + native = SolanaNativeBalance(lamports = "0", uiAmountString = "0"), + total = 1, + syncedAt = 1L + ) + } + + override suspend fun getAssets(url: String): SolanaWalletAssetsResponse { + lastUrl = url + return SolanaWalletAssetsResponse( + wallet = WALLET, + total = 0, + syncedAt = 1L + ) + } + + override suspend fun getState(url: String): SolanaWalletStateResponse { + lastUrl = url + return SolanaWalletStateResponse( + wallet = WALLET, + exists = true, + lamports = "0", + executable = false, + dataLength = 0, + syncedAt = 1L + ) + } + + override suspend fun getTransactions(url: String): SolanaWalletTransactionsResponse { + lastUrl = url + return SolanaWalletTransactionsResponse( + wallet = WALLET, + limit = 25, + total = 0, + syncedAt = 1L + ) + } + + override suspend fun getTokenMetadata(url: String): SolanaTokenMetadata { + lastUrl = url + return SolanaTokenMetadata( + mint = MINT, + exists = true, + program = "spl-token", + extensions = listOf("transferFeeConfig", "transferHook"), + transferFeeConfig = SolanaTokenTransferFeeConfig(withheldAmount = "0"), + transferHook = SolanaTokenTransferHook(programId = WALLET, extraAccountMetasAddress = WALLET), + syncedAt = 1L + ) + } + + override suspend fun getTokenMetadataBatch( + url: String, + body: SolanaTokenMetadataBatchRequest + ): SolanaTokenMetadataBatchResponse { + lastUrl = url + lastMetadataBatchRequest = body + return SolanaTokenMetadataBatchResponse( + total = body.mints.size, + syncedAt = 1L + ) + } + } + + private companion object { + const val WALLET = "HAgk14JpMQLgt6rVgv7cBQFJWFto5Dqxi472uT3DKpqk" + const val MINT = "So11111111111111111111111111111111111111112" + val SIGNATURE = "1".repeat(88) + } +} diff --git a/common/src/test/java/jp/co/soramitsu/common/wallet/SolanaIndexerRoutesTest.kt b/common/src/test/java/jp/co/soramitsu/common/wallet/SolanaIndexerRoutesTest.kt new file mode 100644 index 0000000000..6077462fe7 --- /dev/null +++ b/common/src/test/java/jp/co/soramitsu/common/wallet/SolanaIndexerRoutesTest.kt @@ -0,0 +1,173 @@ +package jp.co.soramitsu.common.wallet + +import com.google.gson.Gson +import jp.co.soramitsu.common.data.network.solana.SolanaIndexerServiceInfo +import jp.co.soramitsu.common.data.network.solana.SolanaIndexerRoutes +import jp.co.soramitsu.common.data.network.solana.SolanaIndexerRoutes.ErrorCode +import jp.co.soramitsu.common.data.network.solana.SolanaIndexerRoutes.SolanaIndexerRouteException +import jp.co.soramitsu.common.data.network.solana.SolanaWalletBalancesResponse +import jp.co.soramitsu.common.data.network.solana.isExpectedSiServiceInfo +import jp.co.soramitsu.common.model.UniversalWalletRegistry +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertThrows +import org.junit.Assert.assertTrue +import org.junit.Test + +class SolanaIndexerRoutesTest { + + @Test + fun `builds si wallet endpoint urls with validated parameters`() { + assertEquals( + "${UniversalWalletRegistry.SOLANA_INDEXER_BASE_URL}/api/indexer/v1/service-info", + SolanaIndexerRoutes.serviceInfoUrl() + ) + assertEquals( + "${UniversalWalletRegistry.SOLANA_INDEXER_BASE_URL}/api/indexer/v1/accounts/$WALLET/balances", + SolanaIndexerRoutes.balancesUrl(WALLET) + ) + assertEquals( + "https://si.soramitsu.io/api/indexer/v1/accounts/$WALLET/assets", + SolanaIndexerRoutes.assetsUrl(WALLET, "https://si.soramitsu.io/") + ) + assertEquals( + "https://si.soramitsu.io/api/indexer/v1/accounts/$WALLET/state", + SolanaIndexerRoutes.stateUrl(WALLET, "https://si.soramitsu.io/") + ) + assertEquals( + "https://si.soramitsu.io/api/indexer/v1/accounts/$WALLET/txs?limit=25&before=$SIGNATURE", + SolanaIndexerRoutes.transactionsUrl(WALLET, "https://si.soramitsu.io/", SIGNATURE, 25) + ) + assertEquals( + "https://si.soramitsu.io/api/indexer/v1/tokens/$MINT/metadata", + SolanaIndexerRoutes.tokenMetadataUrl(MINT, "https://si.soramitsu.io/") + ) + assertEquals( + "https://si.soramitsu.io/api/indexer/v1/tokens/metadata", + SolanaIndexerRoutes.tokenMetadataBatchUrl("https://si.soramitsu.io/") + ) + assertEquals(listOf(MINT), SolanaIndexerRoutes.tokenMetadataBatchRequest(listOf(MINT)).mints) + } + + @Test + fun `allows local http base urls but rejects nonlocal insecure base urls`() { + assertEquals("http://localhost:3000", SolanaIndexerRoutes.normalizeBaseUrl("http://localhost:3000/")) + assertEquals("http://127.0.0.1:3000", SolanaIndexerRoutes.normalizeBaseUrl("http://127.0.0.1:3000/")) + + assertRouteError(ErrorCode.INVALID_BASE_URL) { + SolanaIndexerRoutes.normalizeBaseUrl("http://si.soramitsu.io") + } + assertRouteError(ErrorCode.INVALID_BASE_URL) { + SolanaIndexerRoutes.normalizeBaseUrl("not a url") + } + } + + @Test + fun `rejects malformed wallet tx and token metadata inputs`() { + assertRouteError(ErrorCode.INVALID_WALLET) { + SolanaIndexerRoutes.balancesUrl("../bad") + } + assertRouteError(ErrorCode.INVALID_MINT) { + SolanaIndexerRoutes.tokenMetadataUrl("../bad") + } + assertRouteError(ErrorCode.INVALID_LIMIT) { + SolanaIndexerRoutes.transactionsUrl(WALLET, limit = 0) + } + assertRouteError(ErrorCode.INVALID_LIMIT) { + SolanaIndexerRoutes.transactionsUrl(WALLET, limit = 251) + } + assertRouteError(ErrorCode.INVALID_BEFORE) { + SolanaIndexerRoutes.transactionsUrl(WALLET, before = "../../../bad") + } + assertRouteError(ErrorCode.INVALID_MINTS) { + SolanaIndexerRoutes.tokenMetadataBatchRequest(emptyList()) + } + assertRouteError(ErrorCode.INVALID_MINTS) { + SolanaIndexerRoutes.tokenMetadataBatchRequest(List(101) { MINT }) + } + } + + @Test + fun `parses si balances without losing large integer precision`() { + val response = Gson().fromJson( + """ + { + "wallet": "$WALLET", + "native": { + "type": "native", + "mint": "SOL", + "lamports": "1234567890", + "decimals": 9, + "uiAmountString": "1.234567890" + }, + "tokens": [ + { + "type": "token", + "accountAddress": "$TOKEN_ACCOUNT", + "mint": "$MINT", + "owner": "$WALLET", + "program": "spl-token", + "programId": "$TOKEN_PROGRAM", + "amount": "18446744073709551616", + "decimals": 6, + "uiAmountString": "18446744073709.551616", + "state": "initialized", + "isNative": false, + "delegatedAmount": null, + "rentExemptReserve": null + } + ], + "total": 2, + "syncedAt": 1710000000000 + } + """.trimIndent(), + SolanaWalletBalancesResponse::class.java + ) + + assertEquals(WALLET, response.wallet) + assertEquals("1234567890", response.native.lamports) + assertEquals("18446744073709551616", response.tokens.single().amount) + assertEquals("spl-token", response.tokens.single().program) + assertEquals(2, response.total) + } + + @Test + fun `verifies si service identity and rejects misrouted service info`() { + val response = Gson().fromJson( + """ + { + "schemaVersion": 1, + "serviceId": "si.soramitsu.io", + "serviceName": "Solswap Indexer", + "ecosystem": "solana", + "chainId": "solana:mainnet", + "network": "mainnet", + "publicBaseUrl": "https://si.soramitsu.io", + "readOnly": true, + "capabilities": ["wallet-transactions"], + "endpoints": { + "transactions": "/api/indexer/v1/accounts/{wallet}/txs" + } + } + """.trimIndent(), + SolanaIndexerServiceInfo::class.java + ) + + assertTrue(response.isExpectedSiServiceInfo()) + assertFalse(response.copy(serviceId = "ti.soramitsu.io").isExpectedSiServiceInfo()) + assertFalse(response.copy(ecosystem = "ton").isExpectedSiServiceInfo()) + } + + private fun assertRouteError(expected: ErrorCode, block: () -> Unit) { + val error = assertThrows(SolanaIndexerRouteException::class.java) { block() } + assertEquals(expected, error.code) + } + + private companion object { + const val WALLET = "HAgk14JpMQLgt6rVgv7cBQFJWFto5Dqxi472uT3DKpqk" + const val MINT = "So11111111111111111111111111111111111111112" + const val TOKEN_ACCOUNT = "9xQeWvG816bUx9EPfQ4vF5xXw4wa9VFeTuzA7h4sFnH" + const val TOKEN_PROGRAM = "TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA" + val SIGNATURE = "1".repeat(88) + } +} diff --git a/common/src/test/java/jp/co/soramitsu/common/wallet/SolanaKeyDerivationTest.kt b/common/src/test/java/jp/co/soramitsu/common/wallet/SolanaKeyDerivationTest.kt new file mode 100644 index 0000000000..23f88bfc66 --- /dev/null +++ b/common/src/test/java/jp/co/soramitsu/common/wallet/SolanaKeyDerivationTest.kt @@ -0,0 +1,107 @@ +package jp.co.soramitsu.common.wallet + +import com.google.gson.JsonObject +import com.google.gson.JsonParser +import jp.co.soramitsu.common.model.UniversalWalletDerivationPaths +import jp.co.soramitsu.common.utils.SolanaKeyDerivation +import org.bouncycastle.util.encoders.Hex +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNotEquals +import org.junit.Assert.assertThrows +import org.junit.Assert.assertTrue +import org.junit.Test +import java.io.InputStreamReader + +class SolanaKeyDerivationTest { + + @Test + fun `derives universal wallet v2 solana vectors`() { + loadFixture().getAsJsonArray("vectors").forEach { element -> + val vector = element.asJsonObject + val expected = vector.getAsJsonObject("expected").getAsJsonObject("solana") + + val account = SolanaKeyDerivation.deriveAccount(vector["mnemonic"].asString) + + assertEquals(UniversalWalletDerivationPaths.SOLANA_DEFAULT, account.derivationPath) + assertEquals(expected["publicKeyHex"].asString, Hex.toHexString(account.publicKey)) + assertEquals(expected["address"].asString, account.address) + assertEquals(32, account.privateKey.size) + assertEquals(32, account.chainCode.size) + } + } + + @Test + fun `normalizes mnemonic whitespace before derivation`() { + val vector = loadFixture().getAsJsonArray("vectors").first().asJsonObject + val mnemonic = vector["mnemonic"].asString + val expected = vector.getAsJsonObject("expected").getAsJsonObject("solana") + val padded = " ${mnemonic.replace(" ", " \n\t")} " + + val account = SolanaKeyDerivation.deriveAccount(padded) + + assertEquals(expected["publicKeyHex"].asString, Hex.toHexString(account.publicKey)) + assertEquals(expected["address"].asString, account.address) + } + + @Test + fun `different solana derivation paths and passphrases cannot reuse vector addresses`() { + val vector = loadFixture().getAsJsonArray("vectors").first().asJsonObject + val expected = vector.getAsJsonObject("expected").getAsJsonObject("solana") + val wrongPath = SolanaKeyDerivation.deriveAccount( + mnemonic = vector["mnemonic"].asString, + derivationPath = "m/44'/501'/1'/0'" + ) + val withPassphrase = SolanaKeyDerivation.deriveAccount( + mnemonic = vector["mnemonic"].asString, + passphrase = "fearless" + ) + + assertNotEquals(expected["publicKeyHex"].asString, Hex.toHexString(wrongPath.publicKey)) + assertNotEquals(expected["address"].asString, wrongPath.address) + assertNotEquals(expected["publicKeyHex"].asString, Hex.toHexString(withPassphrase.publicKey)) + assertNotEquals(expected["address"].asString, withPassphrase.address) + } + + @Test + fun `rejects malformed or unsafe solana derivation inputs`() { + assertThrows(IllegalArgumentException::class.java) { + SolanaKeyDerivation.deriveAccount("") + } + assertThrows(IllegalArgumentException::class.java) { + SolanaKeyDerivation.deriveAccount("abandon abandon abandon", derivationPath = "") + } + assertThrows(IllegalArgumentException::class.java) { + SolanaKeyDerivation.deriveAccount("abandon abandon abandon", derivationPath = "44'/501'/0'/0'") + } + assertThrows(IllegalArgumentException::class.java) { + SolanaKeyDerivation.deriveAccount("abandon abandon abandon", derivationPath = "m/44'/501/0'/0'") + } + assertThrows(IllegalArgumentException::class.java) { + SolanaKeyDerivation.deriveAccount("abandon abandon abandon", derivationPath = "m/44'/501'/x'/0'") + } + assertThrows(IllegalArgumentException::class.java) { + SolanaKeyDerivation.deriveAccount("abandon abandon abandon", derivationPath = "m/44'/501'/2147483648'/0'") + } + } + + @Test + fun `rejects invalid solana public and private key lengths`() { + assertThrows(IllegalArgumentException::class.java) { + SolanaKeyDerivation.publicKeyFromPrivateKey(ByteArray(31)) + } + assertThrows(IllegalArgumentException::class.java) { + SolanaKeyDerivation.addressFromPublicKey(ByteArray(31)) + } + + assertTrue(SolanaKeyDerivation.addressFromPublicKey(ByteArray(32)).isNotBlank()) + } + + private fun loadFixture(): JsonObject { + val stream = javaClass.classLoader?.getResourceAsStream("universal-wallet-v2-vectors.json") + ?: error("universal-wallet-v2-vectors.json is missing from test resources") + + return stream.use { + JsonParser.parseReader(InputStreamReader(it)).asJsonObject + } + } +} diff --git a/common/src/test/java/jp/co/soramitsu/common/wallet/SolanaRpcClientTest.kt b/common/src/test/java/jp/co/soramitsu/common/wallet/SolanaRpcClientTest.kt new file mode 100644 index 0000000000..af1e141053 --- /dev/null +++ b/common/src/test/java/jp/co/soramitsu/common/wallet/SolanaRpcClientTest.kt @@ -0,0 +1,230 @@ +package jp.co.soramitsu.common.wallet + +import com.google.gson.JsonObject +import com.google.gson.JsonParser +import jp.co.soramitsu.common.data.network.solana.RetrofitSolanaRpcClient +import jp.co.soramitsu.common.data.network.solana.SolanaBroadcastOptions +import jp.co.soramitsu.common.data.network.solana.SolanaRpcApi +import jp.co.soramitsu.common.data.network.solana.SolanaRpcCommitment +import jp.co.soramitsu.common.data.network.solana.SolanaRpcException +import jp.co.soramitsu.common.data.network.solana.SolanaRpcRoutes +import jp.co.soramitsu.common.data.network.solana.SolanaSimulationOptions +import kotlinx.coroutines.runBlocking +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Assert.assertThrows +import org.junit.Test + +class SolanaRpcClientTest { + + @Test + fun `posts validated json rpc requests to configured solana rpc endpoints`() = runBlocking { + val api = FakeSolanaRpcApi() + val client = RetrofitSolanaRpcClient(api, defaultRpcUrl = DEVNET_RPC) + + val blockhash = client.latestBlockhash(SolanaRpcCommitment.Finalized) + val fee = client.feeForMessage(TRANSACTION, SolanaRpcCommitment.Confirmed) + val rent = client.minimumBalanceForRentExemption(165, SolanaRpcCommitment.Processed) + val accountExists = client.accountExists(ACCOUNT_ADDRESS, SolanaRpcCommitment.Finalized) + val simulation = client.simulateTransaction(TRANSACTION, SolanaSimulationOptions(commitment = SolanaRpcCommitment.Processed)) + val signature = client.sendRawTransaction( + TRANSACTION, + SolanaBroadcastOptions( + maxRetries = 3, + preflightCommitment = SolanaRpcCommitment.Confirmed, + skipPreflight = true + ) + ) + + assertEquals(BLOCKHASH, blockhash.value.blockhash) + assertEquals(456L, blockhash.value.lastValidBlockHeight) + assertEquals(5000L, fee.value) + assertEquals(890_880L, rent) + assertEquals(true, accountExists) + assertEquals(listOf("Program log: ok"), simulation.value.logs) + assertEquals(BLOCKHASH, simulation.value.replacementBlockhash?.blockhash) + assertEquals(321L, simulation.value.unitsConsumed) + assertEquals(SIGNATURE, signature) + assertEquals( + listOf( + "getLatestBlockhash", + "getFeeForMessage", + "getMinimumBalanceForRentExemption", + "getAccountInfo", + "simulateTransaction", + "sendTransaction" + ), + api.bodies.map { it["method"].asString } + ) + assertEquals(List(6) { DEVNET_RPC }, api.urls) + assertEquals("finalized", api.bodies[0]["params"].asJsonArray[0].asJsonObject["commitment"].asString) + assertEquals(TRANSACTION, api.bodies[1]["params"].asJsonArray[0].asString) + assertEquals(165, api.bodies[2]["params"].asJsonArray[0].asInt) + assertEquals(ACCOUNT_ADDRESS, api.bodies[3]["params"].asJsonArray[0].asString) + assertEquals("base64", api.bodies[3]["params"].asJsonArray[1].asJsonObject["encoding"].asString) + assertEquals(false, api.bodies[4]["params"].asJsonArray[1].asJsonObject["sigVerify"].asBoolean) + assertEquals(3, api.bodies[5]["params"].asJsonArray[1].asJsonObject["maxRetries"].asInt) + } + + @Test + fun `rejects unsafe urls malformed payloads and invalid options before fetch`() { + assertRouteError(SolanaRpcException.Code.INVALID_RPC_URL) { + SolanaRpcRoutes.normalizeRpcUrl("http://api.mainnet-beta.solana.com") + } + assertEquals("http://localhost:8899", SolanaRpcRoutes.normalizeRpcUrl("http://localhost:8899/?api-key=secret#frag")) + + val api = FakeSolanaRpcApi() + val client = RetrofitSolanaRpcClient(api) + + assertRpcError(SolanaRpcException.Code.INVALID_TRANSACTION) { + runBlocking { client.feeForMessage("not base64") } + } + assertRpcError(SolanaRpcException.Code.INVALID_DATA_LENGTH) { + runBlocking { client.minimumBalanceForRentExemption(-1) } + } + assertRpcError(SolanaRpcException.Code.INVALID_DATA_LENGTH) { + runBlocking { client.minimumBalanceForRentExemption(10_000_001) } + } + assertRpcError(SolanaRpcException.Code.INVALID_ACCOUNT_ADDRESS) { + runBlocking { client.accountExists("not-base58") } + } + assertRpcError(SolanaRpcException.Code.INVALID_SIMULATION_OPTIONS) { + runBlocking { + client.simulateTransaction( + TRANSACTION, + SolanaSimulationOptions(replaceRecentBlockhash = true, sigVerify = true) + ) + } + } + assertRpcError(SolanaRpcException.Code.INVALID_MAX_RETRIES) { + runBlocking { client.sendRawTransaction(TRANSACTION, SolanaBroadcastOptions(maxRetries = 11)) } + } + assertEquals(0, api.bodies.size) + } + + @Test + fun `maps rpc errors malformed envelopes and bad result shapes to typed exceptions`() { + runBlocking { + assertRpcError(SolanaRpcException.Code.RPC_ERROR) { + runBlocking { + RetrofitSolanaRpcClient( + FakeSolanaRpcApi(JsonParser.parseString("""{"jsonrpc":"2.0","id":1,"error":{"code":-32002,"message":"simulation failed","data":{"logs":[]}}}""").asJsonObject) + ).latestBlockhash() + } + }.also { + assertEquals(-32002, it.rpcError?.code) + assertEquals("simulation failed", it.rpcError?.message) + assertEquals("""{"logs":[]}""", it.rpcError?.dataJson) + } + + assertRpcError(SolanaRpcException.Code.INVALID_RPC_RESPONSE) { + runBlocking { + RetrofitSolanaRpcClient( + FakeSolanaRpcApi(JsonParser.parseString("""{"jsonrpc":"2.0","id":2,"result":{}}""").asJsonObject) + ).latestBlockhash() + } + } + + assertRpcError(SolanaRpcException.Code.INVALID_SIMULATION_RESPONSE) { + runBlocking { + RetrofitSolanaRpcClient( + FakeSolanaRpcApi(rpcResponse(1, """{"context":{"slot":1},"value":{"err":null,"logs":[1]}}""")) + ).simulateTransaction(TRANSACTION) + } + } + + assertRpcError(SolanaRpcException.Code.INVALID_FEE_RESPONSE) { + runBlocking { + RetrofitSolanaRpcClient(FakeSolanaRpcApi(rpcResponse(1, """{"context":{"slot":1},"value":-1}"""))) + .feeForMessage(TRANSACTION) + } + } + + val unavailableFee = RetrofitSolanaRpcClient(FakeSolanaRpcApi(rpcResponse(1, """{"context":{"slot":1},"value":null}"""))) + .feeForMessage(TRANSACTION) + assertNull(unavailableFee.value) + + assertRpcError(SolanaRpcException.Code.INVALID_RENT_RESPONSE) { + runBlocking { + RetrofitSolanaRpcClient(FakeSolanaRpcApi(rpcResponse(1, """"890880""""))) + .minimumBalanceForRentExemption(165) + } + } + + val missingAccount = RetrofitSolanaRpcClient( + FakeSolanaRpcApi(rpcResponse(1, """{"context":{"slot":1},"value":null}""")) + ).accountExists(ACCOUNT_ADDRESS) + assertEquals(false, missingAccount) + + assertRpcError(SolanaRpcException.Code.INVALID_ACCOUNT_INFO_RESPONSE) { + runBlocking { + RetrofitSolanaRpcClient( + FakeSolanaRpcApi(rpcResponse(1, """{"context":{"slot":1},"value":[]}""")) + ).accountExists(ACCOUNT_ADDRESS) + } + } + + assertRpcError(SolanaRpcException.Code.INVALID_SIGNATURE_RESPONSE) { + runBlocking { + RetrofitSolanaRpcClient(FakeSolanaRpcApi(rpcResponse(1, """"not-a-signature""""))) + .sendRawTransaction(TRANSACTION) + } + } + } + } + + private class FakeSolanaRpcApi( + private val fixedResponse: JsonObject? = null + ) : SolanaRpcApi { + val bodies = mutableListOf() + val urls = mutableListOf() + + override suspend fun postJsonRpc(url: String, body: JsonObject): JsonObject { + urls += url + bodies += body + fixedResponse?.let { return it } + + return when (body["method"].asString) { + "getLatestBlockhash" -> rpcResponse( + body["id"].asLong, + """{"context":{"apiVersion":"2.0.0","slot":123},"value":{"blockhash":"$BLOCKHASH","lastValidBlockHeight":456}}""" + ) + "getFeeForMessage" -> rpcResponse(body["id"].asLong, """{"context":{"slot":124},"value":5000}""") + "getMinimumBalanceForRentExemption" -> rpcResponse(body["id"].asLong, "890880") + "getAccountInfo" -> rpcResponse(body["id"].asLong, """{"context":{"slot":124},"value":{"lamports":2039280,"owner":"TokenzQdBNbLqP5VEkvhsuKq2G6Z1Lx8pGMyrEKHKzJ"}}""") + "simulateTransaction" -> rpcResponse( + body["id"].asLong, + """{"context":{"slot":124},"value":{"err":null,"logs":["Program log: ok"],"replacementBlockhash":{"blockhash":"$BLOCKHASH","lastValidBlockHeight":789},"unitsConsumed":321}}""" + ) + "sendTransaction" -> rpcResponse(body["id"].asLong, """"$SIGNATURE"""") + else -> error("Unexpected RPC method") + } + } + } + + private fun assertRpcError( + expected: SolanaRpcException.Code, + block: () -> Unit + ): SolanaRpcException { + val error = assertThrows(SolanaRpcException::class.java) { block() } + assertEquals(expected, error.code) + return error + } + + private fun assertRouteError( + expected: SolanaRpcException.Code, + block: () -> Unit + ): SolanaRpcException = assertRpcError(expected, block) + + private companion object { + const val DEVNET_RPC = "https://api.devnet.solana.com" + const val TRANSACTION = "AQIDBA==" + const val ACCOUNT_ADDRESS = "So11111111111111111111111111111111111111112" + const val BLOCKHASH = "7GjNiPun3AzEazTZoFEjZgcBMeuaXdpjHq2raZTmTrfs" + const val SIGNATURE = "5NfHnqDyzT9qyfxZDq2sSskAMGuFZ3VRqW4EQxghKqrKYdKq6cZNW1J34w7qE6nGx1eDQe5s2eKxB2ZtE1xU9qgN" + + fun rpcResponse(id: Long, resultJson: String): JsonObject { + return JsonParser.parseString("""{"jsonrpc":"2.0","id":$id,"result":$resultJson}""").asJsonObject + } + } +} diff --git a/common/src/test/java/jp/co/soramitsu/common/wallet/SolanaSendServiceTest.kt b/common/src/test/java/jp/co/soramitsu/common/wallet/SolanaSendServiceTest.kt new file mode 100644 index 0000000000..fe68b8531b --- /dev/null +++ b/common/src/test/java/jp/co/soramitsu/common/wallet/SolanaSendServiceTest.kt @@ -0,0 +1,629 @@ +package jp.co.soramitsu.common.wallet + +import jp.co.soramitsu.common.data.network.solana.SolanaBroadcastOptions +import jp.co.soramitsu.common.data.network.solana.SolanaFeeForMessageResponse +import jp.co.soramitsu.common.data.network.solana.SolanaLatestBlockhash +import jp.co.soramitsu.common.data.network.solana.SolanaLatestBlockhashResponse +import jp.co.soramitsu.common.data.network.solana.SolanaRpcClient +import jp.co.soramitsu.common.data.network.solana.SolanaRpcCommitment +import jp.co.soramitsu.common.data.network.solana.SolanaRpcContext +import jp.co.soramitsu.common.data.network.solana.SolanaSendRequest +import jp.co.soramitsu.common.data.network.solana.SolanaSendBalanceProvider +import jp.co.soramitsu.common.data.network.solana.SolanaSendService +import jp.co.soramitsu.common.data.network.solana.SolanaSendServiceException +import jp.co.soramitsu.common.data.network.solana.SolanaSimulationOptions +import jp.co.soramitsu.common.data.network.solana.SolanaSimulationResponse +import jp.co.soramitsu.common.data.network.solana.SolanaSimulationValue +import jp.co.soramitsu.common.data.network.solana.SolanaBalanceSyncResult +import jp.co.soramitsu.common.data.network.solana.SolanaTokenProgram +import jp.co.soramitsu.common.data.network.solana.SolanaTokenSendRequest +import jp.co.soramitsu.common.data.network.solana.SolanaTokenMetadata +import jp.co.soramitsu.common.data.network.solana.SolanaTokenTransferFeeConfig +import jp.co.soramitsu.common.data.network.solana.SolanaTokenTransferExtraAccount +import jp.co.soramitsu.common.data.network.solana.SolanaTokenTransferHook +import jp.co.soramitsu.common.data.network.solana.SolanaTransferTransactionBuilder +import jp.co.soramitsu.common.data.network.solana.SolanaTransferTransactionException +import jp.co.soramitsu.common.model.UniversalWalletEcosystem +import jp.co.soramitsu.common.model.UniversalWalletIndexedAssetBalance +import jp.co.soramitsu.common.utils.SolanaKeyDerivation +import jp.co.soramitsu.common.utils.SolanaTransactionSigner +import kotlinx.coroutines.runBlocking +import org.bouncycastle.util.encoders.Base64 +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Assert.assertThrows +import org.junit.Test +import java.math.BigInteger + +class SolanaSendServiceTest { + + @Test + fun `prepares signed transfer with fee and simulation without broadcasting`() = runBlocking { + val rpc = FakeSolanaRpcClient() + val prepared = SolanaSendService(rpc).prepare(request(rpcUrl = DEVNET_RPC)) + + assertEquals(5_000L, prepared.feeLamports) + assertEquals(SENDER, prepared.unsigned.senderAddress) + assertEquals(RECIPIENT, prepared.unsigned.recipientAddress) + assertEquals(BLOCKHASH, prepared.unsigned.recentBlockhash) + assertEquals(SENDER, prepared.signed.signer) + assertEquals(prepared.unsigned.messageBase64, rpc.feeMessages.single()) + assertEquals(prepared.signed.signedTransactionBase64, rpc.simulatedTransactions.single()) + assertEquals(42L, prepared.simulation?.value?.unitsConsumed) + assertNull(rpc.sentTransaction) + assertEquals(List(3) { DEVNET_RPC }, rpc.urls) + } + + @Test + fun `sends signed transfer and requires matching broadcast signature`() = runBlocking { + val rpc = FakeSolanaRpcClient() + val sent = SolanaSendService(rpc).send(request(rpcUrl = DEVNET_RPC)) + + assertEquals(sent.prepared.signed.signatureBase58, sent.signature) + assertEquals(sent.prepared.signed.signedTransactionBase64, rpc.sentTransaction) + assertEquals(SolanaRpcCommitment.Finalized, rpc.broadcastOptions?.preflightCommitment) + assertEquals(3, rpc.broadcastOptions?.maxRetries) + } + + @Test + fun `prepares signed token transfer with fee and simulation without broadcasting`() = runBlocking { + val rpc = FakeSolanaRpcClient() + val prepared = SolanaSendService(rpc).prepareTokenTransfer(tokenRequest(rpcUrl = DEVNET_RPC)) + val parsed = SolanaTransactionSigner.parseSerializedTransaction(prepared.unsigned.transaction) + + assertEquals(5_000L, prepared.feeLamports) + assertEquals(SENDER, prepared.unsigned.ownerAddress) + assertEquals(SOURCE_TOKEN_ACCOUNT, prepared.unsigned.sourceTokenAccount) + assertEquals(DESTINATION_TOKEN_ACCOUNT, prepared.unsigned.destinationTokenAccount) + assertEquals(MINT, prepared.unsigned.mintAddress) + assertEquals(false, prepared.unsigned.createsDestinationAssociatedTokenAccount) + assertEquals(1, parsed.instructionCount) + assertEquals(SENDER, prepared.signed.signer) + assertEquals(prepared.unsigned.messageBase64, rpc.feeMessages.single()) + assertEquals(prepared.signed.signedTransactionBase64, rpc.simulatedTransactions.single()) + assertEquals(42L, prepared.simulation?.value?.unitsConsumed) + assertNull(rpc.sentTransaction) + assertEquals(List(3) { DEVNET_RPC }, rpc.urls) + } + + @Test + fun `sends token transfer with associated token account create and requires matching signature`() = runBlocking { + val rpc = FakeSolanaRpcClient() + val sent = SolanaSendService(rpc).sendTokenTransfer( + tokenRequest( + rpcUrl = DEVNET_RPC, + destinationTokenAccount = null, + destinationWalletAddress = DESTINATION_WALLET, + tokenProgram = SolanaTokenProgram.Token2022, + extraAccounts = listOf( + SolanaTokenTransferExtraAccount(EXTRA_READONLY_ACCOUNT), + SolanaTokenTransferExtraAccount(EXTRA_WRITABLE_ACCOUNT, isWritable = true) + ) + ) + ) + val parsed = SolanaTransactionSigner.parseSerializedTransaction(sent.prepared.unsigned.transaction) + + assertEquals(sent.prepared.signed.signatureBase58, sent.signature) + assertEquals(true, sent.prepared.unsigned.createsDestinationAssociatedTokenAccount) + assertEquals(DESTINATION_WALLET, sent.prepared.unsigned.destinationWalletAddress) + assertEquals(DESTINATION_WALLET_TOKEN_2022_ASSOCIATED_TOKEN_ACCOUNT, sent.prepared.unsigned.destinationTokenAccount) + assertEquals(SolanaTokenProgram.Token2022, sent.prepared.unsigned.tokenProgram) + assertEquals(2, parsed.instructionCount) + assertEquals(listOf(DESTINATION_WALLET_TOKEN_2022_ASSOCIATED_TOKEN_ACCOUNT), rpc.accountExistsRequests) + assertEquals(sent.prepared.signed.signedTransactionBase64, rpc.sentTransaction) + assertEquals(SolanaRpcCommitment.Finalized, rpc.broadcastOptions?.preflightCommitment) + assertEquals(3, rpc.broadcastOptions?.maxRetries) + } + + @Test + fun `prepares token transfer without associated token account create when destination account exists`() = runBlocking { + val rpc = FakeSolanaRpcClient( + existingAccounts = setOf(DESTINATION_WALLET_TOKEN_2022_ASSOCIATED_TOKEN_ACCOUNT) + ) + val prepared = SolanaSendService(rpc).prepareTokenTransfer( + tokenRequest( + destinationTokenAccount = null, + destinationWalletAddress = DESTINATION_WALLET, + tokenProgram = SolanaTokenProgram.Token2022 + ) + ) + val parsed = SolanaTransactionSigner.parseSerializedTransaction(prepared.unsigned.transaction) + + assertEquals(false, prepared.unsigned.createsDestinationAssociatedTokenAccount) + assertEquals(null, prepared.unsigned.destinationWalletAddress) + assertEquals(DESTINATION_WALLET_TOKEN_2022_ASSOCIATED_TOKEN_ACCOUNT, prepared.unsigned.destinationTokenAccount) + assertEquals(1, parsed.instructionCount) + assertEquals(listOf(DESTINATION_WALLET_TOKEN_2022_ASSOCIATED_TOKEN_ACCOUNT), rpc.accountExistsRequests) + } + + @Test + fun `rejects invalid request parameters before rpc calls`() { + val rpc = FakeSolanaRpcClient() + assertThrows(SolanaTransferTransactionException::class.java) { + runBlocking { SolanaSendService(rpc).prepare(request(lamports = 0)) } + } + assertThrows(SolanaTransferTransactionException::class.java) { + runBlocking { SolanaSendService(rpc).prepare(request(recipientAddress = "not-base58")) } + } + assertEquals(0, rpc.calls) + } + + @Test + fun `rejects invalid token request parameters and account derivation failures`() { + val rpc = FakeSolanaRpcClient() + + assertSendError(SolanaSendServiceException.Code.INVALID_ACCOUNT) { + SolanaSendService(rpc).prepareTokenTransfer(tokenRequest(derivationPath = "m/44'/501'/0/0'")) + } + assertEquals(0, rpc.calls) + + assertTransferError(SolanaTransferTransactionException.Code.INVALID_TOKEN_AMOUNT) { + SolanaSendService(rpc).prepareTokenTransfer(tokenRequest(rawAmount = 0)) + } + assertTransferError(SolanaTransferTransactionException.Code.INVALID_WALLET) { + SolanaSendService(rpc).prepareTokenTransfer(tokenRequest(destinationWalletAddress = "not-base58")) + } + assertTransferError(SolanaTransferTransactionException.Code.INVALID_ASSOCIATED_TOKEN_ACCOUNT) { + SolanaSendService(rpc).prepareTokenTransfer( + tokenRequest( + destinationTokenAccount = DESTINATION_TOKEN_ACCOUNT, + destinationWalletAddress = DESTINATION_WALLET, + tokenProgram = SolanaTokenProgram.Token2022 + ) + ) + } + assertEquals(emptyList(), rpc.accountExistsRequests) + } + + @Test + fun `rejects unsafe token 2022 extension metadata before rpc calls`() { + assertSendError(SolanaSendServiceException.Code.TOKEN_METADATA_MISMATCH) { + SolanaSendService(FakeSolanaRpcClient()).prepareTokenTransfer( + tokenRequest( + tokenProgram = SolanaTokenProgram.Token2022, + tokenMetadata = tokenMetadata(mint = DESTINATION_TOKEN_ACCOUNT) + ) + ) + } + assertSendError(SolanaSendServiceException.Code.UNSUPPORTED_TOKEN_2022_EXTENSION) { + SolanaSendService(FakeSolanaRpcClient()).prepareTokenTransfer( + tokenRequest( + tokenProgram = SolanaTokenProgram.Token2022, + tokenMetadata = tokenMetadata(extensions = listOf("nonTransferable")) + ) + ) + } + assertSendError(SolanaSendServiceException.Code.TOKEN_2022_TRANSFER_FEE_NOT_ACKNOWLEDGED) { + SolanaSendService(FakeSolanaRpcClient()).prepareTokenTransfer( + tokenRequest( + tokenProgram = SolanaTokenProgram.Token2022, + tokenMetadata = tokenMetadata( + extensions = listOf("transferFeeConfig"), + transferFeeConfig = SolanaTokenTransferFeeConfig(withheldAmount = "0") + ) + ) + ) + } + val hookRpc = FakeSolanaRpcClient() + assertSendError(SolanaSendServiceException.Code.TOKEN_2022_TRANSFER_HOOK_ACCOUNTS_MISSING) { + SolanaSendService(hookRpc).prepareTokenTransfer( + tokenRequest( + tokenProgram = SolanaTokenProgram.Token2022, + tokenMetadata = tokenMetadata( + extensions = listOf("transferHook"), + transferHook = SolanaTokenTransferHook(programId = EXTRA_READONLY_ACCOUNT) + ) + ) + ) + } + + assertEquals(0, hookRpc.calls) + } + + @Test + fun `allows token 2022 transfer fee after acknowledgement and hook with extra accounts`() = runBlocking { + val feePrepared = SolanaSendService(FakeSolanaRpcClient()).prepareTokenTransfer( + tokenRequest( + tokenProgram = SolanaTokenProgram.Token2022, + tokenMetadata = tokenMetadata( + extensions = listOf("transferFeeConfig"), + transferFeeConfig = SolanaTokenTransferFeeConfig(withheldAmount = "0") + ), + acknowledgeToken2022TransferFee = true + ) + ) + val hookPrepared = SolanaSendService(FakeSolanaRpcClient()).prepareTokenTransfer( + tokenRequest( + tokenProgram = SolanaTokenProgram.Token2022, + extraAccounts = listOf(SolanaTokenTransferExtraAccount(EXTRA_READONLY_ACCOUNT)), + tokenMetadata = tokenMetadata( + extensions = listOf("transferHook"), + transferHook = SolanaTokenTransferHook(programId = EXTRA_READONLY_ACCOUNT) + ) + ) + ) + + assertEquals(SolanaTokenProgram.Token2022, feePrepared.unsigned.tokenProgram) + assertEquals(SolanaTokenProgram.Token2022, hookPrepared.unsigned.tokenProgram) + assertEquals(listOf(SolanaTokenTransferExtraAccount(EXTRA_READONLY_ACCOUNT)), hookPrepared.unsigned.extraAccounts) + } + + @Test + fun `rejects native send when sol balance cannot cover amount plus fee`() { + val rpc = FakeSolanaRpcClient() + val balances = FakeSolanaSendBalanceProvider(nativeLamports = "1004999") + + assertSendError(SolanaSendServiceException.Code.INSUFFICIENT_SOL_BALANCE) { + SolanaSendService(rpc, balances).prepare(request()) + } + + assertEquals(listOf(SENDER), balances.wallets) + assertEquals(emptyList(), rpc.simulatedTransactions) + assertNull(rpc.sentTransaction) + } + + @Test + fun `rejects token send when source token balance is missing or too small`() { + val rpc = FakeSolanaRpcClient() + val missingTokenBalances = FakeSolanaSendBalanceProvider(nativeLamports = "9999999999") + + assertSendError(SolanaSendServiceException.Code.INSUFFICIENT_TOKEN_BALANCE) { + SolanaSendService(rpc, missingTokenBalances).prepareTokenTransfer(tokenRequest()) + } + + val tooSmallTokenBalances = FakeSolanaSendBalanceProvider( + nativeLamports = "9999999999", + tokenBalances = listOf(tokenIndexedBalance(amount = "999999")) + ) + assertSendError(SolanaSendServiceException.Code.INSUFFICIENT_TOKEN_BALANCE) { + SolanaSendService(FakeSolanaRpcClient(), tooSmallTokenBalances).prepareTokenTransfer(tokenRequest()) + } + } + + @Test + fun `includes associated token account rent in token send sol balance check`() { + val rent = 2_039_280L + val rpc = FakeSolanaRpcClient(rentLamports = rent) + val balances = FakeSolanaSendBalanceProvider( + nativeLamports = (5_000L + rent - 1).toString(), + tokenBalances = listOf(tokenIndexedBalance(amount = "1000000")) + ) + + assertSendError(SolanaSendServiceException.Code.INSUFFICIENT_SOL_BALANCE) { + SolanaSendService(rpc, balances).prepareTokenTransfer( + tokenRequest(destinationTokenAccount = null, destinationWalletAddress = DESTINATION_WALLET) + ) + } + + assertEquals(listOf(165), rpc.rentDataLengthRequests) + assertEquals(listOf(DESTINATION_WALLET_ASSOCIATED_TOKEN_ACCOUNT), rpc.accountExistsRequests) + assertEquals(emptyList(), rpc.simulatedTransactions) + } + + @Test + fun `skips associated token account rent when destination account exists`() = runBlocking { + val rpc = FakeSolanaRpcClient(existingAccounts = setOf(DESTINATION_WALLET_ASSOCIATED_TOKEN_ACCOUNT)) + val balances = FakeSolanaSendBalanceProvider( + nativeLamports = "5000", + tokenBalances = listOf(tokenIndexedBalance(amount = "1000000")) + ) + val prepared = SolanaSendService(rpc, balances).prepareTokenTransfer( + tokenRequest(destinationTokenAccount = null, destinationWalletAddress = DESTINATION_WALLET) + ) + + assertEquals(false, prepared.unsigned.createsDestinationAssociatedTokenAccount) + assertEquals(emptyList(), rpc.rentDataLengthRequests) + assertEquals(listOf(DESTINATION_WALLET_ASSOCIATED_TOKEN_ACCOUNT), rpc.accountExistsRequests) + } + + @Test + fun `rejects unavailable fees simulation failures and broadcast mismatches`() { + assertSendError(SolanaSendServiceException.Code.FEE_UNAVAILABLE) { + SolanaSendService(FakeSolanaRpcClient(fee = null)).prepare(request()) + } + assertSendError(SolanaSendServiceException.Code.SIMULATION_FAILED) { + SolanaSendService(FakeSolanaRpcClient(simulationError = """{"InstructionError":[0,"Custom"]}""")).prepare(request()) + } + assertSendError(SolanaSendServiceException.Code.BROADCAST_SIGNATURE_MISMATCH) { + SolanaSendService(FakeSolanaRpcClient(signatureOverride = "5NfHnqDyzT9qyfxZDq2sSskAMGuFZ3VRqW4EQxghKqrKYdKq6cZNW1J34w7qE6nGx1eDQe5s2eKxB2ZtE1xU9qgN")).send(request()) + } + } + + private class FakeSolanaRpcClient( + private val fee: Long? = 5_000, + private val simulationError: String? = null, + private val signatureOverride: String? = null, + private val rentLamports: Long = 2_039_280, + private val existingAccounts: Set = emptySet() + ) : SolanaRpcClient { + val feeMessages = mutableListOf() + val simulatedTransactions = mutableListOf() + val rentDataLengthRequests = mutableListOf() + val accountExistsRequests = mutableListOf() + val urls = mutableListOf() + var broadcastOptions: SolanaBroadcastOptions? = null + private set + var calls = 0 + private set + var sentTransaction: String? = null + private set + + override suspend fun latestBlockhash( + commitment: SolanaRpcCommitment, + rpcUrl: String? + ): SolanaLatestBlockhashResponse { + calls += 1 + urls += rpcUrl + return SolanaLatestBlockhashResponse( + context = SolanaRpcContext(slot = 1), + value = SolanaLatestBlockhash(blockhash = BLOCKHASH, lastValidBlockHeight = 99) + ) + } + + override suspend fun feeForMessage( + messageBase64: String, + commitment: SolanaRpcCommitment, + rpcUrl: String? + ): SolanaFeeForMessageResponse { + calls += 1 + urls += rpcUrl + feeMessages += messageBase64 + return SolanaFeeForMessageResponse(SolanaRpcContext(slot = 2), fee) + } + + override suspend fun minimumBalanceForRentExemption( + dataLength: Int, + commitment: SolanaRpcCommitment, + rpcUrl: String? + ): Long { + calls += 1 + urls += rpcUrl + rentDataLengthRequests += dataLength + return rentLamports + } + + override suspend fun accountExists( + address: String, + commitment: SolanaRpcCommitment, + rpcUrl: String? + ): Boolean { + calls += 1 + urls += rpcUrl + accountExistsRequests += address + return address in existingAccounts + } + + override suspend fun simulateTransaction( + transactionBase64: String, + options: SolanaSimulationOptions, + rpcUrl: String? + ): SolanaSimulationResponse { + calls += 1 + urls += rpcUrl + simulatedTransactions += transactionBase64 + return SolanaSimulationResponse( + context = SolanaRpcContext(slot = 3), + value = SolanaSimulationValue( + errorJson = simulationError, + logs = listOf("Program log: ok"), + replacementBlockhash = null, + unitsConsumed = 42 + ) + ) + } + + override suspend fun sendRawTransaction( + transactionBase64: String, + options: SolanaBroadcastOptions, + rpcUrl: String? + ): String { + calls += 1 + urls += rpcUrl + broadcastOptions = options + sentTransaction = transactionBase64 + return signatureOverride ?: firstSignature(transactionBase64) + } + } + + private class FakeSolanaSendBalanceProvider( + private val nativeLamports: String, + private val tokenBalances: List = emptyList() + ) : SolanaSendBalanceProvider { + val wallets = mutableListOf() + + override suspend fun balances(wallet: String): SolanaBalanceSyncResult { + wallets += wallet + val native = nativeIndexedBalance(nativeLamports) + return SolanaBalanceSyncResult( + wallet = wallet, + networkId = "solana-mainnet", + chainId = "solana:mainnet", + syncedAtMillis = 1L, + nativeBalance = native, + tokenBalances = tokenBalances, + balances = listOf(native) + tokenBalances + ) + } + } + + private fun request( + mnemonic: String = MNEMONIC, + recipientAddress: String = RECIPIENT, + lamports: Long = 1_000_000, + rpcUrl: String? = null + ): SolanaSendRequest { + return SolanaSendRequest( + mnemonic = mnemonic, + recipientAddress = recipientAddress, + lamports = lamports, + commitment = SolanaRpcCommitment.Finalized, + rpcUrl = rpcUrl, + broadcastOptions = SolanaBroadcastOptions(maxRetries = 3, preflightCommitment = SolanaRpcCommitment.Finalized) + ) + } + + private fun tokenRequest( + mnemonic: String = MNEMONIC, + sourceTokenAccount: String = SOURCE_TOKEN_ACCOUNT, + destinationTokenAccount: String? = DESTINATION_TOKEN_ACCOUNT, + destinationWalletAddress: String? = null, + mintAddress: String = MINT, + rawAmount: Long = 1_000_000, + decimals: Int = 6, + derivationPath: String = "m/44'/501'/0'/0'", + rpcUrl: String? = null, + tokenProgram: SolanaTokenProgram = SolanaTokenProgram.SplToken, + extraAccounts: List = emptyList(), + tokenMetadata: SolanaTokenMetadata? = null, + acknowledgeToken2022TransferFee: Boolean = false + ): SolanaTokenSendRequest { + return SolanaTokenSendRequest( + mnemonic = mnemonic, + sourceTokenAccount = sourceTokenAccount, + destinationTokenAccount = destinationTokenAccount, + destinationWalletAddress = destinationWalletAddress, + mintAddress = mintAddress, + rawAmount = rawAmount, + decimals = decimals, + derivationPath = derivationPath, + commitment = SolanaRpcCommitment.Finalized, + rpcUrl = rpcUrl, + tokenProgram = tokenProgram, + extraAccounts = extraAccounts, + tokenMetadata = tokenMetadata, + acknowledgeToken2022TransferFee = acknowledgeToken2022TransferFee, + broadcastOptions = SolanaBroadcastOptions(maxRetries = 3, preflightCommitment = SolanaRpcCommitment.Finalized) + ) + } + + private fun assertSendError( + expected: SolanaSendServiceException.Code, + block: suspend () -> Unit + ) { + val error = assertThrows(SolanaSendServiceException::class.java) { + runBlocking { block() } + } + assertEquals(expected, error.code) + } + + private fun assertTransferError( + expected: SolanaTransferTransactionException.Code, + block: suspend () -> Unit + ) { + val error = assertThrows(SolanaTransferTransactionException::class.java) { + runBlocking { block() } + } + assertEquals(expected, error.code) + } + + private companion object { + const val MNEMONIC = "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about" + val SENDER = SolanaKeyDerivation.deriveAccount(MNEMONIC).address + val SOURCE_TOKEN_ACCOUNT = deriveAddress(1) + val DESTINATION_TOKEN_ACCOUNT = deriveAddress(2) + val MINT = deriveAddress(3) + val EXTRA_READONLY_ACCOUNT = deriveAddress(4) + val EXTRA_WRITABLE_ACCOUNT = deriveAddress(5) + val DESTINATION_WALLET = deriveAddress(7) + const val DESTINATION_WALLET_ASSOCIATED_TOKEN_ACCOUNT = "8YLuPLduvEzyNYJRyjRyDvEa3SeQwqsoECRHA4o4kshW" + const val DESTINATION_WALLET_TOKEN_2022_ASSOCIATED_TOKEN_ACCOUNT = "GJJYkqMbsmdXWHeYUitGJzY7iJ6B9G4wAF6MvrcQgU9m" + const val RECIPIENT = "So11111111111111111111111111111111111111112" + const val BLOCKHASH = "7GjNiPun3AzEazTZoFEjZgcBMeuaXdpjHq2raZTmTrfs" + const val DEVNET_RPC = "https://api.devnet.solana.com" + private val BASE58_ALPHABET = "123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz".toCharArray() + + fun deriveAddress(index: Int): String { + return SolanaKeyDerivation + .deriveAccount(MNEMONIC, derivationPath = "m/44'/501'/$index'/0'") + .address + } + + fun nativeIndexedBalance(amount: String): UniversalWalletIndexedAssetBalance { + return UniversalWalletIndexedAssetBalance( + accountId = "solana-mainnet", + ecosystem = UniversalWalletEcosystem.Solana, + chainId = "solana:mainnet", + assetId = "SOL", + amount = amount, + decimals = 9, + isNative = true, + symbol = "SOL", + syncedAtMillis = 1L + ) + } + + fun tokenIndexedBalance( + amount: String, + decimals: Int = 6, + tokenAccount: String = SOURCE_TOKEN_ACCOUNT, + mint: String = MINT + ): UniversalWalletIndexedAssetBalance { + return UniversalWalletIndexedAssetBalance( + accountId = "solana-mainnet", + ecosystem = UniversalWalletEcosystem.Solana, + chainId = "solana:mainnet", + assetId = mint, + amount = amount, + decimals = decimals, + isNative = false, + tokenAccountId = tokenAccount, + contractAddress = mint, + syncedAtMillis = 1L + ) + } + + fun tokenMetadata( + mint: String = MINT, + exists: Boolean = true, + program: String = "token-2022", + programId: String? = SolanaTransferTransactionBuilder.TOKEN_2022_PROGRAM_ADDRESS, + extensions: List = emptyList(), + transferFeeConfig: SolanaTokenTransferFeeConfig? = null, + transferHook: SolanaTokenTransferHook? = null + ): SolanaTokenMetadata { + return SolanaTokenMetadata( + mint = mint, + exists = exists, + program = program, + programId = programId, + extensions = extensions, + transferFeeConfig = transferFeeConfig, + transferHook = transferHook, + decimals = 6, + supply = "100000000", + uiSupplyString = "100", + mintAuthority = null, + freezeAuthority = null, + isInitialized = true, + name = null, + symbol = null, + uri = null, + syncedAt = 1L + ) + } + + fun firstSignature(transactionBase64: String): String { + val transaction = Base64.decode(transactionBase64) + val parsed = SolanaTransactionSigner.parseSerializedTransaction(transaction) + return base58Encode(transaction.copyOfRange(parsed.signaturesOffset, parsed.signaturesOffset + 64)) + } + + fun base58Encode(bytes: ByteArray): String { + var value = BigInteger(1, bytes) + val output = StringBuilder() + val radix = BigInteger.valueOf(BASE58_ALPHABET.size.toLong()) + + while (value > BigInteger.ZERO) { + val divRem = value.divideAndRemainder(radix) + output.append(BASE58_ALPHABET[divRem[1].toInt()]) + value = divRem[0] + } + + bytes.takeWhile { it == 0.toByte() }.forEach { _ -> + output.append(BASE58_ALPHABET[0]) + } + + return output.reverse().toString() + } + } +} diff --git a/common/src/test/java/jp/co/soramitsu/common/wallet/SolanaSignerTest.kt b/common/src/test/java/jp/co/soramitsu/common/wallet/SolanaSignerTest.kt new file mode 100644 index 0000000000..8e126fba77 --- /dev/null +++ b/common/src/test/java/jp/co/soramitsu/common/wallet/SolanaSignerTest.kt @@ -0,0 +1,74 @@ +package jp.co.soramitsu.common.wallet + +import com.google.gson.JsonObject +import com.google.gson.JsonParser +import jp.co.soramitsu.common.model.UniversalWalletDerivationPaths +import jp.co.soramitsu.common.utils.SolanaSigner +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertThrows +import org.junit.Assert.assertTrue +import org.junit.Test +import java.io.InputStreamReader + +class SolanaSignerTest { + + @Test + fun `signs solana messages with deterministic ed25519 signatures`() { + val vector = loadFixture().getAsJsonArray("vectors").first().asJsonObject + val expected = vector.getAsJsonObject("expected").getAsJsonObject("solana") + val message = MESSAGE.toByteArray(Charsets.UTF_8) + + val signature = SolanaSigner.signMessage(vector["mnemonic"].asString, message) + + assertEquals(UniversalWalletDerivationPaths.SOLANA_DEFAULT, signature.derivationPath) + assertEquals(expected["address"].asString, signature.address) + assertEquals(expected["publicKeyHex"].asString, signature.publicKey.toHex()) + assertEquals(EXPECTED_SIGNATURE_HEX, signature.signatureHex) + assertEquals(EXPECTED_SIGNATURE_BASE58, signature.signatureBase58) + assertEquals(64, signature.signature.size) + assertTrue(SolanaSigner.verifyMessage(signature.publicKey, message, signature.signature)) + assertFalse(SolanaSigner.verifyMessage(signature.publicKey, "tampered".toByteArray(Charsets.UTF_8), signature.signature)) + } + + @Test + fun `rejects unsafe solana signing inputs`() { + val vector = loadFixture().getAsJsonArray("vectors").first().asJsonObject + val message = MESSAGE.toByteArray(Charsets.UTF_8) + + assertThrows(IllegalArgumentException::class.java) { + SolanaSigner.signMessage(ByteArray(31), message) + } + assertThrows(IllegalArgumentException::class.java) { + SolanaSigner.signMessage(vector["mnemonic"].asString, ByteArray(0)) + } + assertThrows(IllegalArgumentException::class.java) { + SolanaSigner.signMessage(vector["mnemonic"].asString, ByteArray(64 * 1024 + 1)) + } + assertThrows(IllegalArgumentException::class.java) { + SolanaSigner.verifyMessage(ByteArray(31), message, ByteArray(64)) + } + assertThrows(IllegalArgumentException::class.java) { + SolanaSigner.verifyMessage(ByteArray(32), message, ByteArray(63)) + } + } + + private fun loadFixture(): JsonObject { + val stream = javaClass.classLoader?.getResourceAsStream("universal-wallet-v2-vectors.json") + ?: error("universal-wallet-v2-vectors.json is missing from test resources") + + return stream.use { + JsonParser.parseReader(InputStreamReader(it)).asJsonObject + } + } + + private fun ByteArray.toHex(): String = joinToString("") { byte -> + (byte.toInt() and 0xff).toString(16).padStart(2, '0') + } + + private companion object { + const val MESSAGE = "Fearless Solana sign-in challenge" + const val EXPECTED_SIGNATURE_HEX = "059cfc8a284341824f80de10ec2e9d6816a2f0d2f96cf7e9bec56e000a87d881cc7558c6fae7d8f1aded103607122a8d66d6cfe8c7ae350df31fefb550485c06" + const val EXPECTED_SIGNATURE_BASE58 = "7WXinTJc5WM9nFWaraQyH1fHyG2WFv7QgBAUpmXByiCoaEr2Hm1AEMUXkRfxcp6sZxbrqedhNHUKBBrU94hmGAZ" + } +} diff --git a/common/src/test/java/jp/co/soramitsu/common/wallet/SolanaTransactionHistorySyncTest.kt b/common/src/test/java/jp/co/soramitsu/common/wallet/SolanaTransactionHistorySyncTest.kt new file mode 100644 index 0000000000..f17d4d58b4 --- /dev/null +++ b/common/src/test/java/jp/co/soramitsu/common/wallet/SolanaTransactionHistorySyncTest.kt @@ -0,0 +1,283 @@ +package jp.co.soramitsu.common.wallet + +import jp.co.soramitsu.common.data.network.solana.SolanaIndexerClient +import jp.co.soramitsu.common.data.network.solana.SolanaIndexerRoutes +import jp.co.soramitsu.common.data.network.solana.SolanaIndexerServiceInfo +import jp.co.soramitsu.common.data.network.solana.SolanaNativeBalance +import jp.co.soramitsu.common.data.network.solana.SolanaTokenBalance +import jp.co.soramitsu.common.data.network.solana.SolanaTokenMetadata +import jp.co.soramitsu.common.data.network.solana.SolanaTokenMetadataBatchResponse +import jp.co.soramitsu.common.data.network.solana.SolanaTransactionHistoryException +import jp.co.soramitsu.common.data.network.solana.SolanaTransactionHistorySync +import jp.co.soramitsu.common.data.network.solana.SolanaWalletAssetsResponse +import jp.co.soramitsu.common.data.network.solana.SolanaWalletBalancesResponse +import jp.co.soramitsu.common.data.network.solana.SolanaWalletStateResponse +import jp.co.soramitsu.common.data.network.solana.SolanaWalletTransactionRecord +import jp.co.soramitsu.common.data.network.solana.SolanaWalletTransactionsResponse +import jp.co.soramitsu.common.model.UniversalWalletIndexedOperationType +import jp.co.soramitsu.common.model.UniversalWalletIndexedTransactionDirection +import jp.co.soramitsu.common.model.UniversalWalletIndexedTransactionStatus +import jp.co.soramitsu.common.model.UniversalWalletRegistry +import kotlinx.coroutines.runBlocking +import org.junit.Assert.assertEquals +import org.junit.Assert.assertThrows +import org.junit.Test + +class SolanaTransactionHistorySyncTest { + + @Test + fun `normalizes native SOL transaction history from SI`() = runBlocking { + val client = FakeSolanaIndexerClient( + response = transactionsResponse( + listOf( + transaction(signature = SIGNATURE_1), + transaction(signature = SIGNATURE_2, nativeDelta = "0") + ), + nextBefore = SIGNATURE_2 + ) + ) + + val page = SolanaTransactionHistorySync(client).history( + wallet = WALLET, + baseUrl = "https://si.soramitsu.io/", + before = SIGNATURE_0, + limit = 25 + ) + + assertEquals(listOf("https://si.soramitsu.io"), client.verifiedBaseUrls) + assertEquals(WALLET, client.lastWallet) + assertEquals("https://si.soramitsu.io", client.lastBaseUrl) + assertEquals(SIGNATURE_0, client.lastBefore) + assertEquals(25, client.lastLimit) + assertEquals(SIGNATURE_2, page.pageInfo.nextCursor) + assertEquals(1, page.transactions.size) + + val transaction = page.transactions.single() + assertEquals("solana-mainnet", transaction.accountId) + assertEquals("solana:mainnet", transaction.chainId) + assertEquals(SIGNATURE_1, transaction.transactionId) + assertEquals(UniversalWalletIndexedTransactionStatus.Confirmed, transaction.status) + assertEquals(UniversalWalletIndexedTransactionDirection.Outgoing, transaction.direction) + assertEquals(UniversalWalletIndexedOperationType.Transfer, transaction.operationType) + assertEquals("1005000", transaction.amount) + assertEquals("SOL", transaction.assetId) + assertEquals("5000", transaction.feeAmount) + assertEquals("SOL", transaction.feeAssetId) + assertEquals("100", transaction.blockNumber) + assertEquals(1_710_000_000_000L, transaction.timestampMillis) + } + + @Test + fun `aggregates matching token deltas and marks solswap routes as swaps`() = runBlocking { + val client = FakeSolanaIndexerClient( + response = transactionsResponse( + listOf( + transaction( + tokenChanges = listOf( + change(MINT, "500"), + change(MINT, "-200"), + change(OTHER_MINT, "999") + ), + solswapRoute = "batch" + ), + transaction( + signature = SIGNATURE_2, + tokenChanges = listOf(change(OTHER_MINT, "1")) + ) + ) + ) + ) + + val page = SolanaTransactionHistorySync(client).history( + wallet = WALLET, + assetId = MINT, + isNative = false + ) + + val transaction = page.transactions.single() + assertEquals(MINT, transaction.assetId) + assertEquals("300", transaction.amount) + assertEquals(UniversalWalletIndexedTransactionDirection.Incoming, transaction.direction) + assertEquals(UniversalWalletIndexedOperationType.Swap, transaction.operationType) + assertEquals("5000", transaction.feeAmount) + } + + @Test + fun `filters malformed zero amount and unsupported status transactions`() = runBlocking { + val client = FakeSolanaIndexerClient( + response = transactionsResponse( + listOf( + transaction(signature = "not-a-signature"), + transaction(signature = SIGNATURE_1, nativeDelta = "0"), + transaction(signature = SIGNATURE_2, status = "pending"), + transaction(signature = SIGNATURE_3, feeLamports = "-1") + ), + nextBefore = "not-a-signature" + ) + ) + + val page = SolanaTransactionHistorySync(client).history(WALLET) + + assertEquals(0, page.transactions.size) + assertEquals(null, page.pageInfo.nextCursor) + } + + @Test + fun `rejects invalid wallet or asset before fetching history`() { + val client = FakeSolanaIndexerClient() + + val invalidWallet = assertThrows(SolanaTransactionHistoryException::class.java) { + runBlocking { + SolanaTransactionHistorySync(client).history("../bad") + } + } + assertEquals(SolanaTransactionHistoryException.Code.INVALID_INPUT, invalidWallet.code) + + val invalidAsset = assertThrows(SolanaTransactionHistoryException::class.java) { + runBlocking { + SolanaTransactionHistorySync(client).history( + wallet = WALLET, + assetId = "../bad", + isNative = false + ) + } + } + assertEquals(SolanaTransactionHistoryException.Code.INVALID_INPUT, invalidAsset.code) + assertEquals(0, client.transactionCalls) + } + + private class FakeSolanaIndexerClient( + private val response: SolanaWalletTransactionsResponse = transactionsResponse(emptyList()) + ) : SolanaIndexerClient { + val verifiedBaseUrls = mutableListOf() + var transactionCalls = 0 + private set + var lastWallet: String? = null + private set + var lastBaseUrl: String? = null + private set + var lastBefore: String? = null + private set + var lastLimit: Int? = null + private set + + override suspend fun serviceInfo(baseUrl: String?): SolanaIndexerServiceInfo { + error("Unexpected Solana service-info call") + } + + override suspend fun verifyServiceInfo(baseUrl: String?): SolanaIndexerServiceInfo { + verifiedBaseUrls += baseUrl.orEmpty() + return SolanaIndexerServiceInfo( + schemaVersion = 1, + serviceId = "si.soramitsu.io", + serviceName = "Solswap Indexer", + ecosystem = "solana", + chainId = "solana:mainnet", + network = "mainnet", + publicBaseUrl = "https://si.soramitsu.io", + readOnly = true + ) + } + + override suspend fun balances(wallet: String, baseUrl: String?): SolanaWalletBalancesResponse { + return SolanaWalletBalancesResponse( + wallet = wallet, + native = SolanaNativeBalance(lamports = "0", uiAmountString = "0"), + total = 1, + syncedAt = 1L + ) + } + + override suspend fun assets(wallet: String, baseUrl: String?): SolanaWalletAssetsResponse { + error("Unexpected Solana assets call") + } + + override suspend fun state(wallet: String, baseUrl: String?): SolanaWalletStateResponse { + error("Unexpected Solana state call") + } + + override suspend fun transactions( + wallet: String, + baseUrl: String?, + before: String?, + limit: Int + ): SolanaWalletTransactionsResponse { + transactionCalls += 1 + lastWallet = wallet + lastBaseUrl = baseUrl + lastBefore = before + lastLimit = limit + return response + } + + override suspend fun tokenMetadata(mint: String, baseUrl: String?): SolanaTokenMetadata { + error("Unexpected Solana single metadata call") + } + + override suspend fun tokenMetadataBatch( + mints: List, + baseUrl: String? + ): SolanaTokenMetadataBatchResponse { + error("Unexpected Solana metadata batch call") + } + } + + private companion object { + const val WALLET = "HAgk14JpMQLgt6rVgv7cBQFJWFto5Dqxi472uT3DKpqk" + const val MINT = "So11111111111111111111111111111111111111112" + const val OTHER_MINT = "5Pobwp6d9ihN9Nz38f87gVCEBFMgipFiSM2VtUhVit6w" + val SIGNATURE_0 = "1".repeat(88) + val SIGNATURE_1 = "2".repeat(88) + val SIGNATURE_2 = "3".repeat(88) + val SIGNATURE_3 = "4".repeat(88) + + fun transactionsResponse( + transactions: List, + nextBefore: String? = null + ): SolanaWalletTransactionsResponse { + return SolanaWalletTransactionsResponse( + wallet = WALLET, + before = null, + nextBefore = nextBefore, + limit = 25, + total = transactions.size, + syncedAt = 1_710_000_000_000L, + transactions = transactions + ) + } + + fun transaction( + signature: String = SIGNATURE_1, + status: String = "success", + feeLamports: String? = "5000", + nativeDelta: String? = "-1005000", + tokenChanges: List = emptyList(), + solswapRoute: String? = null + ): SolanaWalletTransactionRecord { + return SolanaWalletTransactionRecord( + signature = signature, + slot = 100, + timestamp = 1_710_000_000L, + status = status, + feeLamports = feeLamports, + nativeBalanceChangeLamports = nativeDelta, + tokenBalanceChanges = tokenChanges, + solswapRoute = solswapRoute + ) + } + + fun change( + mint: String, + amountDelta: String + ): jp.co.soramitsu.common.data.network.solana.SolanaTokenBalanceChange { + return jp.co.soramitsu.common.data.network.solana.SolanaTokenBalanceChange( + mint = mint, + preAmount = "0", + postAmount = amountDelta, + amountDelta = amountDelta, + decimals = 6, + uiAmountDeltaString = amountDelta + ) + } + } +} diff --git a/common/src/test/java/jp/co/soramitsu/common/wallet/SolanaTransactionSignerTest.kt b/common/src/test/java/jp/co/soramitsu/common/wallet/SolanaTransactionSignerTest.kt new file mode 100644 index 0000000000..7a823c5cfe --- /dev/null +++ b/common/src/test/java/jp/co/soramitsu/common/wallet/SolanaTransactionSignerTest.kt @@ -0,0 +1,177 @@ +package jp.co.soramitsu.common.wallet + +import com.google.gson.JsonObject +import com.google.gson.JsonParser +import jp.co.soramitsu.common.utils.SolanaTransactionSigner +import jp.co.soramitsu.common.utils.SolanaTransactionSigner.ErrorCode +import jp.co.soramitsu.common.utils.SolanaTransactionSigner.SolanaTransactionException +import jp.co.soramitsu.common.utils.SolanaTransactionSigner.SolanaTransactionVersion +import org.bouncycastle.util.encoders.Base64 +import org.junit.Assert.assertEquals +import org.junit.Assert.assertThrows +import org.junit.Assert.assertTrue +import org.junit.Test +import java.io.InputStreamReader + +class SolanaTransactionSignerTest { + + @Test + fun `parses and signs legacy serialized solana transactions`() { + val fixture = loadFixture() + val vector = fixture.getAsJsonArray("vectors")[0].asJsonObject + val expected = vector.getAsJsonObject("expected").getAsJsonObject("solana") + val raw = Base64.decode(LEGACY_RAW_BASE64) + + val parsed = SolanaTransactionSigner.parseSerializedTransaction(raw) + val signed = SolanaTransactionSigner.signSerializedTransaction( + mnemonic = vector["mnemonic"].asString, + transaction = raw, + expectedSigner = expected["address"].asString + ) + + assertEquals(SolanaTransactionVersion.Legacy, parsed.version) + assertEquals(1, parsed.requiredSignatures) + assertEquals(listOf(expected["address"].asString, SYSTEM_PROGRAM), parsed.accountKeys) + assertEquals(0, parsed.addressTableLookupCount) + assertEquals(1, parsed.instructionCount) + assertEquals(0, parsed.readonlySignedAccounts) + assertEquals(1, parsed.readonlyUnsignedAccounts) + assertEquals(SolanaTransactionVersion.Legacy, signed.version) + assertEquals(expected["address"].asString, signed.signer) + assertEquals(LEGACY_SIGNATURE_BASE58, signed.signatureBase58) + assertEquals(LEGACY_SIGNED_BASE64, signed.signedTransactionBase64) + assertTrue(signed.signedTransaction.copyOfRange(1, 65).any { it != 0.toByte() }) + } + + @Test + fun `parses and signs v0 serialized solana transactions from base64`() { + val vector = loadFixture().getAsJsonArray("vectors")[0].asJsonObject + + val parsed = SolanaTransactionSigner.parseSerializedTransaction(V0_RAW_BASE64) + val signed = SolanaTransactionSigner.signSerializedTransaction( + mnemonic = vector["mnemonic"].asString, + transactionBase64 = V0_RAW_BASE64 + ) + + assertEquals(SolanaTransactionVersion.V0, parsed.version) + assertEquals(0, parsed.addressTableLookupCount) + assertEquals(0, parsed.instructionCount) + assertEquals(SolanaTransactionVersion.V0, signed.version) + assertEquals(V0_SIGNATURE_BASE58, signed.signatureBase58) + assertEquals(V0_SIGNED_BASE64, signed.signedTransactionBase64) + } + + @Test + fun `rejects malformed solana transaction envelopes and signer states`() { + val fixture = loadFixture() + val vector = fixture.getAsJsonArray("vectors")[0].asJsonObject + val otherVector = fixture.getAsJsonArray("vectors")[1].asJsonObject + val expected = vector.getAsJsonObject("expected").getAsJsonObject("solana") + val otherExpected = otherVector.getAsJsonObject("expected").getAsJsonObject("solana") + + assertTransactionError(ErrorCode.EMPTY_TRANSACTION) { + SolanaTransactionSigner.parseSerializedTransaction(ByteArray(0)) + } + assertTransactionError(ErrorCode.INVALID_TRANSACTION_BASE64) { + SolanaTransactionSigner.parseSerializedTransaction("*not-base64*") + } + assertTransactionError(ErrorCode.MISSING_SIGNATURE_SLOT) { + SolanaTransactionSigner.parseSerializedTransaction(byteArrayOf(0)) + } + assertTransactionError(ErrorCode.TRUNCATED_SIGNATURES) { + SolanaTransactionSigner.parseSerializedTransaction(byteArrayOf(1, 2, 3)) + } + assertTransactionError(ErrorCode.UNSUPPORTED_TRANSACTION_VERSION) { + SolanaTransactionSigner.parseSerializedTransaction(transaction(byteArrayOf(0x81.toByte(), 1, 0, 0))) + } + assertTransactionError(ErrorCode.SIGNER_MISMATCH) { + SolanaTransactionSigner.signSerializedTransaction( + mnemonic = vector["mnemonic"].asString, + transaction = Base64.decode(LEGACY_RAW_BASE64), + expectedSigner = otherExpected["address"].asString + ) + } + assertTransactionError(ErrorCode.SIGNER_NOT_FOUND) { + SolanaTransactionSigner.signSerializedTransaction( + mnemonic = otherVector["mnemonic"].asString, + transaction = Base64.decode(LEGACY_RAW_BASE64) + ) + } + assertTransactionError(ErrorCode.SIGNER_NOT_REQUIRED) { + SolanaTransactionSigner.signSerializedTransaction( + mnemonic = vector["mnemonic"].asString, + transaction = transaction( + legacyMessage( + accountKeys = listOf(SYSTEM_PROGRAM_KEY, expected["publicKeyHex"].asString.hexToBytes()) + ) + ) + ) + } + } + + private fun loadFixture(): JsonObject { + val stream = javaClass.classLoader?.getResourceAsStream("universal-wallet-v2-vectors.json") + ?: error("universal-wallet-v2-vectors.json is missing from test resources") + + return stream.use { + JsonParser.parseReader(InputStreamReader(it)).asJsonObject + } + } + + private fun assertTransactionError(expected: ErrorCode, block: () -> Unit) { + val error = assertThrows(SolanaTransactionException::class.java) { block() } + assertEquals(expected, error.code) + } + + private fun transaction(message: ByteArray): ByteArray { + return byteArrayOf(1) + ByteArray(64) + message + } + + private fun legacyMessage( + accountKeys: List, + requiredSignatures: Int = 1 + ): ByteArray { + val readonlyUnsignedAccounts = if (accountKeys.size > requiredSignatures) 1 else 0 + + return byteArrayOf(requiredSignatures.toByte(), 0, readonlyUnsignedAccounts.toByte()) + + compact(accountKeys.size) + + accountKeys.fold(ByteArray(0)) { result, key -> result + key } + + ByteArray(32) { 9 } + + compact(1) + + byteArrayOf(1) + + compact(1) + + byteArrayOf(0) + + compact(0) + } + + private fun compact(value: Int): ByteArray { + val result = mutableListOf() + var next = value + + do { + var byte = next and 0x7f + next = next shr 7 + if (next > 0) { + byte = byte or 0x80 + } + result.add(byte.toByte()) + } while (next > 0) + + return result.toByteArray() + } + + private fun String.hexToBytes(): ByteArray { + return chunked(2).map { it.toInt(16).toByte() }.toByteArray() + } + + private companion object { + const val SYSTEM_PROGRAM = "11111111111111111111111111111111" + val SYSTEM_PROGRAM_KEY = ByteArray(32) + const val LEGACY_RAW_BASE64 = "AQAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAABAAEC8DYnYkanW53jNJ7UKxXiMvZRj8IPX81PHWToH5vSWPcAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAkJCQkJCQkJCQkJCQkJCQkJCQkJCQkJCQkJCQkJCQkJAQEBAAA=" + const val LEGACY_SIGNATURE_BASE58 = "2ThwZApYQ5MZxP3JNL2Mn3t5Vt1Yzgkx3T3QRxpxgiCngvVSvScRgiX61f8Bp8E6KsXVr28sXMzGTzRvXJyarvGv" + const val LEGACY_SIGNED_BASE64 = "AUkMEKU0Py6fPLWbTMeNdpmB19EgEAmgZZUCQb7ZIhsqaZswN1kb3eQxPXumIsv5ufYBItmez8Hu3ia4j/yyFgcBAAEC8DYnYkanW53jNJ7UKxXiMvZRj8IPX81PHWToH5vSWPcAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAkJCQkJCQkJCQkJCQkJCQkJCQkJCQkJCQkJCQkJCQkJAQEBAAA=" + const val V0_RAW_BASE64 = "AQAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAACAAQABAvA2J2JGp1ud4zSe1CsV4jL2UY/CD1/NTx1k6B+b0lj3AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAHBwcHBwcHBwcHBwcHBwcHBwcHBwcHBwcHBwcHBwcHBwAA" + const val V0_SIGNATURE_BASE58 = "3CuDBJeYdH4aTrEW6J3EHAfdFrBFqrbdZymtX55eBpDmA5MWGobPnWm8iK9dA8nRkbXVsZJhPVaMymsCFJvciJtW" + const val V0_SIGNED_BASE64 = "AW5L1z7qDYCdFiFsof2uB0Mlm2sckSBc25Q/jZEg6BQYUnvnDZHOmB+jspSxyQv2X2FrECOOHQg3Qp1AEYoPKweAAQABAvA2J2JGp1ud4zSe1CsV4jL2UY/CD1/NTx1k6B+b0lj3AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAHBwcHBwcHBwcHBwcHBwcHBwcHBwcHBwcHBwcHBwcHBwAA" + } +} diff --git a/common/src/test/java/jp/co/soramitsu/common/wallet/SolanaTransferTransactionBuilderTest.kt b/common/src/test/java/jp/co/soramitsu/common/wallet/SolanaTransferTransactionBuilderTest.kt new file mode 100644 index 0000000000..01ac9fe1b1 --- /dev/null +++ b/common/src/test/java/jp/co/soramitsu/common/wallet/SolanaTransferTransactionBuilderTest.kt @@ -0,0 +1,586 @@ +package jp.co.soramitsu.common.wallet + +import jp.co.soramitsu.common.data.network.solana.SolanaTransferTransactionBuilder +import jp.co.soramitsu.common.data.network.solana.SolanaTransferTransactionException +import jp.co.soramitsu.common.data.network.solana.SolanaTokenProgram +import jp.co.soramitsu.common.data.network.solana.SolanaTokenTransferExtraAccount +import jp.co.soramitsu.common.utils.SolanaKeyDerivation +import jp.co.soramitsu.common.utils.SolanaTransactionSigner +import org.junit.Assert.assertArrayEquals +import org.junit.Assert.assertEquals +import org.junit.Assert.assertThrows +import org.junit.Test + +class SolanaTransferTransactionBuilderTest { + + @Test + fun `builds canonical legacy system transfer transaction envelope`() { + val sender = SolanaKeyDerivation.deriveAccount(MNEMONIC).address + val unsigned = SolanaTransferTransactionBuilder.buildNativeTransfer( + senderAddress = " $sender ", + recipientAddress = " $RECIPIENT ", + lamports = 123_456_789, + recentBlockhash = " $BLOCKHASH " + ) + val parsed = SolanaTransactionSigner.parseSerializedTransaction(unsigned.transaction) + + assertEquals(sender, unsigned.senderAddress) + assertEquals(RECIPIENT, unsigned.recipientAddress) + assertEquals(BLOCKHASH, unsigned.recentBlockhash) + assertEquals(unsigned.messageBase64, parsed.messageBytes.toBase64()) + assertEquals(1, parsed.requiredSignatures) + assertEquals(0, parsed.readonlySignedAccounts) + assertEquals(1, parsed.readonlyUnsignedAccounts) + assertEquals(listOf(sender, RECIPIENT, SolanaTransferTransactionBuilder.SYSTEM_PROGRAM_ADDRESS), parsed.accountKeys) + assertEquals(BLOCKHASH, parsed.recentBlockhash) + assertEquals(1, parsed.instructionCount) + assertEquals(1, parsed.signatureCount) + assertArrayEquals(ByteArray(64), unsigned.transaction.copyOfRange(1, 65)) + assertTransferInstruction(unsigned.message, 123_456_789) + } + + @Test + fun `builds canonical spl token transfer checked transaction envelope`() { + val unsigned = SolanaTransferTransactionBuilder.buildTokenTransferChecked( + ownerAddress = OWNER, + sourceTokenAccount = SOURCE_TOKEN_ACCOUNT, + destinationTokenAccount = DESTINATION_TOKEN_ACCOUNT, + mintAddress = MINT, + rawAmount = 1_234_567, + decimals = 6, + recentBlockhash = BLOCKHASH + ) + val parsed = SolanaTransactionSigner.parseSerializedTransaction(unsigned.transaction) + + assertEquals(OWNER, unsigned.ownerAddress) + assertEquals(SOURCE_TOKEN_ACCOUNT, unsigned.sourceTokenAccount) + assertEquals(DESTINATION_TOKEN_ACCOUNT, unsigned.destinationTokenAccount) + assertEquals(MINT, unsigned.mintAddress) + assertEquals(SolanaTokenProgram.SplToken, unsigned.tokenProgram) + assertEquals(1, parsed.requiredSignatures) + assertEquals(0, parsed.readonlySignedAccounts) + assertEquals(2, parsed.readonlyUnsignedAccounts) + assertEquals( + listOf( + OWNER, + SOURCE_TOKEN_ACCOUNT, + DESTINATION_TOKEN_ACCOUNT, + MINT, + SolanaTransferTransactionBuilder.SPL_TOKEN_PROGRAM_ADDRESS + ), + parsed.accountKeys + ) + assertEquals(1, parsed.instructionCount) + assertArrayEquals(ByteArray(64), unsigned.transaction.copyOfRange(1, 65)) + assertTokenTransferCheckedInstruction( + message = unsigned.message, + accountCount = 5, + programIndex = 4, + accountIndexes = listOf(1, 3, 2, 0), + amount = 1_234_567, + decimals = 6 + ) + } + + @Test + fun `builds token 2022 transfer checked with extension extra accounts in instruction order`() { + val unsigned = SolanaTransferTransactionBuilder.buildTokenTransferChecked( + ownerAddress = OWNER, + sourceTokenAccount = SOURCE_TOKEN_ACCOUNT, + destinationTokenAccount = DESTINATION_TOKEN_ACCOUNT, + mintAddress = MINT, + rawAmount = Long.MAX_VALUE, + decimals = 255, + recentBlockhash = BLOCKHASH, + tokenProgram = SolanaTokenProgram.Token2022, + extraAccounts = listOf( + SolanaTokenTransferExtraAccount(EXTRA_READONLY_ACCOUNT), + SolanaTokenTransferExtraAccount(EXTRA_WRITABLE_ACCOUNT, isWritable = true) + ) + ) + val parsed = SolanaTransactionSigner.parseSerializedTransaction(unsigned.transaction) + + assertEquals(SolanaTokenProgram.Token2022, unsigned.tokenProgram) + assertEquals(0, parsed.readonlySignedAccounts) + assertEquals(3, parsed.readonlyUnsignedAccounts) + assertEquals( + listOf( + OWNER, + SOURCE_TOKEN_ACCOUNT, + DESTINATION_TOKEN_ACCOUNT, + EXTRA_WRITABLE_ACCOUNT, + MINT, + SolanaTransferTransactionBuilder.TOKEN_2022_PROGRAM_ADDRESS, + EXTRA_READONLY_ACCOUNT + ), + parsed.accountKeys + ) + assertTokenTransferCheckedInstruction( + message = unsigned.message, + accountCount = 7, + programIndex = 5, + accountIndexes = listOf(1, 4, 2, 0, 6, 3), + amount = Long.MAX_VALUE, + decimals = 255 + ) + } + + @Test + fun `builds idempotent associated token account create with payer wallet de duplicated`() { + val unsigned = SolanaTransferTransactionBuilder.buildCreateAssociatedTokenAccount( + fundingAddress = OWNER, + walletAddress = OWNER, + mintAddress = MINT, + recentBlockhash = BLOCKHASH + ) + val parsed = SolanaTransactionSigner.parseSerializedTransaction(unsigned.transaction) + + assertEquals(OWNER, unsigned.fundingAddress) + assertEquals(OWNER, unsigned.walletAddress) + assertEquals(OWNER_SPL_ASSOCIATED_TOKEN_ACCOUNT, unsigned.associatedTokenAccount) + assertEquals(SolanaTokenProgram.SplToken, unsigned.tokenProgram) + assertEquals(1, parsed.requiredSignatures) + assertEquals(0, parsed.readonlySignedAccounts) + assertEquals(4, parsed.readonlyUnsignedAccounts) + assertEquals( + listOf( + OWNER, + OWNER_SPL_ASSOCIATED_TOKEN_ACCOUNT, + MINT, + SolanaTransferTransactionBuilder.SYSTEM_PROGRAM_ADDRESS, + SolanaTransferTransactionBuilder.SPL_TOKEN_PROGRAM_ADDRESS, + SolanaTransferTransactionBuilder.ASSOCIATED_TOKEN_PROGRAM_ADDRESS + ), + parsed.accountKeys + ) + assertAssociatedTokenCreateInstruction( + message = unsigned.message, + accountCount = 6, + programIndex = 5, + accountIndexes = listOf(0, 1, 0, 2, 3, 4), + discriminator = 1 + ) + } + + @Test + fun `builds token 2022 associated token account create with separate payer and create discriminator`() { + val associatedTokenAccount = SolanaTransferTransactionBuilder.deriveAssociatedTokenAccountAddress( + walletAddress = OWNER, + mintAddress = MINT, + tokenProgram = SolanaTokenProgram.Token2022 + ) + val unsigned = SolanaTransferTransactionBuilder.buildCreateAssociatedTokenAccount( + fundingAddress = SENDER, + walletAddress = OWNER, + associatedTokenAccount = associatedTokenAccount, + mintAddress = MINT, + recentBlockhash = BLOCKHASH, + tokenProgram = SolanaTokenProgram.Token2022, + instruction = jp.co.soramitsu.common.data.network.solana.SolanaAssociatedTokenAccountInstruction.Create + ) + val parsed = SolanaTransactionSigner.parseSerializedTransaction(unsigned.transaction) + + assertEquals(SolanaTokenProgram.Token2022, unsigned.tokenProgram) + assertEquals(5, parsed.readonlyUnsignedAccounts) + assertEquals( + listOf( + SENDER, + associatedTokenAccount, + OWNER, + MINT, + SolanaTransferTransactionBuilder.SYSTEM_PROGRAM_ADDRESS, + SolanaTransferTransactionBuilder.TOKEN_2022_PROGRAM_ADDRESS, + SolanaTransferTransactionBuilder.ASSOCIATED_TOKEN_PROGRAM_ADDRESS + ), + parsed.accountKeys + ) + assertAssociatedTokenCreateInstruction( + message = unsigned.message, + accountCount = 7, + programIndex = 6, + accountIndexes = listOf(0, 1, 2, 3, 4, 5), + discriminator = 0 + ) + } + + @Test + fun `builds token send with idempotent associated token account create then transfer checked`() { + val unsigned = SolanaTransferTransactionBuilder.buildTokenSendChecked( + ownerAddress = OWNER, + sourceTokenAccount = SOURCE_TOKEN_ACCOUNT, + destinationWalletAddress = DESTINATION_WALLET, + mintAddress = MINT, + rawAmount = 99_000, + decimals = 8, + recentBlockhash = BLOCKHASH, + tokenProgram = SolanaTokenProgram.Token2022, + extraAccounts = listOf( + SolanaTokenTransferExtraAccount(EXTRA_READONLY_ACCOUNT), + SolanaTokenTransferExtraAccount(EXTRA_WRITABLE_ACCOUNT, isWritable = true) + ) + ) + val parsed = SolanaTransactionSigner.parseSerializedTransaction(unsigned.transaction) + + assertEquals(true, unsigned.createsDestinationAssociatedTokenAccount) + assertEquals(DESTINATION_WALLET, unsigned.destinationWalletAddress) + assertEquals(DESTINATION_WALLET_TOKEN_2022_ASSOCIATED_TOKEN_ACCOUNT, unsigned.destinationTokenAccount) + assertEquals(SolanaTokenProgram.Token2022, unsigned.tokenProgram) + assertEquals(1, parsed.requiredSignatures) + assertEquals(0, parsed.readonlySignedAccounts) + assertEquals(6, parsed.readonlyUnsignedAccounts) + assertEquals( + listOf( + OWNER, + SOURCE_TOKEN_ACCOUNT, + DESTINATION_WALLET_TOKEN_2022_ASSOCIATED_TOKEN_ACCOUNT, + EXTRA_WRITABLE_ACCOUNT, + DESTINATION_WALLET, + MINT, + SolanaTransferTransactionBuilder.SYSTEM_PROGRAM_ADDRESS, + SolanaTransferTransactionBuilder.TOKEN_2022_PROGRAM_ADDRESS, + SolanaTransferTransactionBuilder.ASSOCIATED_TOKEN_PROGRAM_ADDRESS, + EXTRA_READONLY_ACCOUNT + ), + parsed.accountKeys + ) + assertEquals(2, parsed.instructionCount) + assertArrayEquals(ByteArray(64), unsigned.transaction.copyOfRange(1, 65)) + assertTokenSendWithAssociatedAccountCreateInstructions( + message = unsigned.message, + accountCount = 10, + associatedProgramIndex = 8, + associatedAccountIndexes = listOf(0, 2, 4, 5, 6, 7), + associatedDiscriminator = 1, + tokenProgramIndex = 7, + tokenAccountIndexes = listOf(1, 5, 2, 0, 9, 3), + amount = 99_000, + decimals = 8 + ) + } + + @Test + fun `derives associated token accounts for spl token and token 2022`() { + assertEquals( + DESTINATION_WALLET_SPL_ASSOCIATED_TOKEN_ACCOUNT, + SolanaTransferTransactionBuilder.deriveAssociatedTokenAccountAddress( + walletAddress = DESTINATION_WALLET, + mintAddress = MINT + ) + ) + assertEquals( + DESTINATION_WALLET_TOKEN_2022_ASSOCIATED_TOKEN_ACCOUNT, + SolanaTransferTransactionBuilder.deriveAssociatedTokenAccountAddress( + walletAddress = DESTINATION_WALLET, + mintAddress = MINT, + tokenProgram = SolanaTokenProgram.Token2022 + ) + ) + } + + @Test + fun `rejects malformed transfer parameters`() { + assertTransferError(SolanaTransferTransactionException.Code.INVALID_LAMPORTS) { + SolanaTransferTransactionBuilder.buildNativeTransfer(SENDER, RECIPIENT, 0, BLOCKHASH) + } + assertTransferError(SolanaTransferTransactionException.Code.INVALID_SENDER) { + SolanaTransferTransactionBuilder.buildNativeTransfer("not-base58", RECIPIENT, 1, BLOCKHASH) + } + assertTransferError(SolanaTransferTransactionException.Code.INVALID_RECIPIENT) { + SolanaTransferTransactionBuilder.buildNativeTransfer(SENDER, "O0Il", 1, BLOCKHASH) + } + assertTransferError(SolanaTransferTransactionException.Code.INVALID_RECENT_BLOCKHASH) { + SolanaTransferTransactionBuilder.buildNativeTransfer(SENDER, RECIPIENT, 1, "111") + } + assertTransferError(SolanaTransferTransactionException.Code.INVALID_TOKEN_AMOUNT) { + SolanaTransferTransactionBuilder.buildTokenTransferChecked( + OWNER, + SOURCE_TOKEN_ACCOUNT, + DESTINATION_TOKEN_ACCOUNT, + MINT, + 0, + 6, + BLOCKHASH + ) + } + assertTransferError(SolanaTransferTransactionException.Code.INVALID_TOKEN_DECIMALS) { + SolanaTransferTransactionBuilder.buildTokenTransferChecked( + OWNER, + SOURCE_TOKEN_ACCOUNT, + DESTINATION_TOKEN_ACCOUNT, + MINT, + 1, + 256, + BLOCKHASH + ) + } + assertTransferError(SolanaTransferTransactionException.Code.DUPLICATE_ACCOUNT) { + SolanaTransferTransactionBuilder.buildTokenTransferChecked( + OWNER, + SOURCE_TOKEN_ACCOUNT, + SOURCE_TOKEN_ACCOUNT, + MINT, + 1, + 6, + BLOCKHASH + ) + } + assertTransferError(SolanaTransferTransactionException.Code.INVALID_WALLET) { + SolanaTransferTransactionBuilder.buildTokenSendChecked( + OWNER, + SOURCE_TOKEN_ACCOUNT, + DESTINATION_TOKEN_ACCOUNT, + MINT, + 1, + 6, + BLOCKHASH, + destinationWalletAddress = "not-base58" + ) + } + assertTransferError(SolanaTransferTransactionException.Code.DUPLICATE_ACCOUNT) { + SolanaTransferTransactionBuilder.buildTokenSendChecked( + ownerAddress = OWNER, + sourceTokenAccount = SOURCE_TOKEN_ACCOUNT, + mintAddress = MINT, + rawAmount = 1, + decimals = 6, + recentBlockhash = BLOCKHASH, + destinationWalletAddress = SOURCE_TOKEN_ACCOUNT + ) + } + assertTransferError(SolanaTransferTransactionException.Code.DUPLICATE_ACCOUNT) { + SolanaTransferTransactionBuilder.buildTokenSendChecked( + ownerAddress = OWNER, + sourceTokenAccount = SOURCE_TOKEN_ACCOUNT, + mintAddress = MINT, + rawAmount = 1, + decimals = 6, + recentBlockhash = BLOCKHASH, + extraAccounts = listOf(SolanaTokenTransferExtraAccount(SolanaTransferTransactionBuilder.ASSOCIATED_TOKEN_PROGRAM_ADDRESS)), + destinationWalletAddress = DESTINATION_WALLET + ) + } + assertTransferError(SolanaTransferTransactionException.Code.INVALID_ASSOCIATED_TOKEN_ACCOUNT) { + SolanaTransferTransactionBuilder.buildTokenSendChecked( + OWNER, + SOURCE_TOKEN_ACCOUNT, + DESTINATION_TOKEN_ACCOUNT, + MINT, + 1, + 6, + BLOCKHASH, + destinationWalletAddress = DESTINATION_WALLET + ) + } + assertTransferError(SolanaTransferTransactionException.Code.INVALID_EXTRA_ACCOUNT) { + SolanaTransferTransactionBuilder.buildTokenTransferChecked( + OWNER, + SOURCE_TOKEN_ACCOUNT, + DESTINATION_TOKEN_ACCOUNT, + MINT, + 1, + 6, + BLOCKHASH, + extraAccounts = listOf(SolanaTokenTransferExtraAccount("not-base58")) + ) + } + assertTransferError(SolanaTransferTransactionException.Code.TOO_MANY_EXTRA_ACCOUNTS) { + SolanaTransferTransactionBuilder.buildTokenTransferChecked( + OWNER, + SOURCE_TOKEN_ACCOUNT, + DESTINATION_TOKEN_ACCOUNT, + MINT, + 1, + 6, + BLOCKHASH, + extraAccounts = List(33) { SolanaTokenTransferExtraAccount(deriveAddress(it + 10)) } + ) + } + assertTransferError(SolanaTransferTransactionException.Code.INVALID_ASSOCIATED_TOKEN_ACCOUNT) { + SolanaTransferTransactionBuilder.buildCreateAssociatedTokenAccount( + OWNER, + OWNER, + "not-base58", + MINT, + BLOCKHASH + ) + } + assertTransferError(SolanaTransferTransactionException.Code.DUPLICATE_ACCOUNT) { + SolanaTransferTransactionBuilder.buildCreateAssociatedTokenAccount( + fundingAddress = OWNER, + walletAddress = OWNER, + mintAddress = OWNER, + recentBlockhash = BLOCKHASH + ) + } + assertTransferError(SolanaTransferTransactionException.Code.INVALID_ASSOCIATED_TOKEN_ACCOUNT) { + SolanaTransferTransactionBuilder.buildCreateAssociatedTokenAccount( + OWNER, + OWNER, + OWNER, + MINT, + BLOCKHASH + ) + } + } + + private fun assertTransferInstruction(message: ByteArray, lamports: Long) { + assertEquals(150, message.size) + assertArrayEquals(byteArrayOf(1, 0, 1), message.copyOfRange(0, 3)) + assertEquals(3, message[3].toInt()) + assertEquals(1, message[132].toInt()) + assertEquals(2, message[133].toInt()) + assertEquals(2, message[134].toInt()) + assertArrayEquals(byteArrayOf(0, 1), message.copyOfRange(135, 137)) + assertEquals(12, message[137].toInt()) + assertArrayEquals(byteArrayOf(2, 0, 0, 0), message.copyOfRange(138, 142)) + assertEquals(lamports, message.copyOfRange(142, 150).littleEndianLong()) + } + + private fun assertTokenTransferCheckedInstruction( + message: ByteArray, + accountCount: Int, + programIndex: Int, + accountIndexes: List, + amount: Long, + decimals: Int + ) { + var offset = 0 + offset += 3 + assertEquals(accountCount, message[offset].toInt() and 0xff) + offset += 1 + accountCount * 32 + offset += 32 + assertEquals(1, message[offset].toInt() and 0xff) + offset += 1 + assertEquals(programIndex, message[offset].toInt() and 0xff) + offset += 1 + assertEquals(accountIndexes.size, message[offset].toInt() and 0xff) + offset += 1 + assertArrayEquals(accountIndexes.map { it.toByte() }.toByteArray(), message.copyOfRange(offset, offset + accountIndexes.size)) + offset += accountIndexes.size + assertEquals(10, message[offset].toInt() and 0xff) + offset += 1 + assertEquals(12, message[offset].toInt() and 0xff) + offset += 1 + assertEquals(amount, message.copyOfRange(offset, offset + 8).littleEndianLong()) + offset += 8 + assertEquals(decimals, message[offset].toInt() and 0xff) + offset += 1 + assertEquals(message.size, offset) + } + + private fun assertTokenSendWithAssociatedAccountCreateInstructions( + message: ByteArray, + accountCount: Int, + associatedProgramIndex: Int, + associatedAccountIndexes: List, + associatedDiscriminator: Int, + tokenProgramIndex: Int, + tokenAccountIndexes: List, + amount: Long, + decimals: Int + ) { + var offset = 0 + offset += 3 + assertEquals(accountCount, message[offset].toInt() and 0xff) + offset += 1 + accountCount * 32 + offset += 32 + assertEquals(2, message[offset].toInt() and 0xff) + offset += 1 + + assertEquals(associatedProgramIndex, message[offset].toInt() and 0xff) + offset += 1 + assertEquals(associatedAccountIndexes.size, message[offset].toInt() and 0xff) + offset += 1 + assertArrayEquals(associatedAccountIndexes.map { it.toByte() }.toByteArray(), message.copyOfRange(offset, offset + associatedAccountIndexes.size)) + offset += associatedAccountIndexes.size + assertEquals(1, message[offset].toInt() and 0xff) + offset += 1 + assertEquals(associatedDiscriminator, message[offset].toInt() and 0xff) + offset += 1 + + assertEquals(tokenProgramIndex, message[offset].toInt() and 0xff) + offset += 1 + assertEquals(tokenAccountIndexes.size, message[offset].toInt() and 0xff) + offset += 1 + assertArrayEquals(tokenAccountIndexes.map { it.toByte() }.toByteArray(), message.copyOfRange(offset, offset + tokenAccountIndexes.size)) + offset += tokenAccountIndexes.size + assertEquals(10, message[offset].toInt() and 0xff) + offset += 1 + assertEquals(12, message[offset].toInt() and 0xff) + offset += 1 + assertEquals(amount, message.copyOfRange(offset, offset + 8).littleEndianLong()) + offset += 8 + assertEquals(decimals, message[offset].toInt() and 0xff) + offset += 1 + assertEquals(message.size, offset) + } + + private fun assertAssociatedTokenCreateInstruction( + message: ByteArray, + accountCount: Int, + programIndex: Int, + accountIndexes: List, + discriminator: Int + ) { + var offset = 0 + offset += 3 + assertEquals(accountCount, message[offset].toInt() and 0xff) + offset += 1 + accountCount * 32 + offset += 32 + assertEquals(1, message[offset].toInt() and 0xff) + offset += 1 + assertEquals(programIndex, message[offset].toInt() and 0xff) + offset += 1 + assertEquals(accountIndexes.size, message[offset].toInt() and 0xff) + offset += 1 + assertArrayEquals(accountIndexes.map { it.toByte() }.toByteArray(), message.copyOfRange(offset, offset + accountIndexes.size)) + offset += accountIndexes.size + assertEquals(1, message[offset].toInt() and 0xff) + offset += 1 + assertEquals(discriminator, message[offset].toInt() and 0xff) + offset += 1 + assertEquals(message.size, offset) + } + + private fun assertTransferError( + expected: SolanaTransferTransactionException.Code, + block: () -> Unit + ) { + val error = assertThrows(SolanaTransferTransactionException::class.java) { block() } + assertEquals(expected, error.code) + } + + private fun ByteArray.littleEndianLong(): Long { + var value = 0L + forEachIndexed { index, byte -> + value = value or ((byte.toLong() and 0xff) shl (index * 8)) + } + + return value + } + + private fun ByteArray.toBase64(): String = org.bouncycastle.util.encoders.Base64.toBase64String(this) + + private companion object { + const val MNEMONIC = "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about" + val SENDER = SolanaKeyDerivation.deriveAccount(MNEMONIC).address + val OWNER = deriveAddress(6) + val SOURCE_TOKEN_ACCOUNT = deriveAddress(1) + val DESTINATION_TOKEN_ACCOUNT = deriveAddress(2) + val MINT = deriveAddress(3) + val EXTRA_READONLY_ACCOUNT = deriveAddress(4) + val EXTRA_WRITABLE_ACCOUNT = deriveAddress(5) + val DESTINATION_WALLET = deriveAddress(7) + const val OWNER_SPL_ASSOCIATED_TOKEN_ACCOUNT = "J6SUEJJ82hffdpF15LHppEoRn3e7rzu4sf4dS1WWzBpA" + const val DESTINATION_WALLET_SPL_ASSOCIATED_TOKEN_ACCOUNT = "8YLuPLduvEzyNYJRyjRyDvEa3SeQwqsoECRHA4o4kshW" + const val DESTINATION_WALLET_TOKEN_2022_ASSOCIATED_TOKEN_ACCOUNT = "GJJYkqMbsmdXWHeYUitGJzY7iJ6B9G4wAF6MvrcQgU9m" + const val RECIPIENT = "So11111111111111111111111111111111111111112" + const val BLOCKHASH = "7GjNiPun3AzEazTZoFEjZgcBMeuaXdpjHq2raZTmTrfs" + + fun deriveAddress(index: Int): String { + return SolanaKeyDerivation + .deriveAccount(MNEMONIC, derivationPath = "m/44'/501'/$index'/0'") + .address + } + } +} diff --git a/common/src/test/java/jp/co/soramitsu/common/wallet/TonIndexerClientTest.kt b/common/src/test/java/jp/co/soramitsu/common/wallet/TonIndexerClientTest.kt new file mode 100644 index 0000000000..04d8d4f6c9 --- /dev/null +++ b/common/src/test/java/jp/co/soramitsu/common/wallet/TonIndexerClientTest.kt @@ -0,0 +1,233 @@ +package jp.co.soramitsu.common.wallet + +import jp.co.soramitsu.common.data.network.ton.RetrofitTonIndexerClient +import jp.co.soramitsu.common.data.network.ton.TonAccountStateResponse +import jp.co.soramitsu.common.data.network.ton.TonBalanceResponse +import jp.co.soramitsu.common.data.network.ton.TonBalancesResponse +import jp.co.soramitsu.common.data.network.ton.TonContractsResponse +import jp.co.soramitsu.common.data.network.ton.TonHealthStatus +import jp.co.soramitsu.common.data.network.ton.TonIndexerApi +import jp.co.soramitsu.common.data.network.ton.TonIndexerIdentityException +import jp.co.soramitsu.common.data.network.ton.TonIndexerRoutes +import jp.co.soramitsu.common.data.network.ton.TonIndexerServiceInfo +import jp.co.soramitsu.common.data.network.ton.TonJettonTransferPayloadResponse +import jp.co.soramitsu.common.data.network.ton.TonNativeBalance +import jp.co.soramitsu.common.data.network.ton.TonRunGetMethodRequest +import jp.co.soramitsu.common.data.network.ton.TonRunGetMethodResponse +import jp.co.soramitsu.common.data.network.ton.TonRunGetMethodsRequest +import jp.co.soramitsu.common.data.network.ton.TonRunGetMethodsResponse +import jp.co.soramitsu.common.data.network.ton.TonSwapsResponse +import jp.co.soramitsu.common.data.network.ton.TonTransactionsResponse +import kotlinx.coroutines.runBlocking +import org.junit.Assert.assertEquals +import org.junit.Assert.assertThrows +import org.junit.Test + +class TonIndexerClientTest { + + @Test + fun `delegates account endpoints through validated ton indexer routes`() = runBlocking { + val api = FakeTonIndexerApi() + val client = RetrofitTonIndexerClient(api) + + client.verifyServiceInfo() + assertEquals("https://ti.soramitsu.io/api/indexer/v1/service-info", api.lastUrl) + + client.balance(ADDRESS) + assertEquals("https://ti.soramitsu.io/api/indexer/v1/accounts/$ADDRESS/balance", api.lastUrl) + + client.transactions(address = ADDRESS, page = 2, cursorLt = "10", cursorHash = HASH) + assertEquals( + "https://ti.soramitsu.io/api/indexer/v1/accounts/$ADDRESS/txs?page=2&cursor_lt=10&cursor_hash=$ENCODED_HASH", + api.lastUrl + ) + + client.swaps( + address = ADDRESS, + limit = 25, + fromUtime = 100, + toUtime = 200, + payToken = "TON", + receiveToken = "TST", + executionType = TonIndexerRoutes.TonSwapExecutionType.Market, + status = TonIndexerRoutes.TonSwapStatus.Success, + includeReverse = true + ) + assertEquals( + "https://ti.soramitsu.io/api/indexer/v1/accounts/$ADDRESS/swaps?limit=25&from_utime=100&to_utime=200&pay_token=TON&receive_token=TST&execution_type=market&status=success&include_reverse=true", + api.lastUrl + ) + + client.jettonTransferPayload(ADDRESS, RAW_ADDRESS.uppercase()) + assertEquals( + "https://ti.soramitsu.io/api/indexer/v1/jettons/$ADDRESS/transfer/$RAW_ADDRESS/payload", + api.lastUrl + ) + } + + @Test + fun `delegates getter calls with normalized request bodies`() { + runBlocking { + val api = FakeTonIndexerApi() + val client = RetrofitTonIndexerClient(api) + + client.runGetMethod(ADDRESS, " seqno ") + assertEquals("https://ti.soramitsu.io/api/indexer/v1/runGetMethod", api.lastUrl) + assertEquals(ADDRESS, api.lastRunGetMethodRequest?.address) + assertEquals("seqno", api.lastRunGetMethodRequest?.method) + + val call = TonIndexerRoutes.runGetMethodRequest(ADDRESS, "seqno") + client.runGetMethods(listOf(call)) + assertEquals("https://ti.soramitsu.io/api/indexer/v1/runGetMethods", api.lastUrl) + assertEquals(listOf(call), api.lastRunGetMethodsRequest?.calls) + + assertThrows(TonIndexerRoutes.TonIndexerRouteException::class.java) { + runBlocking { + client.runGetMethod(ADDRESS, "bad-method") + } + } + assertThrows(TonIndexerRoutes.TonIndexerRouteException::class.java) { + runBlocking { + client.runGetMethods(emptyList()) + } + } + } + } + + @Test + fun `verify service info rejects misrouted ton indexer`() = runBlocking { + val api = FakeTonIndexerApi() + val client = RetrofitTonIndexerClient(api) + + api.serviceInfoResponse = api.serviceInfoResponse.copy(serviceId = "si.soramitsu.io") + + val error = assertThrows(TonIndexerIdentityException::class.java) { + runBlocking { + client.verifyServiceInfo() + } + } + assertEquals("si.soramitsu.io", error.serviceInfo.serviceId) + assertEquals("https://ti.soramitsu.io/api/indexer/v1/service-info", api.lastUrl) + } + + private class FakeTonIndexerApi : TonIndexerApi { + var lastUrl: String? = null + private set + var lastRunGetMethodRequest: TonRunGetMethodRequest? = null + private set + var lastRunGetMethodsRequest: TonRunGetMethodsRequest? = null + private set + var serviceInfoResponse = TonIndexerServiceInfo( + schemaVersion = 1, + serviceId = "ti.soramitsu.io", + serviceName = "TON Indexer", + ecosystem = "ton", + chainId = "ton:mainnet", + network = "mainnet", + publicBaseUrl = "https://ti.soramitsu.io", + readOnly = true + ) + + override suspend fun getHealth(url: String): TonHealthStatus { + lastUrl = url + return TonHealthStatus() + } + + override suspend fun getContracts(url: String): TonContractsResponse { + lastUrl = url + return TonContractsResponse(count = 0) + } + + override suspend fun getServiceInfo(url: String): TonIndexerServiceInfo { + lastUrl = url + return serviceInfoResponse + } + + override suspend fun getBalance(url: String): TonBalanceResponse { + lastUrl = url + return TonBalanceResponse( + ton = TonNativeBalance(balance = "0"), + confirmed = true, + updatedAt = 1L, + network = "mainnet" + ) + } + + override suspend fun getBalances(url: String): TonBalancesResponse { + lastUrl = url + return TonBalancesResponse( + address = ADDRESS, + tonRaw = "0", + ton = "0", + confirmed = true, + updatedAt = 1L, + network = "mainnet" + ) + } + + override suspend fun getAssets(url: String): TonBalancesResponse { + lastUrl = url + return getBalances(url) + } + + override suspend fun getState(url: String): TonAccountStateResponse { + lastUrl = url + return TonAccountStateResponse( + address = ADDRESS, + balance = "0", + updatedAt = 1L + ) + } + + override suspend fun getTransactions(url: String): TonTransactionsResponse { + lastUrl = url + return TonTransactionsResponse( + page = 1, + pageSize = 0, + totalTxs = 0, + totalPagesMin = 0, + historyComplete = true, + network = "mainnet" + ) + } + + override suspend fun getSwaps(url: String): TonSwapsResponse { + lastUrl = url + return TonSwapsResponse( + address = ADDRESS, + count = 0, + network = "mainnet" + ) + } + + override suspend fun getJettonTransferPayload(url: String): TonJettonTransferPayloadResponse { + lastUrl = url + return TonJettonTransferPayloadResponse() + } + + override suspend fun runGetMethod( + url: String, + body: TonRunGetMethodRequest + ): TonRunGetMethodResponse { + lastUrl = url + lastRunGetMethodRequest = body + return TonRunGetMethodResponse(exitCode = 0, gasUsed = 0) + } + + override suspend fun runGetMethods( + url: String, + body: TonRunGetMethodsRequest + ): TonRunGetMethodsResponse { + lastUrl = url + lastRunGetMethodsRequest = body + return TonRunGetMethodsResponse() + } + } + + private companion object { + const val ADDRESS = "UQDxAUFadQXDd3EXGa3TLF_EF66gMc9h3_aZ0j0zXNoIYUCc" + const val RAW_ADDRESS = "0:f101415a7505c377711719add32c5fc417aea031cf61dff699d23d335cda0861" + const val HASH = "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=" + const val ENCODED_HASH = "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA%3D" + } +} diff --git a/common/src/test/java/jp/co/soramitsu/common/wallet/TonIndexerRoutesTest.kt b/common/src/test/java/jp/co/soramitsu/common/wallet/TonIndexerRoutesTest.kt new file mode 100644 index 0000000000..9dd6e84282 --- /dev/null +++ b/common/src/test/java/jp/co/soramitsu/common/wallet/TonIndexerRoutesTest.kt @@ -0,0 +1,213 @@ +package jp.co.soramitsu.common.wallet + +import com.google.gson.Gson +import jp.co.soramitsu.common.data.network.ton.TonBalanceResponse +import jp.co.soramitsu.common.data.network.ton.TonIndexerServiceInfo +import jp.co.soramitsu.common.data.network.ton.TonIndexerRoutes +import jp.co.soramitsu.common.data.network.ton.TonIndexerRoutes.ErrorCode +import jp.co.soramitsu.common.data.network.ton.TonIndexerRoutes.TonIndexerRouteException +import jp.co.soramitsu.common.data.network.ton.TonIndexerRoutes.TonSwapExecutionType +import jp.co.soramitsu.common.data.network.ton.TonIndexerRoutes.TonSwapStatus +import jp.co.soramitsu.common.data.network.ton.isExpectedTiServiceInfo +import jp.co.soramitsu.common.model.UniversalWalletRegistry +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertThrows +import org.junit.Assert.assertTrue +import org.junit.Test + +class TonIndexerRoutesTest { + + @Test + fun `builds ti endpoint urls with validated parameters`() { + assertEquals( + "${UniversalWalletRegistry.TON_INDEXER_BASE_URL}/api/indexer/v1/health", + TonIndexerRoutes.healthUrl() + ) + assertEquals( + "https://ti.soramitsu.io/api/indexer/v1/contracts", + TonIndexerRoutes.contractsUrl("https://ti.soramitsu.io/") + ) + assertEquals( + "https://ti.soramitsu.io/api/indexer/v1/service-info", + TonIndexerRoutes.serviceInfoUrl("https://ti.soramitsu.io/") + ) + assertEquals( + "https://ti.soramitsu.io/api/indexer/v1/accounts/$ADDRESS/balance", + TonIndexerRoutes.balanceUrl(ADDRESS, "https://ti.soramitsu.io/") + ) + assertEquals( + "https://ti.soramitsu.io/api/indexer/v1/accounts/$ADDRESS/balances", + TonIndexerRoutes.balancesUrl(ADDRESS, "https://ti.soramitsu.io/") + ) + assertEquals( + "https://ti.soramitsu.io/api/indexer/v1/accounts/$ADDRESS/assets", + TonIndexerRoutes.assetsUrl(ADDRESS, "https://ti.soramitsu.io/") + ) + assertEquals( + "https://ti.soramitsu.io/api/indexer/v1/accounts/$ADDRESS/state", + TonIndexerRoutes.stateUrl(ADDRESS, "https://ti.soramitsu.io/") + ) + assertEquals( + "https://ti.soramitsu.io/api/indexer/v1/accounts/$ADDRESS/txs?page=2&cursor_lt=10&cursor_hash=$ENCODED_HASH", + TonIndexerRoutes.transactionsUrl(ADDRESS, "https://ti.soramitsu.io/", page = 2, cursorLt = "10", cursorHash = HASH) + ) + assertEquals( + "https://ti.soramitsu.io/api/indexer/v1/accounts/$ADDRESS/swaps?limit=25&from_utime=100&to_utime=200&pay_token=TON&receive_token=TST&execution_type=market&status=success&include_reverse=true", + TonIndexerRoutes.swapsUrl( + address = ADDRESS, + baseUrl = "https://ti.soramitsu.io/", + limit = 25, + fromUtime = 100, + toUtime = 200, + payToken = "TON", + receiveToken = "TST", + executionType = TonSwapExecutionType.Market, + status = TonSwapStatus.Success, + includeReverse = true + ) + ) + assertEquals( + "https://ti.soramitsu.io/api/indexer/v1/jettons/$ADDRESS/transfer/$RAW_ADDRESS/payload", + TonIndexerRoutes.jettonTransferPayloadUrl(ADDRESS, RAW_ADDRESS.uppercase(), "https://ti.soramitsu.io/") + ) + assertEquals( + "https://ti.soramitsu.io/api/indexer/v1/runGetMethod", + TonIndexerRoutes.runGetMethodUrl("https://ti.soramitsu.io/") + ) + assertEquals( + "seqno", + TonIndexerRoutes.runGetMethodRequest(ADDRESS, " seqno ").method + ) + assertEquals( + 1, + TonIndexerRoutes.runGetMethodsRequest(listOf(TonIndexerRoutes.runGetMethodRequest(ADDRESS, "seqno"))).calls.size + ) + } + + @Test + fun `allows local http base urls but rejects nonlocal insecure base urls`() { + assertEquals("http://localhost:3000", TonIndexerRoutes.normalizeBaseUrl("http://localhost:3000/")) + assertEquals("http://127.0.0.1:3000", TonIndexerRoutes.normalizeBaseUrl("http://127.0.0.1:3000/")) + + assertRouteError(ErrorCode.INVALID_BASE_URL) { + TonIndexerRoutes.normalizeBaseUrl("http://ti.soramitsu.io") + } + assertRouteError(ErrorCode.INVALID_BASE_URL) { + TonIndexerRoutes.normalizeBaseUrl("not a url") + } + } + + @Test + fun `rejects malformed ton route inputs before network calls`() { + assertRouteError(ErrorCode.INVALID_ADDRESS) { + TonIndexerRoutes.balanceUrl("../bad") + } + assertRouteError(ErrorCode.INVALID_PAGE) { + TonIndexerRoutes.transactionsUrl(ADDRESS, page = 0) + } + assertRouteError(ErrorCode.CURSOR_MISMATCH) { + TonIndexerRoutes.transactionsUrl(ADDRESS, cursorLt = "1") + } + assertRouteError(ErrorCode.INVALID_CURSOR) { + TonIndexerRoutes.transactionsUrl(ADDRESS, cursorLt = "bad", cursorHash = HASH) + } + assertRouteError(ErrorCode.INVALID_CURSOR) { + TonIndexerRoutes.transactionsUrl(ADDRESS, cursorLt = "1", cursorHash = "../../../bad") + } + assertRouteError(ErrorCode.INVALID_LIMIT) { + TonIndexerRoutes.swapsUrl(ADDRESS, limit = 0) + } + assertRouteError(ErrorCode.INVALID_LIMIT) { + TonIndexerRoutes.swapsUrl(ADDRESS, limit = 501) + } + assertRouteError(ErrorCode.INVALID_UTIME_RANGE) { + TonIndexerRoutes.swapsUrl(ADDRESS, fromUtime = 20, toUtime = 10) + } + assertRouteError(ErrorCode.INVALID_TOKEN_FILTER) { + TonIndexerRoutes.swapsUrl(ADDRESS, payToken = "../bad") + } + assertRouteError(ErrorCode.INVALID_METHOD) { + TonIndexerRoutes.runGetMethodRequest(ADDRESS, "bad-method") + } + assertRouteError(ErrorCode.INVALID_CALLS) { + TonIndexerRoutes.runGetMethodsRequest(emptyList()) + } + assertRouteError(ErrorCode.INVALID_CALLS) { + val call = TonIndexerRoutes.runGetMethodRequest(ADDRESS, "seqno") + TonIndexerRoutes.runGetMethodsRequest(List(65) { call }) + } + } + + @Test + fun `parses ti balance without losing large integer precision`() { + val response = Gson().fromJson( + """ + { + "ton": { + "balance": "340282366920938463463374607431768211455", + "last_tx_lt": "12345678901234567890", + "last_tx_hash": "$HASH" + }, + "jettons": [ + { + "master": "$ADDRESS", + "wallet": "$RAW_ADDRESS", + "balance": "18446744073709551616", + "decimals": 9, + "symbol": "TST" + } + ], + "confirmed": true, + "updated_at": 1710000000000, + "network": "mainnet" + } + """.trimIndent(), + TonBalanceResponse::class.java + ) + + assertEquals("340282366920938463463374607431768211455", response.ton.balance) + assertEquals("12345678901234567890", response.ton.lastTxLt) + assertEquals("18446744073709551616", response.jettons.single().balance) + assertEquals("mainnet", response.network) + } + + @Test + fun `verifies ti service identity and rejects misrouted service info`() { + val response = Gson().fromJson( + """ + { + "schemaVersion": 1, + "serviceId": "ti.soramitsu.io", + "serviceName": "TON Indexer", + "ecosystem": "ton", + "chainId": "ton:mainnet", + "network": "mainnet", + "publicBaseUrl": "https://ti.soramitsu.io", + "readOnly": true, + "capabilities": ["account-transactions"], + "endpoints": { + "transactions": "/api/indexer/v1/accounts/{addr}/txs" + } + } + """.trimIndent(), + TonIndexerServiceInfo::class.java + ) + + assertTrue(response.isExpectedTiServiceInfo()) + assertFalse(response.copy(serviceId = "si.soramitsu.io").isExpectedTiServiceInfo()) + assertFalse(response.copy(readOnly = false).isExpectedTiServiceInfo()) + } + + private fun assertRouteError(expected: ErrorCode, block: () -> Unit) { + val error = assertThrows(TonIndexerRouteException::class.java) { block() } + assertEquals(expected, error.code) + } + + private companion object { + const val ADDRESS = "UQDxAUFadQXDd3EXGa3TLF_EF66gMc9h3_aZ0j0zXNoIYUCc" + const val RAW_ADDRESS = "0:f101415a7505c377711719add32c5fc417aea031cf61dff699d23d335cda0861" + const val HASH = "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=" + const val ENCODED_HASH = "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA%3D" + } +} diff --git a/common/src/test/java/jp/co/soramitsu/common/wallet/TonKeyDerivationTest.kt b/common/src/test/java/jp/co/soramitsu/common/wallet/TonKeyDerivationTest.kt new file mode 100644 index 0000000000..8d6e8a4085 --- /dev/null +++ b/common/src/test/java/jp/co/soramitsu/common/wallet/TonKeyDerivationTest.kt @@ -0,0 +1,98 @@ +package jp.co.soramitsu.common.wallet + +import com.google.gson.JsonObject +import com.google.gson.JsonParser +import jp.co.soramitsu.common.model.UniversalWalletDerivationPaths +import jp.co.soramitsu.common.utils.TonKeyDerivation +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNotEquals +import org.junit.Assert.assertThrows +import org.junit.Assert.assertTrue +import org.junit.Test +import java.io.InputStreamReader + +class TonKeyDerivationTest { + + @Test + fun `derives universal wallet v2 ton vectors`() { + loadVectors().forEach { vector -> + val expected = vector.getAsJsonObject("expected").getAsJsonObject("ton") + + val account = TonKeyDerivation.deriveAccount(vector["mnemonic"].asString) + + assertEquals(UniversalWalletDerivationPaths.TON_DEFAULT, account.derivationPath) + assertEquals(expected["derivationPath"].asString, account.derivationPath) + assertEquals(expected["publicKeyHex"].asString, account.publicKeyHex) + assertEquals(expected["addressBounceable"].asString, account.addressBounceable) + assertEquals(expected["addressNonBounceable"].asString, account.addressNonBounceable) + assertEquals(expected["testnetNonBounceable"].asString, account.testnetNonBounceable) + assertEquals(32, account.privateKey.size) + assertEquals(32, account.chainCode.size) + assertEquals(32, account.publicKey.size) + assertTrue(account.accountId.startsWith("0:")) + } + } + + @Test + fun `normalizes mnemonic whitespace before ton derivation`() { + val vector = loadVectors().first() + val mnemonic = vector["mnemonic"].asString + val expected = vector.getAsJsonObject("expected").getAsJsonObject("ton") + val padded = " ${mnemonic.replace(" ", " \n\t")} " + + val account = TonKeyDerivation.deriveAccount(padded) + + assertEquals(expected["publicKeyHex"].asString, account.publicKeyHex) + assertEquals(expected["addressNonBounceable"].asString, account.addressNonBounceable) + } + + @Test + fun `does not reuse ton vectors across paths passphrases or network flags`() { + val vector = loadVectors().first() + val expected = vector.getAsJsonObject("expected").getAsJsonObject("ton") + val wrongPath = TonKeyDerivation.deriveAccount( + mnemonic = vector["mnemonic"].asString, + derivationPath = "m/44'/607'/1'/0'/0'" + ) + val withPassphrase = TonKeyDerivation.deriveAccount( + mnemonic = vector["mnemonic"].asString, + passphrase = "fearless" + ) + + assertNotEquals(expected["publicKeyHex"].asString, wrongPath.publicKeyHex) + assertNotEquals(expected["addressNonBounceable"].asString, wrongPath.addressNonBounceable) + assertNotEquals(expected["publicKeyHex"].asString, withPassphrase.publicKeyHex) + assertNotEquals(expected["addressNonBounceable"].asString, withPassphrase.addressNonBounceable) + assertNotEquals(expected["addressNonBounceable"].asString, expected["testnetNonBounceable"].asString) + } + + @Test + fun `rejects malformed ton derivation inputs`() { + val mnemonic = loadVectors().first()["mnemonic"].asString + + assertThrows(IllegalArgumentException::class.java) { + TonKeyDerivation.deriveAccount("") + } + assertThrows(IllegalArgumentException::class.java) { + TonKeyDerivation.deriveAccount(mnemonic, derivationPath = "") + } + assertThrows(IllegalArgumentException::class.java) { + TonKeyDerivation.deriveAccount(mnemonic, derivationPath = "44'/607'/0'/0'/0'") + } + assertThrows(IllegalArgumentException::class.java) { + TonKeyDerivation.deriveAccount(mnemonic, derivationPath = "m/44'/607/0'/0'/0'") + } + assertThrows(IllegalArgumentException::class.java) { + TonKeyDerivation.addressFromPublicKey(ByteArray(31)) + } + } + + private fun loadVectors(): List { + val stream = javaClass.classLoader?.getResourceAsStream("universal-wallet-v2-vectors.json") + ?: error("universal-wallet-v2-vectors.json is missing from test resources") + + return stream.use { + JsonParser.parseReader(InputStreamReader(it)).asJsonObject.getAsJsonArray("vectors").map { vector -> vector.asJsonObject } + } + } +} diff --git a/common/src/test/java/jp/co/soramitsu/common/wallet/UniversalWalletIdentityTest.kt b/common/src/test/java/jp/co/soramitsu/common/wallet/UniversalWalletIdentityTest.kt new file mode 100644 index 0000000000..fe8c252067 --- /dev/null +++ b/common/src/test/java/jp/co/soramitsu/common/wallet/UniversalWalletIdentityTest.kt @@ -0,0 +1,183 @@ +package jp.co.soramitsu.common.wallet + +import com.google.gson.Gson +import jp.co.soramitsu.common.model.UniversalWalletDerivationPaths +import jp.co.soramitsu.common.model.UniversalWalletEcosystem +import jp.co.soramitsu.common.model.UniversalWalletIdentity +import jp.co.soramitsu.common.model.UniversalWalletIdentity.UniversalWalletPublicAccount +import jp.co.soramitsu.common.model.UniversalWalletIdentity.UniversalWalletSource +import jp.co.soramitsu.common.model.UniversalWalletIdentity.UniversalWalletStatus +import jp.co.soramitsu.common.model.UniversalWalletIdentity.ValidationError +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertThrows +import org.junit.Assert.assertTrue +import org.junit.Test + +class UniversalWalletIdentityTest { + + @Test + fun `validates and serializes active universal wallet identity`() { + val identity = activeIdentity() + + assertTrue(identity.validationErrors().isEmpty()) + assertEquals(identity, identity.requireValid()) + + val json = Gson().toJson(identity) + assertTrue(json.contains("\"schemaVersion\":2")) + assertTrue(json.contains("\"source\":\"created-24-word\"")) + assertTrue(json.contains("\"status\":\"active\"")) + assertTrue(json.contains("\"ecosystem\":\"bitcoin\"")) + } + + @Test + fun `rejects partial active wallets and duplicate accounts`() { + val partial = activeIdentity( + accounts = defaultAccounts().filterNot { it.ecosystem == UniversalWalletEcosystem.Iroha.id } + ) + val duplicate = activeIdentity( + accounts = defaultAccounts() + defaultAccounts().first().copy(address = "bc1qcr8te4kr609gcawutmrza0j4xv80jy8z306abc") + ) + + assertTrue(partial.validationErrors().contains(ValidationError.MissingActiveEcosystem)) + assertTrue(duplicate.validationErrors().contains(ValidationError.DuplicateAccountId)) + } + + @Test + fun `rejects malformed identity and account fields`() { + val account = defaultAccounts().first().copy( + accountId = "../bad", + ecosystem = "unknown", + address = " bc1bad ", + chainId = "../bad", + derivationPath = "m/44'/0'/x", + publicKeyHex = "ABC" + ) + val identity = activeIdentity( + walletId = "bad", + displayName = "bad\u0000name", + createdAtMillis = 10, + updatedAtMillis = 9, + accounts = listOf(account) + ) + + val errors = identity.validationErrors() + + assertTrue(errors.contains(ValidationError.InvalidWalletId)) + assertTrue(errors.contains(ValidationError.InvalidDisplayName)) + assertTrue(errors.contains(ValidationError.InvalidTimestamps)) + assertTrue(errors.contains(ValidationError.InvalidAccountId)) + assertTrue(errors.contains(ValidationError.InvalidEcosystem)) + assertTrue(errors.contains(ValidationError.InvalidAddress)) + assertTrue(errors.contains(ValidationError.InvalidChainId)) + assertTrue(errors.contains(ValidationError.InvalidDerivationPath)) + assertTrue(errors.contains(ValidationError.InvalidPublicKeyHex)) + assertThrows(UniversalWalletIdentity.UniversalWalletIdentityException::class.java) { + identity.requireValid() + } + } + + @Test + fun `enforces legacy export only source and reason`() { + val legacy = activeIdentity( + source = UniversalWalletSource.LegacyImport, + status = UniversalWalletStatus.LegacyExportOnly, + accounts = listOf(defaultAccounts().first()), + legacyExportOnlyReason = "pre-cutoff account export" + ) + val wrongSource = legacy.copy(source = UniversalWalletSource.Created24Word) + val missingReason = legacy.copy(legacyExportOnlyReason = " ") + val activeWithReason = activeIdentity(legacyExportOnlyReason = "not allowed") + + assertTrue(legacy.validationErrors().isEmpty()) + assertTrue(wrongSource.validationErrors().contains(ValidationError.InvalidLegacySource)) + assertTrue(missingReason.validationErrors().contains(ValidationError.LegacyReasonRequired)) + assertTrue(activeWithReason.validationErrors().contains(ValidationError.LegacyReasonNotAllowed)) + } + + @Test + fun `allows migration-required identity to be partial but not empty`() { + val migrating = activeIdentity( + status = UniversalWalletStatus.MigrationRequired, + accounts = listOf(defaultAccounts().first()) + ) + val emptyMigrating = migrating.copy(publicAccounts = emptyList()) + + assertFalse(migrating.validationErrors().contains(ValidationError.MissingActiveEcosystem)) + assertTrue(migrating.validationErrors().isEmpty()) + assertTrue(emptyMigrating.validationErrors().contains(ValidationError.PublicAccountsRequired)) + } + + private fun activeIdentity( + walletId: String = "uw2_1234567890abcdef", + displayName: String = "Fearless Universal", + source: UniversalWalletSource = UniversalWalletSource.Created24Word, + status: UniversalWalletStatus = UniversalWalletStatus.Active, + accounts: List = defaultAccounts(), + createdAtMillis: Long = 1_710_000_000_000, + updatedAtMillis: Long? = null, + legacyExportOnlyReason: String? = null + ) = UniversalWalletIdentity( + walletId = walletId, + displayName = displayName, + source = source, + status = status, + publicAccounts = accounts, + createdAtMillis = createdAtMillis, + updatedAtMillis = updatedAtMillis, + legacyExportOnlyReason = legacyExportOnlyReason + ) + + private fun defaultAccounts() = listOf( + UniversalWalletPublicAccount( + accountId = "substrate-polkadot", + ecosystem = UniversalWalletEcosystem.Substrate, + address = "15FKRtF6nX3SE4PaW4LX69XqYHq2oSep9JX5KF4cgN1XkZ4q", + chainId = "polkadot", + derivationPath = UniversalWalletDerivationPaths.SUBSTRATE_ROOT, + publicKeyHex = HEX_32 + ), + UniversalWalletPublicAccount( + accountId = "evm-default", + ecosystem = UniversalWalletEcosystem.Evm, + address = "0x1111111111111111111111111111111111111111", + chainId = "eip155:1", + derivationPath = UniversalWalletDerivationPaths.EVM_DEFAULT + ), + UniversalWalletPublicAccount( + accountId = "bitcoin-mainnet", + ecosystem = UniversalWalletEcosystem.Bitcoin, + address = "bc1qcr8te4kr609gcawutmrza0j4xv80jy8z306fyu", + chainId = "bitcoin:mainnet", + derivationPath = UniversalWalletDerivationPaths.BITCOIN_MAINNET_FIRST_RECEIVE + ), + UniversalWalletPublicAccount( + accountId = "solana-mainnet", + ecosystem = UniversalWalletEcosystem.Solana, + address = "HAgk14JpMQLgt6rVgv7cBQFJWFto5Dqxi472uT3DKpqk", + chainId = "solana:mainnet", + derivationPath = UniversalWalletDerivationPaths.SOLANA_DEFAULT, + publicKeyHex = HEX_32 + ), + UniversalWalletPublicAccount( + accountId = "ton-mainnet", + ecosystem = UniversalWalletEcosystem.Ton, + address = "UQDxAUFadQXDd3EXGa3TLF_EF66gMc9h3_aZ0j0zXNoIYUCc", + chainId = "ton:mainnet", + derivationPath = UniversalWalletDerivationPaths.TON_DEFAULT, + publicKeyHex = HEX_32 + ), + UniversalWalletPublicAccount( + accountId = "iroha-taira", + ecosystem = UniversalWalletEcosystem.Iroha, + address = "testuロ1Pcナ2ラtノaリLユス2MヱミホウモヱヌニイMメSマムヱヌJヱFmJヌMs6YN687Y", + chainId = "iroha3-taira", + derivationPath = UniversalWalletDerivationPaths.IROHA_DEFAULT, + publicKeyHex = HEX_32 + ) + ) + + private companion object { + val HEX_32 = "11".repeat(32) + } +} diff --git a/common/src/test/java/jp/co/soramitsu/common/wallet/UniversalWalletIndexerContractTest.kt b/common/src/test/java/jp/co/soramitsu/common/wallet/UniversalWalletIndexerContractTest.kt new file mode 100644 index 0000000000..a86825f732 --- /dev/null +++ b/common/src/test/java/jp/co/soramitsu/common/wallet/UniversalWalletIndexerContractTest.kt @@ -0,0 +1,224 @@ +package jp.co.soramitsu.common.wallet + +import com.google.gson.Gson +import jp.co.soramitsu.common.model.UniversalWalletEcosystem +import jp.co.soramitsu.common.model.UniversalWalletIndexedAssetBalance +import jp.co.soramitsu.common.model.UniversalWalletIndexedOperationType +import jp.co.soramitsu.common.model.UniversalWalletIndexedTokenMetadata +import jp.co.soramitsu.common.model.UniversalWalletIndexedTransaction +import jp.co.soramitsu.common.model.UniversalWalletIndexedTransactionDirection +import jp.co.soramitsu.common.model.UniversalWalletIndexedTransactionStatus +import jp.co.soramitsu.common.model.UniversalWalletIndexerPageInfo +import jp.co.soramitsu.common.model.UniversalWalletIndexerValidationError +import org.junit.Assert.assertTrue +import org.junit.Test + +class UniversalWalletIndexerContractTest { + + @Test + fun `validates and serializes normalized asset balances`() { + val balance = assetBalance() + + assertTrue(balance.validationErrors().isEmpty()) + + val json = Gson().toJson(balance) + assertTrue(json.contains("\"ecosystem\":\"solana\"")) + assertTrue(json.contains("\"assetId\":\"SOL\"")) + assertTrue(json.contains("\"amount\":\"123456789\"")) + assertTrue(json.contains("\"tokenProgram\":\"spl-token\"")) + } + + @Test + fun `rejects malformed asset balances`() { + val balance = assetBalance().copy( + accountId = "../bad", + ecosystem = "unknown", + chainId = "../bad", + assetId = " SOL ", + amount = "-1", + decimals = 256, + symbol = "bad\u0000symbol", + name = "bad\u0000name", + tokenAccountId = " token ", + contractAddress = " contract ", + tokenProgram = " token program ", + syncedAtMillis = 0 + ) + + val errors = balance.validationErrors() + + assertTrue(errors.contains(UniversalWalletIndexerValidationError.InvalidAccountId)) + assertTrue(errors.contains(UniversalWalletIndexerValidationError.InvalidEcosystem)) + assertTrue(errors.contains(UniversalWalletIndexerValidationError.InvalidChainId)) + assertTrue(errors.contains(UniversalWalletIndexerValidationError.InvalidAssetId)) + assertTrue(errors.contains(UniversalWalletIndexerValidationError.InvalidAmount)) + assertTrue(errors.contains(UniversalWalletIndexerValidationError.InvalidDecimals)) + assertTrue(errors.contains(UniversalWalletIndexerValidationError.InvalidSymbol)) + assertTrue(errors.contains(UniversalWalletIndexerValidationError.InvalidName)) + assertTrue(errors.contains(UniversalWalletIndexerValidationError.InvalidAddress)) + assertTrue(errors.contains(UniversalWalletIndexerValidationError.InvalidSyncedAt)) + } + + @Test + fun `validates and serializes normalized transactions`() { + val transaction = indexedTransaction() + + assertTrue(transaction.validationErrors().isEmpty()) + + val json = Gson().toJson(transaction) + assertTrue(json.contains("\"status\":\"confirmed\"")) + assertTrue(json.contains("\"direction\":\"outgoing\"")) + assertTrue(json.contains("\"operationType\":\"transfer\"")) + } + + @Test + fun `serializes Nexus operation buckets`() { + assertTrue( + Gson() + .toJson(indexedTransaction().copy(operationType = UniversalWalletIndexedOperationType.OfflineCash)) + .contains("\"operationType\":\"offline-cash\"") + ) + assertTrue( + Gson() + .toJson(indexedTransaction().copy(operationType = UniversalWalletIndexedOperationType.Sccp)) + .contains("\"operationType\":\"sccp\"") + ) + assertTrue( + Gson() + .toJson(indexedTransaction().copy(operationType = UniversalWalletIndexedOperationType.Governance)) + .contains("\"operationType\":\"governance\"") + ) + } + + @Test + fun `rejects malformed normalized transactions`() { + val transaction = indexedTransaction().copy( + accountId = "bad account", + ecosystem = "bad", + chainId = "bad chain", + transactionId = " tx ", + timestampMillis = -1, + amount = "01", + assetId = " asset ", + feeAmount = "1.2", + feeAssetId = " fee ", + counterpartyAddress = " counterparty ", + blockNumber = "-2", + cursor = " cursor ", + explorerUrl = "http://example.com/tx", + syncedAtMillis = 0 + ) + + val errors = transaction.validationErrors() + + assertTrue(errors.contains(UniversalWalletIndexerValidationError.InvalidAccountId)) + assertTrue(errors.contains(UniversalWalletIndexerValidationError.InvalidEcosystem)) + assertTrue(errors.contains(UniversalWalletIndexerValidationError.InvalidChainId)) + assertTrue(errors.contains(UniversalWalletIndexerValidationError.InvalidTransactionId)) + assertTrue(errors.contains(UniversalWalletIndexerValidationError.InvalidTimestamp)) + assertTrue(errors.contains(UniversalWalletIndexerValidationError.InvalidAmount)) + assertTrue(errors.contains(UniversalWalletIndexerValidationError.InvalidAssetId)) + assertTrue(errors.contains(UniversalWalletIndexerValidationError.InvalidAddress)) + assertTrue(errors.contains(UniversalWalletIndexerValidationError.InvalidBlockNumber)) + assertTrue(errors.contains(UniversalWalletIndexerValidationError.InvalidCursor)) + assertTrue(errors.contains(UniversalWalletIndexerValidationError.InvalidUrl)) + assertTrue(errors.contains(UniversalWalletIndexerValidationError.InvalidSyncedAt)) + } + + @Test + fun `validates token metadata and page info contracts`() { + val metadata = UniversalWalletIndexedTokenMetadata( + ecosystem = UniversalWalletEcosystem.Ton.id, + chainId = "ton:mainnet", + assetId = "EQAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAM9c", + decimals = 9, + symbol = "TON", + name = "Toncoin", + iconUrl = "ipfs://bafybeigdyrzt", + metadataUrl = "https://example.com/ton.json", + isVerified = true, + syncedAtMillis = 1_710_000_000_000 + ) + val pageInfo = UniversalWalletIndexerPageInfo( + nextCursor = "next-cursor", + limit = 100, + total = 1, + syncedAtMillis = 1_710_000_000_000 + ) + + assertTrue(metadata.validationErrors().isEmpty()) + assertTrue(pageInfo.validationErrors().isEmpty()) + } + + @Test + fun `rejects malformed token metadata and page info contracts`() { + val metadata = UniversalWalletIndexedTokenMetadata( + ecosystem = "bad", + chainId = "bad chain", + assetId = " asset ", + decimals = -1, + symbol = "bad\u0000symbol", + name = "bad\u0000name", + iconUrl = "ftp://example.com/icon.png", + metadataUrl = "http://example.com/meta.json", + syncedAtMillis = 0 + ) + val pageInfo = UniversalWalletIndexerPageInfo( + nextCursor = " cursor ", + limit = 251, + total = -1, + syncedAtMillis = 0 + ) + + val metadataErrors = metadata.validationErrors() + val pageErrors = pageInfo.validationErrors() + + assertTrue(metadataErrors.contains(UniversalWalletIndexerValidationError.InvalidEcosystem)) + assertTrue(metadataErrors.contains(UniversalWalletIndexerValidationError.InvalidChainId)) + assertTrue(metadataErrors.contains(UniversalWalletIndexerValidationError.InvalidAssetId)) + assertTrue(metadataErrors.contains(UniversalWalletIndexerValidationError.InvalidDecimals)) + assertTrue(metadataErrors.contains(UniversalWalletIndexerValidationError.InvalidSymbol)) + assertTrue(metadataErrors.contains(UniversalWalletIndexerValidationError.InvalidName)) + assertTrue(metadataErrors.contains(UniversalWalletIndexerValidationError.InvalidUrl)) + assertTrue(metadataErrors.contains(UniversalWalletIndexerValidationError.InvalidSyncedAt)) + assertTrue(pageErrors.contains(UniversalWalletIndexerValidationError.InvalidCursor)) + assertTrue(pageErrors.contains(UniversalWalletIndexerValidationError.InvalidLimit)) + assertTrue(pageErrors.contains(UniversalWalletIndexerValidationError.InvalidTotal)) + assertTrue(pageErrors.contains(UniversalWalletIndexerValidationError.InvalidSyncedAt)) + } + + private fun assetBalance() = UniversalWalletIndexedAssetBalance( + accountId = "solana-mainnet", + ecosystem = UniversalWalletEcosystem.Solana, + chainId = "solana:mainnet", + assetId = "SOL", + amount = "123456789", + decimals = 9, + isNative = true, + symbol = "SOL", + name = "Solana", + uiAmountString = "0.123456789", + tokenProgram = "spl-token", + syncedAtMillis = 1_710_000_000_000 + ) + + private fun indexedTransaction() = UniversalWalletIndexedTransaction( + accountId = "bitcoin-mainnet", + ecosystem = UniversalWalletEcosystem.Bitcoin.id, + chainId = "bitcoin:mainnet", + transactionId = "a".repeat(64), + status = UniversalWalletIndexedTransactionStatus.Confirmed, + direction = UniversalWalletIndexedTransactionDirection.Outgoing, + operationType = UniversalWalletIndexedOperationType.Transfer, + timestampMillis = 1_710_000_000_000, + amount = "1000", + assetId = "BTC", + feeAmount = "100", + feeAssetId = "BTC", + counterpartyAddress = "bc1qcr8te4kr609gcawutmrza0j4xv80jy8z306fyu", + blockNumber = "800000", + cursor = "a".repeat(64), + explorerUrl = "https://mempool.space/tx/${"a".repeat(64)}", + syncedAtMillis = 1_710_000_000_000 + ) +} diff --git a/common/src/test/java/jp/co/soramitsu/common/wallet/UniversalWalletMigrationContractTest.kt b/common/src/test/java/jp/co/soramitsu/common/wallet/UniversalWalletMigrationContractTest.kt new file mode 100644 index 0000000000..72501aac29 --- /dev/null +++ b/common/src/test/java/jp/co/soramitsu/common/wallet/UniversalWalletMigrationContractTest.kt @@ -0,0 +1,112 @@ +package jp.co.soramitsu.common.wallet + +import com.google.gson.Gson +import jp.co.soramitsu.common.model.UniversalWalletEcosystem +import jp.co.soramitsu.common.model.UniversalWalletLegacyVaultDescriptor +import jp.co.soramitsu.common.model.UniversalWalletMigrationPlatform +import jp.co.soramitsu.common.model.UniversalWalletMigrationRequiredAction +import jp.co.soramitsu.common.model.UniversalWalletMigrationSnapshot +import jp.co.soramitsu.common.model.UniversalWalletMigrationValidationError +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test + +class UniversalWalletMigrationContractTest { + + @Test + fun `blocks normal access when legacy vaults exist without a universal wallet`() { + val snapshot = snapshot(hasUniversalWallet = false, legacyVaults = listOf(legacyVault())) + + assertTrue(snapshot.validationErrors().isEmpty()) + assertTrue(snapshot.requiredAction() == UniversalWalletMigrationRequiredAction.MigrateBeforeAccess) + assertFalse(snapshot.allowsNormalWalletAccess()) + assertTrue(snapshot.allowsLegacySecretExport()) + + val json = Gson().toJson(snapshot) + assertTrue(json.contains("\"platform\":\"android\"")) + assertTrue(json.contains("\"mode\":\"export-only\"")) + assertTrue(json.contains("\"canSignTransactions\":false")) + } + + @Test + fun `requires a new universal wallet when no wallet material exists`() { + val snapshot = snapshot(hasUniversalWallet = false, legacyVaults = emptyList()) + + assertTrue(snapshot.validationErrors().isEmpty()) + assertTrue(snapshot.requiredAction() == UniversalWalletMigrationRequiredAction.CreateUniversalWallet) + assertFalse(snapshot.allowsNormalWalletAccess()) + assertFalse(snapshot.allowsLegacySecretExport()) + } + + @Test + fun `allows normal access once a universal wallet exists while keeping legacy export-only`() { + val snapshot = snapshot(hasUniversalWallet = true, legacyVaults = listOf(legacyVault())) + + assertTrue(snapshot.validationErrors().isEmpty()) + assertTrue(snapshot.requiredAction() == UniversalWalletMigrationRequiredAction.NormalAccess) + assertTrue(snapshot.allowsNormalWalletAccess()) + assertTrue(snapshot.allowsLegacySecretExport()) + } + + @Test + fun `rejects malformed migration snapshots and legacy vaults`() { + val badVault = legacyVault().copy( + vaultId = "bad", + accountId = "../bad", + ecosystem = "unknown", + address = " address ", + displayName = "bad\u0000name", + exportOnlyReason = " ", + canExportSecrets = false, + canSignTransactions = true, + discoveredAtMillis = 10, + lastExportedAtMillis = 9 + ) + val snapshot = snapshot( + schemaVersion = 99, + cutoffAtMillis = 0, + evaluatedAtMillis = 0, + legacyVaults = listOf(badVault, badVault) + ) + + val errors = snapshot.validationErrors() + + assertTrue(errors.contains(UniversalWalletMigrationValidationError.InvalidSchemaVersion)) + assertTrue(errors.contains(UniversalWalletMigrationValidationError.InvalidTimestamp)) + assertTrue(errors.contains(UniversalWalletMigrationValidationError.InvalidVaultId)) + assertTrue(errors.contains(UniversalWalletMigrationValidationError.DuplicateVaultId)) + assertTrue(errors.contains(UniversalWalletMigrationValidationError.InvalidAccountId)) + assertTrue(errors.contains(UniversalWalletMigrationValidationError.InvalidEcosystem)) + assertTrue(errors.contains(UniversalWalletMigrationValidationError.InvalidAddress)) + assertTrue(errors.contains(UniversalWalletMigrationValidationError.InvalidDisplayName)) + assertTrue(errors.contains(UniversalWalletMigrationValidationError.InvalidExportReason)) + assertTrue(errors.contains(UniversalWalletMigrationValidationError.ExportDisabled)) + assertTrue(errors.contains(UniversalWalletMigrationValidationError.LegacySigningEnabled)) + } + + private fun snapshot( + schemaVersion: Int = UniversalWalletMigrationSnapshot.SCHEMA_VERSION, + hasUniversalWallet: Boolean = false, + legacyVaults: List = listOf(legacyVault()), + cutoffAtMillis: Long = 1_710_000_000_000, + evaluatedAtMillis: Long = 1_710_000_000_100 + ) = UniversalWalletMigrationSnapshot( + schemaVersion = schemaVersion, + platform = UniversalWalletMigrationPlatform.Android, + hasUniversalWallet = hasUniversalWallet, + legacyVaults = legacyVaults, + cutoffAtMillis = cutoffAtMillis, + evaluatedAtMillis = evaluatedAtMillis + ) + + private fun legacyVault() = UniversalWalletLegacyVaultDescriptor( + vaultId = "legacy_12345678", + accountId = "substrate-legacy", + ecosystem = UniversalWalletEcosystem.Substrate, + address = "15FKRtF6nX3SE4PaW4LX69XqYHq2oSep9JX5KF4cgN1XkZ4q", + displayName = "Legacy DOT", + exportOnlyReason = "pre-cutoff account export", + discoveredAtMillis = 1_700_000_000_000, + lastExportedAtMillis = 1_700_000_000_100 + ) +} diff --git a/common/src/test/java/jp/co/soramitsu/common/wallet/UniversalWalletRegistryContractTest.kt b/common/src/test/java/jp/co/soramitsu/common/wallet/UniversalWalletRegistryContractTest.kt new file mode 100644 index 0000000000..d64a3088fb --- /dev/null +++ b/common/src/test/java/jp/co/soramitsu/common/wallet/UniversalWalletRegistryContractTest.kt @@ -0,0 +1,213 @@ +package jp.co.soramitsu.common.wallet + +import com.google.gson.Gson +import jp.co.soramitsu.common.model.UniversalWalletChainRegistry +import jp.co.soramitsu.common.model.UniversalWalletChainRegistryEntry +import jp.co.soramitsu.common.model.UniversalWalletEcosystem +import jp.co.soramitsu.common.model.UniversalWalletRegistry +import jp.co.soramitsu.common.model.UniversalWalletRegistryAsset +import jp.co.soramitsu.common.model.UniversalWalletRegistryEndpoint +import jp.co.soramitsu.common.model.UniversalWalletRegistryEndpointKind +import jp.co.soramitsu.common.model.UniversalWalletRegistryValidationError +import org.junit.Assert.assertTrue +import org.junit.Test + +class UniversalWalletRegistryContractTest { + + @Test + fun `validates and serializes public chain registry entries`() { + val registry = UniversalWalletChainRegistry(chains = listOf(solanaMainnet(), taira())) + + assertTrue(registry.validationErrors().isEmpty()) + + val json = Gson().toJson(registry) + assertTrue(json.contains("\"ecosystem\":\"solana\"")) + assertTrue(json.contains("\"kind\":\"indexer\"")) + assertTrue(json.contains("\"kind\":\"torii-mcp\"")) + assertTrue(json.contains("\"readOnly\":true")) + assertTrue(json.contains("\"features\":[\"transfer\"]")) + } + + @Test + fun `allows disabled gated networks without endpoints`() { + val nexus = UniversalWalletChainRegistryEntry( + id = "sora-nexus-mainnet", + ecosystem = UniversalWalletEcosystem.Iroha, + chainId = "sora:nexus:global", + displayName = "SORA Nexus", + enabledByDefault = false, + features = listOf("transfer", "offline-cash", "sccp", "governance"), + endpoints = emptyList() + ) + + assertTrue(nexus.validationErrors().isEmpty()) + } + + @Test + fun `actual default registry entries validate and expose expected public endpoints`() { + val registry = UniversalWalletRegistry.chainRegistry + + assertTrue(registry.validationErrors().isEmpty()) + assertTrue(registry.chains.map { it.id }.containsAll( + listOf( + "bitcoin-mainnet", + "bitcoin-testnet", + "ton-mainnet", + "solana-mainnet", + "solana-devnet", + "taira-testnet", + "sora-nexus-mainnet" + ) + )) + + val solana = registry.chains.first { it.id == "solana-mainnet" } + assertTrue(solana.endpoints.any { + it.kind == UniversalWalletRegistryEndpointKind.Indexer && + it.url == "https://si.soramitsu.io" && + it.readOnly + }) + assertTrue(solana.endpoints.any { + it.kind == UniversalWalletRegistryEndpointKind.Rpc && + it.url == "https://api.mainnet-beta.solana.com" && + !it.readOnly + }) + + val ton = registry.chains.first { it.id == "ton-mainnet" } + assertTrue(ton.endpoints.any { + it.kind == UniversalWalletRegistryEndpointKind.Indexer && + it.url == "https://ti.soramitsu.io" && + it.readOnly + }) + + val taira = registry.chains.first { it.id == "taira-testnet" } + assertTrue(taira.features == listOf("transfer")) + assertTrue(taira.endpoints.any { + it.kind == UniversalWalletRegistryEndpointKind.ToriiMcp && + it.url == "https://taira.sora.org/v1/mcp" && + !it.readOnly + }) + + val nexus = registry.chains.first { it.id == "sora-nexus-mainnet" } + assertTrue(!nexus.enabledByDefault) + assertTrue(nexus.features == listOf("transfer", "offline-cash", "sccp", "governance")) + assertTrue(nexus.endpoints.any { + it.kind == UniversalWalletRegistryEndpointKind.ToriiMcp && + it.url == "https://minamoto.sora.org/v1/mcp" && + !it.readOnly + }) + } + + @Test + fun `rejects malformed registry entries and public write indexers`() { + val chain = solanaMainnet().copy( + id = "../bad", + ecosystem = "unknown", + chainId = "bad chain", + displayName = "bad\u0000name", + nativeAsset = UniversalWalletRegistryAsset( + id = "bad id", + symbol = "sol", + decimals = 256, + name = "bad\u0000name" + ), + derivationPath = "m/44'/x", + slip44CoinType = -1, + features = listOf("governance", "governance", "bad feature"), + endpoints = listOf( + UniversalWalletRegistryEndpoint( + id = "bad endpoint", + kind = UniversalWalletRegistryEndpointKind.Indexer, + url = "http://si.soramitsu.io", + readOnly = false, + priority = -1 + ) + ) + ) + + val errors = chain.validationErrors() + + assertTrue(errors.contains(UniversalWalletRegistryValidationError.InvalidId)) + assertTrue(errors.contains(UniversalWalletRegistryValidationError.InvalidEcosystem)) + assertTrue(errors.contains(UniversalWalletRegistryValidationError.InvalidChainId)) + assertTrue(errors.contains(UniversalWalletRegistryValidationError.InvalidDisplayName)) + assertTrue(errors.contains(UniversalWalletRegistryValidationError.InvalidAssetId)) + assertTrue(errors.contains(UniversalWalletRegistryValidationError.InvalidAssetSymbol)) + assertTrue(errors.contains(UniversalWalletRegistryValidationError.InvalidAssetDecimals)) + assertTrue(errors.contains(UniversalWalletRegistryValidationError.InvalidAssetName)) + assertTrue(errors.contains(UniversalWalletRegistryValidationError.InvalidDerivationPath)) + assertTrue(errors.contains(UniversalWalletRegistryValidationError.InvalidSlip44CoinType)) + assertTrue(errors.contains(UniversalWalletRegistryValidationError.DuplicateFeatureId)) + assertTrue(errors.contains(UniversalWalletRegistryValidationError.InvalidFeatureId)) + assertTrue(errors.contains(UniversalWalletRegistryValidationError.InvalidEndpointId)) + assertTrue(errors.contains(UniversalWalletRegistryValidationError.InvalidEndpointUrl)) + assertTrue(errors.contains(UniversalWalletRegistryValidationError.InvalidEndpointPriority)) + assertTrue(errors.contains(UniversalWalletRegistryValidationError.PublicWriteIndexer)) + } + + @Test + fun `rejects duplicate chain and endpoint identifiers`() { + val duplicateEndpoint = solanaMainnet().copy( + endpoints = listOf(indexer(), indexer()) + ) + val registry = UniversalWalletChainRegistry( + schemaVersion = 99, + chains = listOf(solanaMainnet(), solanaMainnet(), duplicateEndpoint) + ) + + val errors = registry.validationErrors() + + assertTrue(errors.contains(UniversalWalletRegistryValidationError.InvalidSchemaVersion)) + assertTrue(errors.contains(UniversalWalletRegistryValidationError.DuplicateChainId)) + assertTrue(errors.contains(UniversalWalletRegistryValidationError.DuplicateEndpointId)) + } + + private fun solanaMainnet() = UniversalWalletChainRegistryEntry( + id = "solana-mainnet", + ecosystem = UniversalWalletEcosystem.Solana, + chainId = "solana:mainnet", + displayName = "Solana", + enabledByDefault = true, + nativeAsset = UniversalWalletRegistryAsset( + id = "SOL", + symbol = "SOL", + decimals = 9, + name = "Solana" + ), + derivationPath = "m/44'/501'/0'/0'", + slip44CoinType = 501, + endpoints = listOf( + indexer(), + UniversalWalletRegistryEndpoint( + id = "solana-mainnet-rpc", + kind = UniversalWalletRegistryEndpointKind.Rpc, + url = "https://api.mainnet-beta.solana.com", + readOnly = false, + priority = 1 + ) + ) + ) + + private fun taira() = UniversalWalletChainRegistryEntry( + id = "taira-testnet", + ecosystem = UniversalWalletEcosystem.Iroha, + chainId = "iroha3-taira", + displayName = "Taira Testnet", + enabledByDefault = true, + features = listOf("transfer"), + endpoints = listOf( + UniversalWalletRegistryEndpoint( + id = "taira-torii-mcp", + kind = UniversalWalletRegistryEndpointKind.ToriiMcp, + url = "https://taira.sora.org/v1/mcp", + readOnly = false + ) + ) + ) + + private fun indexer() = UniversalWalletRegistryEndpoint( + id = "solana-mainnet-indexer", + kind = UniversalWalletRegistryEndpointKind.Indexer, + url = "https://si.soramitsu.io", + readOnly = true + ) +} diff --git a/common/src/test/java/jp/co/soramitsu/common/wallet/UniversalWalletSigningContractTest.kt b/common/src/test/java/jp/co/soramitsu/common/wallet/UniversalWalletSigningContractTest.kt new file mode 100644 index 0000000000..e601fa0d0d --- /dev/null +++ b/common/src/test/java/jp/co/soramitsu/common/wallet/UniversalWalletSigningContractTest.kt @@ -0,0 +1,167 @@ +package jp.co.soramitsu.common.wallet + +import com.google.gson.Gson +import jp.co.soramitsu.common.model.UniversalWalletSigningDisplay +import jp.co.soramitsu.common.model.UniversalWalletSigningMethod +import jp.co.soramitsu.common.model.UniversalWalletSigningPayload +import jp.co.soramitsu.common.model.UniversalWalletSigningPayloadEncoding +import jp.co.soramitsu.common.model.UniversalWalletSigningRequest +import jp.co.soramitsu.common.model.UniversalWalletSigningResult +import jp.co.soramitsu.common.model.UniversalWalletSigningResultStatus +import jp.co.soramitsu.common.model.UniversalWalletSigningValidationError +import org.junit.Assert.assertTrue +import org.junit.Test + +class UniversalWalletSigningContractTest { + + @Test + fun `validates and serializes message signing requests and results`() { + val request = messageRequest() + val result = approvedMessageResult() + + assertTrue(request.validationErrors().isEmpty()) + assertTrue(result.validationErrors().isEmpty()) + + val requestJson = Gson().toJson(request) + val resultJson = Gson().toJson(result) + assertTrue(requestJson.contains("\"method\":\"sign-message\"")) + assertTrue(requestJson.contains("\"encoding\":\"base64\"")) + assertTrue(resultJson.contains("\"status\":\"approved\"")) + assertTrue(resultJson.contains("\"signatureHex\":\"${"ab".repeat(64)}\"")) + } + + @Test + fun `rejects malformed message signing requests`() { + val request = messageRequest().copy( + requestId = "bad", + accountId = "../bad", + ecosystem = "unknown", + chainId = "bad chain", + origin = " https://dapp.example ", + message = UniversalWalletSigningPayload( + encoding = UniversalWalletSigningPayloadEncoding.Base64, + value = "*not-base64*", + display = UniversalWalletSigningDisplay.Utf8 + ), + transactionBase64 = "AQID", + createdAtMillis = 10, + expiresAtMillis = 9 + ) + + val errors = request.validationErrors() + + assertTrue(errors.contains(UniversalWalletSigningValidationError.InvalidRequestId)) + assertTrue(errors.contains(UniversalWalletSigningValidationError.InvalidAccountId)) + assertTrue(errors.contains(UniversalWalletSigningValidationError.InvalidEcosystem)) + assertTrue(errors.contains(UniversalWalletSigningValidationError.InvalidChainId)) + assertTrue(errors.contains(UniversalWalletSigningValidationError.InvalidOrigin)) + assertTrue(errors.contains(UniversalWalletSigningValidationError.InvalidBase64)) + assertTrue(errors.contains(UniversalWalletSigningValidationError.PayloadNotAllowed)) + assertTrue(errors.contains(UniversalWalletSigningValidationError.InvalidTimestamp)) + } + + @Test + fun `validates transaction and batch signing requests`() { + val transaction = messageRequest().copy( + method = UniversalWalletSigningMethod.SignTransaction, + message = null, + transactionBase64 = "AQIDBA==" + ) + val signAndSend = transaction.copy(method = UniversalWalletSigningMethod.SignAndSendTransaction) + val batch = transaction.copy( + method = UniversalWalletSigningMethod.SignAllTransactions, + transactionBase64 = null, + transactionsBase64 = listOf("AQIDBA==", "BQYHCA==") + ) + + assertTrue(transaction.validationErrors().isEmpty()) + assertTrue(signAndSend.validationErrors().isEmpty()) + assertTrue(batch.validationErrors().isEmpty()) + } + + @Test + fun `rejects malformed transaction and batch signing requests`() { + val transaction = messageRequest().copy( + method = UniversalWalletSigningMethod.SignTransaction, + message = null, + transactionBase64 = "*bad*" + ) + val batch = transaction.copy( + method = UniversalWalletSigningMethod.SignAllTransactions, + transactionBase64 = null, + transactionsBase64 = List(17) { "AQIDBA==" } + ) + + assertTrue(transaction.validationErrors().contains(UniversalWalletSigningValidationError.TransactionRequired)) + assertTrue(batch.validationErrors().contains(UniversalWalletSigningValidationError.InvalidBatch)) + } + + @Test + fun `validates approved transaction and rejected signing results`() { + val transaction = approvedMessageResult().copy( + method = UniversalWalletSigningMethod.SignAndSendTransaction, + signatureHex = null, + signedTransactionBase64 = "AQIDBA==", + transactionHash = "5NfHnqDyzT9qyfxZDq2sSskAMGuFZ3VRqW4EQxghKqrKYdKq6cZNW1J34w7qE6nGx1eDQe5s2eKxB2ZtE1xU9qgN" + ) + val rejected = approvedMessageResult().copy( + status = UniversalWalletSigningResultStatus.Rejected, + publicKey = null, + signatureHex = null, + errorCode = "user_rejected" + ) + + assertTrue(transaction.validationErrors().isEmpty()) + assertTrue(rejected.validationErrors().isEmpty()) + } + + @Test + fun `rejects inconsistent signing results`() { + val approved = approvedMessageResult().copy( + publicKey = null, + signatureHex = "ABC", + errorCode = "not_allowed" + ) + val failed = approvedMessageResult().copy( + status = UniversalWalletSigningResultStatus.Failed, + errorCode = null + ) + + val approvedErrors = approved.validationErrors() + val failedErrors = failed.validationErrors() + + assertTrue(approvedErrors.contains(UniversalWalletSigningValidationError.PublicKeyRequired)) + assertTrue(approvedErrors.contains(UniversalWalletSigningValidationError.InvalidSignature)) + assertTrue(approvedErrors.contains(UniversalWalletSigningValidationError.ErrorNotAllowed)) + assertTrue(failedErrors.contains(UniversalWalletSigningValidationError.ErrorCodeRequired)) + assertTrue(failedErrors.contains(UniversalWalletSigningValidationError.SignatureNotAllowed)) + } + + private fun messageRequest() = UniversalWalletSigningRequest( + requestId = "sign_1234567890abcdef", + accountId = "solana-mainnet", + ecosystem = "solana", + chainId = "solana:mainnet", + origin = "https://dapp.example", + method = UniversalWalletSigningMethod.SignMessage, + message = UniversalWalletSigningPayload( + encoding = UniversalWalletSigningPayloadEncoding.Base64, + value = "ZmVhcmxlc3M=", + display = UniversalWalletSigningDisplay.Utf8 + ), + createdAtMillis = 1_710_000_000_000, + expiresAtMillis = 1_710_000_060_000 + ) + + private fun approvedMessageResult() = UniversalWalletSigningResult( + requestId = "sign_1234567890abcdef", + accountId = "solana-mainnet", + ecosystem = "solana", + chainId = "solana:mainnet", + method = UniversalWalletSigningMethod.SignMessage, + status = UniversalWalletSigningResultStatus.Approved, + publicKey = "HAgk14JpMQLgt6rVgv7cBQFJWFto5Dqxi472uT3DKpqk", + signatureHex = "ab".repeat(64), + signedAtMillis = 1_710_000_000_100 + ) +} diff --git a/common/src/test/java/jp/co/soramitsu/common/wallet/UniversalWalletV2VectorsTest.kt b/common/src/test/java/jp/co/soramitsu/common/wallet/UniversalWalletV2VectorsTest.kt new file mode 100644 index 0000000000..04d9258469 --- /dev/null +++ b/common/src/test/java/jp/co/soramitsu/common/wallet/UniversalWalletV2VectorsTest.kt @@ -0,0 +1,227 @@ +package jp.co.soramitsu.common.wallet + +import com.google.gson.JsonObject +import com.google.gson.JsonParser +import jp.co.soramitsu.common.model.UniversalWalletDerivationPaths +import jp.co.soramitsu.common.model.UniversalWalletEcosystem +import jp.co.soramitsu.common.model.UniversalWalletRegistry +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNotEquals +import org.junit.Assert.assertTrue +import org.junit.Test +import java.io.InputStreamReader + +class UniversalWalletV2VectorsTest { + + @Test + fun `golden vectors define every universal wallet ecosystem`() { + val fixture = loadFixture() + + assertEquals(1, fixture["version"].asInt) + + val vectors = fixture.getAsJsonArray("vectors") + assertEquals(listOf("import12", "default24"), vectors.map { it.asJsonObject["id"].asString }) + assertEquals(setOf("bitcoin-wrong-purpose", "solana-wrong-path", "iroha-wrong-discriminant", "ton-testnet-flag"), fixture.getAsJsonArray("negativeCases").map { it.asJsonObject["id"].asString }.toSet()) + assertEquals(UNIVERSAL_ECOSYSTEM_IDS, UniversalWalletEcosystem.values().map { it.id }.toSet()) + + vectors.forEach { element -> + val vector = element.asJsonObject + val mnemonicWords = vector["mnemonic"].asString.split(" ") + + assertEquals(vector["wordCount"].asInt, mnemonicWords.size) + assertTrue(vector["wordCount"].asInt == 12 || vector["wordCount"].asInt == 24) + + val expected = vector.getAsJsonObject("expected") + assertEquals(UNIVERSAL_ECOSYSTEM_IDS, expected.keySet()) + + assertHex(expected.getAsJsonObject("substrate")["publicKeyHex"].asString, 32) + assertEquals("sr25519", expected.getAsJsonObject("substrate")["cryptoType"].asString) + assertEquals(0, expected.getAsJsonObject("substrate")["ss58Prefix"].asInt) + assertTrue(expected.getAsJsonObject("substrate")["polkadotAddress"].asString.startsWith("1")) + + assertEquals("m/44'/60'/0'/0/0", expected.getAsJsonObject("evm")["derivationPath"].asString) + assertTrue(EVM_ADDRESS.matches(expected.getAsJsonObject("evm")["address"].asString)) + + assertBitcoin(expected.getAsJsonObject("bitcoin")) + assertSolana(expected.getAsJsonObject("solana")) + assertTon(expected.getAsJsonObject("ton")) + assertIroha(expected.getAsJsonObject("iroha")) + } + } + + @Test + fun `network-specific fixture values cannot be silently reused across networks`() { + val vectors = loadFixture().getAsJsonArray("vectors").map { it.asJsonObject } + + vectors.forEach { vector -> + val expected = vector.getAsJsonObject("expected") + val bitcoin = expected.getAsJsonObject("bitcoin") + val ton = expected.getAsJsonObject("ton") + val iroha = expected.getAsJsonObject("iroha") + + assertNotEquals(bitcoin.getAsJsonObject("mainnet")["firstReceiveAddress"].asString, bitcoin.getAsJsonObject("testnet")["firstReceiveAddress"].asString) + assertTrue(bitcoin.getAsJsonObject("mainnet")["firstReceiveAddress"].asString.startsWith("bc1q")) + assertTrue(bitcoin.getAsJsonObject("testnet")["firstReceiveAddress"].asString.startsWith("tb1q")) + + assertFalse(ton["addressNonBounceable"].asString.startsWith("0Q")) + assertTrue(ton["testnetNonBounceable"].asString.startsWith("0Q")) + + val taira = iroha.getAsJsonObject("taira") + val nexus = iroha.getAsJsonObject("nexus") + assertEquals(taira["canonicalHex"].asString, nexus["canonicalHex"].asString) + assertEquals(369, taira["chainDiscriminant"].asInt) + assertEquals(753, nexus["chainDiscriminant"].asInt) + assertTrue(taira["i105"].asString.startsWith("test")) + assertTrue(nexus["i105"].asString.startsWith("sora")) + assertNotEquals(taira["i105"].asString, nexus["i105"].asString) + } + } + + @Test + fun `registry constants define public indexers and gated Iroha networks`() { + assertEquals("https://ti.soramitsu.io", UniversalWalletRegistry.TON_INDEXER_BASE_URL) + assertEquals("https://si.soramitsu.io", UniversalWalletRegistry.SOLANA_INDEXER_BASE_URL) + assertEquals("https://blockstream.info/api", UniversalWalletRegistry.BITCOIN_MAINNET_INDEXER_BASE_URL) + assertEquals("https://blockstream.info/testnet/api", UniversalWalletRegistry.BITCOIN_TESTNET_INDEXER_BASE_URL) + + assertEquals("bitcoin-mainnet", UniversalWalletRegistry.bitcoinMainnet.id) + assertEquals("bitcoin:mainnet", UniversalWalletRegistry.bitcoinMainnet.chainId) + assertEquals("Bitcoin", UniversalWalletRegistry.bitcoinMainnet.name) + assertEquals(0, UniversalWalletRegistry.bitcoinMainnet.slip44CoinType) + assertEquals("bc", UniversalWalletRegistry.bitcoinMainnet.addressHrp) + assertEquals("m/84'/0'/0'", UniversalWalletRegistry.bitcoinMainnet.accountPath) + assertEquals("m/84'/0'/0'/0/0", UniversalWalletRegistry.bitcoinMainnet.firstReceivePath) + assertEquals("https://blockstream.info/api", UniversalWalletRegistry.bitcoinMainnet.indexerBaseUrl) + assertEquals(20, UniversalWalletRegistry.bitcoinMainnet.defaultGapLimit) + assertEquals("BTC", UniversalWalletRegistry.bitcoinMainnet.nativeAsset.id) + assertEquals("BTC", UniversalWalletRegistry.bitcoinMainnet.nativeAsset.symbol) + assertEquals(8, UniversalWalletRegistry.bitcoinMainnet.nativeAsset.decimals) + assertTrue(UniversalWalletRegistry.bitcoinMainnet.enabledByDefault) + + assertEquals("bitcoin-testnet", UniversalWalletRegistry.bitcoinTestnet.id) + assertEquals("bitcoin:testnet", UniversalWalletRegistry.bitcoinTestnet.chainId) + assertEquals("Bitcoin Testnet", UniversalWalletRegistry.bitcoinTestnet.name) + assertEquals(1, UniversalWalletRegistry.bitcoinTestnet.slip44CoinType) + assertEquals("tb", UniversalWalletRegistry.bitcoinTestnet.addressHrp) + assertEquals("m/84'/1'/0'", UniversalWalletRegistry.bitcoinTestnet.accountPath) + assertEquals("m/84'/1'/0'/0/0", UniversalWalletRegistry.bitcoinTestnet.firstReceivePath) + assertEquals("https://blockstream.info/testnet/api", UniversalWalletRegistry.bitcoinTestnet.indexerBaseUrl) + assertEquals(20, UniversalWalletRegistry.bitcoinTestnet.defaultGapLimit) + assertEquals("BTC", UniversalWalletRegistry.bitcoinTestnet.nativeAsset.id) + assertEquals("BTC", UniversalWalletRegistry.bitcoinTestnet.nativeAsset.symbol) + assertEquals(8, UniversalWalletRegistry.bitcoinTestnet.nativeAsset.decimals) + assertFalse(UniversalWalletRegistry.bitcoinTestnet.enabledByDefault) + + assertEquals("solana-mainnet", UniversalWalletRegistry.solanaMainnet.id) + assertEquals("solana:mainnet", UniversalWalletRegistry.solanaMainnet.chainId) + assertEquals("Solana", UniversalWalletRegistry.solanaMainnet.name) + assertEquals("https://si.soramitsu.io", UniversalWalletRegistry.solanaMainnet.indexerBaseUrl) + assertEquals("https://api.mainnet-beta.solana.com", UniversalWalletRegistry.solanaMainnet.rpcUrl) + assertEquals("SOL", UniversalWalletRegistry.solanaMainnet.nativeAsset.id) + assertEquals("SOL", UniversalWalletRegistry.solanaMainnet.nativeAsset.symbol) + assertEquals(9, UniversalWalletRegistry.solanaMainnet.nativeAsset.decimals) + assertTrue(UniversalWalletRegistry.solanaMainnet.enabledByDefault) + + assertEquals("solana-devnet", UniversalWalletRegistry.solanaDevnet.id) + assertEquals("solana:devnet", UniversalWalletRegistry.solanaDevnet.chainId) + assertEquals("Solana Devnet", UniversalWalletRegistry.solanaDevnet.name) + assertEquals("https://si.soramitsu.io", UniversalWalletRegistry.solanaDevnet.indexerBaseUrl) + assertEquals("https://api.devnet.solana.com", UniversalWalletRegistry.solanaDevnet.rpcUrl) + assertEquals("SOL", UniversalWalletRegistry.solanaDevnet.nativeAsset.id) + assertEquals("SOL", UniversalWalletRegistry.solanaDevnet.nativeAsset.symbol) + assertEquals(9, UniversalWalletRegistry.solanaDevnet.nativeAsset.decimals) + assertFalse(UniversalWalletRegistry.solanaDevnet.enabledByDefault) + + assertEquals("taira-testnet", UniversalWalletRegistry.taira.id) + assertEquals("iroha3-taira", UniversalWalletRegistry.taira.chainId) + assertEquals(369, UniversalWalletRegistry.taira.chainDiscriminant) + assertEquals("https://taira.sora.org", UniversalWalletRegistry.taira.toriiBaseUrl) + assertEquals("/v1/mcp", UniversalWalletRegistry.taira.mcpPath) + assertTrue(UniversalWalletRegistry.taira.enabledByDefault) + + assertEquals("sora-nexus-mainnet", UniversalWalletRegistry.nexus.id) + assertEquals("sora:nexus:global", UniversalWalletRegistry.nexus.chainId) + assertEquals(753, UniversalWalletRegistry.nexus.chainDiscriminant) + assertEquals("https://minamoto.sora.org", UniversalWalletRegistry.nexus.toriiBaseUrl) + assertEquals("/v1/mcp", UniversalWalletRegistry.nexus.mcpPath) + assertFalse(UniversalWalletRegistry.nexus.enabledByDefault) + } + + @Test + fun `derivation constants match fixture defaults`() { + val vector = loadFixture().getAsJsonArray("vectors").first().asJsonObject.getAsJsonObject("expected") + + assertEquals(UniversalWalletDerivationPaths.SUBSTRATE_ROOT, vector.getAsJsonObject("substrate")["derivationPath"].asString) + assertEquals(UniversalWalletDerivationPaths.EVM_DEFAULT, vector.getAsJsonObject("evm")["derivationPath"].asString) + assertEquals(UniversalWalletDerivationPaths.BITCOIN_MAINNET_ACCOUNT, vector.getAsJsonObject("bitcoin").getAsJsonObject("mainnet")["accountPath"].asString) + assertEquals(UniversalWalletDerivationPaths.BITCOIN_MAINNET_FIRST_RECEIVE, vector.getAsJsonObject("bitcoin").getAsJsonObject("mainnet")["firstReceivePath"].asString) + assertEquals(UniversalWalletDerivationPaths.BITCOIN_TESTNET_ACCOUNT, vector.getAsJsonObject("bitcoin").getAsJsonObject("testnet")["accountPath"].asString) + assertEquals(UniversalWalletDerivationPaths.BITCOIN_TESTNET_FIRST_RECEIVE, vector.getAsJsonObject("bitcoin").getAsJsonObject("testnet")["firstReceivePath"].asString) + assertEquals(UniversalWalletDerivationPaths.SOLANA_DEFAULT, vector.getAsJsonObject("solana")["derivationPath"].asString) + assertEquals(UniversalWalletDerivationPaths.TON_DEFAULT, vector.getAsJsonObject("ton")["derivationPath"].asString) + assertEquals(UniversalWalletDerivationPaths.IROHA_DEFAULT, vector.getAsJsonObject("iroha")["derivationPath"].asString) + } + + private fun loadFixture(): JsonObject { + val stream = javaClass.classLoader?.getResourceAsStream("universal-wallet-v2-vectors.json") + ?: error("universal-wallet-v2-vectors.json is missing from test resources") + + return stream.use { + JsonParser.parseReader(InputStreamReader(it)).asJsonObject + } + } + + private fun assertBitcoin(bitcoin: JsonObject) { + val mainnet = bitcoin.getAsJsonObject("mainnet") + val testnet = bitcoin.getAsJsonObject("testnet") + + assertEquals("m/84'/0'/0'", mainnet["accountPath"].asString) + assertEquals("m/84'/0'/0'/0/0", mainnet["firstReceivePath"].asString) + assertTrue(mainnet["accountXpub"].asString.startsWith("xpub")) + assertTrue(mainnet["firstReceiveAddress"].asString.startsWith("bc1q")) + + assertEquals("m/84'/1'/0'", testnet["accountPath"].asString) + assertEquals("m/84'/1'/0'/0/0", testnet["firstReceivePath"].asString) + assertTrue(testnet["accountXpub"].asString.startsWith("xpub")) + assertTrue(testnet["firstReceiveAddress"].asString.startsWith("tb1q")) + } + + private fun assertSolana(solana: JsonObject) { + assertEquals("m/44'/501'/0'/0'", solana["derivationPath"].asString) + assertHex(solana["publicKeyHex"].asString, 32) + assertTrue(BASE58_32_TO_44.matches(solana["address"].asString)) + } + + private fun assertTon(ton: JsonObject) { + assertEquals("m/44'/607'/0'/0'/0'", ton["derivationPath"].asString) + assertEquals("v4r2", ton["walletVersion"].asString) + assertEquals(0, ton["workchain"].asInt) + assertHex(ton["publicKeyHex"].asString, 32) + assertTrue(ton["addressBounceable"].asString.startsWith("EQ")) + assertTrue(ton["addressNonBounceable"].asString.startsWith("UQ")) + assertTrue(ton["testnetNonBounceable"].asString.startsWith("0Q")) + } + + private fun assertIroha(iroha: JsonObject) { + assertEquals("m/44'/617'/0'/0'", iroha["derivationPath"].asString) + + listOf("taira", "nexus").forEach { network -> + val address = iroha.getAsJsonObject(network) + assertHex(address["publicKeyHex"].asString, 32) + assertTrue(address["canonicalHex"].asString.startsWith("0x02000120")) + assertTrue(address["i105"].asString.isNotBlank()) + } + } + + private fun assertHex(value: String, byteLength: Int) { + assertTrue(Regex("^[0-9a-f]+$").matches(value)) + assertEquals(byteLength * 2, value.length) + } + + private companion object { + val UNIVERSAL_ECOSYSTEM_IDS = setOf("substrate", "evm", "ton", "bitcoin", "solana", "iroha") + val EVM_ADDRESS = Regex("^0x[0-9a-fA-F]{40}$") + val BASE58_32_TO_44 = Regex("^[1-9A-HJ-NP-Za-km-z]{32,44}$") + } +} diff --git a/common/src/test/resources/universal-wallet-v2-vectors.json b/common/src/test/resources/universal-wallet-v2-vectors.json new file mode 100644 index 0000000000..99fade5385 --- /dev/null +++ b/common/src/test/resources/universal-wallet-v2-vectors.json @@ -0,0 +1,147 @@ +{ + "version": 1, + "generatedAt": "2026-06-23", + "derivationSpec": "Fearless Universal Wallet V2", + "vectors": [ + { + "id": "import12", + "mnemonic": "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about", + "wordCount": 12, + "expected": { + "substrate": { + "derivationPath": "", + "cryptoType": "sr25519", + "publicKeyHex": "66933bd1f37070ef87bd1198af3dacceb095237f803f3d32b173e6b425ed7972", + "polkadotAddress": "13KVd4f2a4S5pLp4gTTFezyXdPWx27vQ9vS6xBXJ9yWVd7xo", + "ss58Prefix": 0 + }, + "evm": { + "derivationPath": "m/44'/60'/0'/0/0", + "address": "0x9858EfFD232B4033E47d90003D41EC34EcaEda94" + }, + "bitcoin": { + "mainnet": { + "accountPath": "m/84'/0'/0'", + "firstReceivePath": "m/84'/0'/0'/0/0", + "accountXpub": "xpub6CatWdiZiodmUeTDp8LT5or8nmbKNcuyvz7WyksVFkKB4RHwCD3XyuvPEbvqAQY3rAPshWcMLoP2fMFMKHPJ4ZeZXYVUhLv1VMrjPC7PW6V", + "firstReceiveAddress": "bc1qcr8te4kr609gcawutmrza0j4xv80jy8z306fyu" + }, + "testnet": { + "accountPath": "m/84'/1'/0'", + "firstReceivePath": "m/84'/1'/0'/0/0", + "accountXpub": "xpub6Bm9M1SxZdzL3TxdNV8897FgtTLBgehR1wVNnMyJ5VLRK5n3tFqXxrCVnVQj4zooN4eFSkf6Sma84reWc5ZCXMxPbLXQs3BcaBdTd4YQa3B", + "firstReceiveAddress": "tb1q6rz28mcfaxtmd6v789l9rrlrusdprr9pqcpvkl" + } + }, + "solana": { + "derivationPath": "m/44'/501'/0'/0'", + "publicKeyHex": "f036276246a75b9de3349ed42b15e232f6518fc20f5fcd4f1d64e81f9bd258f7", + "address": "HAgk14JpMQLgt6rVgv7cBQFJWFto5Dqxi472uT3DKpqk" + }, + "ton": { + "derivationPath": "m/44'/607'/0'/0'/0'", + "walletVersion": "v4r2", + "workchain": 0, + "publicKeyHex": "3b5dd9de5a5a4f6b17ecec17135772bea6b132ecaa9413d930aa0dcc75d29fa6", + "addressBounceable": "EQDxAUFadQXDd3EXGa3TLF_EF66gMc9h3_aZ0j0zXNoIYR1Z", + "addressNonBounceable": "UQDxAUFadQXDd3EXGa3TLF_EF66gMc9h3_aZ0j0zXNoIYUCc", + "testnetNonBounceable": "0QDxAUFadQXDd3EXGa3TLF_EF66gMc9h3_aZ0j0zXNoIYfsW" + }, + "iroha": { + "derivationPath": "m/44'/617'/0'/0'", + "taira": { + "publicKeyHex": "8dea60946dd00558e61254c7aadeccabd1e39d5b9a12a45b678e22209f19426f", + "canonicalHex": "0x020001208dea60946dd00558e61254c7aadeccabd1e39d5b9a12a45b678e22209f19426f", + "i105": "testu\uff9b1Pc\uff852\uff97t\uff89a\uff98L\uff95\uff7d2M\u30f1\uff90\uff8e\uff73\uff93\u30f1\uff77\uff86\uff72M\uff92S\uff8f\uff71\u30f1\uff77J\u30f1FmJ\uff87Ms6YN687Y", + "chainDiscriminant": 369 + }, + "nexus": { + "publicKeyHex": "8dea60946dd00558e61254c7aadeccabd1e39d5b9a12a45b678e22209f19426f", + "canonicalHex": "0x020001208dea60946dd00558e61254c7aadeccabd1e39d5b9a12a45b678e22209f19426f", + "i105": "sorau\uff9b1Pc\uff852\uff97t\uff89a\uff98L\uff95\uff7d2M\u30f1\uff90\uff8e\uff73\uff93\u30f1\uff77\uff86\uff72M\uff92S\uff8f\uff71\u30f1\uff77J\u30f1FmJ\uff87Ms6YN687Y", + "chainDiscriminant": 753 + } + } + } + }, + { + "id": "default24", + "mnemonic": "abandon amount liar amount expire adjust cage candy arch gather drum bullet absurd math era live bid rhythm alien crouch range attend journey unaware", + "wordCount": 24, + "expected": { + "substrate": { + "derivationPath": "", + "cryptoType": "sr25519", + "publicKeyHex": "4a50a9606f3b0c47e0582f9a2dce9da3ec59819c6e15468f6f03f07e7fcfed23", + "polkadotAddress": "12gSWHjzeFyd2aSLVCVjByvQ8DVXSVWzQV4jAdjpdzCjExyg", + "ss58Prefix": 0 + }, + "evm": { + "derivationPath": "m/44'/60'/0'/0/0", + "address": "0xF9297b542BDb5DA50C364f9AE4Cbe1F3933bA40F" + }, + "bitcoin": { + "mainnet": { + "accountPath": "m/84'/0'/0'", + "firstReceivePath": "m/84'/0'/0'/0/0", + "accountXpub": "xpub6BqBNbtUuW88D24ZcyjziejtCDfiqyXsAoXVAoHEQxmyNBxHqomJcqq8sufg8YbyD25ScJeHcENXxUeLPmW9sxt5xyd7Y1HvvpziHQEopcs", + "firstReceiveAddress": "bc1qslk39wvggqa0vl8nd6jckaz54dw3vk45c5w60m" + }, + "testnet": { + "accountPath": "m/84'/1'/0'", + "firstReceivePath": "m/84'/1'/0'/0/0", + "accountXpub": "xpub6CTZ93vw865e4ZwMboznYa8vnpCho3kAsqjK6WT5mY9c8yePzWKLAtGHV3ikjdLW1Yf72fmHdoT6bAeUeqVG1HZCENtwvNckQJtCxCuuox9", + "firstReceiveAddress": "tb1q2mhcnxyddvq4vxja3mg5t73uknyppfx2u4k54f" + } + }, + "solana": { + "derivationPath": "m/44'/501'/0'/0'", + "publicKeyHex": "41463818479793c75705f6984a7ddd22f83d3e968c5c2c036cec881addfd5cec", + "address": "5Pobwp6d9ihN9Nz38f87gVCEBFMgipFiSM2VtUhVit6w" + }, + "ton": { + "derivationPath": "m/44'/607'/0'/0'/0'", + "walletVersion": "v4r2", + "workchain": 0, + "publicKeyHex": "c1f91bfd0283d9c25f14488d8d252c8ce74fe05edcaf4f5c2f647f92a08af09c", + "addressBounceable": "EQAXzfXU_vaoDQkasgmNRC2gG0cU0bS83sV4bEXVswywW4Lx", + "addressNonBounceable": "UQAXzfXU_vaoDQkasgmNRC2gG0cU0bS83sV4bEXVswywW980", + "testnetNonBounceable": "0QAXzfXU_vaoDQkasgmNRC2gG0cU0bS83sV4bEXVswywW2S-" + }, + "iroha": { + "derivationPath": "m/44'/617'/0'/0'", + "taira": { + "publicKeyHex": "9a676c350154f7b7183c5b2f44a49f926a75967260840f43062afdcf4259b995", + "canonicalHex": "0x020001209a676c350154f7b7183c5b2f44a49f926a75967260840f43062afdcf4259b995", + "i105": "testu\uff9b1Pn4Z3\uff81e\uff78ZzXm\uff86Wdh\uff8cy\uff7e\uff93T\uff90T\uff86aU\uff9aS\uff991\uff76\uff9b\uff79C\uff77e8\uff73\uff8b154C93", + "chainDiscriminant": 369 + }, + "nexus": { + "publicKeyHex": "9a676c350154f7b7183c5b2f44a49f926a75967260840f43062afdcf4259b995", + "canonicalHex": "0x020001209a676c350154f7b7183c5b2f44a49f926a75967260840f43062afdcf4259b995", + "i105": "sorau\uff9b1Pn4Z3\uff81e\uff78ZzXm\uff86Wdh\uff8cy\uff7e\uff93T\uff90T\uff86aU\uff9aS\uff991\uff76\uff9b\uff79C\uff77e8\uff73\uff8b154C93", + "chainDiscriminant": 753 + } + } + } + } + ], + "negativeCases": [ + { + "id": "bitcoin-wrong-purpose", + "reason": "BIP44 m/44 paths must not match BIP84 native SegWit addresses." + }, + { + "id": "solana-wrong-path", + "reason": "m/44'/501'/0' must not match the default m/44'/501'/0'/0' account." + }, + { + "id": "iroha-wrong-discriminant", + "reason": "Taira discriminant 369 and Nexus discriminant 753 must not be accepted interchangeably." + }, + { + "id": "ton-testnet-flag", + "reason": "TON testnet-only address encoding must not be accepted as a mainnet receive address." + } + ] +} diff --git a/config/todo-debt-baseline.tsv b/config/todo-debt-baseline.tsv new file mode 100644 index 0000000000..5a923aa142 --- /dev/null +++ b/config/todo-debt-baseline.tsv @@ -0,0 +1,48 @@ +app/src/main/java/jp/co/soramitsu/app/root/presentation/RootViewModel.kt // todo this code triggers redundant requests and balance updates. Needs research +buildSrc/src/main/kotlin/detekt-setup.gradle.kts // TODO: Remove exclude paths after merge detekt to develop +common/src/main/java/jp/co/soramitsu/common/base/errors/FearlessException.kt return FearlessException(Kind.NETWORK, "", throwable) // TODO: add common error text to resources +common/src/main/java/jp/co/soramitsu/common/base/errors/FearlessException.kt return FearlessException(Kind.UNEXPECTED, exception.message ?: "", exception) // TODO: add common error text to resources +common/src/main/java/jp/co/soramitsu/common/data/network/rpc/SocketSingleRequestExecutor.kt // TODO this is not being used anywhere. Can we delete it? +common/src/main/java/jp/co/soramitsu/common/data/network/ton/AccountEvent.kt /* TODO */ +common/src/main/java/jp/co/soramitsu/common/data/storage/PreferencesImpl.kt which cause listener to be GC-ed (TODO - research why). +common/src/main/java/jp/co/soramitsu/common/utils/Base58Ext.kt // TODO make Base58 static in fearless utils +common/src/main/java/jp/co/soramitsu/common/utils/KoltinExt.kt // TODO possible to optimize +feature-account-api/src/main/java/jp/co/soramitsu/account/api/domain/model/Account.kt val cryptoType: CryptoType, // TODO make optional +feature-account-api/src/main/java/jp/co/soramitsu/account/api/presentation/account/AddressDisplayUseCase.kt // TODO adopt for meta account logic +feature-account-impl/src/main/java/jp/co/soramitsu/account/impl/data/repository/datasource/AccountDataSource.kt // TODO for compatibility only +feature-account-impl/src/main/java/jp/co/soramitsu/account/impl/domain/AssetNotNeedAccountUseCaseImpl.kt // todo make better way to check that we support price provider +feature-account-impl/src/main/java/jp/co/soramitsu/account/impl/domain/TotalBalanceUseCaseImpl.kt // todo I did this workaround because sometimes there is a wrong symbol in asset list. Need research +feature-account-impl/src/main/java/jp/co/soramitsu/account/impl/presentation/options_switch_node/OptionsSwitchNodeContent.kt // TODO temporarily hide this button +feature-crowdloan-impl/src/main/java/jp/co/soramitsu/crowdloan/impl/domain/contribute/CrowdloanContributeInteractor.kt val accountId = selectedMetaAccount.accountId(chain)!! // TODO optional for ethereum chains +feature-crowdloan-impl/src/main/java/jp/co/soramitsu/crowdloan/impl/domain/contribute/custom/karura/KaruraContributeInteractor.kt // TODO crowdloan +feature-crowdloan-impl/src/main/java/jp/co/soramitsu/crowdloan/impl/presentation/contribute/custom/CustomContributeViewModel.kt // todo rework with dagger separate validate systems +feature-staking-impl/src/main/java/jp/co/soramitsu/staking/impl/data/network/blockhain/calls/ExtrinsicBuilderExt.kt // TODO rename to createPool when polkadot runtime upgrades to 9390 +feature-staking-impl/src/main/java/jp/co/soramitsu/staking/impl/data/repository/StakingConstantsRepository.kt // todo need research +feature-staking-impl/src/main/java/jp/co/soramitsu/staking/impl/data/repository/StakingPoolApi.kt // todo temporary fix until all runtimes will be updated +feature-staking-impl/src/main/java/jp/co/soramitsu/staking/impl/domain/rewards/SoraStakingRewardsScenario.kt // todo make better way to check that we support price provider +feature-staking-impl/src/main/java/jp/co/soramitsu/staking/impl/presentation/collators/change/custom/select/SelectCustomCollatorsViewModel.kt // todo do we need apply selected collators when moving back? +feature-staking-impl/src/main/java/jp/co/soramitsu/staking/impl/presentation/common/SetupStakingSharedState.kt // is Payload.Collators -> SelectBlockProducersStep.Payload.Collators //todo +feature-staking-impl/src/main/java/jp/co/soramitsu/staking/impl/presentation/confirm/ConfirmStakingViewModel.kt is StakingState.Parachain.Collator -> 0 // todo add collators support +feature-staking-impl/src/main/java/jp/co/soramitsu/staking/impl/presentation/mappers/Validator.kt // FIXME Wrong logic for isOversubscribed & isSlashed - should not require elected state/nominator info +feature-staking-impl/src/main/java/jp/co/soramitsu/staking/impl/presentation/payouts/detail/PayoutDetailsFragment.kt // TODO perform date formatting in viewModel +feature-staking-impl/src/main/java/jp/co/soramitsu/staking/impl/presentation/setup/pool/StartStakingPoolViewModel.kt // todo hardcoded returns for demo +feature-staking-impl/src/main/java/jp/co/soramitsu/staking/impl/presentation/staking/bond/select/SelectBondMoreViewModel.kt // todo don't do like this, we must create a reversed formatter from String to BigDecimal +feature-staking-impl/src/main/java/jp/co/soramitsu/staking/impl/presentation/staking/main/StakingViewModel.kt if (selection.type != StakingType.POOL) return@map null // todo it's a stub +feature-staking-impl/src/main/java/jp/co/soramitsu/staking/impl/presentation/staking/main/StakingViewModel.kt val networkInfoState: StakingAssetInfoViewState?, // todo shouldn't be nullable - it's just a stub +feature-staking-impl/src/main/java/jp/co/soramitsu/staking/impl/presentation/staking/main/StakingViewStateOld.kt ): Pair { // todo fix +feature-staking-impl/src/main/java/jp/co/soramitsu/staking/impl/presentation/staking/main/StakingViewStateOld.kt // todo stub +feature-staking-impl/src/main/java/jp/co/soramitsu/staking/impl/presentation/staking/main/compose/StakingAssetInfoViewState.kt val stories: String, // todo stories view state +feature-staking-impl/src/main/java/jp/co/soramitsu/staking/impl/presentation/staking/main/scenarios/StakingPoolViewModel.kt // todo hardcoded returns for demo +feature-staking-impl/src/main/java/jp/co/soramitsu/staking/impl/scenarios/parachain/StakingParachainScenarioInteractor.kt // todo move to overrides parameter of chain_type.json +feature-staking-impl/src/main/java/jp/co/soramitsu/staking/impl/scenarios/relaychain/StakingRelayChainScenarioInteractor.kt val accountId = metaAccount.accountId(chain)!! // TODO may be null for ethereum chains +feature-wallet-api/src/main/java/jp/co/soramitsu/wallet/api/data/cache/AssetCache.kt // todo make better way to check that we support price provider +feature-wallet-impl/src/main/java/jp/co/soramitsu/wallet/impl/data/historySource/GiantsquidHistorySource.kt // todo complete history parse +feature-wallet-impl/src/main/java/jp/co/soramitsu/wallet/impl/data/network/blockchain/WssSubstrateSource.kt val dest = when (typeRegistry["Address"]) { // this logic was added to support Moonbeam/Moonriver chains; todo separate assets in json like orml +feature-wallet-impl/src/main/java/jp/co/soramitsu/wallet/impl/data/network/blockchain/balance/SubstrateBalanceLoader.kt chainAssetId = chain.utilityAsset?.id.orEmpty(), // TODO do not hardcode chain asset id +feature-wallet-impl/src/main/java/jp/co/soramitsu/wallet/impl/data/repository/tranfser/TonTransferService.kt val sendMode = 3 // todo //if (max && isTon) (TonSendMode.CARRY_ALL_REMAINING_BALANCE.value + TonSendMode.IGNORE_ERRORS.value) else (TonSendMode.PAY_GAS_SEPARATELY.value + TonSendMode.IGNORE_ERRORS.value) +feature-wallet-impl/src/main/java/jp/co/soramitsu/wallet/impl/domain/XcmInteractor.kt // todo remove this sora ksm check when https://github.com/sora-xor/sora2-network/issues/845 will be fixed +feature-wallet-impl/src/main/java/jp/co/soramitsu/wallet/impl/presentation/balance/list/BalanceListViewModel.kt // todo check if it's OK to use the amount from QR code for evm chains/assets +feature-wallet-impl/src/main/java/jp/co/soramitsu/wallet/impl/presentation/balance/list/BalanceListViewModel.kt // todo move error state to the ndt list screen +feature-wallet-impl/src/main/java/jp/co/soramitsu/wallet/impl/presentation/beacon/main/BeaconViewModel.kt // todo hardcoded westend +feature-wallet-impl/src/main/java/jp/co/soramitsu/wallet/impl/presentation/beacon/main/BeaconViewModel.kt // todo think about multiple networks +feature-wallet-impl/src/main/res/layout/item_manage_asset.xml diff --git a/core-api/build.gradle b/core-api/build.gradle index f5779555c5..027051a49b 100644 --- a/core-api/build.gradle +++ b/core-api/build.gradle @@ -24,6 +24,11 @@ android { } dependencies { - implementation libs.coroutines.core - implementation libs.sharedFeaturesCoreDep + api libs.coroutines.core + api libs.kotlinx.serialization.json + api libs.sharedFeaturesCoreDep + + implementation libs.gson + + testImplementation libs.junit } diff --git a/core-api/src/main/java/jp/co/soramitsu/core/crypto/CryptoMappers.kt b/core-api/src/main/java/jp/co/soramitsu/core/crypto/CryptoMappers.kt new file mode 100644 index 0000000000..ca8b129a94 --- /dev/null +++ b/core-api/src/main/java/jp/co/soramitsu/core/crypto/CryptoMappers.kt @@ -0,0 +1,20 @@ +package jp.co.soramitsu.core.crypto + +import jp.co.soramitsu.core.models.CryptoType +import jp.co.soramitsu.fearless_utils.encrypt.EncryptionType + +fun mapCryptoTypeToEncryption(cryptoType: CryptoType): EncryptionType { + return when (cryptoType) { + CryptoType.SR25519 -> EncryptionType.SR25519 + CryptoType.ED25519 -> EncryptionType.ED25519 + CryptoType.ECDSA -> EncryptionType.ECDSA + } +} + +fun mapEncryptionToCryptoType(encryptionType: EncryptionType): CryptoType { + return when (encryptionType) { + EncryptionType.SR25519 -> CryptoType.SR25519 + EncryptionType.ED25519 -> CryptoType.ED25519 + EncryptionType.ECDSA -> CryptoType.ECDSA + } +} diff --git a/core-api/src/main/java/jp/co/soramitsu/core/extrinsic/ExtrinsicService.kt b/core-api/src/main/java/jp/co/soramitsu/core/extrinsic/ExtrinsicService.kt new file mode 100644 index 0000000000..f66d246997 --- /dev/null +++ b/core-api/src/main/java/jp/co/soramitsu/core/extrinsic/ExtrinsicService.kt @@ -0,0 +1,244 @@ +package jp.co.soramitsu.core.extrinsic + +import jp.co.soramitsu.core.crypto.mapCryptoTypeToEncryption +import jp.co.soramitsu.core.extrinsic.keypair_provider.KeypairProvider +import jp.co.soramitsu.core.extrinsic.mortality.Mortality +import jp.co.soramitsu.core.extrinsic.mortality.MortalityConstructor +import jp.co.soramitsu.core.models.IChain +import jp.co.soramitsu.core.rpc.RpcCalls +import jp.co.soramitsu.core.rpc.normalizeAuthorStatusBlockHash +import jp.co.soramitsu.core.runtime.IChainRegistry +import jp.co.soramitsu.fearless_utils.encrypt.MultiChainEncryption +import jp.co.soramitsu.fearless_utils.encrypt.EncryptionType +import jp.co.soramitsu.fearless_utils.encrypt.Signer +import jp.co.soramitsu.fearless_utils.encrypt.keypair.BaseKeypair +import jp.co.soramitsu.fearless_utils.encrypt.keypair.Keypair +import jp.co.soramitsu.fearless_utils.extensions.fromHex +import jp.co.soramitsu.fearless_utils.extensions.toHexString +import jp.co.soramitsu.fearless_utils.hash.Hasher.blake2b256 +import jp.co.soramitsu.fearless_utils.runtime.definitions.types.instances.AddressInstanceConstructor +import jp.co.soramitsu.fearless_utils.runtime.extrinsic.ExtrinsicBuilder +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.mapNotNull +import kotlinx.coroutines.withTimeoutOrNull +import java.math.BigInteger + +private val DEFAULT_TIP = BigInteger.ZERO +private val DEFAULT_FEE_ACCOUNT_ID = ByteArray(32) +private val DEFAULT_FEE_KEYPAIR = BaseKeypair(ByteArray(32) { 1 }, ByteArray(32)) +private const val WATCH_TIMEOUT_MILLIS = 120_000L + +class ExtrinsicBuilderFactory( + private val rpcCalls: RpcCalls, + private val chainRegistry: IChainRegistry, + private val mortalityConstructor: MortalityConstructor +) { + suspend fun createForSubmit( + chain: IChain, + accountId: ByteArray, + keypairProvider: KeypairProvider, + tip: BigInteger?, + appId: BigInteger? + ): ExtrinsicBuilder { + val cryptoType = keypairProvider.getCryptoTypeFor(chain, accountId) + val keypair = keypairProvider.getKeypairFor(chain, accountId) + + return create( + chain = chain, + accountId = accountId, + keypair = keypair, + encryption = mapCryptoTypeToEncryption(cryptoType), + nonce = rpcCalls.getAccountNonce(chain, accountId), + mortality = mortalityConstructor.construct(chain), + tip = tip, + appId = appId + ) + } + + suspend fun createForFee(chain: IChain): ExtrinsicBuilder { + return create( + chain = chain, + accountId = DEFAULT_FEE_ACCOUNT_ID, + keypair = DEFAULT_FEE_KEYPAIR, + encryption = EncryptionType.ED25519, + nonce = BigInteger.ZERO, + mortality = mortalityConstructor.construct(chain), + tip = null, + appId = null + ) + } + + suspend fun createForFee( + chain: IChain, + accountId: ByteArray, + keypairProvider: KeypairProvider, + tip: BigInteger?, + appId: BigInteger? + ): ExtrinsicBuilder { + val cryptoType = keypairProvider.getCryptoTypeFor(chain, accountId) + val keypair = keypairProvider.getKeypairFor(chain, accountId) + + return create( + chain = chain, + accountId = accountId, + keypair = keypair, + encryption = mapCryptoTypeToEncryption(cryptoType), + nonce = rpcCalls.getAccountNonce(chain, accountId), + mortality = mortalityConstructor.construct(chain), + tip = tip, + appId = appId + ) + } + + private suspend fun create( + chain: IChain, + accountId: ByteArray, + keypair: Keypair, + encryption: EncryptionType, + nonce: BigInteger, + mortality: Mortality, + tip: BigInteger?, + appId: BigInteger? + ): ExtrinsicBuilder { + val runtime = chainRegistry.getRuntime(chain.id) + + return ExtrinsicBuilder( + runtime = runtime, + keypair = keypair, + nonce = nonce, + runtimeVersion = rpcCalls.getRuntimeVersion(chain.id), + genesisHash = chain.id.fromHex(), + multiChainEncryption = MultiChainEncryption.Substrate(encryption), + accountIdentifier = AddressInstanceConstructor.constructInstance(runtime.typeRegistry, accountId), + blockHash = mortality.blockHash, + era = mortality.era, + tip = tip ?: DEFAULT_TIP, + appId = appId + ) + } +} + +class ExtrinsicService( + private val rpcCalls: RpcCalls, + private val keypairProvider: KeypairProvider, + private val extrinsicBuilderFactory: ExtrinsicBuilderFactory +) { + fun createSignature( + encryption: EncryptionType, + keypair: Keypair, + message: String + ): String { + val signatureWrapper = Signer.sign( + MultiChainEncryption.Substrate(encryption), + message.fromHex(), + keypair + ) + + val bytes = byteArrayOf(1) + signatureWrapper.signature + + return bytes.toHexString(withPrefix = true) + } + + suspend fun submitExtrinsic( + chain: IChain, + accountId: ByteArray, + useBatchAll: Boolean = false, + tip: BigInteger? = null, + appId: BigInteger? = null, + formExtrinsic: suspend ExtrinsicBuilder.() -> Unit + ): Result = runCatching { + val extrinsic = buildSubmitExtrinsic(chain, accountId, useBatchAll, tip, appId, formExtrinsic) + rpcCalls.submitExtrinsic(chain.id, extrinsic) + } + + suspend fun estimateFee( + chain: IChain, + useBatchAll: Boolean = false, + formExtrinsic: suspend ExtrinsicBuilder.() -> Unit + ): BigInteger { + val extrinsic = buildFeeExtrinsic(chain, useBatchAll, formExtrinsic) + return rpcCalls.estimateExtrinsicFee(chain.id, extrinsic) + } + + suspend fun estimateFee( + chain: IChain, + accountId: ByteArray, + useBatchAll: Boolean = false, + tip: BigInteger? = null, + appId: BigInteger? = null, + formExtrinsic: suspend ExtrinsicBuilder.() -> Unit + ): BigInteger { + val extrinsic = buildFeeExtrinsic(chain, accountId, useBatchAll, tip, appId, formExtrinsic) + return rpcCalls.estimateExtrinsicFee(chain.id, extrinsic) + } + + suspend fun submitAndWatchExtrinsic( + chain: IChain, + accountId: ByteArray, + useBatchAll: Boolean = false, + tip: BigInteger? = null, + appId: BigInteger? = null, + formExtrinsic: suspend ExtrinsicBuilder.() -> Unit + ): Pair? = runCatching { + val extrinsic = buildSubmitExtrinsic(chain, accountId, useBatchAll, tip, appId, formExtrinsic) + val txHash = extrinsic.fromHex().blake2b256().toHexString(withPrefix = true) + val blockHash = withTimeoutOrNull(WATCH_TIMEOUT_MILLIS) { + rpcCalls.extrinsicStatusFlow(chain.id, extrinsic) + .mapNotNull { normalizeAuthorStatusBlockHash(it.params.result) } + .first() + } + + blockHash?.let { txHash to it } + }.getOrNull() + + private suspend fun buildSubmitExtrinsic( + chain: IChain, + accountId: ByteArray, + useBatchAll: Boolean, + tip: BigInteger?, + appId: BigInteger?, + formExtrinsic: suspend ExtrinsicBuilder.() -> Unit + ): String { + val builder = extrinsicBuilderFactory.createForSubmit( + chain = chain, + accountId = accountId, + keypairProvider = keypairProvider, + tip = tip, + appId = appId + ) + builder.formExtrinsic() + + return builder.build(useBatchAll) + } + + private suspend fun buildFeeExtrinsic( + chain: IChain, + useBatchAll: Boolean, + formExtrinsic: suspend ExtrinsicBuilder.() -> Unit + ): String { + val builder = extrinsicBuilderFactory.createForFee(chain) + builder.formExtrinsic() + + return builder.build(useBatchAll) + } + + private suspend fun buildFeeExtrinsic( + chain: IChain, + accountId: ByteArray, + useBatchAll: Boolean, + tip: BigInteger?, + appId: BigInteger?, + formExtrinsic: suspend ExtrinsicBuilder.() -> Unit + ): String { + val builder = extrinsicBuilderFactory.createForFee( + chain = chain, + accountId = accountId, + keypairProvider = keypairProvider, + tip = tip, + appId = appId + ) + builder.formExtrinsic() + + return builder.build(useBatchAll) + } +} diff --git a/core-api/src/main/java/jp/co/soramitsu/core/extrinsic/keypair_provider/KeypairProvider.kt b/core-api/src/main/java/jp/co/soramitsu/core/extrinsic/keypair_provider/KeypairProvider.kt new file mode 100644 index 0000000000..640e55a287 --- /dev/null +++ b/core-api/src/main/java/jp/co/soramitsu/core/extrinsic/keypair_provider/KeypairProvider.kt @@ -0,0 +1,20 @@ +package jp.co.soramitsu.core.extrinsic.keypair_provider + +import jp.co.soramitsu.core.models.CryptoType +import jp.co.soramitsu.core.models.IChain +import jp.co.soramitsu.fearless_utils.encrypt.keypair.Keypair + +interface KeypairProvider { + suspend fun getCryptoTypeFor(chain: IChain, accountId: ByteArray): CryptoType + + suspend fun getKeypairFor(chain: IChain, accountId: ByteArray): Keypair +} + +class SingleKeypairProvider( + private val keypair: Keypair, + private val cryptoType: CryptoType +) : KeypairProvider { + override suspend fun getCryptoTypeFor(chain: IChain, accountId: ByteArray): CryptoType = cryptoType + + override suspend fun getKeypairFor(chain: IChain, accountId: ByteArray): Keypair = keypair +} diff --git a/core-api/src/main/java/jp/co/soramitsu/core/extrinsic/mortality/Mortality.kt b/core-api/src/main/java/jp/co/soramitsu/core/extrinsic/mortality/Mortality.kt new file mode 100644 index 0000000000..7c7dd37f89 --- /dev/null +++ b/core-api/src/main/java/jp/co/soramitsu/core/extrinsic/mortality/Mortality.kt @@ -0,0 +1,118 @@ +package jp.co.soramitsu.core.extrinsic.mortality + +import jp.co.soramitsu.core.models.ChainId +import jp.co.soramitsu.core.models.IChain +import jp.co.soramitsu.core.rpc.RpcCalls +import jp.co.soramitsu.core.rpc.normalizeRpcBlockHash +import jp.co.soramitsu.fearless_utils.extensions.fromHex +import jp.co.soramitsu.fearless_utils.runtime.definitions.types.generics.Era +import kotlinx.coroutines.flow.Flow +import java.math.BigInteger +import kotlin.math.max + +interface IChainStateRepository { + suspend fun expectedBlockTimeInMillis( + chainId: ChainId, + defaultTime: BigInteger = DEFAULT_BLOCK_TIME_MILLIS + ): BigInteger + + suspend fun blockHashCount(chainId: ChainId): BigInteger? + + suspend fun currentBlock(chainId: ChainId): BigInteger + + fun currentBlockNumberFlow(chainId: ChainId): Flow +} + +private val DEFAULT_BLOCK_TIME_MILLIS = BigInteger.valueOf(6_000) +private val MORTAL_PERIOD_MILLIS = BigInteger.valueOf(5 * 60 * 1_000) +private const val MIN_MORTAL_PERIOD_BLOCKS = 4 +private const val MAX_MORTAL_PERIOD_BLOCKS = 1 shl 16 + +class MortalityConstructor( + private val rpcCalls: RpcCalls, + private val chainStateRepository: IChainStateRepository +) { + suspend fun construct(chain: IChain): Mortality { + val genesisHash = chain.id.fromHex() + + return runCatching { + val currentBlock = chainStateRepository.currentBlock(chain.id) + val blockHash = rpcCalls.getBlockHash(chain.id, currentBlock) + + constructMortalEra( + currentBlock = currentBlock, + blockHashCount = chainStateRepository.blockHashCount(chain.id), + expectedBlockTimeInMillis = chainStateRepository.expectedBlockTimeInMillis(chain.id), + blockHash = blockHash + ) + }.getOrNull() ?: Mortality( + era = Era.Immortal, + blockHash = genesisHash + ) + } +} + +data class Mortality( + val era: Era, + val blockHash: ByteArray +) + +internal fun constructMortalEra( + currentBlock: BigInteger, + blockHashCount: BigInteger?, + expectedBlockTimeInMillis: BigInteger, + blockHash: String +): Mortality? { + if (currentBlock.signum() < 0 || expectedBlockTimeInMillis.signum() <= 0) return null + + val currentBlockInt = currentBlock.toIntOrNull() ?: return null + val periodInBlocks = mortalityPeriodInBlocks(expectedBlockTimeInMillis, blockHashCount) ?: return null + val normalizedBlockHash = runCatching { + normalizeRpcBlockHash(blockHash, "block hash") + }.getOrNull() ?: return null + + return Mortality( + era = Era.getEraFromBlockPeriod(currentBlockInt, periodInBlocks), + blockHash = normalizedBlockHash.fromHex() + ) +} + +private fun mortalityPeriodInBlocks( + expectedBlockTimeInMillis: BigInteger, + blockHashCount: BigInteger? +): Int? { + val targetBlocks = MORTAL_PERIOD_MILLIS + .divideCeil(expectedBlockTimeInMillis) + .coerceAtLeast(BigInteger.ONE) + .coerceAtMost(BigInteger.valueOf(MAX_MORTAL_PERIOD_BLOCKS.toLong())) + .toInt() + + var period = nextPowerOfTwo(targetBlocks) + .coerceIn(MIN_MORTAL_PERIOD_BLOCKS, MAX_MORTAL_PERIOD_BLOCKS) + + val maxPeriodFromHashCount = blockHashCount?.let { + if (it < BigInteger.valueOf(MIN_MORTAL_PERIOD_BLOCKS.toLong())) return null + it.coerceAtMost(BigInteger.valueOf(MAX_MORTAL_PERIOD_BLOCKS.toLong())).toInt() + } + + if (maxPeriodFromHashCount != null && period > maxPeriodFromHashCount) { + period = Integer.highestOneBit(maxPeriodFromHashCount) + if (period < MIN_MORTAL_PERIOD_BLOCKS) return null + } + + return period +} + +private fun BigInteger.divideCeil(divisor: BigInteger): BigInteger { + val (quotient, remainder) = divideAndRemainder(divisor) + return if (remainder.signum() == 0) quotient else quotient + BigInteger.ONE +} + +private fun nextPowerOfTwo(value: Int): Int { + if (value <= 1) return 1 + return max(1, Integer.highestOneBit(value - 1) shl 1) +} + +private fun BigInteger.toIntOrNull(): Int? { + return runCatching { intValueExact() }.getOrNull() +} diff --git a/core-api/src/main/java/jp/co/soramitsu/core/model/SecuritySource.kt b/core-api/src/main/java/jp/co/soramitsu/core/model/SecuritySource.kt index e48d64c29f..4af6f9c90e 100644 --- a/core-api/src/main/java/jp/co/soramitsu/core/model/SecuritySource.kt +++ b/core-api/src/main/java/jp/co/soramitsu/core/model/SecuritySource.kt @@ -1,6 +1,6 @@ package jp.co.soramitsu.core.model -import jp.co.soramitsu.shared_utils.encrypt.keypair.Keypair +import jp.co.soramitsu.fearless_utils.encrypt.keypair.Keypair sealed class SecuritySource( val keypair: Keypair diff --git a/core-api/src/main/java/jp/co/soramitsu/core/models/CoreModels.kt b/core-api/src/main/java/jp/co/soramitsu/core/models/CoreModels.kt new file mode 100644 index 0000000000..2b37b2d63c --- /dev/null +++ b/core-api/src/main/java/jp/co/soramitsu/core/models/CoreModels.kt @@ -0,0 +1,162 @@ +package jp.co.soramitsu.core.models + +import jp.co.soramitsu.fearless_utils.runtime.definitions.types.composite.DictEnum + +typealias ChainId = String + +const val SoraTestChainId = "3266816be9fa51b32cfea58d3e33ca77246bc9618595a4300e44c8856a8d8a17" +const val SoraMainChainId = "7e4e32d0feafd4f9c9414b0be86373f9a1efa904809b683453a9af6856d38ad5" + +enum class CryptoType { + SR25519, + ED25519, + ECDSA +} + +enum class Ecosystem { + Substrate, + EthereumBased, + Ethereum, + Ton; + + companion object { + fun fromString(value: String?): Ecosystem = entries.firstOrNull { + it.name.equals(value, ignoreCase = true) + } ?: Substrate + } +} + +enum class ChainAssetType { + Normal, + OrmlChain, + OrmlAsset, + ForeignAsset, + StableAssetPoolToken, + LiquidCrowdloan, + VToken, + VSToken, + Stable, + Equilibrium, + SoraAsset, + SoraUtilityAsset, + Assets, + AssetId, + Token2, + Xcm, + ERC20, + BEP20, + Jetton, + Unknown +} + +data class ChainNode( + val url: String, + val name: String, + val isActive: Boolean, + val isDefault: Boolean +) + +interface IChain { + val id: ChainId + val paraId: String? + val assets: List + val nodes: List + val addressPrefix: Int + val isEthereumBased: Boolean + val parentId: String? + val ecosystem: Ecosystem +} + +data class ChainIdWithMetadata( + val chainId: ChainId, + val metadata: String? +) + +data class Asset( + val id: String, + val name: String?, + val symbol: String, + val iconUrl: String, + val chainId: ChainId, + val chainName: String, + val chainIcon: String?, + val isTestNet: Boolean, + val priceId: String?, + val precision: Int, + val staking: StakingType, + val purchaseProviders: List?, + val supportStakingPool: Boolean, + val isUtility: Boolean, + val type: ChainAssetType?, + val currencyId: String?, + val existentialDeposit: String?, + val color: String?, + val isNative: Boolean?, + val priceProvider: PriceProvider? = null, + val coinbaseUrl: String? = null +) { + val currency: Any? + get() = currencyId?.let { parseCurrencyId(it) } + + val orderInStaking: Int + get() = when { + supportStakingPool -> 2 + staking == StakingType.RELAYCHAIN -> 0 + staking == StakingType.PARACHAIN -> 1 + else -> Int.MAX_VALUE + } + + val typeExtra: ChainAssetType? + get() = type + + enum class StakingType { + RELAYCHAIN, + PARACHAIN, + UNSUPPORTED + } + + data class PriceProvider( + val id: String, + val type: PriceProviderType, + val precision: Int = 0 + ) + + enum class PriceProviderType { + Chainlink, + Unknown + } +} + +private fun parseCurrencyId(currencyId: String): Any { + return currencyId.toBigIntegerOrNull() ?: currencyId +} + +sealed class MultiAddress(open val type: String, open val value: Any?) { + data class Id(val accountId: ByteArray) : MultiAddress(TYPE_ID, accountId) + data class Index(val index: Any?) : MultiAddress(TYPE_INDEX, index) + data class Raw(val bytes: ByteArray) : MultiAddress(TYPE_RAW, bytes) + data class Address32(val accountId: ByteArray) : MultiAddress(TYPE_ADDRESS32, accountId) + data class Address20(val accountId: ByteArray) : MultiAddress(TYPE_ADDRESS20, accountId) + + 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" + } +} + +fun bindMultiAddress(multiAddress: MultiAddress): DictEnum.Entry { + return when (multiAddress) { + is MultiAddress.Id -> DictEnum.Entry(MultiAddress.TYPE_ID, multiAddress.accountId) + is MultiAddress.Index -> DictEnum.Entry(MultiAddress.TYPE_INDEX, multiAddress.index) + is MultiAddress.Raw -> DictEnum.Entry(MultiAddress.TYPE_RAW, multiAddress.bytes) + is MultiAddress.Address32 -> DictEnum.Entry(MultiAddress.TYPE_ADDRESS32, multiAddress.accountId) + is MultiAddress.Address20 -> DictEnum.Entry(MultiAddress.TYPE_ADDRESS20, multiAddress.accountId) + } +} + +fun ChainId.isSoraBasedChain(): Boolean = this == SoraMainChainId || this == SoraTestChainId + +fun IChain.isSoraBasedChain(): Boolean = id.isSoraBasedChain() diff --git a/core-api/src/main/java/jp/co/soramitsu/core/models/remote/PriceProvider.kt b/core-api/src/main/java/jp/co/soramitsu/core/models/remote/PriceProvider.kt new file mode 100644 index 0000000000..08fca65a95 --- /dev/null +++ b/core-api/src/main/java/jp/co/soramitsu/core/models/remote/PriceProvider.kt @@ -0,0 +1,7 @@ +package jp.co.soramitsu.core.models.remote + +data class PriceProvider( + val id: String, + val type: String, + val precision: Int = 0 +) diff --git a/core-api/src/main/java/jp/co/soramitsu/core/network/JsonFactory.kt b/core-api/src/main/java/jp/co/soramitsu/core/network/JsonFactory.kt new file mode 100644 index 0000000000..f003a3dc69 --- /dev/null +++ b/core-api/src/main/java/jp/co/soramitsu/core/network/JsonFactory.kt @@ -0,0 +1,12 @@ +package jp.co.soramitsu.core.network + +import kotlinx.serialization.json.Json + +object JsonFactory { + fun create(): Json { + return Json { + ignoreUnknownKeys = true + isLenient = true + } + } +} diff --git a/core-api/src/main/java/jp/co/soramitsu/core/rpc/RpcCalls.kt b/core-api/src/main/java/jp/co/soramitsu/core/rpc/RpcCalls.kt new file mode 100644 index 0000000000..d615a46c81 --- /dev/null +++ b/core-api/src/main/java/jp/co/soramitsu/core/rpc/RpcCalls.kt @@ -0,0 +1,194 @@ +package jp.co.soramitsu.core.rpc + +import com.google.gson.Gson +import jp.co.soramitsu.core.models.IChain +import jp.co.soramitsu.core.runtime.IChainRegistry +import jp.co.soramitsu.fearless_utils.runtime.AccountId +import jp.co.soramitsu.fearless_utils.runtime.RuntimeSnapshot +import jp.co.soramitsu.fearless_utils.ss58.SS58Encoder.toAddress +import jp.co.soramitsu.fearless_utils.wsrpc.executeAsync +import jp.co.soramitsu.fearless_utils.wsrpc.mappers.ResponseMapper +import jp.co.soramitsu.fearless_utils.wsrpc.mappers.nonNull +import jp.co.soramitsu.fearless_utils.wsrpc.mappers.pojo +import jp.co.soramitsu.fearless_utils.wsrpc.mappers.string +import jp.co.soramitsu.fearless_utils.wsrpc.request.runtime.RuntimeRequest +import jp.co.soramitsu.fearless_utils.wsrpc.request.runtime.author.SubmitAndWatchExtrinsicRequest +import jp.co.soramitsu.fearless_utils.wsrpc.request.runtime.author.SubmitExtrinsicRequest +import jp.co.soramitsu.fearless_utils.wsrpc.request.runtime.chain.RuntimeVersion +import jp.co.soramitsu.fearless_utils.wsrpc.request.runtime.chain.RuntimeVersionRequest +import jp.co.soramitsu.fearless_utils.wsrpc.response.RpcResponse +import jp.co.soramitsu.fearless_utils.wsrpc.subscription.response.SubscriptionChange +import jp.co.soramitsu.fearless_utils.wsrpc.subscriptionFlow +import kotlinx.coroutines.flow.Flow +import java.math.BigDecimal +import java.math.BigInteger + +class RpcCalls( + private val chainRegistry: IChainRegistry +) { + companion object { + const val FINALIZED = "finalized" + const val IN_BLOCK = "inBlock" + const val DEFAULT_ASSETS_PAGE_SIZE = 100 + } + + suspend fun getRuntime(chainId: String): RuntimeSnapshot { + return chainRegistry.getRuntime(chainId) + } + + suspend fun getRuntimeVersion(chainId: String): RuntimeVersion { + return chainRegistry.getConnection(chainId).socketService.executeAsync( + RuntimeVersionRequest(), + mapper = pojo().nonNull() + ) + } + + suspend fun getAccountNonce(chain: IChain, accountId: AccountId): BigInteger { + return chainRegistry.getConnection(chain.id).socketService.executeAsync( + AccountNextIndexRequest(accountId.toAddress(chain.addressPrefix.toShort())), + mapper = RpcBigIntegerMapper("account nonce") + ) + } + + suspend fun getBlockHash(chainId: String, blockNumber: BigInteger): String { + return chainRegistry.getConnection(chainId).socketService.executeAsync( + ChainGetBlockHashRequest(blockNumber.longValueExact()), + mapper = BlockHashMapper + ) + } + + suspend fun estimateExtrinsicFee(chainId: String, extrinsic: String): BigInteger { + return chainRegistry.getConnection(chainId).socketService.executeAsync( + PaymentQueryInfoRequest(extrinsic), + mapper = PaymentInfoFeeMapper + ) + } + + suspend fun submitExtrinsic(chainId: String, extrinsic: String): String { + return chainRegistry.getConnection(chainId).socketService.executeAsync( + SubmitExtrinsicRequest(extrinsic), + mapper = string().nonNull() + ) + } + + fun extrinsicStatusFlow(chainId: String, extrinsic: String): Flow { + return chainRegistry.getConnection(chainId).socketService.subscriptionFlow( + SubmitAndWatchExtrinsicRequest(extrinsic) + ) + } +} + +private class AccountNextIndexRequest(accountAddress: String) : RuntimeRequest( + method = "system_accountNextIndex", + params = listOf(accountAddress) +) + +private class PaymentQueryInfoRequest(extrinsic: String) : RuntimeRequest( + method = "payment_queryInfo", + params = listOf(extrinsic) +) + +private class ChainGetBlockHashRequest(blockNumber: Long) : RuntimeRequest( + method = "chain_getBlockHash", + params = listOf(blockNumber) +) + +private class RpcBigIntegerMapper( + private val fieldName: String +) : ResponseMapper { + override fun map(rpcResponse: RpcResponse, jsonMapper: Gson): BigInteger { + return normalizeRpcBigInteger(rpcResponse.result, fieldName) + } +} + +private object PaymentInfoFeeMapper : ResponseMapper { + override fun map(rpcResponse: RpcResponse, jsonMapper: Gson): BigInteger { + return normalizePaymentPartialFee(rpcResponse.result) + } +} + +private object BlockHashMapper : ResponseMapper { + override fun map(rpcResponse: RpcResponse, jsonMapper: Gson): String { + return normalizeRpcBlockHash(rpcResponse.result, "block hash") + } +} + +internal fun normalizePaymentPartialFee(result: Any?): BigInteger { + val map = result as? Map<*, *> ?: throw IllegalArgumentException("Payment info result must be an object") + return normalizeRpcBigInteger(map["partialFee"], "partialFee") +} + +internal fun normalizeAuthorStatusBlockHash(result: Any?): String? { + val map = result as? Map<*, *> ?: return null + val blockHash = map[RpcCalls.FINALIZED] ?: map[RpcCalls.IN_BLOCK] ?: return null + return (blockHash as? String) + ?.takeIf { HEX_HASH_REGEX.matches(it) } +} + +internal fun normalizeRpcBlockHash(value: Any?, fieldName: String): String { + val hash = value as? String ?: throw IllegalArgumentException("$fieldName must be a 32-byte hex hash") + require(HEX_HASH_REGEX.matches(hash)) { "$fieldName must be a 32-byte hex hash" } + return hash +} + +internal fun normalizeRpcBigInteger(value: Any?, fieldName: String): BigInteger { + val parsed = when (value) { + is BigInteger -> value + is BigDecimal -> value.toBigIntegerExact() + is Byte, + is Short, + is Int, + is Long -> BigInteger.valueOf((value as Number).toLong()) + is Float, + is Double -> BigDecimal.valueOf((value as Number).toDouble()).toBigIntegerExact() + is String -> value.toBigIntegerFlexible(fieldName) + else -> throw IllegalArgumentException("$fieldName must be an unsigned integer") + } + + require(parsed.signum() >= 0) { "$fieldName must be an unsigned integer" } + + return parsed +} + +private fun String.toBigIntegerFlexible(fieldName: String): BigInteger { + val normalized = trim() + require(normalized.isNotEmpty()) { "$fieldName must be an unsigned integer" } + + return if (normalized.startsWith("0x", ignoreCase = true)) { + BigInteger(normalized.removePrefix("0x").removePrefix("0X"), 16) + } else { + BigInteger(normalized, 10) + } +} + +private val HEX_HASH_REGEX = Regex("^0x[0-9a-fA-F]{64}$") + +interface RuntimeCall { + val path: String + val args: ByteArray + + fun parseResult(resultBytes: ByteArray): T + + interface NominationPoolsApi : RuntimeCall { + class PendingRewards(accountId: AccountId) : NominationPoolsApi { + override val path = "NominationPoolsApi_pending_rewards" + override val args: ByteArray = accountId + + override fun parseResult(resultBytes: ByteArray): BigInteger { + return BigInteger(1, resultBytes) + } + } + } +} + +data class SignedBlock( + val block: Block +) { + data class Block( + val extrinsics: List + ) +} + +data class LiquidityProxyQuote( + val amount: BigInteger? +) diff --git a/core-api/src/main/java/jp/co/soramitsu/core/rpc/calls/RpcCallExtensions.kt b/core-api/src/main/java/jp/co/soramitsu/core/rpc/calls/RpcCallExtensions.kt new file mode 100644 index 0000000000..b668663858 --- /dev/null +++ b/core-api/src/main/java/jp/co/soramitsu/core/rpc/calls/RpcCallExtensions.kt @@ -0,0 +1,42 @@ +package jp.co.soramitsu.core.rpc.calls + +import jp.co.soramitsu.core.rpc.LiquidityProxyQuote +import jp.co.soramitsu.core.rpc.RpcCalls +import jp.co.soramitsu.core.rpc.RuntimeCall +import jp.co.soramitsu.core.rpc.SignedBlock +import java.math.BigInteger + +private const val PUBLIC_RPC_UNAVAILABLE = + "Substrate RPC calls are unavailable in the public compatibility layer" + +suspend fun RpcCalls.getExistentialDeposit( + chainId: String, + assetIdentifier: Pair +): BigInteger { + throw UnsupportedOperationException(PUBLIC_RPC_UNAVAILABLE) +} + +suspend fun RpcCalls.getBlock( + chainId: String, + blockHash: String +): SignedBlock { + throw UnsupportedOperationException(PUBLIC_RPC_UNAVAILABLE) +} + +suspend fun RpcCalls.executeRuntimeCall( + chainId: String, + call: RuntimeCall<*> +): Result = Result.failure(UnsupportedOperationException(PUBLIC_RPC_UNAVAILABLE)) + +suspend fun RpcCalls.liquidityProxyQuote( + chainId: String, + inputAssetId: String, + outputAssetId: String, + amount: BigInteger, + swapVariant: String, + selectedSourceTypes: List, + filterMode: String, + dexId: Int +): LiquidityProxyQuote? { + throw UnsupportedOperationException(PUBLIC_RPC_UNAVAILABLE) +} diff --git a/core-api/src/main/java/jp/co/soramitsu/core/runtime/ChainConnection.kt b/core-api/src/main/java/jp/co/soramitsu/core/runtime/ChainConnection.kt new file mode 100644 index 0000000000..b7305ffc3a --- /dev/null +++ b/core-api/src/main/java/jp/co/soramitsu/core/runtime/ChainConnection.kt @@ -0,0 +1,70 @@ +package jp.co.soramitsu.core.runtime + +import jp.co.soramitsu.core.models.ChainNode +import jp.co.soramitsu.core.models.IChain +import jp.co.soramitsu.fearless_utils.wsrpc.SocketService +import jp.co.soramitsu.fearless_utils.wsrpc.state.SocketStateMachine +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.filter +import kotlinx.coroutines.flow.map + +class ChainConnection( + val chain: IChain, + val socketService: SocketService, + initialNodes: List, + private val externalRequirementFlow: MutableStateFlow, + private val onSelectedNodeChange: (String) -> Unit, + private val isAutoBalanceEnabled: () -> Boolean +) { + enum class ExternalRequirement { + ALLOWED, + STOPPED, + FORBIDDEN + } + + private val nodesFlow = MutableStateFlow(initialNodes) + private val selectedNodeFlow = MutableStateFlow(initialNodes.firstOrNull()) + private val stateFlow = MutableStateFlow(SocketStateMachine.initialState()) + val state: StateFlow = stateFlow + val isConnected: Flow = stateFlow + .filter { it is SocketStateMachine.State.Connected } + .map { Unit } + + init { + socketService.addStateObserver { stateFlow.value = it } + } + + val nodes: List + get() = nodesFlow.value + + val selectedNode: ChainNode? + get() = selectedNodeFlow.value + + fun considerUpdateNodes(nodes: List) { + nodesFlow.value = nodes + if (selectedNodeFlow.value == null || selectedNodeFlow.value !in nodes) { + selectedNodeFlow.value = nodes.firstOrNull() + selectedNodeFlow.value?.url?.let(onSelectedNodeChange) + } + + if (state.value is SocketStateMachine.State.Connected && isAutoBalanceEnabled()) { + selectedNodeFlow.value?.url?.let(socketService::switchUrl) + } + } + + fun finish() { + socketService.stop() + } + + @Suppress("unused") + fun applyExternalRequirement(requirement: ExternalRequirement) { + externalRequirementFlow.value = requirement + when (requirement) { + ExternalRequirement.ALLOWED -> socketService.resume() + ExternalRequirement.STOPPED, + ExternalRequirement.FORBIDDEN -> socketService.pause() + } + } +} diff --git a/core-api/src/main/java/jp/co/soramitsu/core/runtime/IChainRegistry.kt b/core-api/src/main/java/jp/co/soramitsu/core/runtime/IChainRegistry.kt new file mode 100644 index 0000000000..a06e6716c3 --- /dev/null +++ b/core-api/src/main/java/jp/co/soramitsu/core/runtime/IChainRegistry.kt @@ -0,0 +1,15 @@ +package jp.co.soramitsu.core.runtime + +import jp.co.soramitsu.core.models.ChainId +import jp.co.soramitsu.core.models.IChain +import jp.co.soramitsu.fearless_utils.runtime.RuntimeSnapshot + +interface IChainRegistry { + suspend fun getChain(chainId: ChainId): IChain + + suspend fun getChains(): List + + fun getConnection(chainId: String): ChainConnection + + suspend fun getRuntime(chainId: ChainId): RuntimeSnapshot +} diff --git a/core-api/src/main/java/jp/co/soramitsu/core/runtime/RuntimeFactory.kt b/core-api/src/main/java/jp/co/soramitsu/core/runtime/RuntimeFactory.kt new file mode 100644 index 0000000000..1e46db7679 --- /dev/null +++ b/core-api/src/main/java/jp/co/soramitsu/core/runtime/RuntimeFactory.kt @@ -0,0 +1,147 @@ +package jp.co.soramitsu.core.runtime + +import jp.co.soramitsu.fearless_utils.runtime.RuntimeSnapshot +import jp.co.soramitsu.fearless_utils.runtime.definitions.TypeDefinitionParserV2 +import jp.co.soramitsu.fearless_utils.runtime.definitions.TypeDefinitionsTreeV2 +import jp.co.soramitsu.fearless_utils.runtime.definitions.dynamic.DynamicTypeResolver +import jp.co.soramitsu.fearless_utils.runtime.definitions.dynamic.extentsions.GenericsExtension +import jp.co.soramitsu.fearless_utils.runtime.definitions.registry.TypePreset +import jp.co.soramitsu.fearless_utils.runtime.definitions.registry.TypeRegistry +import jp.co.soramitsu.fearless_utils.runtime.definitions.registry.v13Preset +import jp.co.soramitsu.fearless_utils.runtime.definitions.registry.v14Preset +import jp.co.soramitsu.fearless_utils.runtime.definitions.v14.TypesParserV14 +import jp.co.soramitsu.fearless_utils.runtime.metadata.RuntimeMetadataReader +import jp.co.soramitsu.fearless_utils.runtime.metadata.builder.VersionedRuntimeBuilder +import jp.co.soramitsu.fearless_utils.runtime.metadata.v14.RuntimeMetadataSchemaV14 +import kotlinx.serialization.json.Json +import java.security.MessageDigest + +data class ConstructedRuntime( + val runtime: RuntimeSnapshot, + val metadataHash: String, + val ownTypesHash: String +) + +class RuntimeFactory( + private val json: Json +) { + fun constructRuntime(metadataRaw: String, ownTypesRaw: String, runtimeVersion: Int): ConstructedRuntime { + val metadataReader = RuntimeMetadataReader.read(metadataRaw) + val typeRegistry = if (metadataReader.metadataVersion < 14) { + buildV13Registry(ownTypesRaw, defaultTypesRaw = null, runtimeVersion = runtimeVersion) + } else { + buildV14Registry(metadataReader, ownTypesRaw, runtimeVersion) + } + + return ConstructedRuntime( + runtime = RuntimeSnapshot( + typeRegistry = typeRegistry, + metadata = VersionedRuntimeBuilder.buildMetadata(metadataReader, typeRegistry), + overrides = parseOverrides(ownTypesRaw) + ), + metadataHash = metadataRaw.md5(), + ownTypesHash = ownTypesRaw.md5() + ) + } + + fun constructRuntimeV13( + metadataRaw: String, + ownTypesRaw: String, + defaultTypesRaw: String, + runtimeVersion: Int + ): ConstructedRuntime { + val metadataReader = RuntimeMetadataReader.read(metadataRaw) + val typeRegistry = buildV13Registry( + ownTypesRaw = ownTypesRaw, + defaultTypesRaw = defaultTypesRaw, + runtimeVersion = runtimeVersion + ) + + return ConstructedRuntime( + runtime = RuntimeSnapshot( + typeRegistry = typeRegistry, + metadata = VersionedRuntimeBuilder.buildMetadata(metadataReader, typeRegistry), + overrides = parseOverrides(ownTypesRaw) + ), + metadataHash = metadataRaw.md5(), + ownTypesHash = ownTypesRaw.md5() + ) + } + + private fun buildV13Registry( + ownTypesRaw: String, + defaultTypesRaw: String?, + runtimeVersion: Int + ): TypeRegistry { + val basePreset = defaultTypesRaw?.let { raw -> + TypeDefinitionParserV2.parseBaseDefinitions(parseTree(raw), v13Preset()).typePreset + } ?: v13Preset() + + val networkPreset = parseNetworkTypes(ownTypesRaw, basePreset, runtimeVersion, upto14 = true) + + return TypeRegistry( + types = networkPreset, + dynamicTypeResolver = DynamicTypeResolver( + DynamicTypeResolver.DEFAULT_COMPOUND_EXTENSIONS + GenericsExtension + ) + ) + } + + private fun buildV14Registry( + metadataReader: RuntimeMetadataReader, + ownTypesRaw: String, + runtimeVersion: Int + ): TypeRegistry { + val parseResult = TypesParserV14.parse( + lookup = metadataReader.metadata[RuntimeMetadataSchemaV14.lookup], + typePreset = v14Preset() + ) + val networkPreset = parseNetworkTypes( + ownTypesRaw = ownTypesRaw, + basePreset = parseResult.typePreset, + runtimeVersion = runtimeVersion, + upto14 = false + ) + + return TypeRegistry( + types = networkPreset, + dynamicTypeResolver = DynamicTypeResolver.defaultCompoundResolver() + ) + } + + private fun parseNetworkTypes( + ownTypesRaw: String, + basePreset: TypePreset, + runtimeVersion: Int, + upto14: Boolean + ): TypePreset { + val tree = parseTree(ownTypesRaw) + + return if (tree.versioning == null) { + TypeDefinitionParserV2.parseBaseDefinitions(tree, basePreset).typePreset + } else { + TypeDefinitionParserV2.parseNetworkVersioning( + tree = tree, + typePreset = basePreset, + currentRuntimeVersion = tree.runtimeId ?: runtimeVersion, + upto14 = upto14 + ).typePreset + } + } + + private fun parseOverrides(raw: String): Map>? { + return parseTree(raw).overrides + ?.associate { item -> item.module to item.constants.associate { it.name to it.value } } + ?.takeIf { it.isNotEmpty() } + } + + private fun parseTree(raw: String): TypeDefinitionsTreeV2 { + return json.decodeFromString(TypeDefinitionsTreeV2.serializer(), raw) + } + + private fun String.md5(): String { + val hasher = MessageDigest.getInstance("MD5") + + return hasher.digest(encodeToByteArray()).decodeToString() + } +} diff --git a/core-api/src/main/java/jp/co/soramitsu/core/runtime/mappers/PriceProviderMappers.kt b/core-api/src/main/java/jp/co/soramitsu/core/runtime/mappers/PriceProviderMappers.kt new file mode 100644 index 0000000000..e9e343eef6 --- /dev/null +++ b/core-api/src/main/java/jp/co/soramitsu/core/runtime/mappers/PriceProviderMappers.kt @@ -0,0 +1,16 @@ +package jp.co.soramitsu.core.runtime.mappers + +import jp.co.soramitsu.core.models.Asset +import jp.co.soramitsu.core.models.remote.PriceProvider + +fun PriceProvider.mapRemoteToModel(): Asset.PriceProvider { + val providerType = Asset.PriceProviderType.entries.firstOrNull { + it.name.equals(type, ignoreCase = true) + } ?: Asset.PriceProviderType.Unknown + + return Asset.PriceProvider( + id = id, + type = providerType, + precision = precision + ) +} diff --git a/core-api/src/main/java/jp/co/soramitsu/core/runtime/models/requests/GetChildStateRequest.kt b/core-api/src/main/java/jp/co/soramitsu/core/runtime/models/requests/GetChildStateRequest.kt new file mode 100644 index 0000000000..9e17665685 --- /dev/null +++ b/core-api/src/main/java/jp/co/soramitsu/core/runtime/models/requests/GetChildStateRequest.kt @@ -0,0 +1,11 @@ +package jp.co.soramitsu.core.runtime.models.requests + +import jp.co.soramitsu.fearless_utils.wsrpc.request.runtime.RuntimeRequest + +class GetChildStateRequest( + storageKey: String, + childKey: String +) : RuntimeRequest( + method = "state_getChildStorage", + params = listOf(storageKey, childKey) +) diff --git a/core-api/src/main/java/jp/co/soramitsu/core/runtime/models/responses/QuoteResponse.kt b/core-api/src/main/java/jp/co/soramitsu/core/runtime/models/responses/QuoteResponse.kt new file mode 100644 index 0000000000..43b3611d87 --- /dev/null +++ b/core-api/src/main/java/jp/co/soramitsu/core/runtime/models/responses/QuoteResponse.kt @@ -0,0 +1,8 @@ +package jp.co.soramitsu.core.runtime.models.responses + +import java.math.BigInteger + +data class QuoteResponse( + val amount: BigInteger, + val route: List? = null +) diff --git a/core-api/src/main/java/jp/co/soramitsu/core/runtime/storage/StorageExtensions.kt b/core-api/src/main/java/jp/co/soramitsu/core/runtime/storage/StorageExtensions.kt new file mode 100644 index 0000000000..e376a2fd92 --- /dev/null +++ b/core-api/src/main/java/jp/co/soramitsu/core/runtime/storage/StorageExtensions.kt @@ -0,0 +1,12 @@ +package jp.co.soramitsu.core.runtime.storage + +import jp.co.soramitsu.fearless_utils.runtime.definitions.types.Type +import jp.co.soramitsu.fearless_utils.runtime.metadata.module.StorageEntry + +fun StorageEntry.returnType(): Type<*> { + return requireNotNull(type.value) { "Storage entry $moduleName.$name has no return type" } +} + +fun incompatible(): Nothing = throw IllegalStateException("Storage binding is incompatible") + +fun incompatible(message: String): Nothing = throw IllegalStateException(message) diff --git a/core-api/src/main/java/jp/co/soramitsu/core/updater/SubscriptionBuilder.kt b/core-api/src/main/java/jp/co/soramitsu/core/updater/SubscriptionBuilder.kt index a8d0d80d38..9bf58abd58 100644 --- a/core-api/src/main/java/jp/co/soramitsu/core/updater/SubscriptionBuilder.kt +++ b/core-api/src/main/java/jp/co/soramitsu/core/updater/SubscriptionBuilder.kt @@ -1,7 +1,7 @@ package jp.co.soramitsu.core.updater import jp.co.soramitsu.core.model.StorageChange -import jp.co.soramitsu.shared_utils.wsrpc.SocketService +import jp.co.soramitsu.fearless_utils.wsrpc.SocketService import kotlinx.coroutines.flow.Flow interface SubscriptionBuilder { diff --git a/core-api/src/main/java/jp/co/soramitsu/core/utils/AddressExtensions.kt b/core-api/src/main/java/jp/co/soramitsu/core/utils/AddressExtensions.kt new file mode 100644 index 0000000000..cbf34373e3 --- /dev/null +++ b/core-api/src/main/java/jp/co/soramitsu/core/utils/AddressExtensions.kt @@ -0,0 +1,23 @@ +package jp.co.soramitsu.core.utils + +import jp.co.soramitsu.core.models.Ecosystem +import jp.co.soramitsu.core.models.IChain +import jp.co.soramitsu.fearless_utils.ss58.SS58Encoder.toAccountId + +private val ETHEREUM_ADDRESS_REGEX = Regex("^0x[0-9a-fA-F]{40}$") +private val TON_RAW_ADDRESS_REGEX = Regex("^-?\\d+:[0-9a-fA-F]{64}$") +private val TON_USER_FRIENDLY_ADDRESS_REGEX = Regex("^[A-Za-z0-9_-]{48}$") + +fun IChain.isValidAddress(address: String): Boolean { + val normalized = address.trim() + if (normalized.isEmpty()) return false + + return when (ecosystem) { + Ecosystem.Substrate -> runCatching { normalized.toAccountId().size == SUBSTRATE_ACCOUNT_ID_SIZE }.getOrDefault(false) + Ecosystem.EthereumBased, + Ecosystem.Ethereum -> ETHEREUM_ADDRESS_REGEX.matches(normalized) + Ecosystem.Ton -> TON_RAW_ADDRESS_REGEX.matches(normalized) || TON_USER_FRIENDLY_ADDRESS_REGEX.matches(normalized) + } +} + +private const val SUBSTRATE_ACCOUNT_ID_SIZE = 32 diff --git a/core-api/src/main/java/jp/co/soramitsu/core/utils/AssetExtensions.kt b/core-api/src/main/java/jp/co/soramitsu/core/utils/AssetExtensions.kt new file mode 100644 index 0000000000..bdb0b88be5 --- /dev/null +++ b/core-api/src/main/java/jp/co/soramitsu/core/utils/AssetExtensions.kt @@ -0,0 +1,22 @@ +package jp.co.soramitsu.core.utils + +import java.math.BigDecimal +import java.math.BigInteger +import jp.co.soramitsu.core.models.Asset +import jp.co.soramitsu.core.models.ChainAssetType +import jp.co.soramitsu.core.models.IChain + +val IChain.utilityAsset: Asset? + get() = assets.firstOrNull { it.isUtility } ?: assets.firstOrNull { it.isNative == true } + +fun Asset.amountFromPlanks(amountInPlanks: BigInteger): BigDecimal { + return amountInPlanks.toBigDecimal(scale = precision) +} + +fun Asset.planksFromAmount(amount: BigDecimal): BigInteger { + return amount.scaleByPowerOfTen(precision).toBigInteger() +} + +fun Asset.isSoraUtilityAsset(): Boolean { + return type == ChainAssetType.SoraUtilityAsset +} diff --git a/core-api/src/main/java/jp/co/soramitsu/core/utils/AssetSymbolExtensions.kt b/core-api/src/main/java/jp/co/soramitsu/core/utils/AssetSymbolExtensions.kt new file mode 100644 index 0000000000..e3029562ee --- /dev/null +++ b/core-api/src/main/java/jp/co/soramitsu/core/utils/AssetSymbolExtensions.kt @@ -0,0 +1,11 @@ +package jp.co.soramitsu.core.utils + +fun String.removedXcPrefix(): String { + return if (startsWith(XC_PREFIX) && length > XC_PREFIX.length) { + removePrefix(XC_PREFIX) + } else { + this + } +} + +private const val XC_PREFIX = "xc" diff --git a/core-api/src/main/java/jp/co/soramitsu/core/utils/CollectionExtensions.kt b/core-api/src/main/java/jp/co/soramitsu/core/utils/CollectionExtensions.kt new file mode 100644 index 0000000000..3f2570d69d --- /dev/null +++ b/core-api/src/main/java/jp/co/soramitsu/core/utils/CollectionExtensions.kt @@ -0,0 +1,7 @@ +package jp.co.soramitsu.core.utils + +fun List.cycle(): Sequence { + var index = 0 + + return generateSequence { this[index++ % size] } +} diff --git a/core-api/src/main/java/jp/co/soramitsu/core/utils/NumberExtensions.kt b/core-api/src/main/java/jp/co/soramitsu/core/utils/NumberExtensions.kt new file mode 100644 index 0000000000..d00da4da3f --- /dev/null +++ b/core-api/src/main/java/jp/co/soramitsu/core/utils/NumberExtensions.kt @@ -0,0 +1,8 @@ +package jp.co.soramitsu.core.utils + +import java.math.BigDecimal +import java.math.BigInteger + +fun BigInteger?.orZero(): BigInteger = this ?: BigInteger.ZERO + +fun BigDecimal?.orZero(): BigDecimal = this ?: BigDecimal.ZERO diff --git a/core-api/src/test/java/jp/co/soramitsu/core/extrinsic/mortality/MortalityConstructorTest.kt b/core-api/src/test/java/jp/co/soramitsu/core/extrinsic/mortality/MortalityConstructorTest.kt new file mode 100644 index 0000000000..52cc260e7f --- /dev/null +++ b/core-api/src/test/java/jp/co/soramitsu/core/extrinsic/mortality/MortalityConstructorTest.kt @@ -0,0 +1,96 @@ +package jp.co.soramitsu.core.extrinsic.mortality + +import jp.co.soramitsu.fearless_utils.extensions.fromHex +import jp.co.soramitsu.fearless_utils.runtime.definitions.types.generics.Era +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 java.math.BigInteger + +class MortalityConstructorTest { + + @Test + fun `constructs five minute mortal era from current block and block hash`() { + val blockHash = "0x" + "11".repeat(32) + + val mortality = constructMortalEra( + currentBlock = BigInteger.valueOf(97_506), + blockHashCount = null, + expectedBlockTimeInMillis = BigInteger.valueOf(6_000), + blockHash = blockHash + ) + + val era = requireNotNull(mortality).era + + assertTrue(era is Era.Mortal) + era as Era.Mortal + assertEquals(64, era.period) + assertEquals(34, era.phase) + assertArrayEquals(blockHash.fromHex(), mortality.blockHash) + } + + @Test + fun `caps mortal period by block hash count`() { + val mortality = constructMortalEra( + currentBlock = BigInteger.TEN, + blockHashCount = BigInteger.valueOf(128), + expectedBlockTimeInMillis = BigInteger.valueOf(1_000), + blockHash = "0x" + "22".repeat(32) + ) + + val era = requireNotNull(mortality).era as Era.Mortal + + assertEquals(128, era.period) + assertEquals(10, era.phase) + } + + @Test + fun `rejects block hash count smaller than substrate minimum`() { + val mortality = constructMortalEra( + currentBlock = BigInteger.TEN, + blockHashCount = BigInteger.valueOf(3), + expectedBlockTimeInMillis = BigInteger.valueOf(6_000), + blockHash = "0x" + "33".repeat(32) + ) + + assertNull(mortality) + } + + @Test + fun `rejects non positive expected block time`() { + val mortality = constructMortalEra( + currentBlock = BigInteger.TEN, + blockHashCount = null, + expectedBlockTimeInMillis = BigInteger.ZERO, + blockHash = "0x" + "44".repeat(32) + ) + + assertNull(mortality) + } + + @Test + fun `rejects oversized current block numbers`() { + val mortality = constructMortalEra( + currentBlock = BigInteger.valueOf(Int.MAX_VALUE.toLong()) + BigInteger.ONE, + blockHashCount = null, + expectedBlockTimeInMillis = BigInteger.valueOf(6_000), + blockHash = "0x" + "55".repeat(32) + ) + + assertNull(mortality) + } + + @Test + fun `rejects malformed block hashes`() { + val mortality = constructMortalEra( + currentBlock = BigInteger.TEN, + blockHashCount = null, + expectedBlockTimeInMillis = BigInteger.valueOf(6_000), + blockHash = "0x1234" + ) + + assertNull(mortality) + } +} diff --git a/core-api/src/test/java/jp/co/soramitsu/core/rpc/RpcCallsTest.kt b/core-api/src/test/java/jp/co/soramitsu/core/rpc/RpcCallsTest.kt new file mode 100644 index 0000000000..df83a7e1c6 --- /dev/null +++ b/core-api/src/test/java/jp/co/soramitsu/core/rpc/RpcCallsTest.kt @@ -0,0 +1,102 @@ +package jp.co.soramitsu.core.rpc + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Assert.assertThrows +import org.junit.Test +import java.math.BigInteger + +class RpcCallsTest { + + @Test + fun `normalizes exact decimal payment fee strings`() { + val fee = normalizePaymentPartialFee( + mapOf("partialFee" to "123456789012345678901234567890") + ) + + assertEquals(BigInteger("123456789012345678901234567890"), fee) + } + + @Test + fun `normalizes hex integer rpc fields`() { + val value = normalizeRpcBigInteger("0x0100", "nonce") + + assertEquals(BigInteger("256"), value) + } + + @Test + fun `rejects missing payment fee`() { + assertThrows(IllegalArgumentException::class.java) { + normalizePaymentPartialFee(mapOf("weight" to 100)) + } + } + + @Test + fun `rejects negative rpc integers`() { + assertThrows(IllegalArgumentException::class.java) { + normalizeRpcBigInteger("-1", "nonce") + } + } + + @Test + fun `rejects fractional rpc integers`() { + assertThrows(ArithmeticException::class.java) { + normalizeRpcBigInteger(1.25, "nonce") + } + } + + @Test + fun `extracts finalized block hash before in block hash`() { + val finalizedHash = "0x" + "11".repeat(32) + val inBlockHash = "0x" + "22".repeat(32) + + val blockHash = normalizeAuthorStatusBlockHash( + mapOf( + RpcCalls.IN_BLOCK to inBlockHash, + RpcCalls.FINALIZED to finalizedHash + ) + ) + + assertEquals(finalizedHash, blockHash) + } + + @Test + fun `extracts in block hash when finalized hash is absent`() { + val inBlockHash = "0x" + "22".repeat(32) + + val blockHash = normalizeAuthorStatusBlockHash(mapOf(RpcCalls.IN_BLOCK to inBlockHash)) + + assertEquals(inBlockHash, blockHash) + } + + @Test + fun `ignores non terminal author statuses`() { + assertNull(normalizeAuthorStatusBlockHash(mapOf("ready" to null))) + } + + @Test + fun `rejects malformed author block hash`() { + assertNull(normalizeAuthorStatusBlockHash(mapOf(RpcCalls.FINALIZED to "0x1234"))) + } + + @Test + fun `normalizes 32 byte rpc block hashes`() { + val blockHash = "0x" + "33".repeat(32) + + assertEquals(blockHash, normalizeRpcBlockHash(blockHash, "block hash")) + } + + @Test + fun `rejects null rpc block hashes`() { + assertThrows(IllegalArgumentException::class.java) { + normalizeRpcBlockHash(null, "block hash") + } + } + + @Test + fun `rejects malformed rpc block hashes`() { + assertThrows(IllegalArgumentException::class.java) { + normalizeRpcBlockHash("0x1234", "block hash") + } + } +} diff --git a/core-db/schemas/jp.co.soramitsu.coredb.AppDatabase/76.json b/core-db/schemas/jp.co.soramitsu.coredb.AppDatabase/76.json new file mode 100644 index 0000000000..a7f93248ad --- /dev/null +++ b/core-db/schemas/jp.co.soramitsu.coredb.AppDatabase/76.json @@ -0,0 +1,1537 @@ +{ + "formatVersion": 1, + "database": { + "version": 76, + "identityHash": "fd40e319853eeab218d8e5b39137760f", + "entities": [ + { + "tableName": "address_book", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`address` TEXT NOT NULL, `name` TEXT, `chainId` TEXT NOT NULL, `created` INTEGER NOT NULL, `id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL)", + "fields": [ + { + "fieldPath": "address", + "columnName": "address", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT" + }, + { + "fieldPath": "chainId", + "columnName": "chainId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "created", + "columnName": "created", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_address_book_address_chainId", + "unique": true, + "columnNames": [ + "address", + "chainId" + ], + "orders": [], + "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_address_book_address_chainId` ON `${TABLE_NAME}` (`address`, `chainId`)" + } + ] + }, + { + "tableName": "assets", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `chainId` TEXT NOT NULL, `accountId` BLOB NOT NULL, `metaId` INTEGER NOT NULL, `tokenPriceId` TEXT, `freeInPlanks` TEXT, `reservedInPlanks` TEXT, `miscFrozenInPlanks` TEXT, `feeFrozenInPlanks` TEXT, `bondedInPlanks` TEXT, `redeemableInPlanks` TEXT, `unbondingInPlanks` TEXT, `sortIndex` INTEGER NOT NULL, `enabled` INTEGER, `markedNotNeed` INTEGER NOT NULL, `chainAccountName` TEXT, `status` TEXT, PRIMARY KEY(`id`, `chainId`, `accountId`, `metaId`), FOREIGN KEY(`chainId`) REFERENCES `chains`(`id`) ON UPDATE NO ACTION ON DELETE NO ACTION )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "chainId", + "columnName": "chainId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "accountId", + "columnName": "accountId", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "metaId", + "columnName": "metaId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "tokenPriceId", + "columnName": "tokenPriceId", + "affinity": "TEXT" + }, + { + "fieldPath": "freeInPlanks", + "columnName": "freeInPlanks", + "affinity": "TEXT" + }, + { + "fieldPath": "reservedInPlanks", + "columnName": "reservedInPlanks", + "affinity": "TEXT" + }, + { + "fieldPath": "miscFrozenInPlanks", + "columnName": "miscFrozenInPlanks", + "affinity": "TEXT" + }, + { + "fieldPath": "feeFrozenInPlanks", + "columnName": "feeFrozenInPlanks", + "affinity": "TEXT" + }, + { + "fieldPath": "bondedInPlanks", + "columnName": "bondedInPlanks", + "affinity": "TEXT" + }, + { + "fieldPath": "redeemableInPlanks", + "columnName": "redeemableInPlanks", + "affinity": "TEXT" + }, + { + "fieldPath": "unbondingInPlanks", + "columnName": "unbondingInPlanks", + "affinity": "TEXT" + }, + { + "fieldPath": "sortIndex", + "columnName": "sortIndex", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "enabled", + "columnName": "enabled", + "affinity": "INTEGER" + }, + { + "fieldPath": "markedNotNeed", + "columnName": "markedNotNeed", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "chainAccountName", + "columnName": "chainAccountName", + "affinity": "TEXT" + }, + { + "fieldPath": "status", + "columnName": "status", + "affinity": "TEXT" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id", + "chainId", + "accountId", + "metaId" + ] + }, + "indices": [ + { + "name": "index_assets_chainId", + "unique": false, + "columnNames": [ + "chainId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_assets_chainId` ON `${TABLE_NAME}` (`chainId`)" + }, + { + "name": "index_assets_metaId", + "unique": false, + "columnNames": [ + "metaId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_assets_metaId` ON `${TABLE_NAME}` (`metaId`)" + } + ], + "foreignKeys": [ + { + "table": "chains", + "onDelete": "NO ACTION", + "onUpdate": "NO ACTION", + "columns": [ + "chainId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "token_price", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`priceId` TEXT NOT NULL, `fiatSymbol` TEXT NOT NULL, `fiatRate` TEXT, `recentRateChange` TEXT, PRIMARY KEY(`priceId`))", + "fields": [ + { + "fieldPath": "priceId", + "columnName": "priceId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "fiatSymbol", + "columnName": "fiatSymbol", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "fiatRate", + "columnName": "fiatRate", + "affinity": "TEXT" + }, + { + "fieldPath": "recentRateChange", + "columnName": "recentRateChange", + "affinity": "TEXT" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "priceId" + ] + } + }, + { + "tableName": "phishing", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`address` TEXT NOT NULL, `name` TEXT, `type` TEXT NOT NULL, `subtype` TEXT, PRIMARY KEY(`address`, `type`))", + "fields": [ + { + "fieldPath": "address", + "columnName": "address", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT" + }, + { + "fieldPath": "type", + "columnName": "type", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "subtype", + "columnName": "subtype", + "affinity": "TEXT" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "address", + "type" + ] + } + }, + { + "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" + }, + { + "fieldPath": "chainId", + "columnName": "chainId", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "chainId", + "storageKey" + ] + } + }, + { + "tableName": "account_staking_accesses", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`chainId` TEXT NOT NULL, `chainAssetId` TEXT 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": "TEXT", + "notNull": true + }, + { + "fieldPath": "accountId", + "columnName": "accountId", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "stakingAccessInfo.stashId", + "columnName": "stashId", + "affinity": "BLOB" + }, + { + "fieldPath": "stakingAccessInfo.controllerId", + "columnName": "controllerId", + "affinity": "BLOB" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "chainId", + "chainAssetId", + "accountId" + ] + } + }, + { + "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": { + "autoGenerate": false, + "columnNames": [ + "accountAddress" + ] + } + }, + { + "tableName": "operations", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `address` TEXT NOT NULL, `chainId` TEXT NOT NULL, `chainAssetId` TEXT 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, `liquidityFee` TEXT, `market` TEXT, `targetAssetId` TEXT, `targetAmount` 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": "TEXT", + "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" + }, + { + "fieldPath": "call", + "columnName": "call", + "affinity": "TEXT" + }, + { + "fieldPath": "amount", + "columnName": "amount", + "affinity": "TEXT" + }, + { + "fieldPath": "sender", + "columnName": "sender", + "affinity": "TEXT" + }, + { + "fieldPath": "receiver", + "columnName": "receiver", + "affinity": "TEXT" + }, + { + "fieldPath": "hash", + "columnName": "hash", + "affinity": "TEXT" + }, + { + "fieldPath": "fee", + "columnName": "fee", + "affinity": "TEXT" + }, + { + "fieldPath": "isReward", + "columnName": "isReward", + "affinity": "INTEGER" + }, + { + "fieldPath": "era", + "columnName": "era", + "affinity": "INTEGER" + }, + { + "fieldPath": "validator", + "columnName": "validator", + "affinity": "TEXT" + }, + { + "fieldPath": "liquidityFee", + "columnName": "liquidityFee", + "affinity": "TEXT" + }, + { + "fieldPath": "market", + "columnName": "market", + "affinity": "TEXT" + }, + { + "fieldPath": "targetAssetId", + "columnName": "targetAssetId", + "affinity": "TEXT" + }, + { + "fieldPath": "targetAmount", + "columnName": "targetAmount", + "affinity": "TEXT" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id", + "address", + "chainId", + "chainAssetId" + ] + } + }, + { + "tableName": "chains", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `paraId` TEXT, `parentId` TEXT, `rank` INTEGER, `name` TEXT NOT NULL, `minSupportedVersion` TEXT, `icon` TEXT NOT NULL, `prefix` INTEGER NOT NULL, `isEthereumBased` INTEGER NOT NULL, `isTestNet` INTEGER NOT NULL, `hasCrowdloans` INTEGER NOT NULL, `supportStakingPool` INTEGER NOT NULL, `isEthereumChain` INTEGER NOT NULL, `isChainlinkProvider` INTEGER NOT NULL, `supportNft` INTEGER NOT NULL, `isUsesAppId` INTEGER NOT NULL, `identityChain` TEXT, `ecosystem` TEXT NOT NULL, `androidMinAppVersion` TEXT, `remoteAssetsSource` TEXT, `tonBridgeUrl` TEXT, `xcm` TEXT, `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": "paraId", + "columnName": "paraId", + "affinity": "TEXT" + }, + { + "fieldPath": "parentId", + "columnName": "parentId", + "affinity": "TEXT" + }, + { + "fieldPath": "rank", + "columnName": "rank", + "affinity": "INTEGER" + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "minSupportedVersion", + "columnName": "minSupportedVersion", + "affinity": "TEXT" + }, + { + "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": "supportStakingPool", + "columnName": "supportStakingPool", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "isEthereumChain", + "columnName": "isEthereumChain", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "isChainlinkProvider", + "columnName": "isChainlinkProvider", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "supportNft", + "columnName": "supportNft", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "isUsesAppId", + "columnName": "isUsesAppId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "identityChain", + "columnName": "identityChain", + "affinity": "TEXT" + }, + { + "fieldPath": "ecosystem", + "columnName": "ecosystem", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "androidMinAppVersion", + "columnName": "androidMinAppVersion", + "affinity": "TEXT" + }, + { + "fieldPath": "remoteAssetsSource", + "columnName": "remoteAssetsSource", + "affinity": "TEXT" + }, + { + "fieldPath": "tonBridgeUrl", + "columnName": "tonBridgeUrl", + "affinity": "TEXT" + }, + { + "fieldPath": "xcm", + "columnName": "xcm", + "affinity": "TEXT" + }, + { + "fieldPath": "externalApi.staking.url", + "columnName": "staking_url", + "affinity": "TEXT" + }, + { + "fieldPath": "externalApi.staking.type", + "columnName": "staking_type", + "affinity": "TEXT" + }, + { + "fieldPath": "externalApi.history.url", + "columnName": "history_url", + "affinity": "TEXT" + }, + { + "fieldPath": "externalApi.history.type", + "columnName": "history_type", + "affinity": "TEXT" + }, + { + "fieldPath": "externalApi.crowdloans.url", + "columnName": "crowdloans_url", + "affinity": "TEXT" + }, + { + "fieldPath": "externalApi.crowdloans.type", + "columnName": "crowdloans_type", + "affinity": "TEXT" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + } + }, + { + "tableName": "chain_nodes", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`chainId` TEXT NOT NULL, `url` TEXT NOT NULL, `name` TEXT NOT NULL, `isActive` INTEGER NOT NULL, `isDefault` INTEGER 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 + }, + { + "fieldPath": "isActive", + "columnName": "isActive", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "isDefault", + "columnName": "isDefault", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "chainId", + "url" + ] + }, + "indices": [ + { + "name": "index_chain_nodes_chainId", + "unique": false, + "columnNames": [ + "chainId" + ], + "orders": [], + "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` TEXT NOT NULL, `name` TEXT, `symbol` TEXT NOT NULL, `chainId` TEXT NOT NULL, `icon` TEXT NOT NULL, `priceId` TEXT, `staking` TEXT NOT NULL, `precision` INTEGER NOT NULL, `purchaseProviders` TEXT, `isUtility` INTEGER, `type` TEXT, `currencyId` TEXT, `existentialDeposit` TEXT, `color` TEXT, `isNative` INTEGER, `priceProvider` TEXT, `coinbaseUrl` TEXT, PRIMARY KEY(`chainId`, `id`), FOREIGN KEY(`chainId`) REFERENCES `chains`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT" + }, + { + "fieldPath": "symbol", + "columnName": "symbol", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "chainId", + "columnName": "chainId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "icon", + "columnName": "icon", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "priceId", + "columnName": "priceId", + "affinity": "TEXT" + }, + { + "fieldPath": "staking", + "columnName": "staking", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "precision", + "columnName": "precision", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "purchaseProviders", + "columnName": "purchaseProviders", + "affinity": "TEXT" + }, + { + "fieldPath": "isUtility", + "columnName": "isUtility", + "affinity": "INTEGER" + }, + { + "fieldPath": "type", + "columnName": "type", + "affinity": "TEXT" + }, + { + "fieldPath": "currencyId", + "columnName": "currencyId", + "affinity": "TEXT" + }, + { + "fieldPath": "existentialDeposit", + "columnName": "existentialDeposit", + "affinity": "TEXT" + }, + { + "fieldPath": "color", + "columnName": "color", + "affinity": "TEXT" + }, + { + "fieldPath": "isNative", + "columnName": "isNative", + "affinity": "INTEGER" + }, + { + "fieldPath": "priceProvider", + "columnName": "priceProvider", + "affinity": "TEXT" + }, + { + "fieldPath": "coinbaseUrl", + "columnName": "coinbaseUrl", + "affinity": "TEXT" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "chainId", + "id" + ] + }, + "indices": [ + { + "name": "index_chain_assets_chainId", + "unique": false, + "columnNames": [ + "chainId" + ], + "orders": [], + "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": "favorite_chains", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`metaId` INTEGER NOT NULL, `chainId` TEXT NOT NULL, `isFavorite` INTEGER 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": "isFavorite", + "columnName": "isFavorite", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "metaId", + "chainId" + ] + }, + "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": "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": { + "autoGenerate": false, + "columnNames": [ + "chainId" + ] + }, + "indices": [ + { + "name": "index_chain_runtimes_chainId", + "unique": false, + "columnNames": [ + "chainId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_chain_runtimes_chainId` ON `${TABLE_NAME}` (`chainId`)" + } + ] + }, + { + "tableName": "meta_accounts", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`substratePublicKey` BLOB, `substrateCryptoType` TEXT, `substrateAccountId` BLOB, `ethereumPublicKey` BLOB, `ethereumAddress` BLOB, `tonPublicKey` BLOB, `name` TEXT NOT NULL, `isSelected` INTEGER NOT NULL, `position` INTEGER NOT NULL, `isBackedUp` INTEGER NOT NULL, `googleBackupAddress` TEXT, `initialized` INTEGER NOT NULL, `id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL)", + "fields": [ + { + "fieldPath": "substratePublicKey", + "columnName": "substratePublicKey", + "affinity": "BLOB" + }, + { + "fieldPath": "substrateCryptoType", + "columnName": "substrateCryptoType", + "affinity": "TEXT" + }, + { + "fieldPath": "substrateAccountId", + "columnName": "substrateAccountId", + "affinity": "BLOB" + }, + { + "fieldPath": "ethereumPublicKey", + "columnName": "ethereumPublicKey", + "affinity": "BLOB" + }, + { + "fieldPath": "ethereumAddress", + "columnName": "ethereumAddress", + "affinity": "BLOB" + }, + { + "fieldPath": "tonPublicKey", + "columnName": "tonPublicKey", + "affinity": "BLOB" + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "isSelected", + "columnName": "isSelected", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "position", + "columnName": "position", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "isBackedUp", + "columnName": "isBackedUp", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "googleBackupAddress", + "columnName": "googleBackupAddress", + "affinity": "TEXT" + }, + { + "fieldPath": "initialized", + "columnName": "initialized", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_meta_accounts_substrateAccountId", + "unique": false, + "columnNames": [ + "substrateAccountId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_meta_accounts_substrateAccountId` ON `${TABLE_NAME}` (`substrateAccountId`)" + }, + { + "name": "index_meta_accounts_ethereumAddress", + "unique": false, + "columnNames": [ + "ethereumAddress" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_meta_accounts_ethereumAddress` ON `${TABLE_NAME}` (`ethereumAddress`)" + }, + { + "name": "index_meta_accounts_tonPublicKey", + "unique": false, + "columnNames": [ + "tonPublicKey" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_meta_accounts_tonPublicKey` ON `${TABLE_NAME}` (`tonPublicKey`)" + } + ] + }, + { + "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, `name` TEXT NOT NULL, `initialized` INTEGER 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 + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "initialized", + "columnName": "initialized", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "metaId", + "chainId" + ] + }, + "indices": [ + { + "name": "index_chain_accounts_chainId", + "unique": false, + "columnNames": [ + "chainId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_chain_accounts_chainId` ON `${TABLE_NAME}` (`chainId`)" + }, + { + "name": "index_chain_accounts_metaId", + "unique": false, + "columnNames": [ + "metaId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_chain_accounts_metaId` ON `${TABLE_NAME}` (`metaId`)" + }, + { + "name": "index_chain_accounts_accountId", + "unique": false, + "columnNames": [ + "accountId" + ], + "orders": [], + "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": "chain_explorers", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`chainId` TEXT NOT NULL, `type` TEXT NOT NULL, `types` TEXT NOT NULL, `url` TEXT NOT NULL, PRIMARY KEY(`chainId`, `type`), FOREIGN KEY(`chainId`) REFERENCES `chains`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "chainId", + "columnName": "chainId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "type", + "columnName": "type", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "types", + "columnName": "types", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "url", + "columnName": "url", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "chainId", + "type" + ] + }, + "indices": [ + { + "name": "index_chain_explorers_chainId", + "unique": false, + "columnNames": [ + "chainId" + ], + "orders": [], + "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": "chain_types", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`chainId` TEXT NOT NULL, `typesConfig` TEXT NOT NULL, PRIMARY KEY(`chainId`))", + "fields": [ + { + "fieldPath": "chainId", + "columnName": "chainId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "typesConfig", + "columnName": "typesConfig", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "chainId" + ] + } + }, + { + "tableName": "nomis_wallet_score", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`metaId` INTEGER NOT NULL, `score` INTEGER NOT NULL, `updated` INTEGER NOT NULL, `nativeBalanceUsd` TEXT NOT NULL, `holdTokensUsd` TEXT NOT NULL, `walletAgeInMonths` INTEGER NOT NULL, `totalTransactions` INTEGER NOT NULL, `rejectedTransactions` INTEGER NOT NULL, `avgTransactionTimeInHours` REAL NOT NULL, `maxTransactionTimeInHours` REAL NOT NULL, `minTransactionTimeInHours` REAL NOT NULL, `scoredAt` TEXT NOT NULL, PRIMARY KEY(`metaId`), FOREIGN KEY(`metaId`) REFERENCES `meta_accounts`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "metaId", + "columnName": "metaId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "score", + "columnName": "score", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "updated", + "columnName": "updated", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "nativeBalanceUsd", + "columnName": "nativeBalanceUsd", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "holdTokensUsd", + "columnName": "holdTokensUsd", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "walletAgeInMonths", + "columnName": "walletAgeInMonths", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "totalTransactions", + "columnName": "totalTransactions", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "rejectedTransactions", + "columnName": "rejectedTransactions", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "avgTransactionTimeInHours", + "columnName": "avgTransactionTimeInHours", + "affinity": "REAL", + "notNull": true + }, + { + "fieldPath": "maxTransactionTimeInHours", + "columnName": "maxTransactionTimeInHours", + "affinity": "REAL", + "notNull": true + }, + { + "fieldPath": "minTransactionTimeInHours", + "columnName": "minTransactionTimeInHours", + "affinity": "REAL", + "notNull": true + }, + { + "fieldPath": "scoredAt", + "columnName": "scoredAt", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "metaId" + ] + }, + "foreignKeys": [ + { + "table": "meta_accounts", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "metaId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "allpools", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`tokenIdBase` TEXT NOT NULL, `tokenIdTarget` TEXT NOT NULL, `reserveBase` TEXT NOT NULL, `reserveTarget` TEXT NOT NULL, `totalIssuance` TEXT NOT NULL, `reservesAccount` TEXT NOT NULL, PRIMARY KEY(`tokenIdBase`, `tokenIdTarget`))", + "fields": [ + { + "fieldPath": "tokenIdBase", + "columnName": "tokenIdBase", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "tokenIdTarget", + "columnName": "tokenIdTarget", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "reserveBase", + "columnName": "reserveBase", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "reserveTarget", + "columnName": "reserveTarget", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "totalIssuance", + "columnName": "totalIssuance", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "reservesAccount", + "columnName": "reservesAccount", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "tokenIdBase", + "tokenIdTarget" + ] + } + }, + { + "tableName": "userpools", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userTokenIdBase` TEXT NOT NULL, `userTokenIdTarget` TEXT NOT NULL, `accountAddress` TEXT NOT NULL, `poolProvidersBalance` TEXT NOT NULL, PRIMARY KEY(`userTokenIdBase`, `userTokenIdTarget`, `accountAddress`), FOREIGN KEY(`userTokenIdBase`, `userTokenIdTarget`) REFERENCES `allpools`(`tokenIdBase`, `tokenIdTarget`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userTokenIdBase", + "columnName": "userTokenIdBase", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "userTokenIdTarget", + "columnName": "userTokenIdTarget", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "accountAddress", + "columnName": "accountAddress", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "poolProvidersBalance", + "columnName": "poolProvidersBalance", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "userTokenIdBase", + "userTokenIdTarget", + "accountAddress" + ] + }, + "indices": [ + { + "name": "index_userpools_accountAddress", + "unique": false, + "columnNames": [ + "accountAddress" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_userpools_accountAddress` ON `${TABLE_NAME}` (`accountAddress`)" + } + ], + "foreignKeys": [ + { + "table": "allpools", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "userTokenIdBase", + "userTokenIdTarget" + ], + "referencedColumns": [ + "tokenIdBase", + "tokenIdTarget" + ] + } + ] + }, + { + "tableName": "ton_connection", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`metaId` INTEGER NOT NULL, `clientId` TEXT NOT NULL, `name` TEXT NOT NULL, `icon` TEXT NOT NULL, `url` TEXT NOT NULL, `source` TEXT NOT NULL, PRIMARY KEY(`metaId`, `url`, `source`), FOREIGN KEY(`metaId`) REFERENCES `meta_accounts`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "metaId", + "columnName": "metaId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "clientId", + "columnName": "clientId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "icon", + "columnName": "icon", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "url", + "columnName": "url", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "source", + "columnName": "source", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "metaId", + "url", + "source" + ] + }, + "foreignKeys": [ + { + "table": "meta_accounts", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "metaId" + ], + "referencedColumns": [ + "id" + ] + } + ] + } + ], + "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, 'fd40e319853eeab218d8e5b39137760f')" + ] + } +} \ No newline at end of file diff --git a/core-db/src/androidTest/java/jp/co/soramitsu/coredb/dao/Helpers.kt b/core-db/src/androidTest/java/jp/co/soramitsu/coredb/dao/Helpers.kt index 7553eaef80..11ce33e0a6 100644 --- a/core-db/src/androidTest/java/jp/co/soramitsu/coredb/dao/Helpers.kt +++ b/core-db/src/androidTest/java/jp/co/soramitsu/coredb/dao/Helpers.kt @@ -50,7 +50,9 @@ fun chainOf( identityChain = null, ecosystem = "Substrate", androidMinAppVersion = null, - remoteAssetsSource = null + remoteAssetsSource = null, + tonBridgeUrl = null, + xcm = null ) fun ChainLocal.nodeOf( diff --git a/core-db/src/androidTest/java/jp/co/soramitsu/coredb/migrations/V2MigrationTest.kt b/core-db/src/androidTest/java/jp/co/soramitsu/coredb/migrations/V2MigrationTest.kt index 781bb3c90a..58613db3ea 100644 --- a/core-db/src/androidTest/java/jp/co/soramitsu/coredb/migrations/V2MigrationTest.kt +++ b/core-db/src/androidTest/java/jp/co/soramitsu/coredb/migrations/V2MigrationTest.kt @@ -23,17 +23,17 @@ import jp.co.soramitsu.core.models.CryptoType import jp.co.soramitsu.coredb.AppDatabase import jp.co.soramitsu.coredb.model.MetaAccountLocal import jp.co.soramitsu.coredb.model.MetaAccountLocal.Table.Column -import jp.co.soramitsu.shared_utils.encrypt.EncryptionType -import jp.co.soramitsu.shared_utils.encrypt.junction.BIP32JunctionDecoder -import jp.co.soramitsu.shared_utils.encrypt.keypair.Keypair -import jp.co.soramitsu.shared_utils.encrypt.keypair.ethereum.EthereumKeypairFactory -import jp.co.soramitsu.shared_utils.encrypt.keypair.substrate.Sr25519Keypair -import jp.co.soramitsu.shared_utils.encrypt.keypair.substrate.SubstrateKeypairFactory -import jp.co.soramitsu.shared_utils.encrypt.mnemonic.MnemonicCreator -import jp.co.soramitsu.shared_utils.encrypt.seed.ethereum.EthereumSeedFactory -import jp.co.soramitsu.shared_utils.encrypt.seed.substrate.SubstrateSeedFactory -import jp.co.soramitsu.shared_utils.scale.EncodableStruct -import jp.co.soramitsu.shared_utils.ss58.SS58Encoder.toAddress +import jp.co.soramitsu.fearless_utils.encrypt.EncryptionType +import jp.co.soramitsu.fearless_utils.encrypt.junction.BIP32JunctionDecoder +import jp.co.soramitsu.fearless_utils.encrypt.keypair.Keypair +import jp.co.soramitsu.fearless_utils.encrypt.keypair.ethereum.EthereumKeypairFactory +import jp.co.soramitsu.fearless_utils.encrypt.keypair.substrate.Sr25519Keypair +import jp.co.soramitsu.fearless_utils.encrypt.keypair.substrate.SubstrateKeypairFactory +import jp.co.soramitsu.fearless_utils.encrypt.mnemonic.MnemonicCreator +import jp.co.soramitsu.fearless_utils.encrypt.seed.ethereum.EthereumSeedFactory +import jp.co.soramitsu.fearless_utils.encrypt.seed.substrate.SubstrateSeedFactory +import jp.co.soramitsu.fearless_utils.scale.EncodableStruct +import jp.co.soramitsu.fearless_utils.ss58.SS58Encoder.toAddress import jp.co.soramitsu.testshared.HashMapEncryptedPreferences import kotlinx.coroutines.runBlocking import org.junit.Assert.assertArrayEquals diff --git a/core-db/src/main/java/jp/co/soramitsu/coredb/AppDatabase.kt b/core-db/src/main/java/jp/co/soramitsu/coredb/AppDatabase.kt index f2e3d92069..ddc7d656b8 100644 --- a/core-db/src/main/java/jp/co/soramitsu/coredb/AppDatabase.kt +++ b/core-db/src/main/java/jp/co/soramitsu/coredb/AppDatabase.kt @@ -81,6 +81,7 @@ import jp.co.soramitsu.coredb.migrations.Migration_70_71 import jp.co.soramitsu.coredb.migrations.Migration_72_73 import jp.co.soramitsu.coredb.migrations.Migration_73_74 import jp.co.soramitsu.coredb.migrations.Migration_74_75 +import jp.co.soramitsu.coredb.migrations.Migration_75_76 import jp.co.soramitsu.coredb.migrations.RemoveAccountForeignKeyFromAsset_17_18 import jp.co.soramitsu.coredb.migrations.RemoveLegacyData_35_36 import jp.co.soramitsu.coredb.migrations.RemoveStakingRewardsTable_22_23 @@ -109,7 +110,7 @@ import jp.co.soramitsu.coredb.model.chain.ChainTypesLocal import jp.co.soramitsu.coredb.model.chain.FavoriteChainLocal @Database( - version = 75, + version = 76, entities = [ AddressBookContact::class, AssetLocal::class, @@ -210,6 +211,7 @@ abstract class AppDatabase : RoomDatabase() { .addMigrations(Migration_72_73) .addMigrations(Migration_73_74) .addMigrations(Migration_74_75) + .addMigrations(Migration_75_76) .build() } return instance!! diff --git a/core-db/src/main/java/jp/co/soramitsu/coredb/dao/AssetDao.kt b/core-db/src/main/java/jp/co/soramitsu/coredb/dao/AssetDao.kt index 64ba272db1..6e4d97abb1 100644 --- a/core-db/src/main/java/jp/co/soramitsu/coredb/dao/AssetDao.kt +++ b/core-db/src/main/java/jp/co/soramitsu/coredb/dao/AssetDao.kt @@ -11,7 +11,7 @@ import jp.co.soramitsu.coredb.model.AssetBalanceUpdateItem import jp.co.soramitsu.coredb.model.AssetLocal import jp.co.soramitsu.coredb.model.AssetUpdateItem import jp.co.soramitsu.coredb.model.AssetWithToken -import jp.co.soramitsu.shared_utils.runtime.AccountId +import jp.co.soramitsu.fearless_utils.runtime.AccountId import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.flow.Flow diff --git a/core-db/src/main/java/jp/co/soramitsu/coredb/dao/MetaAccountDao.kt b/core-db/src/main/java/jp/co/soramitsu/coredb/dao/MetaAccountDao.kt index be62dc7f55..5379255579 100644 --- a/core-db/src/main/java/jp/co/soramitsu/coredb/dao/MetaAccountDao.kt +++ b/core-db/src/main/java/jp/co/soramitsu/coredb/dao/MetaAccountDao.kt @@ -11,7 +11,7 @@ import jp.co.soramitsu.coredb.model.chain.FavoriteChainLocal import jp.co.soramitsu.coredb.model.MetaAccountLocal import jp.co.soramitsu.coredb.model.MetaAccountPositionUpdate import jp.co.soramitsu.coredb.model.RelationJoinedMetaAccountInfo -import jp.co.soramitsu.shared_utils.runtime.AccountId +import jp.co.soramitsu.fearless_utils.runtime.AccountId import kotlinx.coroutines.flow.Flow /** @@ -21,7 +21,7 @@ import kotlinx.coroutines.flow.Flow * or there is a child chain account which have child.accountId = accountId */ private const val FIND_BY_ADDRESS_QUERY = """ - SELECT * FROM meta_accounts + SELECT * FROM meta_accounts WHERE substrateAccountId = :accountId OR ethereumAddress = :accountId OR id = ( @@ -60,6 +60,14 @@ interface MetaAccountDao { @Transaction fun observeJoinedMetaAccountsInfo(): Flow> + @Query("SELECT * FROM meta_accounts ORDER BY position") + @Transaction + fun observeOrderedJoinedMetaAccountsInfo(): Flow> + + @Query("SELECT * FROM meta_accounts WHERE id = :metaId") + @Transaction + fun observeJoinedMetaAccountInfo(metaId: Long): Flow + @Query("SELECT * FROM meta_accounts ORDER BY position") fun metaAccountsFlow(): Flow> diff --git a/core-db/src/main/java/jp/co/soramitsu/coredb/migrations/AssetsOrderMigration.kt b/core-db/src/main/java/jp/co/soramitsu/coredb/migrations/AssetsOrderMigration.kt index e9501e6c08..ae43f4846c 100644 --- a/core-db/src/main/java/jp/co/soramitsu/coredb/migrations/AssetsOrderMigration.kt +++ b/core-db/src/main/java/jp/co/soramitsu/coredb/migrations/AssetsOrderMigration.kt @@ -7,7 +7,7 @@ import androidx.room.migration.Migration import androidx.sqlite.db.SupportSQLiteDatabase import jp.co.soramitsu.common.utils.map import jp.co.soramitsu.coredb.model.MetaAccountLocal -import jp.co.soramitsu.shared_utils.runtime.AccountId +import jp.co.soramitsu.fearless_utils.runtime.AccountId import java.math.BigDecimal import java.math.BigInteger diff --git a/core-db/src/main/java/jp/co/soramitsu/coredb/migrations/EthereumDerivationPathMigration.kt b/core-db/src/main/java/jp/co/soramitsu/coredb/migrations/EthereumDerivationPathMigration.kt index 56784647c0..8224faa061 100644 --- a/core-db/src/main/java/jp/co/soramitsu/coredb/migrations/EthereumDerivationPathMigration.kt +++ b/core-db/src/main/java/jp/co/soramitsu/coredb/migrations/EthereumDerivationPathMigration.kt @@ -14,10 +14,10 @@ import jp.co.soramitsu.common.utils.deriveSeed32 import jp.co.soramitsu.common.utils.ethereumAddressFromPublicKey import jp.co.soramitsu.common.utils.map import jp.co.soramitsu.coredb.model.MetaAccountLocal -import jp.co.soramitsu.shared_utils.encrypt.junction.BIP32JunctionDecoder -import jp.co.soramitsu.shared_utils.encrypt.keypair.ethereum.EthereumKeypairFactory -import jp.co.soramitsu.shared_utils.encrypt.mnemonic.MnemonicCreator -import jp.co.soramitsu.shared_utils.encrypt.seed.ethereum.EthereumSeedFactory +import jp.co.soramitsu.fearless_utils.encrypt.junction.BIP32JunctionDecoder +import jp.co.soramitsu.fearless_utils.encrypt.keypair.ethereum.EthereumKeypairFactory +import jp.co.soramitsu.fearless_utils.encrypt.mnemonic.MnemonicCreator +import jp.co.soramitsu.fearless_utils.encrypt.seed.ethereum.EthereumSeedFactory import kotlinx.coroutines.runBlocking class EthereumDerivationPathMigration(private val storeV2: SecretStoreV2) : Migration(31, 32) { diff --git a/core-db/src/main/java/jp/co/soramitsu/coredb/migrations/Migrations.kt b/core-db/src/main/java/jp/co/soramitsu/coredb/migrations/Migrations.kt index b02a154985..be39951eae 100644 --- a/core-db/src/main/java/jp/co/soramitsu/coredb/migrations/Migrations.kt +++ b/core-db/src/main/java/jp/co/soramitsu/coredb/migrations/Migrations.kt @@ -3,6 +3,12 @@ package jp.co.soramitsu.coredb.migrations import androidx.room.migration.Migration import androidx.sqlite.db.SupportSQLiteDatabase +val Migration_75_76 = object : Migration(75, 76) { + override fun migrate(db: SupportSQLiteDatabase) { + db.execSQL("ALTER TABLE chains ADD COLUMN `xcm` TEXT NULL DEFAULT NULL") + } +} + val Migration_73_74 = object : Migration(73, 74) { override fun migrate(db: SupportSQLiteDatabase) { db.execSQL("ALTER TABLE chain_assets ADD COLUMN `coinbaseUrl` TEXT NULL DEFAULT NULL") diff --git a/core-db/src/main/java/jp/co/soramitsu/coredb/migrations/TonMigration.kt b/core-db/src/main/java/jp/co/soramitsu/coredb/migrations/TonMigration.kt index da5238cf4c..9b25b1cebf 100644 --- a/core-db/src/main/java/jp/co/soramitsu/coredb/migrations/TonMigration.kt +++ b/core-db/src/main/java/jp/co/soramitsu/coredb/migrations/TonMigration.kt @@ -13,10 +13,10 @@ import jp.co.soramitsu.common.data.secrets.v3.SubstrateSecrets import jp.co.soramitsu.common.data.storage.encrypt.EncryptedPreferences import jp.co.soramitsu.common.utils.map import jp.co.soramitsu.coredb.model.MetaAccountLocal -import jp.co.soramitsu.shared_utils.scale.Schema -import jp.co.soramitsu.shared_utils.scale.byteArray -import jp.co.soramitsu.shared_utils.scale.schema -import jp.co.soramitsu.shared_utils.scale.string +import jp.co.soramitsu.fearless_utils.scale.Schema +import jp.co.soramitsu.fearless_utils.scale.byteArray +import jp.co.soramitsu.fearless_utils.scale.schema +import jp.co.soramitsu.fearless_utils.scale.string import kotlinx.coroutines.runBlocking class TonMigration( diff --git a/core-db/src/main/java/jp/co/soramitsu/coredb/migrations/V2Migration.kt b/core-db/src/main/java/jp/co/soramitsu/coredb/migrations/V2Migration.kt index 2c98bbb3e9..0d0d3a692c 100644 --- a/core-db/src/main/java/jp/co/soramitsu/coredb/migrations/V2Migration.kt +++ b/core-db/src/main/java/jp/co/soramitsu/coredb/migrations/V2Migration.kt @@ -18,10 +18,10 @@ import jp.co.soramitsu.core.model.WithSeed import jp.co.soramitsu.core.models.CryptoType import jp.co.soramitsu.coredb.converters.CryptoTypeConverters import jp.co.soramitsu.coredb.model.MetaAccountLocal -import jp.co.soramitsu.shared_utils.encrypt.junction.BIP32JunctionDecoder -import jp.co.soramitsu.shared_utils.encrypt.keypair.ethereum.EthereumKeypairFactory -import jp.co.soramitsu.shared_utils.encrypt.mnemonic.MnemonicCreator -import jp.co.soramitsu.shared_utils.encrypt.seed.ethereum.EthereumSeedFactory +import jp.co.soramitsu.fearless_utils.encrypt.junction.BIP32JunctionDecoder +import jp.co.soramitsu.fearless_utils.encrypt.keypair.ethereum.EthereumKeypairFactory +import jp.co.soramitsu.fearless_utils.encrypt.mnemonic.MnemonicCreator +import jp.co.soramitsu.fearless_utils.encrypt.seed.ethereum.EthereumSeedFactory import kotlinx.coroutines.runBlocking internal class MigratingAccount( diff --git a/core-db/src/main/java/jp/co/soramitsu/coredb/model/AssetLocal.kt b/core-db/src/main/java/jp/co/soramitsu/coredb/model/AssetLocal.kt index 91882a2d4c..e6f96e8a79 100644 --- a/core-db/src/main/java/jp/co/soramitsu/coredb/model/AssetLocal.kt +++ b/core-db/src/main/java/jp/co/soramitsu/coredb/model/AssetLocal.kt @@ -6,7 +6,7 @@ import androidx.room.ForeignKey import jp.co.soramitsu.common.utils.orZero import jp.co.soramitsu.common.utils.positiveOrNull import jp.co.soramitsu.coredb.model.chain.ChainLocal -import jp.co.soramitsu.shared_utils.runtime.AccountId +import jp.co.soramitsu.fearless_utils.runtime.AccountId import java.math.BigInteger /*** This table is used for storing balances in database. diff --git a/core-db/src/main/java/jp/co/soramitsu/coredb/model/chain/ChainLocal.kt b/core-db/src/main/java/jp/co/soramitsu/coredb/model/chain/ChainLocal.kt index 276c9c9434..260e54b7ab 100644 --- a/core-db/src/main/java/jp/co/soramitsu/coredb/model/chain/ChainLocal.kt +++ b/core-db/src/main/java/jp/co/soramitsu/coredb/model/chain/ChainLocal.kt @@ -28,7 +28,8 @@ data class ChainLocal( val ecosystem: String, val androidMinAppVersion: String?, val remoteAssetsSource: String?, - val tonBridgeUrl: String? + val tonBridgeUrl: String?, + val xcm: String? ) { class ExternalApi( diff --git a/core-db/src/test/java/jp/co/soramitsu/coredb/prepopulate/runtime/PredifinedRuntimeUpdater.kt b/core-db/src/test/java/jp/co/soramitsu/coredb/prepopulate/runtime/PredifinedRuntimeUpdater.kt index 0fa7a4bfa1..1c5477f4e4 100644 --- a/core-db/src/test/java/jp/co/soramitsu/coredb/prepopulate/runtime/PredifinedRuntimeUpdater.kt +++ b/core-db/src/test/java/jp/co/soramitsu/coredb/prepopulate/runtime/PredifinedRuntimeUpdater.kt @@ -1,9 +1,9 @@ package jp.co.soramitsu.coredb.prepopulate.runtime -import jp.co.soramitsu.shared_utils.runtime.metadata.GetMetadataRequest -import jp.co.soramitsu.shared_utils.wsrpc.executeAsync -import jp.co.soramitsu.shared_utils.wsrpc.mappers.nonNull -import jp.co.soramitsu.shared_utils.wsrpc.mappers.pojo +import jp.co.soramitsu.fearless_utils.runtime.metadata.GetMetadataRequest +import jp.co.soramitsu.fearless_utils.wsrpc.executeAsync +import jp.co.soramitsu.fearless_utils.wsrpc.mappers.nonNull +import jp.co.soramitsu.fearless_utils.wsrpc.mappers.pojo import jp.co.soramitsu.testshared.createTestSocket import kotlinx.coroutines.runBlocking import org.junit.Ignore diff --git a/debug-keystore.jks b/debug-keystore.jks deleted file mode 100644 index 1a3a2c0857..0000000000 Binary files a/debug-keystore.jks and /dev/null differ diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index d3ce801335..e93b141795 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -42,7 +42,7 @@ This document explains how the Android app is structured, how modules interact, - Build types: `debug`, `release`, `staging`, `develop`, `pr`. R8/shrinker is enabled on remote builds for closer prod parity. - Static analysis: Detekt (`./gradlew detektAll`) with formatting (`detektFormat`). - Unit tests: `./gradlew runTest` aggregates checks and test reports. -- Utils integration: `settings.gradle` maps the GitHub repo `soramitsu/fearless-utils-Android` as a source dependency (via `sourceControl`). +- Utils integration: `settings.gradle` includes a pinned local checkout of `soramitsu/fearless-utils-Android` as a composite build. CI checks the repo out and verifies the expected commit before Gradle resolution. ## Where To Start - App lifecycle and global initialization: `app/src/main/java/jp/co/soramitsu/app/App.kt`. diff --git a/docs/CURRENT_STATE.md b/docs/CURRENT_STATE.md index d35a6ad475..a0ef432d23 100644 --- a/docs/CURRENT_STATE.md +++ b/docs/CURRENT_STATE.md @@ -45,5 +45,5 @@ These markers indicate areas where behavior may be incomplete or needs refinemen ## What To Verify Locally - Secrets and endpoints present: Without keys, some flows (Moonpay, history providers) won’t fully function. -- Utils integration: The build maps `soramitsu/fearless-utils-Android` as a source dependency; no local path required. +- Utils integration: The build uses a pinned `soramitsu/fearless-utils-Android` checkout as a composite build. CI checks it out automatically; local builds should clone it next to this repo or set `FEARLESS_UTILS_PATH`. - Android SDK/NDK and JDK versions: See README and `scripts/validate-local.sh`. diff --git a/docs/binary-provenance.md b/docs/binary-provenance.md new file mode 100644 index 0000000000..07f36e4f8c --- /dev/null +++ b/docs/binary-provenance.md @@ -0,0 +1,96 @@ +# Binary Provenance + +This repository should not rely on unexplained generated or vendored binaries. +Run this audit before release hardening changes: + +``` +git ls-files '*.jar' '*.aar' '*.so' '*.a' '*.dylib' '*.framework/**' '*.xcframework/**' '*.wasm' '*.keystore' '*.jks' '*.mobileprovision' '*.p12' '*.p8' '*.pem' '*.provisionprofile' +``` + +CI and local validation also run: + +``` +./scripts/audit-public-artifacts.sh +``` + +Release builds must restore private overlays from CI secrets and then run the +strict release audit: + +``` +./scripts/restore-release-overlays.sh +./scripts/audit-public-artifacts.sh --release --strict-provenance +``` + +## Current Findings + +### Reproducible From Vendored Source + +`libsodium.so` is shipped for Android ABIs under `app/src/main/jniLibs`. +Source is vendored under `third_party/libsodium`, and +`scripts/build-libsodium.sh` rebuilds the checked-in binaries with the Android +16 KB page-size linker flags. + +Current checksums: + +| File | SHA-256 | +| --- | --- | +| `app/src/main/jniLibs/arm64-v8a/libsodium.so` | `939ba2865a93abe39c4d6a29802419b36b1bfd0b320396eeaccd4d660468a664` | +| `app/src/main/jniLibs/armeabi-v7a/libsodium.so` | `8bcd304a813f3d814793c8553cd6a8814b64b2c188d66e8462f82053d7855343` | +| `app/src/main/jniLibs/x86/libsodium.so` | `a6cd7f654ff400d080b16b3e794971b2c2b2cbc12bf0cee444f7e2ea950839ae` | +| `app/src/main/jniLibs/x86_64/libsodium.so` | `a8b52ce229d18338b9f0b0f4d2396078582c9742d771367394b8e9b3cbabb81c` | + +### Reproducible From Open Source + +`libsr25519java.so` is built from the open-source +`soramitsu/fearless-utils-Android` repository: + +- source repository: `https://github.com/soramitsu/fearless-utils-Android` +- source commit: `7500809f33243ee47ecb2ec8563fc284ac4de0d6` +- source path: `sr25519-java` +- Gradle module: `:fearless-utils:cargoBuild` +- Android NDK: `28.0.12674087` +- linker flags: `-Wl,-z,common-page-size=4096` and + `-Wl,-z,max-page-size=16384` from the source repo `.cargo/config.toml` + +The checked-in binaries expose both legacy `fearless_utils` JNI names and the +`shared_utils` JNI names used by the wallet app. Rebuild and copy them with: + +``` +FEARLESS_UTILS_ANDROID_PATH=../fearless-utils-Android ./scripts/build-sr25519.sh +``` + +Current checksums: + +| File | SHA-256 | +| --- | --- | +| `app/src/main/jniLibs/arm64-v8a/libsr25519java.so` | `a37f031de78b841dab6a8c69900a64fe427d53d2a4f77d8f3c059caab5849c12` | +| `app/src/main/jniLibs/armeabi-v7a/libsr25519java.so` | `d217b2511bf2c7f90ea29879d5ecad86c62d95a3ee2dd412fbb0f6d804e5f3de` | +| `app/src/main/jniLibs/x86/libsr25519java.so` | `56f26fe8e7e55026cf36be6c22a62037389d4f59cc8ee35d6bb295b5dc5441aa` | +| `app/src/main/jniLibs/x86_64/libsr25519java.so` | `b024dcc2ebf236f21d329e3d637a6fdd39746ad1dc0e0ccc99143279617946dc` | + +### Standard Build Wrapper + +`gradle/wrapper/gradle-wrapper.jar` is the standard Gradle wrapper jar. + +Current checksum: + +| File | SHA-256 | +| --- | --- | +| `gradle/wrapper/gradle-wrapper.jar` | `81a82aaea5abcc8ff68b3dfcb58b3c3c429378efd98e7433460610fecd7ae45f` | + +### Replaced With Source + +`feature-wallet-impl/libs/pushpayment-core-sdk-2.0.6.jar` has been removed. +CBDC QR parsing now lives in +`feature-wallet-impl/src/main/java/jp/co/soramitsu/wallet/impl/domain/qr/CbdcQrParser.kt` +with unit coverage under +`feature-wallet-impl/src/test/java/jp/co/soramitsu/wallet/impl/domain/qr/CbdcQrParserTest.kt`. + +`debug-keystore.jks` has been removed from source control. Use the Android +default generated debug keystore for local builds and CI-provided keystores for +release signing. + +Checked-in `google-services.json` files must stay on the `fearless-public` +placeholder project with empty API keys and no OAuth certificate hashes. Release +CI restores the real `app/src/release/google-services.json` from +`GOOGLE_SERVICES_RELEASE_JSON_B64`. diff --git a/docs/public-dependency-audit.md b/docs/public-dependency-audit.md new file mode 100644 index 0000000000..c1ceecb874 --- /dev/null +++ b/docs/public-dependency-audit.md @@ -0,0 +1,192 @@ +# Public Dependency Audit + +Fearless Android must build from public source and public artifacts without +private Maven repositories or private GitHub repositories. This file tracks +nonstandard Soramitsu dependencies that affect that requirement. + +## Resolved Locally + +### `jp.co.soramitsu:android-foundation:0.0.4` + +The app only used small utility APIs from this artifact: + +- `CoroutineManager` +- `MainCoroutineRule` +- `StringPair` +- BigDecimal and hex formatting helpers + +These APIs now live in the first-party `:public-android-foundation` module. +Gradle substitutes the old Maven coordinate with the local module. + +### `jp.co.soramitsu:ui-core:0.2.39` + +The app only used a narrow Compose UI surface: + +- design-system theme containers and typography locals +- `BasicNumberInput` +- `Modifier.applyIf` +- `Dimens` +- the `Size.ExtraSmall` token + +These APIs now live in the first-party `:public-ui-core` module. Gradle +substitutes the old Maven coordinate with the local module. + +### `jp.co.soramitsu.shared_features:{core,xcm,backup}:1.1.1.44-FLW` + +The app used a broad mix of model, runtime, XCM, and backup contracts from these +artifacts. The imported API surface now lives in first-party compatibility +modules: + +- `:public-shared-features-core` +- `:public-shared-features-xcm` +- `:public-shared-features-backup` + +Gradle substitutes the old Maven coordinates with those local modules. + +### `jp.co.soramitsu.xnetworking:lib-android:1.0.13` + +The app used history, chain-config, block-explorer, and REST client adapters from +this artifact. The required API surface now lives in the first-party +`:public-xnetworking` compatibility module. Gradle substitutes the old Maven +coordinate with the local module. + +### `jp.co.soramitsu.fearless-utils:fearless-utils:1.0.121` + +The app resolves `fearless-utils` from a public source checkout via Gradle +composite build. CI and release workflows check out +`soramitsu/fearless-utils-Android` at +`7500809f33243ee47ecb2ec8563fc284ac4de0d6`, set `FEARLESS_UTILS_PATH`, force +the local include with `FORCE_LOCAL_UTILS=true`, and run +`scripts/ensure-fearless-utils.sh` before Gradle resolution. + +Local developers can use the same contract by cloning the repo next to +`fearless-Android` or setting `FEARLESS_UTILS_PATH` explicitly. The Gradle +`USE_REMOTE_UTILS=true` source-control fallback is not used by public CI because +it does not currently resolve the requested published module version. + +The carried upstream delta is exported with: + +``` +bash ./scripts/test-public-dependency-upstream-delta-export.sh +bash ./scripts/export-public-dependency-upstream-delta.sh --output build/reports/public-dependency-upstream-delta +``` + +The generated `handoff-manifest.json` records the pinned +`fearless-utils-Android` revision, the library-only overlay patch checksum and +touched paths, and SHA-256 digests for all public compatibility modules. Release +reviewers should attach or archive this bundle whenever the pinned dependency +surface changes. + +## Remaining Release Risks + +### Compatibility-layer limits + +The replacement modules are enough to compile and run the current test suite, +but they are not all production-complete feature implementations. + +Known limits: + +- `ExtrinsicService` in the public core compatibility layer now supports + runtime-version lookup, nonce lookup, payment-info fee estimation, extrinsic + submission, and submit-and-watch status handling through public Substrate RPCs. +- The public extrinsic implementation signs a five-minute mortal era from local + block state and `chain_getBlockHash`, falling back to immortal only when chain + state or RPC data is unusable. +- Fee estimation has an account-aware public overload that uses the caller's + real account, nonce, crypto type, signer, tip, app id, and mortal era. Android + production call sites now avoid the anonymous dummy estimator; accountless UI + placeholders return zero instead of building dummy signed fee payloads. +- The public XCM compatibility surface now preserves route metadata from chain + config through Room and exposes origin, destination, asset, and min-amount + discovery with malformed-route filtering. Public builds still keep XCM + transfer execution disabled and fee/submission calls fail explicitly until an + open-source XCM extrinsic engine and end-to-end coverage are added. +- The private Soramitsu Nexus Maven repository remains opt-in only through + `INCLUDE_SORAMITSU_NEXUS=1`; public CI should not require it. + +## Verification + +The pinned public `fearless-utils` checkout guard passes: + +``` +FEARLESS_UTILS_PATH=../fearless-utils-Android \ +./scripts/ensure-fearless-utils.sh +``` + +The public replacement modules and affected app modules compile: + +``` +JAVA_HOME=/opt/homebrew/opt/openjdk@21/libexec/openjdk.jdk/Contents/Home \ +ANDROID_HOME=/opt/homebrew/share/android-commandlinetools \ +ANDROID_SDK_ROOT=/opt/homebrew/share/android-commandlinetools \ +FORCE_LOCAL_UTILS=true \ +FEARLESS_UTILS_LIBRARY_ONLY=true \ +./gradlew \ + :public-xnetworking:compileDebugKotlin \ + :public-shared-features-core:compileDebugKotlin \ + :public-shared-features-xcm:compileDebugKotlin \ + :public-shared-features-backup:compileDebugKotlin \ + :runtime:compileDebugKotlin \ + :feature-account-api:compileDebugKotlin \ + :feature-wallet-api:compileDebugKotlin \ + :feature-staking-api:compileDebugKotlin \ + :feature-polkaswap-api:compileDebugKotlin \ + :feature-account-impl:compileDebugKotlin \ + :feature-wallet-impl:compileDebugKotlin \ + :app:compileDebugKotlin \ + --no-daemon --console=plain --stacktrace +``` + +The public Substrate extrinsic implementation and affected callers also pass: + +``` +./gradlew \ + :core-api:testDebugUnitTest \ + :feature-crowdloan-impl:compileDebugKotlin \ + --no-daemon --console=plain --stacktrace + +./gradlew \ + :feature-wallet-impl:compileDebugKotlin \ + :feature-staking-impl:compileDebugKotlin \ + :feature-polkaswap-impl:compileDebugKotlin \ + :app:compileDebugKotlin \ + --no-daemon --console=plain --stacktrace +``` + +The public XCM route catalog and unsupported-path guards also pass: + +``` +./gradlew \ + :public-shared-features-xcm:testDebugUnitTest \ + :runtime:testDebugUnitTest \ + :core-db:compileDebugKotlin \ + :feature-wallet-impl:compileDebugKotlin \ + --no-daemon --console=plain --stacktrace +``` + +The wallet and staking anonymous fee-preview regression guards also pass: + +``` +./gradlew \ + :feature-wallet-impl:testDebugUnitTest \ + :feature-staking-impl:testDebugUnitTest \ + --no-daemon --console=plain --stacktrace +``` + +The broader debug build and unit suites also pass locally with the same +environment: + +``` +./gradlew :app:assembleDebug testDebugUnitTest --no-daemon --console=plain --stacktrace +``` + +The local `fearless-utils` extrinsic tests pass in library-only mode: + +``` +FEARLESS_UTILS_SKIP_RUST_PLUGIN=true \ +FEARLESS_UTILS_LIBRARY_ONLY=true \ +./gradlew \ + :fearless-utils:testDebugUnitTest \ + --tests 'jp.co.soramitsu.fearless_utils.runtime.extrinsic.*' \ + --no-daemon --console=plain --stacktrace +``` diff --git a/docs/release-checklist.md b/docs/release-checklist.md new file mode 100644 index 0000000000..01b81e80ad --- /dev/null +++ b/docs/release-checklist.md @@ -0,0 +1,105 @@ +# Release Checklist + +Use this checklist for every release PR from `develop` to `master`. The Android +release process in `docs/releases/PROCESS.md` remains the detailed app-specific +procedure. + +## Before The Release PR + +- Confirm all release work has landed on `develop`. +- Confirm the release version and changelog entry are final. +- Confirm no private keys, store credentials, analytics tokens, or local + environment files are committed. +- Run `bash ./scripts/test-branch-flow-audit.sh && bash ./scripts/audit-branch-flow.sh` + and confirm the release branch flow rules still pass. +- Run `./scripts/audit-public-artifacts.sh` and confirm it passes. +- Run `FEARLESS_UTILS_PATH=../fearless-utils-Android ./scripts/ensure-fearless-utils.sh` + and confirm the public `fearless-utils-Android` checkout is still pinned. +- Run `bash ./scripts/test-public-dependency-upstream-delta-export.sh` and + `bash ./scripts/export-public-dependency-upstream-delta.sh --output build/reports/public-dependency-upstream-delta`; + review `build/reports/public-dependency-upstream-delta/handoff-manifest.json` + before the release PR so the carried `fearless-utils` overlay and public + compatibility-module hashes are ready for upstream handoff or removal. +- For release artifact hardening, restore private overlays and run + `./scripts/audit-public-artifacts.sh --release --strict-provenance`; the + Android Release workflow runs the same strict provenance gate before building + the release artifact. +- Run `bash ./scripts/test-todo-debt-audit.sh && bash ./scripts/audit-todo-debt.sh` + and confirm no new TODO/FIXME/STOPSHIP debt was introduced. +- Run + `./gradlew :public-shared-features-xcm:testDebugUnitTest --tests 'jp.co.soramitsu.xcm.XcmServiceTest' --tests 'jp.co.soramitsu.xcm.domain.XcmEntitiesFetcherTest'` + and confirm the public XCM transfer-engine contract remains fail-closed by + default and rejects malformed routes before backend delegation. +- Run + `./gradlew :runtime:testDebugUnitTest --tests 'jp.co.soramitsu.runtime.multiNetwork.chain.remote.XcmLocalChainsRegistryTest'` + and confirm the bundled native relay-asset routes between Polkadot/Kusama and + Polkadot Asset Hub, Moonbeam, Acala, Parallel, Kusama AssetHub, Moonriver, + Karura, and Bifrost still carry executable XCM metadata. +- Run `bash ./scripts/test-xcm-registry-metadata-audit.sh` and then + `bash ./scripts/audit-xcm-registry-metadata.sh --require-executable --write-gap-report build/reports/xcm-registry-gap-report.json --require-route-file scripts/xcm-required-routes.tsv --require-gap-file scripts/xcm-discovery-only-routes.tsv` + and confirm every committed XCM execution spec is valid and the required + native relay-asset routes remain executable. Review + `build/reports/xcm-registry-gap-report.json` and + `scripts/xcm-discovery-only-routes.tsv`; confirm every `missingExecutionSpec` + route is intentionally still discovery-only and categorized as `bridge-sora`, + `non-native-asset`, or `cross-parachain-native`. Before enabling a + non-default public XCM transfer engine for all advertised routes, also run + `bash ./scripts/audit-xcm-registry-metadata.sh --require-executable --require-all-routes-executable` + and confirm the registry contains real executable metadata for every + advertised XCM route. +- Run `bash ./scripts/test-xcm-production-evidence-audit.sh` and + `bash ./scripts/audit-xcm-production-evidence.sh` to confirm the committed + XCM production evidence manifest is internally consistent. Before enabling + broad production XCM, run + `bash ./scripts/test-xcm-production-evidence-template.sh` and + `bash ./scripts/generate-xcm-production-evidence-template.sh --output build/reports/xcm-production-evidence-template.json` + to create the required-route evidence skeleton, update + `scripts/xcm-production-evidence.json` to + `status: ready`, set `releaseEnabled: true`, remove every discovery-only + route gap, attach E2E transfer evidence for every route in + `scripts/xcm-required-routes.tsv`, and run + `bash ./scripts/audit-xcm-production-evidence.sh --require-ready`. +- Run `bash ./scripts/check-iroha-mobile-sdk-release-assets.sh --self-test`. + If `IROHA_MOBILE_SDK_RELEASE_TAG` is configured for the release, also run + `bash ./scripts/check-iroha-mobile-sdk-release-assets.sh --download --tag "$IROHA_MOBILE_SDK_RELEASE_TAG"`. +- From the workspace root, run + `bash scripts/audit-passkey-backup-prerequisites.sh` and confirm + `config/passkey-backup-production.json` still matches Android passkey code, + Drive appdata storage, and the production challenge-service contract. Before + enabling user-facing passkey backup, run the same audit with + `PASSKEY_BACKUP_LIVE_HEALTH=1` and confirm the deployed challenge service + passes. Keep `PASSKEY_BACKUP_ENABLED=false` unless that live release audit is + green for the release. +- Before enabling user-facing passkey backup, confirm the Android flow includes + Google account selection, explicit Google Drive consent, and a recovery path + that offers restore before creating a new backup. +- Confirm public build instructions still work without private overlays. +- When the private Android overlay checkout is available, run + `PRIVATE_OVERLAY_REPORT=build/reports/private-overlay-boundary.tsv PRIVATE_REPO_DIR=../fearless-Android-priv ./scripts/audit-private-overlay-boundary.sh` + and confirm it passes. If it fails, use the full TSV report to move every + listed product-code or public-config path back to the public repo before + release. +- Run or confirm green CI for branch-flow audit, public artifact audit, + TODO-debt audit, Iroha mobile SDK release asset contract, private-overlay + audit self-test, detekt, unit tests, lint, and release/debug build jobs. +- Confirm Universal Wallet migrations, legacy export-only access, and supported + network registry changes are documented in the PR. +- Confirm rollback owner, monitoring owner, and release communication channel. + +## Release PR To `master` + +- Open the PR from `develop` or `release/` to `master`. +- Include test evidence, migration notes, release notes, and rollback notes. +- Confirm the `Branch Flow` workflow is green for the release PR. +- Confirm release CI restored `GOOGLE_SERVICES_RELEASE_JSON_B64`, + `ANDROID_RELEASE_KEYSTORE_B64`, `PLAY_SERVICE_ACCOUNT_JSON_B64`, signing + passwords, and partner keys before building the release artifact. +- Require review and green CI before merge. +- Merge with a merge commit so the release boundary is visible. +- Create the release tag only after the merge commit is on `master`. + +## After Release + +- Verify the distributed artifact matches the tagged commit. +- Monitor crash, ANR, wallet creation, import, transfer, and indexer error rates. +- Keep the hotfix path ready from `master` until rollout completes. diff --git a/docs/releases/PROCESS.md b/docs/releases/PROCESS.md index 1529dfbeb7..c385ba5b77 100644 --- a/docs/releases/PROCESS.md +++ b/docs/releases/PROCESS.md @@ -17,7 +17,7 @@ This document standardizes how we cut beta and stable releases for Fearless Andr ## Preconditions -- Toolchain: JDK 21, Android SDK 35 + build-tools 35.0.0, NDK r28 (and legacy r25 if needed), Rust toolchain. +- Toolchain: JDK 21, Android SDK 36 + build-tools 36.0.0, NDK r28 (and legacy r25 if needed), Rust toolchain. - Secrets: configured via env or `local.properties` (see README / docs samples). - Optional alignment overrides (first run mirrors): `TYPES_URL_OVERRIDE`, `DEFAULT_V13_TYPES_URL_OVERRIDE`, `CHAINS_URL_OVERRIDE`. @@ -26,6 +26,10 @@ This document standardizes how we cut beta and stable releases for Fearless Andr - Alignment print: - `./gradlew printPolkadotSdkAlignment` - Confirm effective URLs and shared_features pin (or "(not pinned)"). +- Public artifact audit: + - `./scripts/audit-public-artifacts.sh` + - Confirm checked-in Firebase files use the `fearless-public` placeholder + project, no signing material is tracked, and pinned binary checksums match. - Static analysis: - `./gradlew detektAll` - Unit tests + coverage: @@ -54,6 +58,12 @@ This document standardizes how we cut beta and stable releases for Fearless Andr ## Stable Release - Bump version to `x.y.z` (remove `-beta.*`). +- Restore release overlays in CI: + - `./scripts/restore-release-overlays.sh` + - `./scripts/audit-public-artifacts.sh --release --strict-provenance` + - Required CI secrets include `GOOGLE_SERVICES_RELEASE_JSON_B64`, + `ANDROID_RELEASE_KEYSTORE_B64`, `PLAY_SERVICE_ACCOUNT_JSON_B64`, signing + passwords, and partner-provider keys. - Tag and push (signed): - `git tag -s x.y.z -m "fearless-Android x.y.z" && git push origin x.y.z` - Staged rollout: diff --git a/docs/roadmap.md b/docs/roadmap.md index d053bec161..2b85e40aff 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -7,7 +7,7 @@ Priority: P0 (must-do), P1 (should-do), P2 (nice-to-have) ## Recent Updates - 2026-03-12: `scripts/build-libsodium.sh` now detects the correct host-specific NDK toolchain directory (darwin/linux/windows) instead of hardcoding macOS paths, so rebuilding libsodium works on Linux and CI hosts. - 2026-03-12: Local validation script now installs Android platform/build-tools 36 so fresh environments match the Gradle compileSdk configuration before running tasks. -- 2026-03-12: Added Gradle compatibility shims inside `settings.gradle` (`jcenter()` repository + `JavaExec.main`) and disabled the local `fearless-utils-Android` include on Gradle 9+ by default (set `FORCE_LOCAL_UTILS=true` to override) so composite builds stay functional until the upstream repository upgrades from AGP 8. +- 2026-03-12: Added Gradle compatibility shims inside `settings.gradle` (`jcenter()` repository + `JavaExec.main`) so the pinned `fearless-utils-Android` composite checkout remains buildable on the current Gradle stack until the upstream repository upgrades. - 2026-03-12: Updated WalletConnect/Reown dependencies to BOM 1.6.9 and bumped AGP (8.9.1) / compileSdk (36) so upstream UniFFI native libraries ship with 16 KB page-size support. - 2026-03-12: Temporarily excluded the WalletConnect Pay dependency (and its `yttrium-wcpay` natives) until Reown publishes 16 KB–aligned builds. - 2026-03-05: Completed Google Play 16 KB page-size compliance for all bundled native libs (sr25519, TonConnect helpers, toolChecker) by rebuilding with NDK r28, verifying `readelf -l` alignment in CI, and clearing the Play Console warning. @@ -39,9 +39,9 @@ Priority: P0 (must-do), P1 (should-do), P2 (nice-to-have) - `TYPES_URL_OVERRIDE=https:///all_chains_types_android.json` (stable2503-aligned) - `DEFAULT_V13_TYPES_URL_OVERRIDE=https:///default_v13_types.json` - `CHAINS_URL_OVERRIDE=https:///chains.json` (points to chain list validated against stable2503) - 2) Utils alignment (remote source): The build fetches `soramitsu/fearless-utils-Android` as a source dependency. + 2) Utils alignment (pinned source checkout): The build includes `soramitsu/fearless-utils-Android` as a composite source dependency. - Ensure NDK r28 (android-ndk-r28 / 28.0.x) and Rust toolchain with Android targets are installed (see README and CI config). - - Build will compile utils from source; no local path configuration is needed. + - CI checks out `7500809f33243ee47ecb2ec8563fc284ac4de0d6`; local builds should clone that repo next to this checkout or set `FEARLESS_UTILS_PATH`. 3) Library version pinning (shared_features): If required, pin via `SHARED_FEATURES_VERSION_OVERRIDE=1.x.y` in `local.properties` or env. 4) Build + quick checks: - `./gradlew detektAll runTest :app:lint` @@ -173,13 +173,14 @@ Priority: P0 (must-do), P1 (should-do), P2 (nice-to-have) - Verify `url = uri(...)`, `namespace = '…'`, and packaging excludes for test APKs. - Keep a CI step that prints Gradle/AGP versions (android-ci.yml). -13) Utils source mapping toggle -- Why: Make remote source dependency for fearless-utils explicit and controllable. +13) Utils source checkout contract +- Why: Keep `fearless-utils` open-source and reproducible without relying on private Maven artifacts. - Acceptance: - - `settings.gradle` maps the GitHub repository only when `USE_REMOTE_UTILS=true` (env or -P). - - CI sets `USE_REMOTE_UTILS=true` to build from source; local builds can rely on published artifacts by default. + - CI checks out `soramitsu/fearless-utils-Android` at the pinned commit and fails early if it drifts. + - `settings.gradle` includes the checked-out repo via composite build and substitutes `jp.co.soramitsu.fearless-utils:fearless-utils`. + - README and validation scripts document the same local workflow. - Prompt: - - Add a settings flag and document it in AGENTS/README; enable flag in CI env. + - Update `FEARLESS_UTILS_COMMIT` intentionally when the source dependency changes, then rerun `scripts/ensure-fearless-utils.sh` and the Android build/test suite. ## P2 — Lower Priority diff --git a/docs/rollback-checklist.md b/docs/rollback-checklist.md new file mode 100644 index 0000000000..a1c5276332 --- /dev/null +++ b/docs/rollback-checklist.md @@ -0,0 +1,32 @@ +# Rollback Checklist + +Use this when a released Android build causes a production-impacting issue. + +## Trigger + +- Stop or pause staged rollout when crash, ANR, signing, migration, transfer, or + indexer-backed read failures exceed the release threshold. +- Assign one incident owner and one communication owner. + +## Immediate Actions + +- Identify the last known-good `master` tag and current rollout percentage. +- Capture failing version, device/OS distribution, feature flags, and relevant + backend/indexer status. +- Disable remote config or feature gates first when that removes the issue + without a binary rollback. +- If binary rollback is required, halt rollout in the store console and prepare a + hotfix branch from `master`. + +## Hotfix Path + +- Create `hotfix/` from `master`. +- Apply the smallest safe fix or revert. +- Run the release checklist checks that cover the changed surface. +- Open a PR to `master`, tag after merge, then merge or cherry-pick back to + `develop`. + +## After Recovery + +- Document root cause, affected versions, user impact, and prevention work. +- Update release notes and the project tracker with the final disposition. diff --git a/docs/samples/local.properties.example b/docs/samples/local.properties.example index 156e8bcced..b1f7494937 100644 --- a/docs/samples/local.properties.example +++ b/docs/samples/local.properties.example @@ -3,6 +3,13 @@ MOONPAY_TEST_SECRET="" MOONPAY_PRODUCTION_SECRET="" +MOONPAY_TEST_PUBLIC_KEY= +MOONPAY_PRODUCTION_PUBLIC_KEY= + +# Buy provider partner config +RAMP_TOKEN_DEBUG= +RAMP_TOKEN_RELEASE= +COINBASE_APP_ID= # X1 plugin X1_ENDPOINT_URL_DEBUG= @@ -39,4 +46,5 @@ FL_ANDROID_ALCHEMY_API_ETHEREUM_KEY= FL_DWELLIR_API_KEY= FL_ANDROID_TON_API_KEY= +# Optional for local or non-mainnet TON testing. Mainnet always uses https://ti.soramitsu.io. FL_ANDROID_TON_INDEXER_URL= diff --git a/docs/samples/local.properties.stable2503 b/docs/samples/local.properties.stable2503 index 1fe800702b..d22b0a4eb8 100644 --- a/docs/samples/local.properties.stable2503 +++ b/docs/samples/local.properties.stable2503 @@ -14,4 +14,7 @@ CHAINS_URL_OVERRIDE=https:///chains.json #SHARED_FEATURES_VERSION_OVERRIDE=1.x.y # Optional: use a local checkout of fearless-utils-Android that supports stable2503 -# Utils are fetched from GitHub via sourceControl; no local path required. +# Clone https://github.com/soramitsu/fearless-utils-Android.git and set FEARLESS_UTILS_PATH +# or place the checkout at ../fearless-utils-Android. CI pins commit +# 7500809f33243ee47ecb2ec8563fc284ac4de0d6 and verifies it with +# scripts/ensure-fearless-utils.sh. diff --git a/docs/status.md b/docs/status.md index ad285b17fb..ce6ba179fd 100644 --- a/docs/status.md +++ b/docs/status.md @@ -27,7 +27,7 @@ This snapshot summarizes the current health, feature coverage, and key risks of - WalletConnect Pay: dependency excluded (until Reown publishes 16 KB-native builds) to avoid packaging the `yttrium-wcpay` 4 KB libraries. - Google Play 16 KB page-size compliance: Native bundles rebuilt with NDK r28, `readelf -l` verification runs in CI on sr25519/toolChecker libraries, and the Play Console warning is cleared. - Native crypto rebuild tooling: `scripts/build-libsodium.sh` now auto-detects the host-specific `toolchains/llvm/prebuilt` directory (darwin/linux/windows) so libsodium can be rebuilt on non-macOS hosts without manual tweaks. -- Utils composite build: `settings.gradle` now shims the removed `jcenter()` repository helper plus the deprecated `JavaExec.main` API so Gradle 9+ can still include the local `fearless-utils-Android` checkout until it is updated upstream. Because that repo still targets AGP/Gradle 8, Gradle 9 builds skip the local include by default and fall back to the published artifact unless `FORCE_LOCAL_UTILS=true` is supplied. +- Utils composite build: public CI checks out `soramitsu/fearless-utils-Android` at `7500809f33243ee47ecb2ec8563fc284ac4de0d6`, verifies it with `scripts/ensure-fearless-utils.sh`, and forces the local composite include. `settings.gradle` keeps Gradle 9 shims for the upstream build until that repo upgrades. ## Runtime & Chains - Default types/chains under `runtime/src/main/assets`. Override via `TYPES_URL_OVERRIDE`, `DEFAULT_V13_TYPES_URL_OVERRIDE`, `CHAINS_URL_OVERRIDE` in `local.properties`. @@ -37,7 +37,7 @@ This snapshot summarizes the current health, feature coverage, and key risks of - Target: polkadot-stable2503 (prepared via override keys). - How to align: set `TYPES_URL_OVERRIDE`, `DEFAULT_V13_TYPES_URL_OVERRIDE`, and `CHAINS_URL_OVERRIDE` to registries validated against stable2503. See `docs/samples/local.properties.stable2503`. - Optional: pin `shared_features` via `SHARED_FEATURES_VERSION_OVERRIDE=1.x.y` if required by the SDK combo. -- Utils integration: the build fetches `soramitsu/fearless-utils-Android` as a source dependency and builds it from source. +- Utils integration: the build uses a pinned `soramitsu/fearless-utils-Android` checkout as a composite source dependency. - Debug: run `./gradlew printPolkadotSdkAlignment` to verify effective overrides. ## Health & Risks (Snapshot) diff --git a/docs/universal-wallet-v2.md b/docs/universal-wallet-v2.md new file mode 100644 index 0000000000..315487605a --- /dev/null +++ b/docs/universal-wallet-v2.md @@ -0,0 +1,214 @@ +# Universal Wallet V2 Contract + +Status: implementation contract for the Bitcoin, Solana, TON, and SORA Nexus +workstreams. + +## Goals + +- New wallets are one encrypted universal wallet, not one wallet per ecosystem. +- The default backup phrase for newly created wallets is 24 BIP39 words. +- Import remains compatible with 12-word BIP39 phrases. +- One phrase deterministically restores every supported account on Android, iOS, + and the browser extension. +- Legacy accounts remain available only for export and fund-safety recovery after + the migration cutoff. +- Wallet core, chain registry defaults, derivation rules, and indexer clients + stay open source. + +## Ecosystems + +The shared ecosystem enum is lowercase at API, registry, and fixture boundaries: + +- `substrate` +- `evm` +- `ton` +- `bitcoin` +- `solana` +- `iroha` + +Platform UI may map these values to native enum names, but persisted and networked +interfaces must use the lowercase identifiers above. + +## Identity Envelope + +Wallet metadata uses a versioned JSON envelope with these fields: + +| Field | Requirement | +| --- | --- | +| `schemaVersion` | Must be `2`. | +| `walletId` | Stable local id matching `uw2_[A-Za-z0-9_-]{16,64}`. | +| `displayName` | Non-empty user-visible name, at most 64 characters, no control characters. | +| `source` | One of `created-24-word`, `imported-12-word`, `imported-24-word`, `legacy-import`. | +| `status` | One of `active`, `migration-required`, `legacy-export-only`. | +| `publicAccounts` | Public account descriptors only; no mnemonic, seed, private key, or encrypted secret material. | +| `createdAtMillis` | Positive Unix epoch timestamp in milliseconds. | +| `updatedAtMillis` | Optional Unix epoch timestamp in milliseconds, greater than or equal to `createdAtMillis`. | +| `legacyExportOnlyReason` | Required only for `legacy-export-only`; otherwise must be absent or blank. | + +An `active` identity must include at least one public account for every supported +ecosystem: `substrate`, `evm`, `ton`, `bitcoin`, `solana`, and `iroha`. +`migration-required` identities may be partial but must contain at least one +public account. `legacy-export-only` identities must use `source = +legacy-import` and include a short human-readable recovery/export reason. + +Public account descriptors use lowercase ecosystem ids, stable account ids +matching `^[a-z0-9][a-z0-9._:-]{1,63}$`, chain ids matching +`^[A-Za-z0-9._:-]{2,128}$` when present, derivation paths rooted at `m`, and +lowercase hex public keys when present. + +## Signing Contract + +Wallet signing prompts use a shared request/result envelope. Requests contain +`requestId`, `accountId`, `ecosystem`, `chainId`, `origin`, `method`, +chain-specific payload fields, `createdAtMillis`, and optional +`expiresAtMillis`. + +Allowed methods are `sign-message`, `sign-transaction`, +`sign-and-send-transaction`, and `sign-all-transactions`. Message payloads use +`base64`, `hex`, or `utf8` encoding plus a display hint of `raw`, `utf8`, or +`hex`. Single transaction methods carry `transactionBase64`. Batch signing +carries `transactionsBase64` with at most 16 transactions. + +Results contain the request identity fields, method, `status`, signing outputs, +optional `errorCode`, and `signedAtMillis`. Allowed statuses are `approved`, +`rejected`, and `failed`. Approved message results must include a public key and +at least one signature representation. Approved transaction results must include +the signed transaction payload. Approved sign-and-send results must also include +a transaction hash. Rejected and failed results must include an error code and +must not include signatures or signed payloads. + +Clients must reject malformed request ids, account ids, ecosystems, chain ids, +origins, timestamps, invalid base64/hex payloads, oversized transaction batches, +and result payloads that do not match their status and method. + +## Hard-Cutoff Migration + +After the migration cutoff, normal wallet access is allowed only when a valid +Universal Wallet V2 identity exists. A wallet with legacy accounts and no +universal wallet must enter `migrate-before-access`: normal balance, transfer, +staking, dApp, and signing flows stay blocked until the user creates or imports a +universal wallet. A fresh install with no legacy material enters +`create-universal-wallet`. + +Legacy vault descriptors are export-only. They may expose backup/export and +fund-safety recovery metadata, but they must set `canExportSecrets = true`, +`canSignTransactions = false`, and `mode = export-only`. Legacy vaults must not +be used for new signatures, broadcasts, staking actions, dApp approvals, or +normal account selection after the cutoff. + +Migration snapshots contain `schemaVersion`, `platform`, `hasUniversalWallet`, +`legacyVaults`, `cutoffAtMillis`, and `evaluatedAtMillis`. Clients must reject +duplicate legacy vault ids, malformed account ids, unknown ecosystems, unsafe +addresses or display names, missing export-only reasons, disabled export, legacy +signing flags, and invalid timestamps. + +## Secure-Storage Migration Requirements + +Android migration must read old account material only through the existing +encrypted stores, write Universal Wallet V2 material through Android Keystore +backed encryption where available, preserve export-only legacy descriptors, and +never copy mnemonic, seed, or private-key material into public logs, analytics, +plain preferences, or repo fixtures. + +iOS migration must read old account material only through Keychain-backed +storage, write Universal Wallet V2 material to the configured Keychain access +group with passcode or biometric protection where available, preserve export-only +legacy descriptors, and avoid storing secret material in Core Data, user defaults, +logs, or public fixtures. + +Web extension migration must read old account material through the extension +keyring, write Universal Wallet V2 material only through the encrypted keyring or +WebCrypto-protected browser storage, preserve export-only legacy descriptors, and +avoid storing secret material in unencrypted local storage, background messages, +Redux/Pinia state, logs, or test fixtures. + +## Derivation Defaults + +| Ecosystem | Default | +| --- | --- | +| Substrate/DOT | Existing sr25519 root derivation; SS58 prefix is chain-specific. | +| EVM | `m/44'/60'/0'/0/0` | +| Bitcoin mainnet | BIP84 account path `m/84'/0'/0'`; first receive path `m/84'/0'/0'/0/0`. | +| Bitcoin testnet | BIP84 account path `m/84'/1'/0'`; first receive path `m/84'/1'/0'/0/0`. | +| Solana | `m/44'/501'/0'/0'` | +| TON | `m/44'/607'/0'/0'/0'`, Wallet V4 R2, workchain `0`. | +| SORA Nexus/Iroha | `m/44'/617'/0'/0'`, Ed25519 I105 account address. | + +Solana import compatibility may support common existing Solana paths, but new +Universal Wallet V2 accounts must use `m/44'/501'/0'/0'`. + +## Registry Requirements + +Registry files use `schemaVersion = 1` and a top-level `chains` array. Each +chain entry contains `id`, lowercase `ecosystem`, `chainId`, `displayName`, +`enabledByDefault`, optional `nativeAsset`, optional `derivationPath`, optional +`slip44CoinType`, and `endpoints`. + +Enabled-by-default chains must include at least one endpoint. Disabled or gated +chains, including Nexus before its production Torii/TLS endpoint is confirmed, +may omit endpoints. Chain ids and endpoint ids must be unique, display names +must be non-empty human-readable strings without control characters, native asset +symbols must be uppercase, decimals must be in `0...255`, and derivation paths +must be rooted at `m`. + +Endpoint kinds are `indexer`, `rpc`, `torii-mcp`, and `explorer`. Public URLs +must be HTTPS; local development URLs may use `http://localhost` or +`http://127.0.0.1`. Public indexer endpoints are read-only. Broadcasts, +transaction simulation, and other write operations must use RPC or Torii +endpoints, never a public indexer endpoint. + +- TON indexer base URL: `https://ti.soramitsu.io`. +- Solana indexer base URL: `https://si.soramitsu.io`. +- `si.soramitsu.io` is read-only. Transaction simulation and broadcast use the + configured Solana RPC endpoint directly. +- Taira testnet is enabled with I105 chain discriminant `369`, Torii root + `https://taira.sora.org`, and chain id `iroha3-taira`. +- Nexus mainnet uses I105 chain discriminant `753` and chain id + `sora:nexus:global`, but remains registry-gated until the + production Torii/TLS endpoint is confirmed. + +## Normalized Indexer Contract + +Wallet-facing indexer code maps raw chain responses into normalized public +payloads before UI, cache, or migration logic consumes them. + +Normalized asset balances contain `accountId`, `ecosystem`, `chainId`, +`assetId`, raw integer `amount`, `decimals`, `isNative`, optional `symbol`, +optional `name`, optional `uiAmountString`, optional token or contract account +ids, and `syncedAtMillis`. + +Normalized transactions contain `accountId`, `ecosystem`, `chainId`, +`transactionId`, `status`, `direction`, `operationType`, optional timestamp, +optional raw integer amount and fee fields, optional counterparty, optional block +number, optional cursor, optional HTTPS explorer URL, and `syncedAtMillis`. +Allowed transaction statuses are `pending`, `confirmed`, and `failed`. Allowed +directions are `incoming`, `outgoing`, `self`, and `unknown`. Allowed operation +types are `transfer`, `swap`, `stake`, `unstake`, `governance`, +`contract-call`, `mint`, `burn`, `fee`, and `unknown`. + +Normalized token metadata contains `ecosystem`, `chainId`, `assetId`, +`decimals`, optional `symbol`, optional `name`, optional `iconUrl`, optional +`metadataUrl`, `isVerified`, and `syncedAtMillis`. Public asset URLs must be +HTTPS or IPFS URLs. Page metadata contains optional `nextCursor`, `limit`, +optional `total`, and `syncedAtMillis`; page limits are capped at `250`. + +All normalized amounts are decimal strings containing unsigned raw integer +amounts. The clients must reject malformed account ids, unknown ecosystems, +malformed chain ids, unsafe cursors, invalid URLs, non-integer amounts, invalid +decimals, and non-positive sync timestamps before persisting normalized data. + +## Fixture Contract + +Golden vectors live in `common/src/test/resources/universal-wallet-v2-vectors.json`. +Every wallet repo must keep an equivalent fixture and test that: + +- validates all six ecosystem sections are present; +- derives each expected public address from the mnemonic and path; +- rejects wrong Bitcoin purpose paths; +- rejects wrong Solana paths; +- rejects TON testnet-only addresses for mainnet receive flows; +- rejects Taira and Nexus I105 discriminants when used interchangeably. + +The fixture intentionally contains a 12-word import vector and a non-zero +24-word default-wallet vector so test suites catch both compatibility and new +wallet creation regressions. diff --git a/feature-account-api/src/main/java/jp/co/soramitsu/account/api/domain/PendulumPreInstalledAccountsScenario.kt b/feature-account-api/src/main/java/jp/co/soramitsu/account/api/domain/PendulumPreInstalledAccountsScenario.kt index fc03b5b650..8bbacbbe33 100644 --- a/feature-account-api/src/main/java/jp/co/soramitsu/account/api/domain/PendulumPreInstalledAccountsScenario.kt +++ b/feature-account-api/src/main/java/jp/co/soramitsu/account/api/domain/PendulumPreInstalledAccountsScenario.kt @@ -6,8 +6,8 @@ import jp.co.soramitsu.common.data.network.config.RemoteConfigFetcher import jp.co.soramitsu.common.data.storage.Preferences import jp.co.soramitsu.common.utils.DEFAULT_DERIVATION_PATH import jp.co.soramitsu.core.models.CryptoType -import jp.co.soramitsu.shared_utils.encrypt.junction.BIP32JunctionDecoder -import jp.co.soramitsu.shared_utils.extensions.fromHex +import jp.co.soramitsu.fearless_utils.encrypt.junction.BIP32JunctionDecoder +import jp.co.soramitsu.fearless_utils.extensions.fromHex class PendulumPreInstalledAccountsScenario( private val accountRepository: AccountRepository, diff --git a/feature-account-api/src/main/java/jp/co/soramitsu/account/api/domain/interfaces/AccountInteractor.kt b/feature-account-api/src/main/java/jp/co/soramitsu/account/api/domain/interfaces/AccountInteractor.kt index 1de06f5966..3bc923fd14 100644 --- a/feature-account-api/src/main/java/jp/co/soramitsu/account/api/domain/interfaces/AccountInteractor.kt +++ b/feature-account-api/src/main/java/jp/co/soramitsu/account/api/domain/interfaces/AccountInteractor.kt @@ -10,14 +10,15 @@ import jp.co.soramitsu.backup.domain.models.BackupAccountMeta import jp.co.soramitsu.backup.domain.models.BackupAccountType import jp.co.soramitsu.common.data.secrets.v2.ChainAccountSecrets import jp.co.soramitsu.common.data.secrets.v3.SubstrateSecrets +import jp.co.soramitsu.common.model.UniversalWalletMigrationSnapshot import jp.co.soramitsu.common.model.WalletEcosystem import jp.co.soramitsu.common.utils.ComponentHolder import jp.co.soramitsu.core.model.Language import jp.co.soramitsu.core.models.CryptoType import jp.co.soramitsu.runtime.multiNetwork.chain.model.Chain import jp.co.soramitsu.runtime.multiNetwork.chain.model.ChainId -import jp.co.soramitsu.shared_utils.encrypt.mnemonic.Mnemonic -import jp.co.soramitsu.shared_utils.scale.EncodableStruct +import jp.co.soramitsu.fearless_utils.encrypt.mnemonic.Mnemonic +import jp.co.soramitsu.fearless_utils.scale.EncodableStruct import kotlinx.coroutines.flow.Flow import java.io.File @@ -112,6 +113,8 @@ interface AccountInteractor { suspend fun selectedLightMetaAccount(): LightMetaAccount fun selectedLightMetaAccountFlow(): Flow fun observeSelectedMetaAccountFavoriteChains(): Flow> + fun universalWalletMigrationSnapshotFlow(): Flow + suspend fun universalWalletMigrationSnapshot(): UniversalWalletMigrationSnapshot suspend fun saveGoogleBackupAccount(metaId: Long, googleBackupPassword: String) suspend fun getGoogleBackupAccounts(): List diff --git a/feature-account-api/src/main/java/jp/co/soramitsu/account/api/domain/interfaces/AccountRepository.kt b/feature-account-api/src/main/java/jp/co/soramitsu/account/api/domain/interfaces/AccountRepository.kt index ec0ddcccbb..ce63b4b721 100644 --- a/feature-account-api/src/main/java/jp/co/soramitsu/account/api/domain/interfaces/AccountRepository.kt +++ b/feature-account-api/src/main/java/jp/co/soramitsu/account/api/domain/interfaces/AccountRepository.kt @@ -18,9 +18,9 @@ import jp.co.soramitsu.core.model.SecuritySource import jp.co.soramitsu.core.models.CryptoType import jp.co.soramitsu.runtime.multiNetwork.chain.model.Chain import jp.co.soramitsu.runtime.multiNetwork.chain.model.ChainId -import jp.co.soramitsu.shared_utils.encrypt.mnemonic.Mnemonic -import jp.co.soramitsu.shared_utils.runtime.AccountId -import jp.co.soramitsu.shared_utils.scale.EncodableStruct +import jp.co.soramitsu.fearless_utils.encrypt.mnemonic.Mnemonic +import jp.co.soramitsu.fearless_utils.runtime.AccountId +import jp.co.soramitsu.fearless_utils.scale.EncodableStruct import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.StateFlow diff --git a/feature-account-api/src/main/java/jp/co/soramitsu/account/api/domain/interfaces/SignWithAccount.kt b/feature-account-api/src/main/java/jp/co/soramitsu/account/api/domain/interfaces/SignWithAccount.kt index 900695236c..678f1de489 100644 --- a/feature-account-api/src/main/java/jp/co/soramitsu/account/api/domain/interfaces/SignWithAccount.kt +++ b/feature-account-api/src/main/java/jp/co/soramitsu/account/api/domain/interfaces/SignWithAccount.kt @@ -3,8 +3,8 @@ package jp.co.soramitsu.account.api.domain.interfaces import jp.co.soramitsu.account.api.domain.model.Account import jp.co.soramitsu.account.api.domain.model.MetaAccount import jp.co.soramitsu.core.crypto.mapCryptoTypeToEncryption -import jp.co.soramitsu.shared_utils.encrypt.MultiChainEncryption -import jp.co.soramitsu.shared_utils.encrypt.Signer +import jp.co.soramitsu.fearless_utils.encrypt.MultiChainEncryption +import jp.co.soramitsu.fearless_utils.encrypt.Signer import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext diff --git a/feature-account-api/src/main/java/jp/co/soramitsu/account/api/domain/model/Account.kt b/feature-account-api/src/main/java/jp/co/soramitsu/account/api/domain/model/Account.kt index 70d80d9d65..a22f83ef83 100644 --- a/feature-account-api/src/main/java/jp/co/soramitsu/account/api/domain/model/Account.kt +++ b/feature-account-api/src/main/java/jp/co/soramitsu/account/api/domain/model/Account.kt @@ -1,7 +1,7 @@ package jp.co.soramitsu.account.api.domain.model import jp.co.soramitsu.core.models.CryptoType -import jp.co.soramitsu.shared_utils.extensions.fromHex +import jp.co.soramitsu.fearless_utils.extensions.fromHex data class diff --git a/feature-account-api/src/main/java/jp/co/soramitsu/account/api/domain/model/AndroidUniversalWalletLegacyExportResolver.kt b/feature-account-api/src/main/java/jp/co/soramitsu/account/api/domain/model/AndroidUniversalWalletLegacyExportResolver.kt new file mode 100644 index 0000000000..403f4000be --- /dev/null +++ b/feature-account-api/src/main/java/jp/co/soramitsu/account/api/domain/model/AndroidUniversalWalletLegacyExportResolver.kt @@ -0,0 +1,78 @@ +package jp.co.soramitsu.account.api.domain.model + +import jp.co.soramitsu.common.model.UniversalWalletEcosystem +import jp.co.soramitsu.common.model.UniversalWalletLegacyVaultDescriptor +import jp.co.soramitsu.common.model.UniversalWalletLegacyVaultMode +import jp.co.soramitsu.runtime.multiNetwork.chain.model.ChainId +import jp.co.soramitsu.runtime.multiNetwork.chain.model.moonriverChainId +import jp.co.soramitsu.runtime.multiNetwork.chain.model.polkadotChainId +import jp.co.soramitsu.runtime.multiNetwork.chain.model.tonChainId + +data class AndroidUniversalWalletLegacyExportTarget( + val metaId: Long, + val chainId: ChainId, + val ecosystem: UniversalWalletEcosystem, + val descriptor: UniversalWalletLegacyVaultDescriptor +) + +object AndroidUniversalWalletLegacyExportResolver { + + fun resolve( + descriptor: UniversalWalletLegacyVaultDescriptor, + accounts: List + ): AndroidUniversalWalletLegacyExportTarget? { + if (!descriptor.canExportSecrets || descriptor.canSignTransactions) { + return null + } + + if (descriptor.mode != UniversalWalletLegacyVaultMode.ExportOnly || descriptor.validationErrors().isNotEmpty()) { + return null + } + + val ecosystem = UniversalWalletEcosystem.fromId(descriptor.ecosystem) ?: return null + val chainId = legacyExportChainId(ecosystem) ?: return null + + val account = accounts.firstOrNull { + descriptor.vaultId == expectedVaultId(it.id, ecosystem) && + descriptor.accountId == expectedAccountId(it.id, ecosystem) && + it.hasLegacyRootMaterial(ecosystem) + } ?: return null + + return AndroidUniversalWalletLegacyExportTarget( + metaId = account.id, + chainId = chainId, + ecosystem = ecosystem, + descriptor = descriptor + ) + } + + private fun legacyExportChainId(ecosystem: UniversalWalletEcosystem): ChainId? { + return when (ecosystem) { + UniversalWalletEcosystem.Substrate -> polkadotChainId + UniversalWalletEcosystem.Evm -> moonriverChainId + UniversalWalletEcosystem.Ton -> tonChainId + UniversalWalletEcosystem.Bitcoin, + UniversalWalletEcosystem.Solana, + UniversalWalletEcosystem.Iroha -> null + } + } + + private fun LightMetaAccount.hasLegacyRootMaterial(ecosystem: UniversalWalletEcosystem): Boolean { + return when (ecosystem) { + UniversalWalletEcosystem.Substrate -> substrateAccountId != null + UniversalWalletEcosystem.Evm -> ethereumAddress != null || ethereumPublicKey != null + UniversalWalletEcosystem.Ton -> tonPublicKey != null + UniversalWalletEcosystem.Bitcoin, + UniversalWalletEcosystem.Solana, + UniversalWalletEcosystem.Iroha -> false + } + } + + private fun expectedVaultId(id: Long, ecosystem: UniversalWalletEcosystem): String { + return "legacy_android_${id}_${ecosystem.id}" + } + + private fun expectedAccountId(id: Long, ecosystem: UniversalWalletEcosystem): String { + return "android-${id}-${ecosystem.id}" + } +} diff --git a/feature-account-api/src/main/java/jp/co/soramitsu/account/api/domain/model/AndroidUniversalWalletMigrationSnapshotBuilder.kt b/feature-account-api/src/main/java/jp/co/soramitsu/account/api/domain/model/AndroidUniversalWalletMigrationSnapshotBuilder.kt new file mode 100644 index 0000000000..965a80f1b2 --- /dev/null +++ b/feature-account-api/src/main/java/jp/co/soramitsu/account/api/domain/model/AndroidUniversalWalletMigrationSnapshotBuilder.kt @@ -0,0 +1,215 @@ +package jp.co.soramitsu.account.api.domain.model + +import jp.co.soramitsu.common.model.UniversalWalletEcosystem +import jp.co.soramitsu.common.model.UniversalWalletLegacyVaultDescriptor +import jp.co.soramitsu.common.model.UniversalWalletMigrationPlatform +import jp.co.soramitsu.common.model.UniversalWalletMigrationSnapshot +import jp.co.soramitsu.common.model.UniversalWalletRegistry +import jp.co.soramitsu.common.utils.BitcoinKeyDerivation +import jp.co.soramitsu.common.utils.IrohaAddressCodec +import jp.co.soramitsu.common.utils.SolanaKeyDerivation +import jp.co.soramitsu.common.utils.ethereumAddressToHex +import jp.co.soramitsu.common.utils.v4r2tonAddress +import jp.co.soramitsu.fearless_utils.extensions.toHexString +import jp.co.soramitsu.fearless_utils.ss58.SS58Encoder.toAddress +import jp.co.soramitsu.runtime.multiNetwork.chain.model.ChainId + +class AndroidUniversalWalletMigrationSnapshotBuilder( + private val cutoffAtMillis: Long, + private val clockMillis: () -> Long = { System.currentTimeMillis() } +) { + + fun build(accounts: List): UniversalWalletMigrationSnapshot { + return UniversalWalletMigrationSnapshot( + platform = UniversalWalletMigrationPlatform.Android, + hasUniversalWallet = accounts.any { it.hasCompleteUniversalWallet() }, + legacyVaults = accounts.flatMap { account -> + if (account.hasCompleteUniversalWallet()) { + emptyList() + } else { + account.legacyVaultDescriptors() + } + }, + cutoffAtMillis = cutoffAtMillis, + evaluatedAtMillis = maxOf(clockMillis(), cutoffAtMillis) + ) + } + + private fun LightMetaAccount.legacyVaultDescriptors(): List { + return listOfNotNull( + substrateAccountId?.let { + legacyVault( + account = this, + ecosystem = UniversalWalletEcosystem.Substrate, + address = substrateAddressOrFallback() + ) + }, + (ethereumAddress ?: ethereumPublicKey)?.let { + legacyVault( + account = this, + ecosystem = UniversalWalletEcosystem.Evm, + address = evmAddressOrFallback() + ) + }, + tonPublicKey?.let { + legacyVault( + account = this, + ecosystem = UniversalWalletEcosystem.Ton, + address = tonAddressOrFallback() + ) + } + ) + } + + private fun legacyVault( + account: LightMetaAccount, + ecosystem: UniversalWalletEcosystem, + address: String + ): UniversalWalletLegacyVaultDescriptor { + return UniversalWalletLegacyVaultDescriptor( + vaultId = "legacy_android_${account.id}_${ecosystem.id}", + accountId = "android-${account.id}-${ecosystem.id}", + ecosystem = ecosystem, + address = address, + displayName = account.name.safeDisplayName(), + exportOnlyReason = "pre-cutoff account export", + canExportSecrets = true, + canSignTransactions = false, + discoveredAtMillis = cutoffAtMillis + ) + } + + private fun LightMetaAccount.hasCompleteUniversalWallet(): Boolean { + return substrateAddressOrNull() != null && + evmAddressOrNull() != null && + tonAddressOrNull() != null && + bitcoinAddressOrNull() != null && + solanaAddressOrNull() != null && + irohaAddressOrNull() != null + } + + private fun LightMetaAccount.substrateAddressOrFallback(): String { + return substrateAddressOrNull() ?: unavailableAddress(UniversalWalletEcosystem.Substrate) + } + + private fun LightMetaAccount.substrateAddressOrNull(): String? { + return runCatching { + substrateAccountId + ?.takeIf { it.size == SUBSTRATE_ACCOUNT_ID_BYTES } + ?.toAddress(0.toShort()) + }.getOrNull() + } + + private fun LightMetaAccount.evmAddressOrFallback(): String { + return evmAddressOrNull() ?: unavailableAddress(UniversalWalletEcosystem.Evm) + } + + private fun LightMetaAccount.evmAddressOrNull(): String? { + return runCatching { + ethereumAddress + ?.takeIf { it.size == EVM_ADDRESS_BYTES } + ?.ethereumAddressToHex() + }.getOrNull() + } + + private fun LightMetaAccount.tonAddressOrFallback(): String { + return tonAddressOrNull() ?: unavailableAddress(UniversalWalletEcosystem.Ton) + } + + private fun LightMetaAccount.tonAddressOrNull(): String? { + return runCatching { + tonPublicKey + ?.takeIf { it.size == TON_PUBLIC_KEY_BYTES } + ?.v4r2tonAddress(isTestnet = false) + }.getOrNull() + } + + private fun LightMetaAccount.bitcoinAddressOrNull(): String? { + return universalWalletChainAccounts.firstPublicKey(BITCOIN_MAINNET_CHAIN_IDS)?.let { + runCatching { + BitcoinKeyDerivation.addressFromPublicKey(it, BitcoinKeyDerivation.Network.Mainnet) + }.getOrNull() + } ?: universalWalletChainAccounts.firstPublicKey(BITCOIN_TESTNET_CHAIN_IDS)?.let { + runCatching { + BitcoinKeyDerivation.addressFromPublicKey(it, BitcoinKeyDerivation.Network.Testnet) + }.getOrNull() + } + } + + private fun LightMetaAccount.solanaAddressOrNull(): String? { + return universalWalletChainAccounts.firstPublicKey(SOLANA_CHAIN_IDS)?.let { + runCatching { + SolanaKeyDerivation.addressFromPublicKey(it) + }.getOrNull() + } + } + + private fun LightMetaAccount.irohaAddressOrNull(): String? { + return universalWalletChainAccounts.firstPublicKey(TAIRA_CHAIN_IDS)?.let { + runCatching { + IrohaAddressCodec.encode( + publicKeyHex = it.toHexString(withPrefix = false), + chainDiscriminant = UniversalWalletRegistry.taira.chainDiscriminant + ) + }.getOrNull() + } ?: universalWalletChainAccounts.firstPublicKey(NEXUS_CHAIN_IDS)?.let { + runCatching { + IrohaAddressCodec.encode( + publicKeyHex = it.toHexString(withPrefix = false), + chainDiscriminant = UniversalWalletRegistry.nexus.chainDiscriminant + ) + }.getOrNull() + } + } + + private fun LightMetaAccount.unavailableAddress(ecosystem: UniversalWalletEcosystem): String { + return "unavailable:android:$id:${ecosystem.id}" + } + + private fun Map.firstPublicKey( + chainIds: Set + ): ByteArray? { + return chainIds.firstNotNullOfOrNull { get(it)?.publicKey } + } + + private fun String.safeDisplayName(): String? { + val sanitized = trim() + .filterNot { it.isISOControl() } + .take(64) + + return sanitized.takeIf { it.isNotBlank() } + } + + companion object { + private const val SUBSTRATE_ACCOUNT_ID_BYTES = 32 + private const val EVM_ADDRESS_BYTES = 20 + private const val TON_PUBLIC_KEY_BYTES = 32 + + private val BITCOIN_MAINNET_CHAIN_IDS = setOf( + UniversalWalletRegistry.bitcoinMainnet.id, + UniversalWalletRegistry.bitcoinMainnet.chainId + ) + + private val BITCOIN_TESTNET_CHAIN_IDS = setOf( + UniversalWalletRegistry.bitcoinTestnet.id, + UniversalWalletRegistry.bitcoinTestnet.chainId + ) + + private val SOLANA_CHAIN_IDS = setOf( + UniversalWalletRegistry.solanaMainnet.id, + UniversalWalletRegistry.solanaMainnet.chainId, + UniversalWalletRegistry.solanaDevnet.id, + UniversalWalletRegistry.solanaDevnet.chainId + ) + + private val TAIRA_CHAIN_IDS = setOf( + UniversalWalletRegistry.taira.id, + UniversalWalletRegistry.taira.chainId + ) + + private val NEXUS_CHAIN_IDS = setOf( + UniversalWalletRegistry.nexus.id, + UniversalWalletRegistry.nexus.chainId + ) + } +} diff --git a/feature-account-api/src/main/java/jp/co/soramitsu/account/api/domain/model/MetaAccount.kt b/feature-account-api/src/main/java/jp/co/soramitsu/account/api/domain/model/MetaAccount.kt index 409c2bcab1..c6c05a344f 100644 --- a/feature-account-api/src/main/java/jp/co/soramitsu/account/api/domain/model/MetaAccount.kt +++ b/feature-account-api/src/main/java/jp/co/soramitsu/account/api/domain/model/MetaAccount.kt @@ -1,15 +1,26 @@ package jp.co.soramitsu.account.api.domain.model import jp.co.soramitsu.common.model.WalletEcosystem +import jp.co.soramitsu.common.model.UniversalWalletRegistry +import jp.co.soramitsu.common.utils.BitcoinKeyDerivation +import jp.co.soramitsu.common.utils.IrohaAddressCodec +import jp.co.soramitsu.common.utils.SolanaKeyDerivation import jp.co.soramitsu.common.utils.ethereumAddressToHex import jp.co.soramitsu.common.utils.v4r2tonAddress import jp.co.soramitsu.core.models.CryptoType import jp.co.soramitsu.core.models.Ecosystem import jp.co.soramitsu.core.models.IChain +import jp.co.soramitsu.fearless_utils.extensions.toHexString import jp.co.soramitsu.runtime.ext.addressOf +import jp.co.soramitsu.runtime.ext.bitcoinAddressFromPublicKey +import jp.co.soramitsu.runtime.ext.irohaAddressFromPublicKey +import jp.co.soramitsu.runtime.ext.isUniversalWalletBitcoin +import jp.co.soramitsu.runtime.ext.isUniversalWalletIroha +import jp.co.soramitsu.runtime.ext.isUniversalWalletSolana +import jp.co.soramitsu.runtime.ext.solanaAddressFromPublicKey import jp.co.soramitsu.runtime.multiNetwork.chain.model.Chain import jp.co.soramitsu.runtime.multiNetwork.chain.model.ChainId -import jp.co.soramitsu.shared_utils.ss58.SS58Encoder.toAddress +import jp.co.soramitsu.fearless_utils.ss58.SS58Encoder.toAddress class MetaAccountOrdering( val id: Long, @@ -24,10 +35,17 @@ interface LightMetaAccount { val ethereumAddress: ByteArray? val ethereumPublicKey: ByteArray? val tonPublicKey: ByteArray? + val universalWalletChainAccounts: Map val isSelected: Boolean val name: String val isBackedUp: Boolean val initialized: Boolean + + class UniversalWalletChainAccount( + val publicKey: ByteArray, + val accountId: ByteArray, + val cryptoType: CryptoType + ) } fun LightMetaAccount( @@ -38,6 +56,7 @@ fun LightMetaAccount( ethereumAddress: ByteArray?, ethereumPublicKey: ByteArray?, tonPublicKey: ByteArray?, + universalWalletChainAccounts: Map = emptyMap(), isSelected: Boolean, name: String, isBackedUp: Boolean, @@ -50,6 +69,7 @@ fun LightMetaAccount( override val ethereumAddress: ByteArray? = ethereumAddress override val ethereumPublicKey: ByteArray? = ethereumPublicKey override val tonPublicKey: ByteArray? = tonPublicKey + override val universalWalletChainAccounts = universalWalletChainAccounts override val isSelected: Boolean = isSelected override val name: String = name override val isBackedUp: Boolean = isBackedUp @@ -87,6 +107,15 @@ data class MetaAccount( val isFavorite: Boolean ) + override val universalWalletChainAccounts: Map + get() = chainAccounts.mapValues { (_, account) -> + LightMetaAccount.UniversalWalletChainAccount( + publicKey = account.publicKey, + accountId = account.accountId, + cryptoType = account.cryptoType + ) + } + override fun equals(other: Any?): Boolean { if (this === other) return true if (javaClass != other?.javaClass) return false @@ -134,6 +163,7 @@ fun MetaAccount.hasChainAccount(chainId: ChainId) = chainId in chainAccounts fun MetaAccount.cryptoType(chain: IChain): CryptoType? { return when { + chain is Chain && chain.isUniversalWalletChain() -> chainAccounts.universalWalletChainAccount(chain)?.cryptoType hasChainAccount(chain.id) -> chainAccounts.getValue(chain.id).cryptoType chain.isEthereumBased -> CryptoType.ECDSA else -> substrateCryptoType @@ -143,6 +173,9 @@ fun MetaAccount.cryptoType(chain: IChain): CryptoType? { fun MetaAccount.address(chain: Chain): String? { return kotlin.runCatching { when { + chain.isUniversalWalletBitcoin() -> chainAccounts.universalWalletChainAccount(chain)?.publicKey?.let(chain::bitcoinAddressFromPublicKey) + chain.isUniversalWalletSolana() -> chainAccounts.universalWalletChainAccount(chain)?.publicKey?.let(chain::solanaAddressFromPublicKey) + chain.isUniversalWalletIroha() -> chainAccounts.universalWalletChainAccount(chain)?.publicKey?.let(chain::irohaAddressFromPublicKey) hasChainAccount(chain.id) -> chain.addressOf(chainAccounts.getValue(chain.id).accountId) chain.ecosystem == Ecosystem.EthereumBased || chain.ecosystem == Ecosystem.Ethereum -> ethereumAddress?.ethereumAddressToHex() chain.ecosystem == Ecosystem.Ton -> { @@ -156,31 +189,133 @@ fun MetaAccount.address(chain: Chain): String? { fun LightMetaAccount.address(chain: Chain): String? { return kotlin.runCatching { - when (chain.ecosystem) { - Ecosystem.Substrate -> substrateAccountId?.toAddress(chain.addressPrefix.toShort()) - Ecosystem.EthereumBased, - Ecosystem.Ethereum -> ethereumAddress?.ethereumAddressToHex() - Ecosystem.Ton -> { + when { + chain.isUniversalWalletBitcoin() -> universalWalletChainAccounts.universalWalletChainAccount(chain)?.publicKey?.let(chain::bitcoinAddressFromPublicKey) + chain.isUniversalWalletSolana() -> universalWalletChainAccounts.universalWalletChainAccount(chain)?.publicKey?.let(chain::solanaAddressFromPublicKey) + chain.isUniversalWalletIroha() -> universalWalletChainAccounts.universalWalletChainAccount(chain)?.publicKey?.let(chain::irohaAddressFromPublicKey) + chain.ecosystem == Ecosystem.Substrate -> substrateAccountId?.toAddress(chain.addressPrefix.toShort()) + chain.ecosystem == Ecosystem.EthereumBased || chain.ecosystem == Ecosystem.Ethereum -> ethereumAddress?.ethereumAddressToHex() + chain.ecosystem == Ecosystem.Ton -> { tonPublicKey?.v4r2tonAddress(chain.isTestNet) } + else -> null } }.getOrNull() } -fun LightMetaAccount.supportedEcosystemWithIconAddress(): Map = listOfNotNull( - tonPublicKey?.let { WalletEcosystem.Ton to it.v4r2tonAddress(false) }, - substratePublicKey?.let { WalletEcosystem.Substrate to it.toAddress(0.toShort()) }, // 0 = polkadotAddressPrefix - ethereumPublicKey?.let { WalletEcosystem.Ethereum to it.ethereumAddressToHex() } -).toMap() +fun LightMetaAccount.supportedEcosystemWithIconAddress(): Map { + return listOfNotNull( + tonPublicKey?.let { WalletEcosystem.Ton to it.v4r2tonAddress(false) }, + substratePublicKey?.let { WalletEcosystem.Substrate to it.toAddress(0.toShort()) }, // 0 = polkadotAddressPrefix + ethereumPublicKey?.let { WalletEcosystem.Ethereum to it.ethereumAddressToHex() }, + universalWalletChainAccounts.bitcoinIconAddress()?.let { WalletEcosystem.Bitcoin to it }, + universalWalletChainAccounts.solanaIconAddress()?.let { WalletEcosystem.Solana to it }, + universalWalletChainAccounts.irohaIconAddress()?.let { WalletEcosystem.Iroha to it } + ).toMap() +} fun LightMetaAccount.supportedEcosystems(): Set = setOfNotNull( tonPublicKey?.let { WalletEcosystem.Ton }, substratePublicKey?.let { WalletEcosystem.Substrate }, - ethereumPublicKey?.let { WalletEcosystem.Ethereum } + ethereumPublicKey?.let { WalletEcosystem.Ethereum }, + universalWalletChainAccounts.takeIf { it.hasAnyChainId(BITCOIN_CHAIN_IDS) }?.let { WalletEcosystem.Bitcoin }, + universalWalletChainAccounts.takeIf { it.hasAnyChainId(SOLANA_CHAIN_IDS) }?.let { WalletEcosystem.Solana }, + universalWalletChainAccounts.takeIf { it.hasAnyChainId(IROHA_CHAIN_IDS) }?.let { WalletEcosystem.Iroha } ) +private fun Map.bitcoinIconAddress(): String? { + val mainnet = firstAccount(BITCOIN_MAINNET_CHAIN_IDS)?.publicKey?.let { + runCatching { + BitcoinKeyDerivation.addressFromPublicKey(it, BitcoinKeyDerivation.Network.Mainnet) + }.getOrNull() + } + + return mainnet ?: firstAccount(BITCOIN_TESTNET_CHAIN_IDS)?.publicKey?.let { + runCatching { + BitcoinKeyDerivation.addressFromPublicKey(it, BitcoinKeyDerivation.Network.Testnet) + }.getOrNull() + } +} + +private fun Map.solanaIconAddress(): String? { + return firstAccount(SOLANA_CHAIN_IDS)?.publicKey?.let { + runCatching { + SolanaKeyDerivation.addressFromPublicKey(it) + }.getOrNull() + } +} + +private fun Map.irohaIconAddress(): String? { + val taira = firstAccount(TAIRA_CHAIN_IDS)?.publicKey?.let { + runCatching { + IrohaAddressCodec.encode( + publicKeyHex = it.toHexString(withPrefix = false), + chainDiscriminant = UniversalWalletRegistry.taira.chainDiscriminant + ) + }.getOrNull() + } + + return taira ?: firstAccount(NEXUS_CHAIN_IDS)?.publicKey?.let { + runCatching { + IrohaAddressCodec.encode( + publicKeyHex = it.toHexString(withPrefix = false), + chainDiscriminant = UniversalWalletRegistry.nexus.chainDiscriminant + ) + }.getOrNull() + } +} + +private fun Map.firstAccount( + chainIds: Set +): LightMetaAccount.UniversalWalletChainAccount? { + return chainIds.firstNotNullOfOrNull { get(it) } +} + +private fun Map.hasAnyChainId( + chainIds: Set +): Boolean { + return keys.any(chainIds::contains) +} + +private val BITCOIN_MAINNET_CHAIN_IDS = setOf( + UniversalWalletRegistry.bitcoinMainnet.id, + UniversalWalletRegistry.bitcoinMainnet.chainId +) + +private val BITCOIN_TESTNET_CHAIN_IDS = setOf( + UniversalWalletRegistry.bitcoinTestnet.id, + UniversalWalletRegistry.bitcoinTestnet.chainId +) + +private val SOLANA_MAINNET_CHAIN_IDS = setOf( + UniversalWalletRegistry.solanaMainnet.id, + UniversalWalletRegistry.solanaMainnet.chainId +) + +private val SOLANA_DEVNET_CHAIN_IDS = setOf( + UniversalWalletRegistry.solanaDevnet.id, + UniversalWalletRegistry.solanaDevnet.chainId +) + +private val TAIRA_CHAIN_IDS = setOf( + UniversalWalletRegistry.taira.id, + UniversalWalletRegistry.taira.chainId +) + +private val NEXUS_CHAIN_IDS = setOf( + UniversalWalletRegistry.nexus.id, + UniversalWalletRegistry.nexus.chainId +) + +private val BITCOIN_CHAIN_IDS = BITCOIN_MAINNET_CHAIN_IDS + BITCOIN_TESTNET_CHAIN_IDS +private val SOLANA_CHAIN_IDS = SOLANA_MAINNET_CHAIN_IDS + SOLANA_DEVNET_CHAIN_IDS +private val IROHA_CHAIN_IDS = TAIRA_CHAIN_IDS + NEXUS_CHAIN_IDS + fun MetaAccount.chainAddress(chain: Chain): String? { return when { + chain.isUniversalWalletBitcoin() -> chainAccounts.universalWalletChainAccount(chain)?.publicKey?.let(chain::bitcoinAddressFromPublicKey) + chain.isUniversalWalletSolana() -> chainAccounts.universalWalletChainAccount(chain)?.publicKey?.let(chain::solanaAddressFromPublicKey) + chain.isUniversalWalletIroha() -> chainAccounts.universalWalletChainAccount(chain)?.publicKey?.let(chain::irohaAddressFromPublicKey) hasChainAccount(chain.id) -> chain.addressOf(chainAccounts.getValue(chain.id).accountId) else -> null } @@ -188,6 +323,7 @@ fun MetaAccount.chainAddress(chain: Chain): String? { fun MetaAccount.accountId(chain: IChain): ByteArray? { return when { + chain is Chain && chain.isUniversalWalletChain() -> chainAccounts.universalWalletChainAccount(chain)?.accountId hasChainAccount(chain.id) -> chainAccounts.getValue(chain.id).accountId chain.ecosystem == Ecosystem.Substrate -> substrateAccountId chain.ecosystem == Ecosystem.Ethereum || chain.ecosystem == Ecosystem.EthereumBased -> ethereumAddress @@ -197,6 +333,35 @@ fun MetaAccount.accountId(chain: IChain): ByteArray? { } } +private fun Chain.isUniversalWalletChain(): Boolean { + return isUniversalWalletBitcoin() || isUniversalWalletSolana() || isUniversalWalletIroha() +} + +private fun Map.universalWalletChainAccount( + chain: Chain +): MetaAccount.ChainAccount? { + return chain.universalWalletChainIds().firstNotNullOfOrNull { get(it) } +} + +private fun Map.universalWalletChainAccount( + chain: Chain +): LightMetaAccount.UniversalWalletChainAccount? { + return chain.universalWalletChainIds().firstNotNullOfOrNull { get(it) } +} + +private fun Chain.universalWalletChainIds(): Set { + return when { + isUniversalWalletBitcoin() && (id in BITCOIN_TESTNET_CHAIN_IDS || isTestNet) -> BITCOIN_TESTNET_CHAIN_IDS + isUniversalWalletBitcoin() -> BITCOIN_MAINNET_CHAIN_IDS + isUniversalWalletSolana() && (id in SOLANA_DEVNET_CHAIN_IDS || isTestNet) -> SOLANA_DEVNET_CHAIN_IDS + isUniversalWalletSolana() -> SOLANA_MAINNET_CHAIN_IDS + isUniversalWalletIroha() && id in TAIRA_CHAIN_IDS -> TAIRA_CHAIN_IDS + isUniversalWalletIroha() && id in NEXUS_CHAIN_IDS -> NEXUS_CHAIN_IDS + isUniversalWalletIroha() && isTestNet -> TAIRA_CHAIN_IDS + else -> emptySet() + } +} + val MetaAccount.hasSubstrate get() = substrateAccountId != null @@ -213,4 +378,4 @@ val LightMetaAccount.hasEthereum get() = ethereumPublicKey != null val LightMetaAccount.hasTon - get() = tonPublicKey != null \ No newline at end of file + get() = tonPublicKey != null diff --git a/feature-account-api/src/main/java/jp/co/soramitsu/account/api/presentation/account/AddressDisplayUseCase.kt b/feature-account-api/src/main/java/jp/co/soramitsu/account/api/presentation/account/AddressDisplayUseCase.kt index c5fa3fcc42..2ed7e0f514 100644 --- a/feature-account-api/src/main/java/jp/co/soramitsu/account/api/presentation/account/AddressDisplayUseCase.kt +++ b/feature-account-api/src/main/java/jp/co/soramitsu/account/api/presentation/account/AddressDisplayUseCase.kt @@ -1,8 +1,8 @@ package jp.co.soramitsu.account.api.presentation.account import jp.co.soramitsu.account.api.domain.interfaces.AccountRepository -import jp.co.soramitsu.shared_utils.extensions.fromHex -import jp.co.soramitsu.shared_utils.ss58.SS58Encoder.toAccountId +import jp.co.soramitsu.fearless_utils.extensions.fromHex +import jp.co.soramitsu.fearless_utils.ss58.SS58Encoder.toAccountId import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext diff --git a/feature-account-api/src/main/java/jp/co/soramitsu/account/api/presentation/exporting/ExportExt.kt b/feature-account-api/src/main/java/jp/co/soramitsu/account/api/presentation/exporting/ExportExt.kt index 4ac9e788dc..774f4d553c 100644 --- a/feature-account-api/src/main/java/jp/co/soramitsu/account/api/presentation/exporting/ExportExt.kt +++ b/feature-account-api/src/main/java/jp/co/soramitsu/account/api/presentation/exporting/ExportExt.kt @@ -1,8 +1,8 @@ package jp.co.soramitsu.account.api.presentation.exporting import jp.co.soramitsu.common.data.secrets.v2.ChainAccountSecrets -import jp.co.soramitsu.shared_utils.scale.EncodableStruct -import jp.co.soramitsu.shared_utils.scale.Schema +import jp.co.soramitsu.fearless_utils.scale.EncodableStruct +import jp.co.soramitsu.fearless_utils.scale.Schema fun > EncodableStruct?.buildChainAccountOptions( diff --git a/feature-account-impl/src/main/java/jp/co/soramitsu/account/impl/data/mappers/Mappers.kt b/feature-account-impl/src/main/java/jp/co/soramitsu/account/impl/data/mappers/Mappers.kt index cd3e63e094..0fdb9dabd0 100644 --- a/feature-account-impl/src/main/java/jp/co/soramitsu/account/impl/data/mappers/Mappers.kt +++ b/feature-account-impl/src/main/java/jp/co/soramitsu/account/impl/data/mappers/Mappers.kt @@ -23,7 +23,7 @@ import jp.co.soramitsu.feature_account_impl.R import jp.co.soramitsu.runtime.ext.hexAccountIdOf import jp.co.soramitsu.runtime.multiNetwork.chain.model.Chain import jp.co.soramitsu.runtime.multiNetwork.chain.model.ChainId -import jp.co.soramitsu.shared_utils.extensions.toHexString +import jp.co.soramitsu.fearless_utils.extensions.toHexString fun mapCryptoTypeToCryptoTypeModel( resourceManager: ResourceManager, @@ -65,6 +65,18 @@ fun mapNodeToNodeModel(node: ChainNode): NodeModel { fun mapMetaAccountLocalToLightMetaAccount( metaAccountLocal: MetaAccountLocal +): LightMetaAccount = mapMetaAccountLocalToLightMetaAccount(metaAccountLocal, emptyList()) + +fun mapJoinedMetaAccountInfoToLightMetaAccount( + joinedMetaAccountInfo: JoinedMetaAccountInfo +): LightMetaAccount = mapMetaAccountLocalToLightMetaAccount( + joinedMetaAccountInfo.metaAccount, + joinedMetaAccountInfo.chainAccounts +) + +private fun mapMetaAccountLocalToLightMetaAccount( + metaAccountLocal: MetaAccountLocal, + chainAccounts: List ): LightMetaAccount = with(metaAccountLocal) { LightMetaAccount( id = id, @@ -74,6 +86,16 @@ fun mapMetaAccountLocalToLightMetaAccount( ethereumAddress = ethereumAddress, ethereumPublicKey = ethereumPublicKey, tonPublicKey = tonPublicKey, + universalWalletChainAccounts = chainAccounts.associateBy( + keySelector = ChainAccountLocal::chainId, + valueTransform = { + LightMetaAccount.UniversalWalletChainAccount( + publicKey = it.publicKey, + accountId = it.accountId, + cryptoType = it.cryptoType + ) + } + ), isSelected = isSelected, name = name, isBackedUp = isBackedUp, @@ -219,4 +241,4 @@ fun NomisResponse.toDomain(): NomisScoreData { minTransactionTimeInHours = data.stats.minTransactionTimeInHours, scoredAt = scoredAtMillis ) -} \ No newline at end of file +} diff --git a/feature-account-impl/src/main/java/jp/co/soramitsu/account/impl/data/repository/AccountRepositoryDelegate.kt b/feature-account-impl/src/main/java/jp/co/soramitsu/account/impl/data/repository/AccountRepositoryDelegate.kt index 4262d674f3..e9761d964e 100644 --- a/feature-account-impl/src/main/java/jp/co/soramitsu/account/impl/data/repository/AccountRepositoryDelegate.kt +++ b/feature-account-impl/src/main/java/jp/co/soramitsu/account/impl/data/repository/AccountRepositoryDelegate.kt @@ -16,12 +16,12 @@ import jp.co.soramitsu.common.utils.substrateAccountId import jp.co.soramitsu.core.crypto.mapCryptoTypeToEncryption import jp.co.soramitsu.coredb.dao.MetaAccountDao import jp.co.soramitsu.coredb.model.MetaAccountLocal -import jp.co.soramitsu.shared_utils.encrypt.junction.BIP32JunctionDecoder -import jp.co.soramitsu.shared_utils.encrypt.junction.SubstrateJunctionDecoder -import jp.co.soramitsu.shared_utils.encrypt.keypair.ethereum.EthereumKeypairFactory -import jp.co.soramitsu.shared_utils.encrypt.keypair.substrate.SubstrateKeypairFactory -import jp.co.soramitsu.shared_utils.encrypt.seed.ethereum.EthereumSeedFactory -import jp.co.soramitsu.shared_utils.encrypt.seed.substrate.SubstrateSeedFactory +import jp.co.soramitsu.fearless_utils.encrypt.junction.BIP32JunctionDecoder +import jp.co.soramitsu.fearless_utils.encrypt.junction.SubstrateJunctionDecoder +import jp.co.soramitsu.fearless_utils.encrypt.keypair.ethereum.EthereumKeypairFactory +import jp.co.soramitsu.fearless_utils.encrypt.keypair.substrate.SubstrateKeypairFactory +import jp.co.soramitsu.fearless_utils.encrypt.seed.ethereum.EthereumSeedFactory +import jp.co.soramitsu.fearless_utils.encrypt.seed.substrate.SubstrateSeedFactory import org.ton.api.pk.PrivateKeyEd25519 class AccountRepositoryDelegate( diff --git a/feature-account-impl/src/main/java/jp/co/soramitsu/account/impl/data/repository/AccountRepositoryImpl.kt b/feature-account-impl/src/main/java/jp/co/soramitsu/account/impl/data/repository/AccountRepositoryImpl.kt index 3a4ba66776..42ca35c760 100644 --- a/feature-account-impl/src/main/java/jp/co/soramitsu/account/impl/data/repository/AccountRepositoryImpl.kt +++ b/feature-account-impl/src/main/java/jp/co/soramitsu/account/impl/data/repository/AccountRepositoryImpl.kt @@ -50,19 +50,19 @@ import jp.co.soramitsu.runtime.multiNetwork.chain.model.Chain import jp.co.soramitsu.runtime.multiNetwork.chain.model.ChainId import jp.co.soramitsu.runtime.multiNetwork.chain.model.polkadotChainId import jp.co.soramitsu.runtime.multiNetwork.chain.model.westendChainId -import jp.co.soramitsu.shared_utils.encrypt.MultiChainEncryption -import jp.co.soramitsu.shared_utils.encrypt.json.JsonSeedDecoder -import jp.co.soramitsu.shared_utils.encrypt.json.JsonSeedEncoder -import jp.co.soramitsu.shared_utils.encrypt.junction.BIP32JunctionDecoder -import jp.co.soramitsu.shared_utils.encrypt.junction.SubstrateJunctionDecoder -import jp.co.soramitsu.shared_utils.encrypt.keypair.ethereum.EthereumKeypairFactory -import jp.co.soramitsu.shared_utils.encrypt.keypair.substrate.SubstrateKeypairFactory -import jp.co.soramitsu.shared_utils.encrypt.mnemonic.Mnemonic -import jp.co.soramitsu.shared_utils.encrypt.mnemonic.MnemonicCreator -import jp.co.soramitsu.shared_utils.runtime.AccountId -import jp.co.soramitsu.shared_utils.scale.EncodableStruct -import jp.co.soramitsu.shared_utils.ss58.SS58Encoder.addressByte -import jp.co.soramitsu.shared_utils.ss58.SS58Encoder.toAccountId +import jp.co.soramitsu.fearless_utils.encrypt.MultiChainEncryption +import jp.co.soramitsu.fearless_utils.encrypt.json.JsonSeedDecoder +import jp.co.soramitsu.fearless_utils.encrypt.json.JsonSeedEncoder +import jp.co.soramitsu.fearless_utils.encrypt.junction.BIP32JunctionDecoder +import jp.co.soramitsu.fearless_utils.encrypt.junction.SubstrateJunctionDecoder +import jp.co.soramitsu.fearless_utils.encrypt.keypair.ethereum.EthereumKeypairFactory +import jp.co.soramitsu.fearless_utils.encrypt.keypair.substrate.SubstrateKeypairFactory +import jp.co.soramitsu.fearless_utils.encrypt.mnemonic.Mnemonic +import jp.co.soramitsu.fearless_utils.encrypt.mnemonic.MnemonicCreator +import jp.co.soramitsu.fearless_utils.runtime.AccountId +import jp.co.soramitsu.fearless_utils.scale.EncodableStruct +import jp.co.soramitsu.fearless_utils.ss58.SS58Encoder.addressByte +import jp.co.soramitsu.fearless_utils.ss58.SS58Encoder.toAccountId import kotlinx.coroutines.CoroutineDispatcher import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.GlobalScope @@ -612,6 +612,10 @@ class AccountRepositoryImpl( return BackupAccountType.PASSPHRASE } } + + WalletEcosystem.Bitcoin, + WalletEcosystem.Solana, + WalletEcosystem.Iroha -> return null } return null } diff --git a/feature-account-impl/src/main/java/jp/co/soramitsu/account/impl/data/repository/KeyPairRepository.kt b/feature-account-impl/src/main/java/jp/co/soramitsu/account/impl/data/repository/KeyPairRepository.kt index 5d1b49d67a..5d5705cd9b 100644 --- a/feature-account-impl/src/main/java/jp/co/soramitsu/account/impl/data/repository/KeyPairRepository.kt +++ b/feature-account-impl/src/main/java/jp/co/soramitsu/account/impl/data/repository/KeyPairRepository.kt @@ -17,8 +17,8 @@ import jp.co.soramitsu.core.extrinsic.keypair_provider.KeypairProvider import jp.co.soramitsu.core.models.CryptoType import jp.co.soramitsu.core.models.Ecosystem import jp.co.soramitsu.core.models.IChain -import jp.co.soramitsu.shared_utils.encrypt.keypair.Keypair -import jp.co.soramitsu.shared_utils.extensions.toHexString +import jp.co.soramitsu.fearless_utils.encrypt.keypair.Keypair +import jp.co.soramitsu.fearless_utils.extensions.toHexString class KeyPairRepository( private val secretStoreV2: SecretStoreV2, diff --git a/feature-account-impl/src/main/java/jp/co/soramitsu/account/impl/data/repository/datasource/AccountDataSource.kt b/feature-account-impl/src/main/java/jp/co/soramitsu/account/impl/data/repository/datasource/AccountDataSource.kt index bfc6e0d6ad..42b031e0d5 100644 --- a/feature-account-impl/src/main/java/jp/co/soramitsu/account/impl/data/repository/datasource/AccountDataSource.kt +++ b/feature-account-impl/src/main/java/jp/co/soramitsu/account/impl/data/repository/datasource/AccountDataSource.kt @@ -10,7 +10,7 @@ import jp.co.soramitsu.core.model.Language import jp.co.soramitsu.core.models.CryptoType import jp.co.soramitsu.coredb.model.chain.FavoriteChainLocal import jp.co.soramitsu.runtime.multiNetwork.chain.model.ChainId -import jp.co.soramitsu.shared_utils.runtime.AccountId +import jp.co.soramitsu.fearless_utils.runtime.AccountId import kotlinx.coroutines.flow.Flow interface AccountDataSource : SecretStoreV1 { diff --git a/feature-account-impl/src/main/java/jp/co/soramitsu/account/impl/data/repository/datasource/AccountDataSourceImpl.kt b/feature-account-impl/src/main/java/jp/co/soramitsu/account/impl/data/repository/datasource/AccountDataSourceImpl.kt index 709afdb658..ff31e2c6a4 100644 --- a/feature-account-impl/src/main/java/jp/co/soramitsu/account/impl/data/repository/datasource/AccountDataSourceImpl.kt +++ b/feature-account-impl/src/main/java/jp/co/soramitsu/account/impl/data/repository/datasource/AccountDataSourceImpl.kt @@ -7,7 +7,7 @@ import jp.co.soramitsu.account.api.domain.model.LightMetaAccount import jp.co.soramitsu.account.api.domain.model.MetaAccount import jp.co.soramitsu.account.api.domain.model.MetaAccountOrdering import jp.co.soramitsu.account.impl.data.mappers.mapChainAccountToAccount -import jp.co.soramitsu.account.impl.data.mappers.mapMetaAccountLocalToLightMetaAccount +import jp.co.soramitsu.account.impl.data.mappers.mapJoinedMetaAccountInfoToLightMetaAccount import jp.co.soramitsu.account.impl.data.mappers.mapMetaAccountLocalToMetaAccount import jp.co.soramitsu.account.impl.data.mappers.mapMetaAccountToAccount import jp.co.soramitsu.common.data.secrets.v1.SecretStoreV1 @@ -22,7 +22,7 @@ import jp.co.soramitsu.coredb.dao.MetaAccountDao import jp.co.soramitsu.coredb.model.ChainAccountLocal import jp.co.soramitsu.coredb.model.MetaAccountPositionUpdate import jp.co.soramitsu.runtime.multiNetwork.chain.ChainsRepository -import jp.co.soramitsu.shared_utils.runtime.AccountId +import jp.co.soramitsu.fearless_utils.runtime.AccountId import kotlinx.coroutines.DelicateCoroutinesApi import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.GlobalScope @@ -123,20 +123,20 @@ class AccountDataSourceImpl( override fun selectedMetaAccountFlow(): Flow = selectedMetaAccountFlow override fun selectedLightMetaAccount(): Flow { - return metaAccountDao.selectedLocalMetaAccountFlow().map { accountLocal -> - accountLocal?.let { mapMetaAccountLocalToLightMetaAccount(it) } + return metaAccountDao.selectedMetaAccountInfoFlow().map { accountLocal -> + accountLocal?.let { mapJoinedMetaAccountInfoToLightMetaAccount(it) } }.filterNotNull().flowOn(Dispatchers.IO) } override fun lightMetaAccountFlow(metaId: Long): Flow { - return metaAccountDao.observeLocalMetaAccount(metaId).map { accountLocal -> - accountLocal?.let { mapMetaAccountLocalToLightMetaAccount(it) } + return metaAccountDao.observeJoinedMetaAccountInfo(metaId).map { accountLocal -> + accountLocal?.let { mapJoinedMetaAccountInfoToLightMetaAccount(it) } }.filterNotNull().flowOn(Dispatchers.IO) } override suspend fun getSelectedLightMetaAccount(): LightMetaAccount { - val local = withContext(Dispatchers.IO) { metaAccountDao.getSelectedLocalMetaAccount() } - return mapMetaAccountLocalToLightMetaAccount(local) + val local = withContext(Dispatchers.IO) { metaAccountDao.selectedMetaAccountInfo() } + return mapJoinedMetaAccountInfoToLightMetaAccount(local) } override suspend fun findMetaAccount(accountId: ByteArray): MetaAccount? { @@ -165,8 +165,8 @@ class AccountDataSourceImpl( } override fun lightMetaAccountsFlow(): Flow> { - return metaAccountDao.metaAccountsFlow().mapList { - mapMetaAccountLocalToLightMetaAccount(it) + return metaAccountDao.observeOrderedJoinedMetaAccountsInfo().mapList { + mapJoinedMetaAccountInfoToLightMetaAccount(it) } } @@ -205,8 +205,8 @@ class AccountDataSourceImpl( } override suspend fun getLightMetaAccount(metaId: Long): LightMetaAccount { - val local = withContext(Dispatchers.IO) { metaAccountDao.getLocalMetaAccount(metaId) } - return mapMetaAccountLocalToLightMetaAccount(local) + val local = withContext(Dispatchers.IO) { metaAccountDao.getJoinedMetaAccountInfo(metaId) } + return mapJoinedMetaAccountInfoToLightMetaAccount(local) } override suspend fun updateMetaAccountName(metaId: Long, newName: String) { diff --git a/feature-account-impl/src/main/java/jp/co/soramitsu/account/impl/di/AccountFeatureModule.kt b/feature-account-impl/src/main/java/jp/co/soramitsu/account/impl/di/AccountFeatureModule.kt index 0bd5fb3fb8..242df80dac 100644 --- a/feature-account-impl/src/main/java/jp/co/soramitsu/account/impl/di/AccountFeatureModule.kt +++ b/feature-account-impl/src/main/java/jp/co/soramitsu/account/impl/di/AccountFeatureModule.kt @@ -56,8 +56,8 @@ import jp.co.soramitsu.coredb.dao.NomisScoresDao import jp.co.soramitsu.coredb.dao.TokenPriceDao import jp.co.soramitsu.runtime.multiNetwork.ChainRegistry import jp.co.soramitsu.runtime.multiNetwork.chain.ChainsRepository -import jp.co.soramitsu.shared_utils.encrypt.json.JsonSeedDecoder -import jp.co.soramitsu.shared_utils.encrypt.json.JsonSeedEncoder +import jp.co.soramitsu.fearless_utils.encrypt.json.JsonSeedDecoder +import jp.co.soramitsu.fearless_utils.encrypt.json.JsonSeedEncoder import jp.co.soramitsu.wallet.impl.domain.interfaces.WalletInteractor @InstallIn(SingletonComponent::class) diff --git a/feature-account-impl/src/main/java/jp/co/soramitsu/account/impl/domain/AccountInteractorImpl.kt b/feature-account-impl/src/main/java/jp/co/soramitsu/account/impl/domain/AccountInteractorImpl.kt index fcc8ccdb77..ec422839ae 100644 --- a/feature-account-impl/src/main/java/jp/co/soramitsu/account/impl/domain/AccountInteractorImpl.kt +++ b/feature-account-impl/src/main/java/jp/co/soramitsu/account/impl/domain/AccountInteractorImpl.kt @@ -6,6 +6,7 @@ import java.io.File import jp.co.soramitsu.account.api.domain.interfaces.AccountInteractor import jp.co.soramitsu.account.api.domain.interfaces.AccountRepository import jp.co.soramitsu.account.api.domain.model.AddAccountPayload +import jp.co.soramitsu.account.api.domain.model.AndroidUniversalWalletMigrationSnapshotBuilder import jp.co.soramitsu.account.api.domain.model.ImportJsonData import jp.co.soramitsu.account.api.domain.model.LightMetaAccount import jp.co.soramitsu.account.api.domain.model.MetaAccountOrdering @@ -23,6 +24,8 @@ import jp.co.soramitsu.common.data.secrets.v3.SubstrateSecrets import jp.co.soramitsu.common.data.secrets.v3.TonSecrets import jp.co.soramitsu.common.data.storage.Preferences import jp.co.soramitsu.common.interfaces.FileProvider +import jp.co.soramitsu.common.model.UNIVERSAL_WALLET_CUTOFF_AT_MILLIS +import jp.co.soramitsu.common.model.UniversalWalletMigrationSnapshot import jp.co.soramitsu.common.model.WalletEcosystem import jp.co.soramitsu.common.utils.ComponentHolder import jp.co.soramitsu.common.utils.DEFAULT_DERIVATION_PATH @@ -34,20 +37,21 @@ import jp.co.soramitsu.runtime.multiNetwork.chain.model.ChainId import jp.co.soramitsu.runtime.multiNetwork.chain.model.moonriverChainId import jp.co.soramitsu.runtime.multiNetwork.chain.model.polkadotChainId import jp.co.soramitsu.runtime.multiNetwork.chain.model.westendChainId -import jp.co.soramitsu.shared_utils.encrypt.junction.BIP32JunctionDecoder -import jp.co.soramitsu.shared_utils.encrypt.junction.SubstrateJunctionDecoder -import jp.co.soramitsu.shared_utils.encrypt.mnemonic.EnglishWordList -import jp.co.soramitsu.shared_utils.encrypt.mnemonic.Mnemonic -import jp.co.soramitsu.shared_utils.encrypt.mnemonic.MnemonicCreator -import jp.co.soramitsu.shared_utils.encrypt.seed.substrate.SubstrateSeedFactory -import jp.co.soramitsu.shared_utils.extensions.toHexString -import jp.co.soramitsu.shared_utils.scale.EncodableStruct +import jp.co.soramitsu.fearless_utils.encrypt.junction.BIP32JunctionDecoder +import jp.co.soramitsu.fearless_utils.encrypt.junction.SubstrateJunctionDecoder +import jp.co.soramitsu.fearless_utils.encrypt.mnemonic.EnglishWordList +import jp.co.soramitsu.fearless_utils.encrypt.mnemonic.Mnemonic +import jp.co.soramitsu.fearless_utils.encrypt.mnemonic.MnemonicCreator +import jp.co.soramitsu.fearless_utils.encrypt.seed.substrate.SubstrateSeedFactory +import jp.co.soramitsu.fearless_utils.extensions.toHexString +import jp.co.soramitsu.fearless_utils.scale.EncodableStruct import jp.co.soramitsu.wallet.impl.domain.interfaces.WalletInteractor import kotlin.coroutines.CoroutineContext import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.flatMapLatest import kotlinx.coroutines.flow.flowOn +import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.mapNotNull import kotlinx.coroutines.withContext @@ -65,7 +69,9 @@ class AccountInteractorImpl( private val backupService: BackupService, private val walletInteractor: WalletInteractor, private val context: CoroutineContext = Dispatchers.Default, - private val nowProvider: () -> Long = { System.currentTimeMillis() } + private val nowProvider: () -> Long = { System.currentTimeMillis() }, + private val migrationSnapshotBuilder: AndroidUniversalWalletMigrationSnapshotBuilder = + AndroidUniversalWalletMigrationSnapshotBuilder(UNIVERSAL_WALLET_CUTOFF_AT_MILLIS) ) : AccountInteractor { override suspend fun generateMnemonic(length: Mnemonic.Length): List { @@ -305,6 +311,18 @@ class AccountInteractorImpl( }.flowOn(Dispatchers.IO) } + override fun universalWalletMigrationSnapshotFlow(): Flow { + return accountRepository.lightMetaAccountsFlow() + .map(migrationSnapshotBuilder::build) + .flowOn(context) + } + + override suspend fun universalWalletMigrationSnapshot(): UniversalWalletMigrationSnapshot { + return withContext(context) { + migrationSnapshotBuilder.build(accountRepository.lightMetaAccountsFlow().first()) + } + } + override suspend fun saveGoogleBackupAccount(metaId: Long, googleBackupPassword: String) { withContext(Dispatchers.IO) { val wallet = getMetaAccount(metaId) @@ -407,6 +425,10 @@ class AccountInteractorImpl( entropy = byteArrayOf() ) } + + WalletEcosystem.Bitcoin, + WalletEcosystem.Solana, + WalletEcosystem.Iroha -> null } } }.mapNotNull { diff --git a/feature-account-impl/src/main/java/jp/co/soramitsu/account/impl/domain/BalancesUtils.kt b/feature-account-impl/src/main/java/jp/co/soramitsu/account/impl/domain/BalancesUtils.kt index 9d6e83f2d8..658a00ae5a 100644 --- a/feature-account-impl/src/main/java/jp/co/soramitsu/account/impl/domain/BalancesUtils.kt +++ b/feature-account-impl/src/main/java/jp/co/soramitsu/account/impl/domain/BalancesUtils.kt @@ -13,11 +13,11 @@ import jp.co.soramitsu.core.models.Asset import jp.co.soramitsu.core.models.ChainAssetType import jp.co.soramitsu.core.utils.utilityAsset import jp.co.soramitsu.runtime.multiNetwork.chain.model.Chain -import jp.co.soramitsu.shared_utils.runtime.AccountId -import jp.co.soramitsu.shared_utils.runtime.RuntimeSnapshot -import jp.co.soramitsu.shared_utils.runtime.metadata.module -import jp.co.soramitsu.shared_utils.runtime.metadata.storage -import jp.co.soramitsu.shared_utils.runtime.metadata.storageKey +import jp.co.soramitsu.fearless_utils.runtime.AccountId +import jp.co.soramitsu.fearless_utils.runtime.RuntimeSnapshot +import jp.co.soramitsu.fearless_utils.runtime.metadata.module +import jp.co.soramitsu.fearless_utils.runtime.metadata.storage +import jp.co.soramitsu.fearless_utils.runtime.metadata.storageKey import jp.co.soramitsu.wallet.api.data.cache.bindAccountInfoOrDefault import jp.co.soramitsu.wallet.api.data.cache.bindAssetsAccountData import jp.co.soramitsu.wallet.api.data.cache.bindEquilibriumAccountData diff --git a/feature-account-impl/src/main/java/jp/co/soramitsu/account/impl/domain/WalletSyncService.kt b/feature-account-impl/src/main/java/jp/co/soramitsu/account/impl/domain/WalletSyncService.kt index 52111860f4..0ee5a5bfa6 100644 --- a/feature-account-impl/src/main/java/jp/co/soramitsu/account/impl/domain/WalletSyncService.kt +++ b/feature-account-impl/src/main/java/jp/co/soramitsu/account/impl/domain/WalletSyncService.kt @@ -3,6 +3,7 @@ package jp.co.soramitsu.account.impl.domain import android.util.Log import jp.co.soramitsu.account.api.domain.model.MetaAccount import jp.co.soramitsu.account.api.domain.model.accountId +import jp.co.soramitsu.account.api.domain.model.hasChainAccount import jp.co.soramitsu.account.impl.data.mappers.mapMetaAccountLocalToMetaAccount import jp.co.soramitsu.account.impl.data.mappers.toLocal import jp.co.soramitsu.common.data.network.nomis.NomisApi @@ -22,8 +23,11 @@ import jp.co.soramitsu.runtime.multiNetwork.chain.model.BSCChainId import jp.co.soramitsu.runtime.multiNetwork.chain.model.ChainId import jp.co.soramitsu.runtime.multiNetwork.chain.model.ethereumChainId import jp.co.soramitsu.runtime.multiNetwork.chain.model.polygonChainId +import jp.co.soramitsu.runtime.ext.isUniversalWalletBitcoin +import jp.co.soramitsu.runtime.ext.isUniversalWalletIroha +import jp.co.soramitsu.runtime.ext.isUniversalWalletSolana import jp.co.soramitsu.runtime.storage.source.RemoteStorageSource -import jp.co.soramitsu.shared_utils.extensions.toHexString +import jp.co.soramitsu.fearless_utils.extensions.toHexString import jp.co.soramitsu.wallet.api.data.BalanceLoader import kotlinx.coroutines.CoroutineDispatcher import kotlinx.coroutines.CoroutineExceptionHandler @@ -101,12 +105,16 @@ class WalletSyncService( val syncedChains = mutableSetOf() supervisorScope { val chainsBalancesDeferred = chainsRepository.getChains().map { chain -> - val filteredMetaAccounts = when(chain.ecosystem) { - Ecosystem.Substrate, - Ecosystem.Ethereum, - Ecosystem.EthereumBased -> metaAccounts.asSequence().filter { it.substratePublicKey != null || it.ethereumPublicKey != null } - Ecosystem.Ton -> metaAccounts.asSequence().filter { it.tonPublicKey != null } - }.toSet() + val filteredMetaAccounts = if (chain.isUniversalWalletBitcoin() || chain.isUniversalWalletSolana() || chain.isUniversalWalletIroha()) { + metaAccounts.asSequence().filter { it.hasChainAccount(chain.id) }.toSet() + } else { + when(chain.ecosystem) { + Ecosystem.Substrate, + Ecosystem.Ethereum, + Ecosystem.EthereumBased -> metaAccounts.asSequence().filter { it.substratePublicKey != null || it.ethereumPublicKey != null } + Ecosystem.Ton -> metaAccounts.asSequence().filter { it.tonPublicKey != null } + }.toSet() + } val chainSyncDeferred = async { val provider = balanceLoaderProvider.invoke(chain) val balances = withTimeoutOrNull(15_000) { diff --git a/feature-account-impl/src/main/java/jp/co/soramitsu/account/impl/domain/account/details/AccountDetailsInteractor.kt b/feature-account-impl/src/main/java/jp/co/soramitsu/account/impl/domain/account/details/AccountDetailsInteractor.kt index a3a1584df9..89a2b878c7 100644 --- a/feature-account-impl/src/main/java/jp/co/soramitsu/account/impl/domain/account/details/AccountDetailsInteractor.kt +++ b/feature-account-impl/src/main/java/jp/co/soramitsu/account/impl/domain/account/details/AccountDetailsInteractor.kt @@ -18,11 +18,14 @@ import jp.co.soramitsu.common.model.WalletEcosystem import jp.co.soramitsu.common.utils.flowOf import jp.co.soramitsu.core.models.Ecosystem import jp.co.soramitsu.coredb.dao.emptyAccountIdValue +import jp.co.soramitsu.runtime.ext.isUniversalWalletBitcoin +import jp.co.soramitsu.runtime.ext.isUniversalWalletIroha +import jp.co.soramitsu.runtime.ext.isUniversalWalletSolana import jp.co.soramitsu.runtime.multiNetwork.ChainRegistry import jp.co.soramitsu.runtime.multiNetwork.chain.model.Chain import jp.co.soramitsu.runtime.multiNetwork.chain.model.ChainId import jp.co.soramitsu.runtime.multiNetwork.chain.model.defaultChainSort -import jp.co.soramitsu.shared_utils.scale.EncodableStruct +import jp.co.soramitsu.fearless_utils.scale.EncodableStruct import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.combine @@ -80,10 +83,7 @@ class AccountDetailsInteractor( flowOf { chainRegistry.getChains() }//.map { it.sortedWith(chainSort()) }, ) { metaAccount, chains -> chains.filter { chain -> - chain.ecosystem == Ecosystem.Ton && type == WalletEcosystem.Ton - || chain.ecosystem == Ecosystem.Ethereum && type == WalletEcosystem.Ethereum - || chain.ecosystem == Ecosystem.EthereumBased && type == WalletEcosystem.Ethereum - || chain.ecosystem == Ecosystem.Substrate && type == WalletEcosystem.Substrate + chain.walletEcosystem() == type }.map { chain -> createAccountInChain(metaAccount, chain, false) }.filter { @@ -99,32 +99,29 @@ class AccountDetailsInteractor( flowOf { chainRegistry.getChains() } ) { metaAccount, chains -> metaAccount to chains.filter { chain -> - when (chain.ecosystem) { - Ecosystem.Substrate -> metaAccount.hasSubstrate - Ecosystem.EthereumBased, - Ecosystem.Ethereum -> metaAccount.hasEthereum - - Ecosystem.Ton -> metaAccount.hasTon + when (chain.walletEcosystem()) { + WalletEcosystem.Substrate -> metaAccount.hasSubstrate + WalletEcosystem.Ethereum -> metaAccount.hasEthereum + WalletEcosystem.Ton -> metaAccount.hasTon + WalletEcosystem.Bitcoin, + WalletEcosystem.Solana, + WalletEcosystem.Iroha -> false } || metaAccount.hasChainAccount(chain.id) }.groupBy { chain -> - when (chain.ecosystem) { - Ecosystem.Substrate -> WalletEcosystem.Substrate - Ecosystem.EthereumBased, - Ecosystem.Ethereum -> WalletEcosystem.Ethereum - - Ecosystem.Ton -> WalletEcosystem.Ton - } + chain.walletEcosystem() } }.mapNotNull { (metaAccount, grouped) -> - if (metaAccount.hasEthereum || metaAccount.hasSubstrate) { - return@mapNotNull listOf(WalletEcosystem.Substrate, WalletEcosystem.Ethereum).map { - it to grouped[it].orEmpty().size + val supported = metaAccount.supportedEcosystems() + val summary = WALLET_ECOSYSTEM_SORT_ORDER.mapNotNull { ecosystem -> + val count = grouped[ecosystem].orEmpty().size + when { + ecosystem in supported -> ecosystem to count + count > 0 -> ecosystem to count + else -> null } } - if (metaAccount.hasTon) { - return@mapNotNull listOf(WalletEcosystem.Ton to grouped[WalletEcosystem.Ton].orEmpty().size) - } - null + + summary.takeIf { it.isNotEmpty() } } } @@ -184,4 +181,26 @@ class AccountDetailsInteractor( private fun chainSort() = compareBy { it.id.defaultChainSort() } .thenBy { it.name } + + private fun Chain.walletEcosystem(): WalletEcosystem { + return when { + isUniversalWalletBitcoin() -> WalletEcosystem.Bitcoin + isUniversalWalletSolana() -> WalletEcosystem.Solana + isUniversalWalletIroha() -> WalletEcosystem.Iroha + ecosystem == Ecosystem.Ton -> WalletEcosystem.Ton + ecosystem == Ecosystem.Ethereum || ecosystem == Ecosystem.EthereumBased -> WalletEcosystem.Ethereum + else -> WalletEcosystem.Substrate + } + } + + private companion object { + val WALLET_ECOSYSTEM_SORT_ORDER = listOf( + WalletEcosystem.Substrate, + WalletEcosystem.Ethereum, + WalletEcosystem.Ton, + WalletEcosystem.Bitcoin, + WalletEcosystem.Solana, + WalletEcosystem.Iroha + ) + } } diff --git a/feature-account-impl/src/main/java/jp/co/soramitsu/account/impl/domain/account/details/AccountInChain.kt b/feature-account-impl/src/main/java/jp/co/soramitsu/account/impl/domain/account/details/AccountInChain.kt index 815761c823..4db83cae26 100644 --- a/feature-account-impl/src/main/java/jp/co/soramitsu/account/impl/domain/account/details/AccountInChain.kt +++ b/feature-account-impl/src/main/java/jp/co/soramitsu/account/impl/domain/account/details/AccountInChain.kt @@ -1,7 +1,7 @@ package jp.co.soramitsu.account.impl.domain.account.details import jp.co.soramitsu.runtime.multiNetwork.chain.model.Chain -import jp.co.soramitsu.shared_utils.runtime.AccountId +import jp.co.soramitsu.fearless_utils.runtime.AccountId class AccountInChain( val chain: Chain, diff --git a/feature-account-impl/src/main/java/jp/co/soramitsu/account/impl/presentation/account/details/AccountDetailsViewModel.kt b/feature-account-impl/src/main/java/jp/co/soramitsu/account/impl/presentation/account/details/AccountDetailsViewModel.kt index 63f8b6de8f..949080bdb4 100644 --- a/feature-account-impl/src/main/java/jp/co/soramitsu/account/impl/presentation/account/details/AccountDetailsViewModel.kt +++ b/feature-account-impl/src/main/java/jp/co/soramitsu/account/impl/presentation/account/details/AccountDetailsViewModel.kt @@ -123,6 +123,9 @@ class AccountDetailsViewModel @Inject constructor( WalletEcosystem.Substrate -> R.string.connected_accounts_substrate_title WalletEcosystem.Ethereum -> R.string.connected_accounts_ethereum_title WalletEcosystem.Ton -> R.string.connected_accounts_ton_title + WalletEcosystem.Bitcoin -> R.string.connected_accounts_bitcoin_title + WalletEcosystem.Solana -> R.string.connected_accounts_solana_title + WalletEcosystem.Iroha -> R.string.connected_accounts_iroha_title } return resourceManager.getString(resId) } diff --git a/feature-account-impl/src/main/java/jp/co/soramitsu/account/impl/presentation/exporting/ExportViewModel.kt b/feature-account-impl/src/main/java/jp/co/soramitsu/account/impl/presentation/exporting/ExportViewModel.kt index 51e373eaa4..a8755e3a1e 100644 --- a/feature-account-impl/src/main/java/jp/co/soramitsu/account/impl/presentation/exporting/ExportViewModel.kt +++ b/feature-account-impl/src/main/java/jp/co/soramitsu/account/impl/presentation/exporting/ExportViewModel.kt @@ -18,7 +18,7 @@ import jp.co.soramitsu.common.utils.sendEvent import jp.co.soramitsu.common.utils.switchMap import jp.co.soramitsu.runtime.multiNetwork.ChainRegistry import jp.co.soramitsu.runtime.multiNetwork.chain.model.ChainId -import jp.co.soramitsu.shared_utils.encrypt.mnemonic.Mnemonic +import jp.co.soramitsu.fearless_utils.encrypt.mnemonic.Mnemonic import kotlinx.coroutines.flow.map abstract class ExportViewModel( diff --git a/feature-account-impl/src/main/java/jp/co/soramitsu/account/impl/presentation/exporting/mnemonic/ExportMnemonicViewModel.kt b/feature-account-impl/src/main/java/jp/co/soramitsu/account/impl/presentation/exporting/mnemonic/ExportMnemonicViewModel.kt index d45e4c0b17..a2448bed3c 100644 --- a/feature-account-impl/src/main/java/jp/co/soramitsu/account/impl/presentation/exporting/mnemonic/ExportMnemonicViewModel.kt +++ b/feature-account-impl/src/main/java/jp/co/soramitsu/account/impl/presentation/exporting/mnemonic/ExportMnemonicViewModel.kt @@ -15,8 +15,8 @@ import jp.co.soramitsu.common.resources.ResourceManager import jp.co.soramitsu.common.utils.map import jp.co.soramitsu.common.utils.switchMap import jp.co.soramitsu.runtime.multiNetwork.ChainRegistry -import jp.co.soramitsu.shared_utils.encrypt.mnemonic.Mnemonic -import jp.co.soramitsu.shared_utils.encrypt.mnemonic.MnemonicCreator +import jp.co.soramitsu.fearless_utils.encrypt.mnemonic.Mnemonic +import jp.co.soramitsu.fearless_utils.encrypt.mnemonic.MnemonicCreator @HiltViewModel class ExportMnemonicViewModel @Inject constructor( diff --git a/feature-account-impl/src/main/java/jp/co/soramitsu/account/impl/presentation/exporting/seed/ExportSeedViewModel.kt b/feature-account-impl/src/main/java/jp/co/soramitsu/account/impl/presentation/exporting/seed/ExportSeedViewModel.kt index 904cf5aecb..3960760526 100644 --- a/feature-account-impl/src/main/java/jp/co/soramitsu/account/impl/presentation/exporting/seed/ExportSeedViewModel.kt +++ b/feature-account-impl/src/main/java/jp/co/soramitsu/account/impl/presentation/exporting/seed/ExportSeedViewModel.kt @@ -16,7 +16,7 @@ import jp.co.soramitsu.common.utils.map import jp.co.soramitsu.common.utils.switchMap import jp.co.soramitsu.feature_account_impl.R import jp.co.soramitsu.runtime.multiNetwork.ChainRegistry -import jp.co.soramitsu.shared_utils.extensions.toHexString +import jp.co.soramitsu.fearless_utils.extensions.toHexString import kotlinx.coroutines.launch @HiltViewModel diff --git a/feature-account-impl/src/main/java/jp/co/soramitsu/account/impl/presentation/importing/ImportAccountViewModel.kt b/feature-account-impl/src/main/java/jp/co/soramitsu/account/impl/presentation/importing/ImportAccountViewModel.kt index 3525730558..bbe0180375 100644 --- a/feature-account-impl/src/main/java/jp/co/soramitsu/account/impl/presentation/importing/ImportAccountViewModel.kt +++ b/feature-account-impl/src/main/java/jp/co/soramitsu/account/impl/presentation/importing/ImportAccountViewModel.kt @@ -39,7 +39,7 @@ import jp.co.soramitsu.common.view.ButtonState import jp.co.soramitsu.common.view.bottomSheet.list.dynamic.DynamicListBottomSheet.Payload import jp.co.soramitsu.core.models.CryptoType import jp.co.soramitsu.feature_account_impl.R -import jp.co.soramitsu.shared_utils.encrypt.junction.BIP32JunctionDecoder +import jp.co.soramitsu.fearless_utils.encrypt.junction.BIP32JunctionDecoder import kotlinx.coroutines.launch import javax.inject.Inject diff --git a/feature-account-impl/src/main/java/jp/co/soramitsu/account/impl/presentation/importing/source/model/ImportSourceModel.kt b/feature-account-impl/src/main/java/jp/co/soramitsu/account/impl/presentation/importing/source/model/ImportSourceModel.kt index 6dcea1aa6e..b8838a9974 100644 --- a/feature-account-impl/src/main/java/jp/co/soramitsu/account/impl/presentation/importing/source/model/ImportSourceModel.kt +++ b/feature-account-impl/src/main/java/jp/co/soramitsu/account/impl/presentation/importing/source/model/ImportSourceModel.kt @@ -20,9 +20,9 @@ import jp.co.soramitsu.common.utils.isNotEmpty import jp.co.soramitsu.common.utils.sendEvent import jp.co.soramitsu.core.models.CryptoType import jp.co.soramitsu.feature_account_impl.R -import jp.co.soramitsu.shared_utils.encrypt.json.JsonSeedDecodingException.IncorrectPasswordException -import jp.co.soramitsu.shared_utils.encrypt.json.JsonSeedDecodingException.InvalidJsonException -import jp.co.soramitsu.shared_utils.exceptions.Bip39Exception +import jp.co.soramitsu.fearless_utils.encrypt.json.JsonSeedDecodingException.IncorrectPasswordException +import jp.co.soramitsu.fearless_utils.encrypt.json.JsonSeedDecodingException.InvalidJsonException +import jp.co.soramitsu.fearless_utils.exceptions.Bip39Exception import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.launch @@ -172,6 +172,9 @@ class JsonImportSource( } WalletEcosystem.Ton -> return + WalletEcosystem.Bitcoin, + WalletEcosystem.Solana, + WalletEcosystem.Iroha -> return } } diff --git a/feature-account-impl/src/main/java/jp/co/soramitsu/account/impl/presentation/importing/source/view/JsonImportView.kt b/feature-account-impl/src/main/java/jp/co/soramitsu/account/impl/presentation/importing/source/view/JsonImportView.kt index bec43d6b6f..32241e3330 100644 --- a/feature-account-impl/src/main/java/jp/co/soramitsu/account/impl/presentation/importing/source/view/JsonImportView.kt +++ b/feature-account-impl/src/main/java/jp/co/soramitsu/account/impl/presentation/importing/source/view/JsonImportView.kt @@ -47,6 +47,10 @@ class JsonImportView @JvmOverloads constructor( WalletEcosystem.Ethereum -> binding.importJsonContent.setLabel(R.string.import_ethereum_recovery) WalletEcosystem.Ton -> { /* not implemented */ } + WalletEcosystem.Bitcoin, + WalletEcosystem.Solana, + WalletEcosystem.Iroha -> { /* handled by universal wallet import */ + } } } diff --git a/feature-account-impl/src/main/java/jp/co/soramitsu/account/impl/presentation/importing/source/view/SeedImportView.kt b/feature-account-impl/src/main/java/jp/co/soramitsu/account/impl/presentation/importing/source/view/SeedImportView.kt index 6408857a65..d5c09ecb71 100644 --- a/feature-account-impl/src/main/java/jp/co/soramitsu/account/impl/presentation/importing/source/view/SeedImportView.kt +++ b/feature-account-impl/src/main/java/jp/co/soramitsu/account/impl/presentation/importing/source/view/SeedImportView.kt @@ -46,6 +46,10 @@ class SeedImportView @JvmOverloads constructor( WalletEcosystem.Ethereum -> binding.importSeedTitle.setText(R.string.account_import_ethereum_raw_seed_placeholder) WalletEcosystem.Ton -> { /* not applicable */ } + WalletEcosystem.Bitcoin, + WalletEcosystem.Solana, + WalletEcosystem.Iroha -> { /* handled by universal wallet import */ + } } } diff --git a/feature-account-impl/src/main/java/jp/co/soramitsu/account/impl/presentation/mnemonic/backup/BackupMnemonicContent.kt b/feature-account-impl/src/main/java/jp/co/soramitsu/account/impl/presentation/mnemonic/backup/BackupMnemonicContent.kt index 7aec479322..1e487bf5ec 100644 --- a/feature-account-impl/src/main/java/jp/co/soramitsu/account/impl/presentation/mnemonic/backup/BackupMnemonicContent.kt +++ b/feature-account-impl/src/main/java/jp/co/soramitsu/account/impl/presentation/mnemonic/backup/BackupMnemonicContent.kt @@ -36,7 +36,7 @@ import jp.co.soramitsu.common.compose.component.ToolbarViewState import jp.co.soramitsu.common.compose.theme.FearlessAppTheme import jp.co.soramitsu.common.compose.theme.customColors import jp.co.soramitsu.common.model.WalletEcosystem -import jp.co.soramitsu.shared_utils.encrypt.EncryptionType +import jp.co.soramitsu.fearless_utils.encrypt.EncryptionType data class BackupMnemonicState( val mnemonicWords: List, diff --git a/feature-account-impl/src/main/java/jp/co/soramitsu/account/impl/presentation/mnemonic/backup/BackupMnemonicDefaults.kt b/feature-account-impl/src/main/java/jp/co/soramitsu/account/impl/presentation/mnemonic/backup/BackupMnemonicDefaults.kt new file mode 100644 index 0000000000..3228dc7cb0 --- /dev/null +++ b/feature-account-impl/src/main/java/jp/co/soramitsu/account/impl/presentation/mnemonic/backup/BackupMnemonicDefaults.kt @@ -0,0 +1,11 @@ +package jp.co.soramitsu.account.impl.presentation.mnemonic.backup + +import jp.co.soramitsu.common.model.WalletEcosystem +import jp.co.soramitsu.fearless_utils.encrypt.mnemonic.Mnemonic + +internal object BackupMnemonicDefaults { + @Suppress("UNUSED_PARAMETER") + fun mnemonicLengthForNewWallet(accountTypes: Collection): Mnemonic.Length { + return Mnemonic.Length.TWENTY_FOUR + } +} diff --git a/feature-account-impl/src/main/java/jp/co/soramitsu/account/impl/presentation/mnemonic/backup/BackupMnemonicFragment.kt b/feature-account-impl/src/main/java/jp/co/soramitsu/account/impl/presentation/mnemonic/backup/BackupMnemonicFragment.kt index e43d1d169e..235169f2df 100644 --- a/feature-account-impl/src/main/java/jp/co/soramitsu/account/impl/presentation/mnemonic/backup/BackupMnemonicFragment.kt +++ b/feature-account-impl/src/main/java/jp/co/soramitsu/account/impl/presentation/mnemonic/backup/BackupMnemonicFragment.kt @@ -20,7 +20,7 @@ import jp.co.soramitsu.common.view.bottomSheet.list.dynamic.DynamicListBottomShe import jp.co.soramitsu.common.view.viewBinding import jp.co.soramitsu.feature_account_impl.R import jp.co.soramitsu.feature_account_impl.databinding.FragmentBackupMnemonicBinding -import jp.co.soramitsu.shared_utils.encrypt.junction.BIP32JunctionDecoder +import jp.co.soramitsu.fearless_utils.encrypt.junction.BIP32JunctionDecoder @AndroidEntryPoint class BackupMnemonicFragment : BaseFragment(R.layout.fragment_backup_mnemonic) { diff --git a/feature-account-impl/src/main/java/jp/co/soramitsu/account/impl/presentation/mnemonic/backup/BackupMnemonicViewModel.kt b/feature-account-impl/src/main/java/jp/co/soramitsu/account/impl/presentation/mnemonic/backup/BackupMnemonicViewModel.kt index bb9e4145c7..8fa809c6dd 100644 --- a/feature-account-impl/src/main/java/jp/co/soramitsu/account/impl/presentation/mnemonic/backup/BackupMnemonicViewModel.kt +++ b/feature-account-impl/src/main/java/jp/co/soramitsu/account/impl/presentation/mnemonic/backup/BackupMnemonicViewModel.kt @@ -29,8 +29,8 @@ import jp.co.soramitsu.common.utils.DEFAULT_DERIVATION_PATH import jp.co.soramitsu.common.utils.Event import jp.co.soramitsu.common.utils.requireException import jp.co.soramitsu.feature_account_impl.R -import jp.co.soramitsu.shared_utils.encrypt.junction.BIP32JunctionDecoder -import jp.co.soramitsu.shared_utils.encrypt.mnemonic.Mnemonic +import jp.co.soramitsu.fearless_utils.encrypt.junction.BIP32JunctionDecoder +import jp.co.soramitsu.fearless_utils.encrypt.mnemonic.Mnemonic import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.SharingStarted @@ -71,11 +71,7 @@ class BackupMnemonicViewModel @Inject constructor( val isShowSkipButton = isTonAccount val mnemonic = flow { - val mnemonicLength = if (isSubstrateOrEthereumAccount) { - Mnemonic.Length.TWELVE - } else { - Mnemonic.Length.TWENTY_FOUR - } + val mnemonicLength = BackupMnemonicDefaults.mnemonicLengthForNewWallet(payload.accountTypes) emit(generateMnemonic(mnemonicLength)) }.stateIn(viewModelScope, SharingStarted.Eagerly, emptyList()) diff --git a/feature-account-impl/src/main/java/jp/co/soramitsu/account/impl/presentation/node/add/AddNodeViewModel.kt b/feature-account-impl/src/main/java/jp/co/soramitsu/account/impl/presentation/node/add/AddNodeViewModel.kt index 7481c8d89e..5d46fe2d71 100644 --- a/feature-account-impl/src/main/java/jp/co/soramitsu/account/impl/presentation/node/add/AddNodeViewModel.kt +++ b/feature-account-impl/src/main/java/jp/co/soramitsu/account/impl/presentation/node/add/AddNodeViewModel.kt @@ -15,9 +15,9 @@ import jp.co.soramitsu.common.utils.combine import jp.co.soramitsu.common.utils.requireException import jp.co.soramitsu.common.view.ButtonState import jp.co.soramitsu.feature_account_impl.R -import jp.co.soramitsu.shared_utils.wsrpc.SocketService -import jp.co.soramitsu.shared_utils.wsrpc.executeAsync -import jp.co.soramitsu.shared_utils.wsrpc.request.runtime.chain.RuntimeVersionRequest +import jp.co.soramitsu.fearless_utils.wsrpc.SocketService +import jp.co.soramitsu.fearless_utils.wsrpc.executeAsync +import jp.co.soramitsu.fearless_utils.wsrpc.request.runtime.chain.RuntimeVersionRequest import kotlinx.coroutines.TimeoutCancellationException import kotlinx.coroutines.launch import kotlinx.coroutines.withTimeout diff --git a/feature-account-impl/src/main/java/jp/co/soramitsu/account/impl/presentation/nomis_scoring/ScoreDetailsViewModel.kt b/feature-account-impl/src/main/java/jp/co/soramitsu/account/impl/presentation/nomis_scoring/ScoreDetailsViewModel.kt index 33c684c82e..d7b2194da8 100644 --- a/feature-account-impl/src/main/java/jp/co/soramitsu/account/impl/presentation/nomis_scoring/ScoreDetailsViewModel.kt +++ b/feature-account-impl/src/main/java/jp/co/soramitsu/account/impl/presentation/nomis_scoring/ScoreDetailsViewModel.kt @@ -12,7 +12,7 @@ import jp.co.soramitsu.common.resources.ClipboardManager import jp.co.soramitsu.common.resources.ResourceManager import jp.co.soramitsu.common.utils.formatFiat import jp.co.soramitsu.feature_account_impl.R -import jp.co.soramitsu.shared_utils.extensions.toHexString +import jp.co.soramitsu.fearless_utils.extensions.toHexString import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.catch import kotlinx.coroutines.flow.launchIn diff --git a/feature-account-impl/src/main/java/jp/co/soramitsu/account/impl/presentation/options_ecosystem_accounts/OptionsEcosystemAccountsViewModel.kt b/feature-account-impl/src/main/java/jp/co/soramitsu/account/impl/presentation/options_ecosystem_accounts/OptionsEcosystemAccountsViewModel.kt index 6ee93d7a5c..cb75eb4390 100644 --- a/feature-account-impl/src/main/java/jp/co/soramitsu/account/impl/presentation/options_ecosystem_accounts/OptionsEcosystemAccountsViewModel.kt +++ b/feature-account-impl/src/main/java/jp/co/soramitsu/account/impl/presentation/options_ecosystem_accounts/OptionsEcosystemAccountsViewModel.kt @@ -53,6 +53,9 @@ class OptionsEcosystemAccountsViewModel @Inject constructor( WalletEcosystem.Substrate -> polkadotChainId WalletEcosystem.Ethereum -> ethereumChainId WalletEcosystem.Ton -> tonChainId + WalletEcosystem.Bitcoin, + WalletEcosystem.Solana, + WalletEcosystem.Iroha -> return@let } when (it) { BackupAccountType.PASSPHRASE -> { diff --git a/feature-account-impl/src/main/java/jp/co/soramitsu/account/impl/presentation/view/advanced/AdvancedBlockView.kt b/feature-account-impl/src/main/java/jp/co/soramitsu/account/impl/presentation/view/advanced/AdvancedBlockView.kt index 667a2f79cf..252f18f364 100644 --- a/feature-account-impl/src/main/java/jp/co/soramitsu/account/impl/presentation/view/advanced/AdvancedBlockView.kt +++ b/feature-account-impl/src/main/java/jp/co/soramitsu/account/impl/presentation/view/advanced/AdvancedBlockView.kt @@ -160,6 +160,12 @@ class AdvancedBlockView @JvmOverloads constructor( configureSubstrate(FieldState.HIDDEN) configureEthereum(FieldState.HIDDEN) } + WalletEcosystem.Bitcoin, + WalletEcosystem.Solana, + WalletEcosystem.Iroha -> { + configureSubstrate(FieldState.HIDDEN) + configureEthereum(FieldState.HIDDEN) + } } } diff --git a/feature-account-impl/src/test/java/jp/co/soramitsu/account/impl/data/mappers/AndroidUniversalWalletLegacyExportResolverTest.kt b/feature-account-impl/src/test/java/jp/co/soramitsu/account/impl/data/mappers/AndroidUniversalWalletLegacyExportResolverTest.kt new file mode 100644 index 0000000000..d7cdba3408 --- /dev/null +++ b/feature-account-impl/src/test/java/jp/co/soramitsu/account/impl/data/mappers/AndroidUniversalWalletLegacyExportResolverTest.kt @@ -0,0 +1,150 @@ +package jp.co.soramitsu.account.impl.data.mappers + +import jp.co.soramitsu.account.api.domain.model.AndroidUniversalWalletLegacyExportResolver +import jp.co.soramitsu.account.api.domain.model.AndroidUniversalWalletMigrationSnapshotBuilder +import jp.co.soramitsu.account.api.domain.model.LightMetaAccount +import jp.co.soramitsu.common.model.UniversalWalletEcosystem +import jp.co.soramitsu.common.model.UniversalWalletLegacyVaultDescriptor +import jp.co.soramitsu.core.models.CryptoType +import jp.co.soramitsu.runtime.multiNetwork.chain.model.moonriverChainId +import jp.co.soramitsu.runtime.multiNetwork.chain.model.polkadotChainId +import jp.co.soramitsu.runtime.multiNetwork.chain.model.tonChainId +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Test + +class AndroidUniversalWalletLegacyExportResolverTest { + + private val builder = AndroidUniversalWalletMigrationSnapshotBuilder( + cutoffAtMillis = CUTOFF_AT, + clockMillis = { EVALUATED_AT } + ) + + @Test + fun `resolves legacy root descriptors to existing export chain targets`() { + val account = lightAccount( + id = 42, + substrateAccountId = ByteArray(32) { 1 }, + ethereumAddress = ByteArray(20) { 2 }, + tonPublicKey = ByteArray(32) { 3 } + ) + val descriptors = builder.build(listOf(account)).legacyVaults.associateBy { it.ecosystem } + + val substrate = AndroidUniversalWalletLegacyExportResolver.resolve( + descriptors.getValue(UniversalWalletEcosystem.Substrate.id), + listOf(account) + ) + val evm = AndroidUniversalWalletLegacyExportResolver.resolve( + descriptors.getValue(UniversalWalletEcosystem.Evm.id), + listOf(account) + ) + val ton = AndroidUniversalWalletLegacyExportResolver.resolve( + descriptors.getValue(UniversalWalletEcosystem.Ton.id), + listOf(account) + ) + + assertEquals(42L, substrate?.metaId) + assertEquals(polkadotChainId, substrate?.chainId) + assertEquals(UniversalWalletEcosystem.Substrate, substrate?.ecosystem) + assertEquals(moonriverChainId, evm?.chainId) + assertEquals(UniversalWalletEcosystem.Evm, evm?.ecosystem) + assertEquals(tonChainId, ton?.chainId) + assertEquals(UniversalWalletEcosystem.Ton, ton?.ecosystem) + } + + @Test + fun `rejects descriptors that are not export-only safe`() { + val account = lightAccount(id = 42, substrateAccountId = ByteArray(32) { 1 }) + val descriptor = builder.build(listOf(account)).legacyVaults.single() + + assertNull( + AndroidUniversalWalletLegacyExportResolver.resolve( + descriptor.copy(canSignTransactions = true), + listOf(account) + ) + ) + assertNull( + AndroidUniversalWalletLegacyExportResolver.resolve( + descriptor.copy(canExportSecrets = false), + listOf(account) + ) + ) + } + + @Test + fun `rejects descriptors with tampered deterministic account ids`() { + val account = lightAccount(id = 42, substrateAccountId = ByteArray(32) { 1 }) + val descriptor = builder.build(listOf(account)).legacyVaults.single() + + assertNull( + AndroidUniversalWalletLegacyExportResolver.resolve( + descriptor.copy(vaultId = "legacy_android_99_substrate"), + listOf(account) + ) + ) + assertNull( + AndroidUniversalWalletLegacyExportResolver.resolve( + descriptor.copy(accountId = "android-99-substrate"), + listOf(account) + ) + ) + } + + @Test + fun `rejects unsupported universal wallet ecosystems for legacy export`() { + val account = lightAccount(id = 42, substrateAccountId = ByteArray(32) { 1 }) + val descriptor = legacyDescriptor(UniversalWalletEcosystem.Bitcoin) + + assertNull(AndroidUniversalWalletLegacyExportResolver.resolve(descriptor, listOf(account))) + } + + @Test + fun `rejects descriptor when matching account lacks root material`() { + val account = lightAccount(id = 42, substrateAccountId = ByteArray(32) { 1 }) + val descriptor = builder.build(listOf(account)).legacyVaults.single() + val accountWithoutRoot = lightAccount(id = 42) + + assertNull(AndroidUniversalWalletLegacyExportResolver.resolve(descriptor, listOf(accountWithoutRoot))) + } + + private fun legacyDescriptor(ecosystem: UniversalWalletEcosystem): UniversalWalletLegacyVaultDescriptor { + return UniversalWalletLegacyVaultDescriptor( + vaultId = "legacy_android_42_${ecosystem.id}", + accountId = "android-42-${ecosystem.id}", + ecosystem = ecosystem, + address = "unavailable:android:42:${ecosystem.id}", + displayName = "Wallet", + exportOnlyReason = "pre-cutoff account export", + canExportSecrets = true, + canSignTransactions = false, + discoveredAtMillis = CUTOFF_AT + ) + } + + private fun lightAccount( + id: Long, + substrateAccountId: ByteArray? = null, + ethereumAddress: ByteArray? = null, + tonPublicKey: ByteArray? = null + ): LightMetaAccount { + return LightMetaAccount( + id = id, + substratePublicKey = substrateAccountId, + substrateCryptoType = substrateAccountId?.let { CryptoType.ED25519 }, + substrateAccountId = substrateAccountId, + ethereumAddress = ethereumAddress, + ethereumPublicKey = ethereumAddress, + tonPublicKey = tonPublicKey, + universalWalletChainAccounts = emptyMap(), + isSelected = true, + name = "Wallet", + isBackedUp = true, + initialized = true + ) + } + + private companion object { + const val CUTOFF_AT = 1_710_000_000_000L + const val EVALUATED_AT = 1_710_000_000_100L + } +} diff --git a/feature-account-impl/src/test/java/jp/co/soramitsu/account/impl/data/mappers/AndroidUniversalWalletMigrationSnapshotBuilderTest.kt b/feature-account-impl/src/test/java/jp/co/soramitsu/account/impl/data/mappers/AndroidUniversalWalletMigrationSnapshotBuilderTest.kt new file mode 100644 index 0000000000..d4538a572d --- /dev/null +++ b/feature-account-impl/src/test/java/jp/co/soramitsu/account/impl/data/mappers/AndroidUniversalWalletMigrationSnapshotBuilderTest.kt @@ -0,0 +1,196 @@ +package jp.co.soramitsu.account.impl.data.mappers + +import jp.co.soramitsu.account.api.domain.model.AndroidUniversalWalletMigrationSnapshotBuilder +import jp.co.soramitsu.account.api.domain.model.LightMetaAccount +import jp.co.soramitsu.common.model.UniversalWalletEcosystem +import jp.co.soramitsu.common.model.UniversalWalletMigrationRequiredAction +import jp.co.soramitsu.common.model.UniversalWalletRegistry +import jp.co.soramitsu.common.utils.BitcoinKeyDerivation +import jp.co.soramitsu.common.utils.IrohaKeyDerivation +import jp.co.soramitsu.common.utils.SolanaKeyDerivation +import jp.co.soramitsu.core.models.CryptoType +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test + +class AndroidUniversalWalletMigrationSnapshotBuilderTest { + + private val builder = AndroidUniversalWalletMigrationSnapshotBuilder( + cutoffAtMillis = CUTOFF_AT, + clockMillis = { EVALUATED_AT } + ) + + @Test + fun `fresh install requires universal wallet creation`() { + val snapshot = builder.build(emptyList()) + + assertFalse(snapshot.hasUniversalWallet) + assertTrue(snapshot.legacyVaults.isEmpty()) + assertEquals(UniversalWalletMigrationRequiredAction.CreateUniversalWallet, snapshot.requiredAction()) + assertTrue(snapshot.validationErrors().isEmpty()) + } + + @Test + fun `builder clamps evaluated time to cutoff`() { + val snapshot = AndroidUniversalWalletMigrationSnapshotBuilder( + cutoffAtMillis = CUTOFF_AT, + clockMillis = { 1L } + ).build(emptyList()) + + assertEquals(CUTOFF_AT, snapshot.evaluatedAtMillis) + assertTrue(snapshot.validationErrors().isEmpty()) + } + + @Test + fun `legacy root accounts become export-only vault descriptors`() { + val snapshot = builder.build( + listOf( + lightAccount( + substrateAccountId = ByteArray(32) { 1 }, + ethereumAddress = ByteArray(20) { 2 }, + tonPublicKey = ByteArray(32) { 3 } + ) + ) + ) + + assertFalse(snapshot.hasUniversalWallet) + assertEquals(UniversalWalletMigrationRequiredAction.MigrateBeforeAccess, snapshot.requiredAction()) + assertEquals( + listOf( + UniversalWalletEcosystem.Substrate.id, + UniversalWalletEcosystem.Evm.id, + UniversalWalletEcosystem.Ton.id + ), + snapshot.legacyVaults.map { it.ecosystem } + ) + assertTrue(snapshot.legacyVaults.all { it.canExportSecrets }) + assertTrue(snapshot.legacyVaults.none { it.canSignTransactions }) + assertEquals(snapshot.legacyVaults.size, snapshot.legacyVaults.map { it.vaultId }.toSet().size) + assertTrue(snapshot.validationErrors().isEmpty()) + } + + @Test + fun `complete universal wallet allows normal access without legacy descriptors`() { + val bitcoin = BitcoinKeyDerivation.deriveAccount( + mnemonic = MNEMONIC, + network = BitcoinKeyDerivation.Network.Mainnet + ) + val solana = SolanaKeyDerivation.deriveAccount(MNEMONIC) + val iroha = IrohaKeyDerivation.deriveAccount(MNEMONIC) + val snapshot = builder.build( + listOf( + lightAccount( + substrateAccountId = ByteArray(32) { 1 }, + ethereumAddress = ByteArray(20) { 2 }, + tonPublicKey = ByteArray(32) { 3 }, + universalWalletChainAccounts = mapOf( + UniversalWalletRegistry.bitcoinMainnet.chainId to universalWalletAccount(bitcoin.publicKey), + UniversalWalletRegistry.solanaMainnet.chainId to universalWalletAccount(solana.publicKey), + UniversalWalletRegistry.taira.chainId to universalWalletAccount(iroha.publicKey) + ) + ) + ) + ) + + assertTrue(snapshot.hasUniversalWallet) + assertTrue(snapshot.legacyVaults.isEmpty()) + assertEquals(UniversalWalletMigrationRequiredAction.NormalAccess, snapshot.requiredAction()) + assertTrue(snapshot.validationErrors().isEmpty()) + } + + @Test + fun `partial universal wallet material does not unlock normal access`() { + val solana = SolanaKeyDerivation.deriveAccount(MNEMONIC) + val snapshot = builder.build( + listOf( + lightAccount( + universalWalletChainAccounts = mapOf( + UniversalWalletRegistry.solanaMainnet.chainId to universalWalletAccount(solana.publicKey) + ) + ) + ) + ) + + assertFalse(snapshot.hasUniversalWallet) + assertTrue(snapshot.legacyVaults.isEmpty()) + assertEquals(UniversalWalletMigrationRequiredAction.CreateUniversalWallet, snapshot.requiredAction()) + assertTrue(snapshot.validationErrors().isEmpty()) + } + + @Test + fun `partial universal wallet material preserves legacy export-only descriptors`() { + val solana = SolanaKeyDerivation.deriveAccount(MNEMONIC) + val snapshot = builder.build( + listOf( + lightAccount( + substrateAccountId = ByteArray(32) { 1 }, + universalWalletChainAccounts = mapOf( + UniversalWalletRegistry.solanaMainnet.chainId to universalWalletAccount(solana.publicKey) + ) + ) + ) + ) + + assertFalse(snapshot.hasUniversalWallet) + assertEquals(UniversalWalletMigrationRequiredAction.MigrateBeforeAccess, snapshot.requiredAction()) + assertEquals(listOf(UniversalWalletEcosystem.Substrate.id), snapshot.legacyVaults.map { it.ecosystem }) + assertTrue(snapshot.validationErrors().isEmpty()) + } + + @Test + fun `malformed legacy public data still produces a valid fail-closed descriptor`() { + val snapshot = builder.build( + listOf( + lightAccount( + substrateAccountId = byteArrayOf(1), + name = " Bad\u0000Name That Is Far Too Long For The Shared Human Text Contract And Must Be Trimmed " + ) + ) + ) + val descriptor = snapshot.legacyVaults.single() + + assertEquals(UniversalWalletMigrationRequiredAction.MigrateBeforeAccess, snapshot.requiredAction()) + assertEquals("unavailable:android:1:substrate", descriptor.address) + assertFalse(descriptor.displayName.orEmpty().contains('\u0000')) + assertTrue(descriptor.displayName.orEmpty().length <= 64) + assertTrue(snapshot.validationErrors().isEmpty()) + } + + private fun universalWalletAccount(publicKey: ByteArray): LightMetaAccount.UniversalWalletChainAccount { + return LightMetaAccount.UniversalWalletChainAccount( + publicKey = publicKey, + accountId = publicKey, + cryptoType = CryptoType.ED25519 + ) + } + + private fun lightAccount( + substrateAccountId: ByteArray? = null, + ethereumAddress: ByteArray? = null, + tonPublicKey: ByteArray? = null, + universalWalletChainAccounts: Map = emptyMap(), + name: String = "Wallet" + ): LightMetaAccount { + return LightMetaAccount( + id = 1, + substratePublicKey = substrateAccountId, + substrateCryptoType = substrateAccountId?.let { CryptoType.ED25519 }, + substrateAccountId = substrateAccountId, + ethereumAddress = ethereumAddress, + ethereumPublicKey = ethereumAddress, + tonPublicKey = tonPublicKey, + universalWalletChainAccounts = universalWalletChainAccounts, + isSelected = true, + name = name, + isBackedUp = true, + initialized = true + ) + } + + private companion object { + const val CUTOFF_AT = 1_710_000_000_000L + const val EVALUATED_AT = 1_710_000_000_100L + const val MNEMONIC = "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about" + } +} diff --git a/feature-account-impl/src/test/java/jp/co/soramitsu/account/impl/data/mappers/LightMetaAccountUniversalWalletTest.kt b/feature-account-impl/src/test/java/jp/co/soramitsu/account/impl/data/mappers/LightMetaAccountUniversalWalletTest.kt new file mode 100644 index 0000000000..5df3056eea --- /dev/null +++ b/feature-account-impl/src/test/java/jp/co/soramitsu/account/impl/data/mappers/LightMetaAccountUniversalWalletTest.kt @@ -0,0 +1,252 @@ +package jp.co.soramitsu.account.impl.data.mappers + +import jp.co.soramitsu.account.api.domain.model.LightMetaAccount +import jp.co.soramitsu.account.api.domain.model.MetaAccount +import jp.co.soramitsu.account.api.domain.model.accountId +import jp.co.soramitsu.account.api.domain.model.address +import jp.co.soramitsu.account.api.domain.model.chainAddress +import jp.co.soramitsu.account.api.domain.model.cryptoType +import jp.co.soramitsu.account.api.domain.model.supportedEcosystemWithIconAddress +import jp.co.soramitsu.account.api.domain.model.supportedEcosystems +import jp.co.soramitsu.common.model.UniversalWalletRegistry +import jp.co.soramitsu.common.model.WalletEcosystem +import jp.co.soramitsu.common.utils.BitcoinKeyDerivation +import jp.co.soramitsu.common.utils.IrohaKeyDerivation +import jp.co.soramitsu.common.utils.SolanaKeyDerivation +import jp.co.soramitsu.core.models.CryptoType +import jp.co.soramitsu.core.models.Ecosystem +import jp.co.soramitsu.coredb.model.ChainAccountLocal +import jp.co.soramitsu.coredb.model.MetaAccountLocal +import jp.co.soramitsu.coredb.model.RelationJoinedMetaAccountInfo +import jp.co.soramitsu.runtime.multiNetwork.chain.model.Chain +import org.junit.Assert.assertArrayEquals +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNull +import org.junit.Test + +class LightMetaAccountUniversalWalletTest { + + @Test + fun `joined light account keeps universal wallet chain account ecosystems and icon addresses`() { + val bitcoin = BitcoinKeyDerivation.deriveAccount( + mnemonic = MNEMONIC, + network = BitcoinKeyDerivation.Network.Mainnet + ) + val solana = SolanaKeyDerivation.deriveAccount(MNEMONIC) + val iroha = IrohaKeyDerivation.deriveAccount(MNEMONIC) + + val lightAccount = mapJoinedMetaAccountInfoToLightMetaAccount( + RelationJoinedMetaAccountInfo( + metaAccount = metaAccountLocal(), + chainAccounts = listOf( + chainAccount(UniversalWalletRegistry.bitcoinMainnet.id, bitcoin.publicKey), + chainAccount(UniversalWalletRegistry.solanaMainnet.id, solana.publicKey), + chainAccount(UniversalWalletRegistry.taira.id, iroha.publicKey) + ), + favoriteChains = emptyList() + ) + ) + + assertEquals( + setOf(WalletEcosystem.Bitcoin, WalletEcosystem.Solana, WalletEcosystem.Iroha), + lightAccount.supportedEcosystems() + ) + + assertEquals( + BitcoinKeyDerivation.addressFromPublicKey(bitcoin.publicKey, BitcoinKeyDerivation.Network.Mainnet), + lightAccount.supportedEcosystemWithIconAddress()[WalletEcosystem.Bitcoin] + ) + assertEquals( + SolanaKeyDerivation.addressFromPublicKey(solana.publicKey), + lightAccount.supportedEcosystemWithIconAddress()[WalletEcosystem.Solana] + ) + assertEquals( + IrohaKeyDerivation.deriveAddress( + mnemonic = MNEMONIC, + chainDiscriminant = UniversalWalletRegistry.taira.chainDiscriminant + ).i105, + lightAccount.supportedEcosystemWithIconAddress()[WalletEcosystem.Iroha] + ) + } + + @Test + fun `malformed universal wallet public keys do not produce icon addresses`() { + val lightAccount = LightMetaAccount( + id = 1, + substratePublicKey = null, + substrateCryptoType = null, + substrateAccountId = null, + ethereumAddress = null, + ethereumPublicKey = null, + tonPublicKey = null, + universalWalletChainAccounts = mapOf( + UniversalWalletRegistry.bitcoinMainnet.id to LightMetaAccount.UniversalWalletChainAccount( + publicKey = ByteArray(32), + accountId = ByteArray(32), + cryptoType = CryptoType.ED25519 + ) + ), + isSelected = true, + name = "Wallet", + isBackedUp = true, + initialized = true + ) + + assertEquals(setOf(WalletEcosystem.Bitcoin), lightAccount.supportedEcosystems()) + assertFalse(lightAccount.supportedEcosystemWithIconAddress().containsKey(WalletEcosystem.Bitcoin)) + } + + @Test + fun `account address lookups match canonical universal wallet accounts from registry chain ids`() { + val solana = SolanaKeyDerivation.deriveAccount(MNEMONIC) + val iroha = IrohaKeyDerivation.deriveAccount(MNEMONIC) + val lightAccount = LightMetaAccount( + id = 1, + substratePublicKey = null, + substrateCryptoType = null, + substrateAccountId = null, + ethereumAddress = null, + ethereumPublicKey = null, + tonPublicKey = null, + universalWalletChainAccounts = mapOf( + UniversalWalletRegistry.solanaMainnet.chainId to LightMetaAccount.UniversalWalletChainAccount( + publicKey = solana.publicKey, + accountId = solana.publicKey, + cryptoType = CryptoType.ED25519 + ) + ), + isSelected = true, + name = "Wallet", + isBackedUp = true, + initialized = true + ) + val metaAccount = metaAccount( + chainAccounts = mapOf( + UniversalWalletRegistry.nexus.chainId to MetaAccount.ChainAccount( + metaId = 1, + chain = null, + publicKey = iroha.publicKey, + accountId = iroha.publicKey, + cryptoType = CryptoType.ED25519, + accountName = "Nexus" + ) + ) + ) + val solanaRegistryChain = universalWalletChain(UniversalWalletRegistry.solanaMainnet.id) + val nexusRegistryChain = universalWalletChain(UniversalWalletRegistry.nexus.id) + val nexusAddress = IrohaKeyDerivation.deriveAddress( + mnemonic = MNEMONIC, + chainDiscriminant = UniversalWalletRegistry.nexus.chainDiscriminant + ).i105 + + assertEquals(solana.address, lightAccount.address(solanaRegistryChain)) + assertEquals(nexusAddress, metaAccount.address(nexusRegistryChain)) + assertEquals(nexusAddress, metaAccount.chainAddress(nexusRegistryChain)) + assertArrayEquals(iroha.publicKey, metaAccount.accountId(nexusRegistryChain)) + assertEquals(CryptoType.ED25519, metaAccount.cryptoType(nexusRegistryChain)) + } + + @Test + fun `missing universal wallet chain accounts do not fall back to substrate account id`() { + val metaAccount = metaAccount( + chainAccounts = emptyMap(), + substratePublicKey = ByteArray(32) { 1 }, + substrateAccountId = ByteArray(32) { 2 }, + substrateCryptoType = CryptoType.ED25519 + ) + val solanaRegistryChain = universalWalletChain(UniversalWalletRegistry.solanaMainnet.id) + + assertNull(metaAccount.address(solanaRegistryChain)) + assertNull(metaAccount.chainAddress(solanaRegistryChain)) + assertNull(metaAccount.accountId(solanaRegistryChain)) + assertNull(metaAccount.cryptoType(solanaRegistryChain)) + } + + private fun metaAccountLocal(): MetaAccountLocal { + return MetaAccountLocal( + substratePublicKey = null, + substrateCryptoType = null, + substrateAccountId = null, + ethereumPublicKey = null, + ethereumAddress = null, + tonPublicKey = null, + name = "Wallet", + isSelected = true, + position = 0, + isBackedUp = true, + googleBackupAddress = null, + initialized = true + ).also { + it.id = 1 + } + } + + private fun chainAccount(chainId: String, publicKey: ByteArray): ChainAccountLocal { + return ChainAccountLocal( + metaId = 1, + chainId = chainId, + publicKey = publicKey, + accountId = publicKey, + cryptoType = CryptoType.ED25519, + name = chainId, + initialized = true + ) + } + + private fun metaAccount( + chainAccounts: Map, + substratePublicKey: ByteArray? = null, + substrateAccountId: ByteArray? = null, + substrateCryptoType: CryptoType? = null + ): MetaAccount { + return MetaAccount( + id = 1, + chainAccounts = chainAccounts, + favoriteChains = emptyMap(), + substratePublicKey = substratePublicKey, + substrateCryptoType = substrateCryptoType, + substrateAccountId = substrateAccountId, + ethereumAddress = null, + ethereumPublicKey = null, + tonPublicKey = null, + isSelected = true, + isBackedUp = true, + googleBackupAddress = null, + name = "Wallet", + initialized = true + ) + } + + private fun universalWalletChain(id: String, isTestNet: Boolean = false): Chain { + return Chain( + id = id, + paraId = null, + rank = null, + name = id, + minSupportedVersion = null, + assets = emptyList(), + nodes = emptyList(), + explorers = emptyList(), + externalApi = null, + icon = "", + addressPrefix = 0, + isEthereumBased = false, + isTestNet = isTestNet, + hasCrowdloans = false, + parentId = null, + supportStakingPool = false, + isEthereumChain = false, + chainlinkProvider = false, + supportNft = false, + isUsesAppId = false, + identityChain = null, + ecosystem = Ecosystem.Substrate, + remoteAssetsSource = null + ) + } + + private companion object { + const val MNEMONIC = "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about" + } +} diff --git a/feature-account-impl/src/test/java/jp/co/soramitsu/account/impl/domain/AccountInteractorImplTest.kt b/feature-account-impl/src/test/java/jp/co/soramitsu/account/impl/domain/AccountInteractorImplTest.kt index 21606dc925..f715b08585 100644 --- a/feature-account-impl/src/test/java/jp/co/soramitsu/account/impl/domain/AccountInteractorImplTest.kt +++ b/feature-account-impl/src/test/java/jp/co/soramitsu/account/impl/domain/AccountInteractorImplTest.kt @@ -4,15 +4,22 @@ import java.io.File import java.lang.reflect.Method import java.lang.reflect.Proxy import jp.co.soramitsu.account.api.domain.interfaces.AccountRepository +import jp.co.soramitsu.account.api.domain.model.AndroidUniversalWalletMigrationSnapshotBuilder +import jp.co.soramitsu.account.api.domain.model.LightMetaAccount import jp.co.soramitsu.backup.BackupService import jp.co.soramitsu.common.data.storage.InitialValueProducer import jp.co.soramitsu.common.data.storage.Preferences import jp.co.soramitsu.common.interfaces.FileProvider +import jp.co.soramitsu.common.model.UniversalWalletEcosystem +import jp.co.soramitsu.common.model.UniversalWalletMigrationRequiredAction +import jp.co.soramitsu.core.models.CryptoType import jp.co.soramitsu.core.model.Language import jp.co.soramitsu.wallet.impl.domain.interfaces.WalletInteractor import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.flowOf import kotlinx.coroutines.runBlocking +import org.junit.Assert.assertEquals import org.junit.Assert.assertFalse import org.junit.Assert.assertTrue import org.junit.Before @@ -107,6 +114,81 @@ class AccountInteractorImplTest { } } + @Test + fun `universalWalletMigrationSnapshotFlow maps public light accounts without secret access`() { + runBlocking { + val interactor = interactorWithLightAccounts( + listOf( + lightAccount( + substrateAccountId = ByteArray(32) { 1 } + ) + ) + ) + + val snapshot = interactor.universalWalletMigrationSnapshotFlow().first() + + assertEquals(UniversalWalletMigrationRequiredAction.MigrateBeforeAccess, snapshot.requiredAction()) + assertEquals(listOf(UniversalWalletEcosystem.Substrate.id), snapshot.legacyVaults.map { it.ecosystem }) + assertTrue(snapshot.validationErrors().isEmpty()) + } + } + + @Test + fun `universalWalletMigrationSnapshot returns create state for empty install`() { + runBlocking { + val interactor = interactorWithLightAccounts(emptyList()) + + val snapshot = interactor.universalWalletMigrationSnapshot() + + assertEquals(UniversalWalletMigrationRequiredAction.CreateUniversalWallet, snapshot.requiredAction()) + assertTrue(snapshot.legacyVaults.isEmpty()) + assertTrue(snapshot.validationErrors().isEmpty()) + } + } + + private fun interactorWithLightAccounts(accounts: List): AccountInteractorImpl { + accountRepository = interfaceProxy { method, _ -> + when (method.name) { + "lightMetaAccountsFlow" -> flowOf(accounts) + else -> unexpectedCall(method) + } + } + + return AccountInteractorImpl( + accountRepository = accountRepository, + fileProvider = fileProvider, + preferences = preferences, + backupService = backupService, + walletInteractor = walletInteractor, + migrationSnapshotBuilder = AndroidUniversalWalletMigrationSnapshotBuilder( + cutoffAtMillis = CUTOFF_AT, + clockMillis = { EVALUATED_AT } + ) + ) + } + + private fun lightAccount( + substrateAccountId: ByteArray? = null, + ethereumAddress: ByteArray? = null, + tonPublicKey: ByteArray? = null, + name: String = "Wallet" + ): LightMetaAccount { + return LightMetaAccount( + id = 1, + substratePublicKey = substrateAccountId, + substrateCryptoType = substrateAccountId?.let { CryptoType.ED25519 }, + substrateAccountId = substrateAccountId, + ethereumAddress = ethereumAddress, + ethereumPublicKey = ethereumAddress, + tonPublicKey = tonPublicKey, + universalWalletChainAccounts = emptyMap(), + isSelected = true, + name = name, + isBackedUp = true, + initialized = true + ) + } + @Suppress("UNCHECKED_CAST") private inline fun interfaceProxy( crossinline handler: (Method, Array?) -> Any? @@ -138,6 +220,11 @@ class AccountInteractorImplTest { return allocateInstance.invoke(unsafe, clazz) as T } + private companion object { + const val CUTOFF_AT = 1_710_000_000_000L + const val EVALUATED_AT = 1_710_000_000_100L + } + private class InMemoryPreferences : Preferences { private val data = mutableMapOf() diff --git a/feature-account-impl/src/test/java/jp/co/soramitsu/account/impl/presentation/mnemonic/backup/BackupMnemonicDefaultsTest.kt b/feature-account-impl/src/test/java/jp/co/soramitsu/account/impl/presentation/mnemonic/backup/BackupMnemonicDefaultsTest.kt new file mode 100644 index 0000000000..548cd13b3f --- /dev/null +++ b/feature-account-impl/src/test/java/jp/co/soramitsu/account/impl/presentation/mnemonic/backup/BackupMnemonicDefaultsTest.kt @@ -0,0 +1,27 @@ +package jp.co.soramitsu.account.impl.presentation.mnemonic.backup + +import jp.co.soramitsu.common.model.WalletEcosystem +import jp.co.soramitsu.fearless_utils.encrypt.mnemonic.Mnemonic +import org.junit.Assert.assertEquals +import org.junit.Test + +class BackupMnemonicDefaultsTest { + + @Test + fun `new Substrate and EVM wallet uses twenty four word mnemonic`() { + assertEquals( + Mnemonic.Length.TWENTY_FOUR, + BackupMnemonicDefaults.mnemonicLengthForNewWallet( + listOf(WalletEcosystem.Substrate, WalletEcosystem.Ethereum) + ) + ) + } + + @Test + fun `new TON wallet uses twenty four word mnemonic`() { + assertEquals( + Mnemonic.Length.TWENTY_FOUR, + BackupMnemonicDefaults.mnemonicLengthForNewWallet(listOf(WalletEcosystem.Ton)) + ) + } +} diff --git a/feature-crowdloan-api/src/main/java/jp/co/soramitsu/crowdloan/api/data/network/blockhain/binding/Contribution.kt b/feature-crowdloan-api/src/main/java/jp/co/soramitsu/crowdloan/api/data/network/blockhain/binding/Contribution.kt index 720f013788..67c20af859 100644 --- a/feature-crowdloan-api/src/main/java/jp/co/soramitsu/crowdloan/api/data/network/blockhain/binding/Contribution.kt +++ b/feature-crowdloan-api/src/main/java/jp/co/soramitsu/crowdloan/api/data/network/blockhain/binding/Contribution.kt @@ -4,8 +4,8 @@ import jp.co.soramitsu.common.data.network.runtime.binding.bindNumber import jp.co.soramitsu.common.data.network.runtime.binding.bindString import jp.co.soramitsu.common.data.network.runtime.binding.cast import jp.co.soramitsu.common.data.network.runtime.binding.incompatible -import jp.co.soramitsu.shared_utils.runtime.RuntimeSnapshot -import jp.co.soramitsu.shared_utils.runtime.definitions.types.fromHex +import jp.co.soramitsu.fearless_utils.runtime.RuntimeSnapshot +import jp.co.soramitsu.fearless_utils.runtime.definitions.types.fromHex import java.math.BigInteger class Contribution( diff --git a/feature-crowdloan-api/src/main/java/jp/co/soramitsu/crowdloan/api/data/network/blockhain/binding/FundInfo.kt b/feature-crowdloan-api/src/main/java/jp/co/soramitsu/crowdloan/api/data/network/blockhain/binding/FundInfo.kt index 91828b2a8b..0acaa92186 100644 --- a/feature-crowdloan-api/src/main/java/jp/co/soramitsu/crowdloan/api/data/network/blockhain/binding/FundInfo.kt +++ b/feature-crowdloan-api/src/main/java/jp/co/soramitsu/crowdloan/api/data/network/blockhain/binding/FundInfo.kt @@ -6,11 +6,11 @@ import jp.co.soramitsu.common.data.network.runtime.binding.cast import jp.co.soramitsu.common.data.network.runtime.binding.fromHexOrIncompatible import jp.co.soramitsu.common.data.network.runtime.binding.storageReturnType import jp.co.soramitsu.common.utils.Modules -import jp.co.soramitsu.shared_utils.runtime.AccountId -import jp.co.soramitsu.shared_utils.runtime.RuntimeSnapshot -import jp.co.soramitsu.shared_utils.runtime.definitions.types.composite.Struct -import jp.co.soramitsu.shared_utils.runtime.definitions.types.primitives.u32 -import jp.co.soramitsu.shared_utils.runtime.definitions.types.toByteArray +import jp.co.soramitsu.fearless_utils.runtime.AccountId +import jp.co.soramitsu.fearless_utils.runtime.RuntimeSnapshot +import jp.co.soramitsu.fearless_utils.runtime.definitions.types.composite.Struct +import jp.co.soramitsu.fearless_utils.runtime.definitions.types.primitives.u32 +import jp.co.soramitsu.fearless_utils.runtime.definitions.types.toByteArray import java.math.BigInteger class FundInfo( diff --git a/feature-crowdloan-api/src/main/java/jp/co/soramitsu/crowdloan/api/data/network/blockhain/binding/LeaseEntry.kt b/feature-crowdloan-api/src/main/java/jp/co/soramitsu/crowdloan/api/data/network/blockhain/binding/LeaseEntry.kt index c2be270be5..62228c4f36 100644 --- a/feature-crowdloan-api/src/main/java/jp/co/soramitsu/crowdloan/api/data/network/blockhain/binding/LeaseEntry.kt +++ b/feature-crowdloan-api/src/main/java/jp/co/soramitsu/crowdloan/api/data/network/blockhain/binding/LeaseEntry.kt @@ -7,9 +7,9 @@ import jp.co.soramitsu.common.data.network.runtime.binding.cast import jp.co.soramitsu.common.data.network.runtime.binding.fromHexOrIncompatible import jp.co.soramitsu.common.utils.slots import jp.co.soramitsu.core.runtime.storage.returnType -import jp.co.soramitsu.shared_utils.runtime.AccountId -import jp.co.soramitsu.shared_utils.runtime.RuntimeSnapshot -import jp.co.soramitsu.shared_utils.runtime.metadata.storage +import jp.co.soramitsu.fearless_utils.runtime.AccountId +import jp.co.soramitsu.fearless_utils.runtime.RuntimeSnapshot +import jp.co.soramitsu.fearless_utils.runtime.metadata.storage class LeaseEntry( val accountId: AccountId, diff --git a/feature-crowdloan-api/src/main/java/jp/co/soramitsu/crowdloan/api/data/repository/CrowdloanRepository.kt b/feature-crowdloan-api/src/main/java/jp/co/soramitsu/crowdloan/api/data/repository/CrowdloanRepository.kt index 8797d780ee..5115093a7c 100644 --- a/feature-crowdloan-api/src/main/java/jp/co/soramitsu/crowdloan/api/data/repository/CrowdloanRepository.kt +++ b/feature-crowdloan-api/src/main/java/jp/co/soramitsu/crowdloan/api/data/repository/CrowdloanRepository.kt @@ -6,7 +6,7 @@ import jp.co.soramitsu.crowdloan.api.data.network.blockhain.binding.FundInfo import jp.co.soramitsu.crowdloan.api.data.network.blockhain.binding.ParaId import jp.co.soramitsu.runtime.multiNetwork.chain.model.Chain import jp.co.soramitsu.runtime.multiNetwork.chain.model.ChainId -import jp.co.soramitsu.shared_utils.runtime.AccountId +import jp.co.soramitsu.fearless_utils.runtime.AccountId import kotlinx.coroutines.flow.Flow import java.math.BigDecimal import java.math.BigInteger diff --git a/feature-crowdloan-api/src/main/java/jp/co/soramitsu/crowdloan/api/data/repository/CrowdloanRepositoryExt.kt b/feature-crowdloan-api/src/main/java/jp/co/soramitsu/crowdloan/api/data/repository/CrowdloanRepositoryExt.kt index 44572a393a..c38b1780d7 100644 --- a/feature-crowdloan-api/src/main/java/jp/co/soramitsu/crowdloan/api/data/repository/CrowdloanRepositoryExt.kt +++ b/feature-crowdloan-api/src/main/java/jp/co/soramitsu/crowdloan/api/data/repository/CrowdloanRepositoryExt.kt @@ -5,7 +5,7 @@ import jp.co.soramitsu.crowdloan.api.data.network.blockhain.binding.FundIndex import jp.co.soramitsu.crowdloan.api.data.network.blockhain.binding.FundInfo import jp.co.soramitsu.crowdloan.api.data.network.blockhain.binding.ParaId import jp.co.soramitsu.runtime.multiNetwork.chain.model.ChainId -import jp.co.soramitsu.shared_utils.runtime.AccountId +import jp.co.soramitsu.fearless_utils.runtime.AccountId import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.async import kotlinx.coroutines.awaitAll diff --git a/feature-crowdloan-impl/src/main/java/jp/co/soramitsu/crowdloan/impl/data/network/blockhain/extrinsic/ExtrinsicBuilderExt.kt b/feature-crowdloan-impl/src/main/java/jp/co/soramitsu/crowdloan/impl/data/network/blockhain/extrinsic/ExtrinsicBuilderExt.kt index f42f5b6f82..402723c225 100644 --- a/feature-crowdloan-impl/src/main/java/jp/co/soramitsu/crowdloan/impl/data/network/blockhain/extrinsic/ExtrinsicBuilderExt.kt +++ b/feature-crowdloan-impl/src/main/java/jp/co/soramitsu/crowdloan/impl/data/network/blockhain/extrinsic/ExtrinsicBuilderExt.kt @@ -2,10 +2,10 @@ package jp.co.soramitsu.crowdloan.impl.data.network.blockhain.extrinsic import java.math.BigInteger import jp.co.soramitsu.crowdloan.api.data.network.blockhain.binding.ParaId -import jp.co.soramitsu.shared_utils.encrypt.EncryptionType -import jp.co.soramitsu.shared_utils.extensions.fromHex -import jp.co.soramitsu.shared_utils.runtime.definitions.types.composite.DictEnum -import jp.co.soramitsu.shared_utils.runtime.extrinsic.ExtrinsicBuilder +import jp.co.soramitsu.fearless_utils.encrypt.EncryptionType +import jp.co.soramitsu.fearless_utils.extensions.fromHex +import jp.co.soramitsu.fearless_utils.runtime.definitions.types.composite.DictEnum +import jp.co.soramitsu.fearless_utils.runtime.extrinsic.ExtrinsicBuilder fun ExtrinsicBuilder.contribute( parachainId: ParaId, diff --git a/feature-crowdloan-impl/src/main/java/jp/co/soramitsu/crowdloan/impl/data/repository/CrowdloanRepositoryImpl.kt b/feature-crowdloan-impl/src/main/java/jp/co/soramitsu/crowdloan/impl/data/repository/CrowdloanRepositoryImpl.kt index 3408b121df..28e5854d68 100644 --- a/feature-crowdloan-impl/src/main/java/jp/co/soramitsu/crowdloan/impl/data/repository/CrowdloanRepositoryImpl.kt +++ b/feature-crowdloan-impl/src/main/java/jp/co/soramitsu/crowdloan/impl/data/repository/CrowdloanRepositoryImpl.kt @@ -24,14 +24,14 @@ import jp.co.soramitsu.runtime.multiNetwork.ChainRegistry import jp.co.soramitsu.runtime.multiNetwork.chain.model.Chain import jp.co.soramitsu.runtime.multiNetwork.chain.model.ChainId import jp.co.soramitsu.runtime.storage.source.StorageDataSource -import jp.co.soramitsu.shared_utils.extensions.toHexString -import jp.co.soramitsu.shared_utils.hash.Hasher.blake2b256 -import jp.co.soramitsu.shared_utils.runtime.AccountId -import jp.co.soramitsu.shared_utils.runtime.definitions.types.bytes -import jp.co.soramitsu.shared_utils.runtime.definitions.types.primitives.u32 -import jp.co.soramitsu.shared_utils.runtime.definitions.types.toByteArray -import jp.co.soramitsu.shared_utils.runtime.metadata.storage -import jp.co.soramitsu.shared_utils.runtime.metadata.storageKey +import jp.co.soramitsu.fearless_utils.extensions.toHexString +import jp.co.soramitsu.fearless_utils.hash.Hasher.blake2b256 +import jp.co.soramitsu.fearless_utils.runtime.AccountId +import jp.co.soramitsu.fearless_utils.runtime.definitions.types.bytes +import jp.co.soramitsu.fearless_utils.runtime.definitions.types.primitives.u32 +import jp.co.soramitsu.fearless_utils.runtime.definitions.types.toByteArray +import jp.co.soramitsu.fearless_utils.runtime.metadata.storage +import jp.co.soramitsu.fearless_utils.runtime.metadata.storageKey import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.withContext diff --git a/feature-crowdloan-impl/src/main/java/jp/co/soramitsu/crowdloan/impl/domain/contribute/CrowdloanContributeInteractor.kt b/feature-crowdloan-impl/src/main/java/jp/co/soramitsu/crowdloan/impl/domain/contribute/CrowdloanContributeInteractor.kt index 0607d979a3..a1b8fe2a06 100644 --- a/feature-crowdloan-impl/src/main/java/jp/co/soramitsu/crowdloan/impl/domain/contribute/CrowdloanContributeInteractor.kt +++ b/feature-crowdloan-impl/src/main/java/jp/co/soramitsu/crowdloan/impl/domain/contribute/CrowdloanContributeInteractor.kt @@ -22,7 +22,7 @@ import jp.co.soramitsu.crowdloan.impl.data.network.blockhain.extrinsic.contribut import jp.co.soramitsu.crowdloan.impl.domain.main.Crowdloan import jp.co.soramitsu.runtime.multiNetwork.ChainRegistry import jp.co.soramitsu.runtime.state.chainAndAsset -import jp.co.soramitsu.shared_utils.runtime.extrinsic.ExtrinsicBuilder +import jp.co.soramitsu.fearless_utils.runtime.extrinsic.ExtrinsicBuilder import jp.co.soramitsu.wallet.impl.domain.interfaces.NotValidTransferStatus import jp.co.soramitsu.wallet.impl.domain.interfaces.WalletRepository import jp.co.soramitsu.wallet.impl.domain.model.Transfer @@ -92,10 +92,12 @@ class CrowdloanContributeInteractor( signature: String? = null ) = withContext(Dispatchers.Default) { val (chain, chainAsset) = crowdloanSharedState.chainAndAsset() + val selectedMetaAccount = accountRepository.getSelectedMetaAccount() + val accountId = selectedMetaAccount.accountId(chain)!! - val encryption = accountRepository.getSelectedMetaAccount().substrateCryptoType?.let { mapCryptoTypeToEncryption(it) } + val encryption = selectedMetaAccount.substrateCryptoType?.let { mapCryptoTypeToEncryption(it) } val contributionInPlanks = chainAsset.planksFromAmount(contribution) - extrinsicService.estimateFee(chain, batchAll) { + extrinsicService.estimateFee(chain, accountId, batchAll) { contribute(parachainId, contributionInPlanks, signature, encryption) additional?.invoke(this) } diff --git a/feature-crowdloan-impl/src/main/java/jp/co/soramitsu/crowdloan/impl/domain/contribute/custom/acala/AcalaContributeInteractor.kt b/feature-crowdloan-impl/src/main/java/jp/co/soramitsu/crowdloan/impl/domain/contribute/custom/acala/AcalaContributeInteractor.kt index 70ecbae62d..752816c62b 100644 --- a/feature-crowdloan-impl/src/main/java/jp/co/soramitsu/crowdloan/impl/domain/contribute/custom/acala/AcalaContributeInteractor.kt +++ b/feature-crowdloan-impl/src/main/java/jp/co/soramitsu/crowdloan/impl/domain/contribute/custom/acala/AcalaContributeInteractor.kt @@ -11,8 +11,8 @@ import jp.co.soramitsu.crowdloan.impl.presentation.contribute.custom.acala.Acala import jp.co.soramitsu.crowdloan.impl.presentation.contribute.custom.acala.AcalaContributionType.DirectDOT import jp.co.soramitsu.crowdloan.impl.presentation.contribute.custom.acala.AcalaContributionType.LcDOT import jp.co.soramitsu.runtime.multiNetwork.ChainRegistry -import jp.co.soramitsu.shared_utils.extensions.toHexString -import jp.co.soramitsu.shared_utils.runtime.extrinsic.ExtrinsicBuilder +import jp.co.soramitsu.fearless_utils.extensions.toHexString +import jp.co.soramitsu.fearless_utils.runtime.extrinsic.ExtrinsicBuilder import jp.co.soramitsu.wallet.impl.domain.model.planksFromAmount import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext diff --git a/feature-crowdloan-impl/src/main/java/jp/co/soramitsu/crowdloan/impl/domain/contribute/custom/astar/AstarContributeInteractor.kt b/feature-crowdloan-impl/src/main/java/jp/co/soramitsu/crowdloan/impl/domain/contribute/custom/astar/AstarContributeInteractor.kt index 7fc1c01549..7d3964bbbb 100644 --- a/feature-crowdloan-impl/src/main/java/jp/co/soramitsu/crowdloan/impl/domain/contribute/custom/astar/AstarContributeInteractor.kt +++ b/feature-crowdloan-impl/src/main/java/jp/co/soramitsu/crowdloan/impl/domain/contribute/custom/astar/AstarContributeInteractor.kt @@ -4,8 +4,8 @@ import jp.co.soramitsu.account.api.domain.interfaces.AccountRepository import jp.co.soramitsu.crowdloan.api.data.network.blockhain.binding.ParaId import jp.co.soramitsu.crowdloan.impl.data.network.blockhain.extrinsic.addMemo import jp.co.soramitsu.runtime.multiNetwork.chain.model.ChainId -import jp.co.soramitsu.shared_utils.runtime.extrinsic.ExtrinsicBuilder -import jp.co.soramitsu.shared_utils.ss58.SS58Encoder.toAccountId +import jp.co.soramitsu.fearless_utils.runtime.extrinsic.ExtrinsicBuilder +import jp.co.soramitsu.fearless_utils.ss58.SS58Encoder.toAccountId import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext diff --git a/feature-crowdloan-impl/src/main/java/jp/co/soramitsu/crowdloan/impl/domain/contribute/custom/bifrost/BifrostContributeInteractor.kt b/feature-crowdloan-impl/src/main/java/jp/co/soramitsu/crowdloan/impl/domain/contribute/custom/bifrost/BifrostContributeInteractor.kt index b197f5c740..4b2b82470a 100644 --- a/feature-crowdloan-impl/src/main/java/jp/co/soramitsu/crowdloan/impl/domain/contribute/custom/bifrost/BifrostContributeInteractor.kt +++ b/feature-crowdloan-impl/src/main/java/jp/co/soramitsu/crowdloan/impl/domain/contribute/custom/bifrost/BifrostContributeInteractor.kt @@ -5,7 +5,7 @@ import jp.co.soramitsu.crowdloan.api.data.network.blockhain.binding.ParaId import jp.co.soramitsu.crowdloan.impl.data.network.api.bifrost.BifrostApi import jp.co.soramitsu.crowdloan.impl.data.network.api.bifrost.getAccountByReferralCode import jp.co.soramitsu.crowdloan.impl.data.network.blockhain.extrinsic.addMemo -import jp.co.soramitsu.shared_utils.runtime.extrinsic.ExtrinsicBuilder +import jp.co.soramitsu.fearless_utils.runtime.extrinsic.ExtrinsicBuilder import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext diff --git a/feature-crowdloan-impl/src/main/java/jp/co/soramitsu/crowdloan/impl/domain/contribute/custom/interlay/InterlayContributeInteractor.kt b/feature-crowdloan-impl/src/main/java/jp/co/soramitsu/crowdloan/impl/domain/contribute/custom/interlay/InterlayContributeInteractor.kt index 44210f17a3..5fe5aeb991 100644 --- a/feature-crowdloan-impl/src/main/java/jp/co/soramitsu/crowdloan/impl/domain/contribute/custom/interlay/InterlayContributeInteractor.kt +++ b/feature-crowdloan-impl/src/main/java/jp/co/soramitsu/crowdloan/impl/domain/contribute/custom/interlay/InterlayContributeInteractor.kt @@ -2,8 +2,8 @@ package jp.co.soramitsu.crowdloan.impl.domain.contribute.custom.interlay import jp.co.soramitsu.crowdloan.api.data.network.blockhain.binding.ParaId import jp.co.soramitsu.crowdloan.impl.data.network.blockhain.extrinsic.addMemo -import jp.co.soramitsu.shared_utils.extensions.fromHex -import jp.co.soramitsu.shared_utils.runtime.extrinsic.ExtrinsicBuilder +import jp.co.soramitsu.fearless_utils.extensions.fromHex +import jp.co.soramitsu.fearless_utils.runtime.extrinsic.ExtrinsicBuilder import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext diff --git a/feature-crowdloan-impl/src/main/java/jp/co/soramitsu/crowdloan/impl/domain/contribute/custom/moonbeam/MoonbeamContributeInteractor.kt b/feature-crowdloan-impl/src/main/java/jp/co/soramitsu/crowdloan/impl/domain/contribute/custom/moonbeam/MoonbeamContributeInteractor.kt index c1fdc0e69e..78bcf20f52 100644 --- a/feature-crowdloan-impl/src/main/java/jp/co/soramitsu/crowdloan/impl/domain/contribute/custom/moonbeam/MoonbeamContributeInteractor.kt +++ b/feature-crowdloan-impl/src/main/java/jp/co/soramitsu/crowdloan/impl/domain/contribute/custom/moonbeam/MoonbeamContributeInteractor.kt @@ -16,10 +16,10 @@ import jp.co.soramitsu.crowdloan.impl.data.network.api.moonbeam.SignatureRequest import jp.co.soramitsu.crowdloan.impl.data.network.blockhain.extrinsic.addMemo import jp.co.soramitsu.runtime.multiNetwork.ChainRegistry import jp.co.soramitsu.runtime.multiNetwork.chain.model.ChainId -import jp.co.soramitsu.shared_utils.extensions.fromHex -import jp.co.soramitsu.shared_utils.extensions.toHexString -import jp.co.soramitsu.shared_utils.runtime.extrinsic.ExtrinsicBuilder -import jp.co.soramitsu.shared_utils.ss58.SS58Encoder.toAddress +import jp.co.soramitsu.fearless_utils.extensions.fromHex +import jp.co.soramitsu.fearless_utils.extensions.toHexString +import jp.co.soramitsu.fearless_utils.runtime.extrinsic.ExtrinsicBuilder +import jp.co.soramitsu.fearless_utils.ss58.SS58Encoder.toAddress import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.first import kotlinx.coroutines.withContext @@ -74,8 +74,10 @@ class MoonbeamContributeInteractor( suspend fun doSystemRemark(apiUrl: String, apiKey: String, chainId: ChainId): Boolean { val remark = requireNotNull(moonbeamRemark) + val accountId = accountRepository.getSelectedMetaAccount().substrateAccountId ?: return false val result = extrinsicService.submitAndWatchExtrinsic( chain = chainRegistry.getChain(chainId), + accountId = accountId, formExtrinsic = { call( moduleName = "System", @@ -93,7 +95,7 @@ class MoonbeamContributeInteractor( apiUrl, apiKey, RemarkVerifyRequest( - accountRepository.getSelectedMetaAccount().substrateAccountId?.toAddress(0.toShort())!!, + accountId.toAddress(0.toShort()), result.second, result.first ) @@ -110,11 +112,12 @@ class MoonbeamContributeInteractor( suspend fun getSystemRemarkFee(apiUrl: String, apiKey: String, chainId: ChainId): BigInteger { val sign = requireNotNull(termsSigned) + val accountId = requireNotNull(accountRepository.getSelectedMetaAccount().substrateAccountId) val remarkResponse = moonbeamApi.agreeRemark( apiUrl, apiKey, RemarkStoreRequest( - accountRepository.getSelectedMetaAccount().substrateAccountId?.toAddress(0.toShort())!!, + accountId.toAddress(0.toShort()), sign ) ) @@ -122,7 +125,8 @@ class MoonbeamContributeInteractor( moonbeamRemark = remark val chain = chainRegistry.getChain(chainId) return extrinsicService.estimateFee( - chain, + chain = chain, + accountId = accountId, formExtrinsic = { call( moduleName = "System", diff --git a/feature-crowdloan-impl/src/main/java/jp/co/soramitsu/crowdloan/impl/domain/main/CrowdloanInteractor.kt b/feature-crowdloan-impl/src/main/java/jp/co/soramitsu/crowdloan/impl/domain/main/CrowdloanInteractor.kt index cf976ddc5e..3ccaf3efa3 100644 --- a/feature-crowdloan-impl/src/main/java/jp/co/soramitsu/crowdloan/impl/domain/main/CrowdloanInteractor.kt +++ b/feature-crowdloan-impl/src/main/java/jp/co/soramitsu/crowdloan/impl/domain/main/CrowdloanInteractor.kt @@ -11,7 +11,7 @@ import jp.co.soramitsu.crowdloan.api.data.repository.ParachainMetadata import jp.co.soramitsu.crowdloan.api.data.repository.getContributions import jp.co.soramitsu.crowdloan.impl.domain.contribute.mapFundInfoToCrowdloan import jp.co.soramitsu.runtime.multiNetwork.chain.model.Chain -import jp.co.soramitsu.shared_utils.ss58.SS58Encoder.toAddress +import jp.co.soramitsu.fearless_utils.ss58.SS58Encoder.toAddress import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.emitAll import kotlinx.coroutines.flow.flow diff --git a/feature-crowdloan-impl/src/main/java/jp/co/soramitsu/crowdloan/impl/presentation/contribute/custom/CustomContribute.kt b/feature-crowdloan-impl/src/main/java/jp/co/soramitsu/crowdloan/impl/presentation/contribute/custom/CustomContribute.kt index a9b0cc1a74..46551bb0f7 100644 --- a/feature-crowdloan-impl/src/main/java/jp/co/soramitsu/crowdloan/impl/presentation/contribute/custom/CustomContribute.kt +++ b/feature-crowdloan-impl/src/main/java/jp/co/soramitsu/crowdloan/impl/presentation/contribute/custom/CustomContribute.kt @@ -6,7 +6,7 @@ import android.util.AttributeSet import androidx.constraintlayout.widget.ConstraintLayout import androidx.lifecycle.LifecycleCoroutineScope import jp.co.soramitsu.crowdloan.impl.presentation.contribute.select.parcel.ParachainMetadataParcelModel -import jp.co.soramitsu.shared_utils.runtime.extrinsic.ExtrinsicBuilder +import jp.co.soramitsu.fearless_utils.runtime.extrinsic.ExtrinsicBuilder import kotlinx.coroutines.flow.Flow import java.math.BigDecimal diff --git a/feature-crowdloan-impl/src/main/java/jp/co/soramitsu/crowdloan/impl/presentation/contribute/custom/acala/AcalaContributeSubmitter.kt b/feature-crowdloan-impl/src/main/java/jp/co/soramitsu/crowdloan/impl/presentation/contribute/custom/acala/AcalaContributeSubmitter.kt index 8c9754a358..329060327d 100644 --- a/feature-crowdloan-impl/src/main/java/jp/co/soramitsu/crowdloan/impl/presentation/contribute/custom/acala/AcalaContributeSubmitter.kt +++ b/feature-crowdloan-impl/src/main/java/jp/co/soramitsu/crowdloan/impl/presentation/contribute/custom/acala/AcalaContributeSubmitter.kt @@ -7,7 +7,7 @@ import jp.co.soramitsu.crowdloan.impl.presentation.contribute.custom.BonusPayloa import jp.co.soramitsu.crowdloan.impl.presentation.contribute.custom.CustomContributeSubmitter import jp.co.soramitsu.crowdloan.impl.presentation.contribute.select.parcel.ParachainMetadataParcelModel import jp.co.soramitsu.crowdloan.impl.presentation.contribute.select.parcel.getString -import jp.co.soramitsu.shared_utils.runtime.extrinsic.ExtrinsicBuilder +import jp.co.soramitsu.fearless_utils.runtime.extrinsic.ExtrinsicBuilder import java.math.BigDecimal class AcalaContributeSubmitter( diff --git a/feature-crowdloan-impl/src/main/java/jp/co/soramitsu/crowdloan/impl/presentation/contribute/custom/astar/AstarContributeSubmitter.kt b/feature-crowdloan-impl/src/main/java/jp/co/soramitsu/crowdloan/impl/presentation/contribute/custom/astar/AstarContributeSubmitter.kt index 6d646d86b1..13c798d6d0 100644 --- a/feature-crowdloan-impl/src/main/java/jp/co/soramitsu/crowdloan/impl/presentation/contribute/custom/astar/AstarContributeSubmitter.kt +++ b/feature-crowdloan-impl/src/main/java/jp/co/soramitsu/crowdloan/impl/presentation/contribute/custom/astar/AstarContributeSubmitter.kt @@ -3,7 +3,7 @@ package jp.co.soramitsu.crowdloan.impl.presentation.contribute.custom.astar import jp.co.soramitsu.crowdloan.impl.domain.contribute.custom.astar.AstarContributeInteractor import jp.co.soramitsu.crowdloan.impl.presentation.contribute.custom.BonusPayload import jp.co.soramitsu.crowdloan.impl.presentation.contribute.custom.CustomContributeSubmitter -import jp.co.soramitsu.shared_utils.runtime.extrinsic.ExtrinsicBuilder +import jp.co.soramitsu.fearless_utils.runtime.extrinsic.ExtrinsicBuilder import java.math.BigDecimal class AstarContributeSubmitter( diff --git a/feature-crowdloan-impl/src/main/java/jp/co/soramitsu/crowdloan/impl/presentation/contribute/custom/bifrost/BifrostContributeSubmitter.kt b/feature-crowdloan-impl/src/main/java/jp/co/soramitsu/crowdloan/impl/presentation/contribute/custom/bifrost/BifrostContributeSubmitter.kt index 30d7e4e9e9..87aa17a115 100644 --- a/feature-crowdloan-impl/src/main/java/jp/co/soramitsu/crowdloan/impl/presentation/contribute/custom/bifrost/BifrostContributeSubmitter.kt +++ b/feature-crowdloan-impl/src/main/java/jp/co/soramitsu/crowdloan/impl/presentation/contribute/custom/bifrost/BifrostContributeSubmitter.kt @@ -3,7 +3,7 @@ package jp.co.soramitsu.crowdloan.impl.presentation.contribute.custom.bifrost import jp.co.soramitsu.crowdloan.impl.domain.contribute.custom.bifrost.BifrostContributeInteractor import jp.co.soramitsu.crowdloan.impl.presentation.contribute.custom.BonusPayload import jp.co.soramitsu.crowdloan.impl.presentation.contribute.custom.CustomContributeSubmitter -import jp.co.soramitsu.shared_utils.runtime.extrinsic.ExtrinsicBuilder +import jp.co.soramitsu.fearless_utils.runtime.extrinsic.ExtrinsicBuilder import java.math.BigDecimal class BifrostContributeSubmitter( diff --git a/feature-crowdloan-impl/src/main/java/jp/co/soramitsu/crowdloan/impl/presentation/contribute/custom/interlay/InterlayContributeSubmitter.kt b/feature-crowdloan-impl/src/main/java/jp/co/soramitsu/crowdloan/impl/presentation/contribute/custom/interlay/InterlayContributeSubmitter.kt index 5dc5bb55ce..5ffb9bd75c 100644 --- a/feature-crowdloan-impl/src/main/java/jp/co/soramitsu/crowdloan/impl/presentation/contribute/custom/interlay/InterlayContributeSubmitter.kt +++ b/feature-crowdloan-impl/src/main/java/jp/co/soramitsu/crowdloan/impl/presentation/contribute/custom/interlay/InterlayContributeSubmitter.kt @@ -3,7 +3,7 @@ package jp.co.soramitsu.crowdloan.impl.presentation.contribute.custom.interlay import jp.co.soramitsu.crowdloan.impl.domain.contribute.custom.interlay.InterlayContributeInteractor import jp.co.soramitsu.crowdloan.impl.presentation.contribute.custom.BonusPayload import jp.co.soramitsu.crowdloan.impl.presentation.contribute.custom.CustomContributeSubmitter -import jp.co.soramitsu.shared_utils.runtime.extrinsic.ExtrinsicBuilder +import jp.co.soramitsu.fearless_utils.runtime.extrinsic.ExtrinsicBuilder import java.math.BigDecimal class InterlayContributeSubmitter( diff --git a/feature-crowdloan-impl/src/main/java/jp/co/soramitsu/crowdloan/impl/presentation/contribute/custom/interlay/InterlayContributeViewState.kt b/feature-crowdloan-impl/src/main/java/jp/co/soramitsu/crowdloan/impl/presentation/contribute/custom/interlay/InterlayContributeViewState.kt index 9c614fee31..3b8fbf7a91 100644 --- a/feature-crowdloan-impl/src/main/java/jp/co/soramitsu/crowdloan/impl/presentation/contribute/custom/interlay/InterlayContributeViewState.kt +++ b/feature-crowdloan-impl/src/main/java/jp/co/soramitsu/crowdloan/impl/presentation/contribute/custom/interlay/InterlayContributeViewState.kt @@ -6,7 +6,7 @@ import jp.co.soramitsu.crowdloan.impl.presentation.contribute.custom.model.Custo import jp.co.soramitsu.crowdloan.impl.presentation.contribute.custom.referral.ReferralCodePayload import jp.co.soramitsu.crowdloan.impl.presentation.contribute.custom.referral.ReferralContributeViewState import jp.co.soramitsu.feature_crowdloan_impl.R -import jp.co.soramitsu.shared_utils.extensions.requireHexPrefix +import jp.co.soramitsu.fearless_utils.extensions.requireHexPrefix class InterlayContributeViewState( private val interactor: InterlayContributeInteractor, diff --git a/feature-crowdloan-impl/src/main/java/jp/co/soramitsu/crowdloan/impl/presentation/contribute/custom/moonbeam/MoonbeamContributeSubmitter.kt b/feature-crowdloan-impl/src/main/java/jp/co/soramitsu/crowdloan/impl/presentation/contribute/custom/moonbeam/MoonbeamContributeSubmitter.kt index 90a2e2c230..b3d5b8661b 100644 --- a/feature-crowdloan-impl/src/main/java/jp/co/soramitsu/crowdloan/impl/presentation/contribute/custom/moonbeam/MoonbeamContributeSubmitter.kt +++ b/feature-crowdloan-impl/src/main/java/jp/co/soramitsu/crowdloan/impl/presentation/contribute/custom/moonbeam/MoonbeamContributeSubmitter.kt @@ -3,7 +3,7 @@ package jp.co.soramitsu.crowdloan.impl.presentation.contribute.custom.moonbeam import jp.co.soramitsu.crowdloan.impl.domain.contribute.custom.moonbeam.MoonbeamContributeInteractor import jp.co.soramitsu.crowdloan.impl.presentation.contribute.custom.BonusPayload import jp.co.soramitsu.crowdloan.impl.presentation.contribute.custom.CustomContributeSubmitter -import jp.co.soramitsu.shared_utils.runtime.extrinsic.ExtrinsicBuilder +import jp.co.soramitsu.fearless_utils.runtime.extrinsic.ExtrinsicBuilder import java.math.BigDecimal class MoonbeamContributeSubmitter( diff --git a/feature-crowdloan-impl/src/main/java/jp/co/soramitsu/crowdloan/impl/presentation/contribute/custom/moonbeam/MoonbeamContributeViewState.kt b/feature-crowdloan-impl/src/main/java/jp/co/soramitsu/crowdloan/impl/presentation/contribute/custom/moonbeam/MoonbeamContributeViewState.kt index 29f261e36d..d2d1572006 100644 --- a/feature-crowdloan-impl/src/main/java/jp/co/soramitsu/crowdloan/impl/presentation/contribute/custom/moonbeam/MoonbeamContributeViewState.kt +++ b/feature-crowdloan-impl/src/main/java/jp/co/soramitsu/crowdloan/impl/presentation/contribute/custom/moonbeam/MoonbeamContributeViewState.kt @@ -20,7 +20,7 @@ import jp.co.soramitsu.crowdloan.impl.presentation.contribute.custom.referral.Re import jp.co.soramitsu.crowdloan.impl.presentation.contribute.select.parcel.getAsBigDecimal import jp.co.soramitsu.crowdloan.impl.presentation.contribute.select.parcel.getString import jp.co.soramitsu.feature_crowdloan_impl.R -import jp.co.soramitsu.shared_utils.ss58.SS58Encoder.toAddress +import jp.co.soramitsu.fearless_utils.ss58.SS58Encoder.toAddress import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.combine diff --git a/feature-crowdloan-impl/src/main/java/jp/co/soramitsu/crowdloan/impl/presentation/main/CrowdloanViewModel.kt b/feature-crowdloan-impl/src/main/java/jp/co/soramitsu/crowdloan/impl/presentation/main/CrowdloanViewModel.kt index 22b4bacd95..ba951f0f00 100644 --- a/feature-crowdloan-impl/src/main/java/jp/co/soramitsu/crowdloan/impl/presentation/main/CrowdloanViewModel.kt +++ b/feature-crowdloan-impl/src/main/java/jp/co/soramitsu/crowdloan/impl/presentation/main/CrowdloanViewModel.kt @@ -44,9 +44,9 @@ import jp.co.soramitsu.runtime.multiNetwork.chain.model.Chain import jp.co.soramitsu.runtime.multiNetwork.chain.model.ChainId import jp.co.soramitsu.runtime.state.chain import jp.co.soramitsu.runtime.state.selectedChainFlow -import jp.co.soramitsu.shared_utils.extensions.toHexString -import jp.co.soramitsu.shared_utils.hash.Hasher.blake2b256 -import jp.co.soramitsu.shared_utils.ss58.SS58Encoder.toAddress +import jp.co.soramitsu.fearless_utils.extensions.toHexString +import jp.co.soramitsu.fearless_utils.hash.Hasher.blake2b256 +import jp.co.soramitsu.fearless_utils.ss58.SS58Encoder.toAddress import jp.co.soramitsu.wallet.api.domain.AssetUseCase import jp.co.soramitsu.wallet.api.presentation.formatters.formatCryptoDetailFromPlanks import jp.co.soramitsu.wallet.api.presentation.formatters.formatCryptoFromPlanks diff --git a/feature-liquiditypools-impl/src/main/java/jp/co/soramitsu/liquiditypools/impl/data/DemeterFarmingRepositoryImpl.kt b/feature-liquiditypools-impl/src/main/java/jp/co/soramitsu/liquiditypools/impl/data/DemeterFarmingRepositoryImpl.kt index 8c3aed02e8..348831cf98 100644 --- a/feature-liquiditypools-impl/src/main/java/jp/co/soramitsu/liquiditypools/impl/data/DemeterFarmingRepositoryImpl.kt +++ b/feature-liquiditypools-impl/src/main/java/jp/co/soramitsu/liquiditypools/impl/data/DemeterFarmingRepositoryImpl.kt @@ -9,6 +9,16 @@ import jp.co.soramitsu.androidfoundation.format.orZero import jp.co.soramitsu.androidfoundation.format.safeCast import jp.co.soramitsu.common.data.network.rpc.BulkRetriever import jp.co.soramitsu.common.data.network.rpc.retrieveAllValues +import jp.co.soramitsu.fearless_utils.extensions.toHexString +import jp.co.soramitsu.fearless_utils.runtime.definitions.types.composite.Struct +import jp.co.soramitsu.fearless_utils.runtime.definitions.types.fromHex +import jp.co.soramitsu.fearless_utils.runtime.metadata.module +import jp.co.soramitsu.fearless_utils.runtime.metadata.storage +import jp.co.soramitsu.fearless_utils.runtime.metadata.storageKey +import jp.co.soramitsu.fearless_utils.ss58.SS58Encoder.toAccountId +import jp.co.soramitsu.fearless_utils.wsrpc.executeAsync +import jp.co.soramitsu.fearless_utils.wsrpc.mappers.pojo +import jp.co.soramitsu.fearless_utils.wsrpc.request.runtime.storage.GetStorageRequest import jp.co.soramitsu.liquiditypools.data.DemeterFarmingRepository import jp.co.soramitsu.liquiditypools.data.PoolsRepository import jp.co.soramitsu.liquiditypools.domain.DemeterFarmingBasicPool @@ -16,16 +26,6 @@ import jp.co.soramitsu.liquiditypools.domain.DemeterFarmingPool import jp.co.soramitsu.runtime.ext.addressOf import jp.co.soramitsu.runtime.multiNetwork.ChainRegistry import jp.co.soramitsu.runtime.multiNetwork.chain.model.ChainId -import jp.co.soramitsu.shared_utils.extensions.toHexString -import jp.co.soramitsu.shared_utils.runtime.definitions.types.composite.Struct -import jp.co.soramitsu.shared_utils.runtime.definitions.types.fromHex -import jp.co.soramitsu.shared_utils.runtime.metadata.module -import jp.co.soramitsu.shared_utils.runtime.metadata.storage -import jp.co.soramitsu.shared_utils.runtime.metadata.storageKey -import jp.co.soramitsu.shared_utils.ss58.SS58Encoder.toAccountId -import jp.co.soramitsu.shared_utils.wsrpc.executeAsync -import jp.co.soramitsu.shared_utils.wsrpc.mappers.pojo -import jp.co.soramitsu.shared_utils.wsrpc.request.runtime.storage.GetStorageRequest import jp.co.soramitsu.wallet.impl.domain.interfaces.WalletRepository import jp.co.soramitsu.wallet.impl.domain.model.Asset import java.math.BigDecimal diff --git a/feature-liquiditypools-impl/src/main/java/jp/co/soramitsu/liquiditypools/impl/data/PoolsRepositoryImpl.kt b/feature-liquiditypools-impl/src/main/java/jp/co/soramitsu/liquiditypools/impl/data/PoolsRepositoryImpl.kt index 28d9050b3b..922817bee9 100644 --- a/feature-liquiditypools-impl/src/main/java/jp/co/soramitsu/liquiditypools/impl/data/PoolsRepositoryImpl.kt +++ b/feature-liquiditypools-impl/src/main/java/jp/co/soramitsu/liquiditypools/impl/data/PoolsRepositoryImpl.kt @@ -17,6 +17,27 @@ import jp.co.soramitsu.coredb.model.BasicPoolLocal import jp.co.soramitsu.coredb.model.UserPoolJoinedLocal import jp.co.soramitsu.coredb.model.UserPoolJoinedLocalNullable import jp.co.soramitsu.coredb.model.UserPoolLocal +import jp.co.soramitsu.fearless_utils.extensions.fromHex +import jp.co.soramitsu.fearless_utils.extensions.toHexString +import jp.co.soramitsu.fearless_utils.runtime.RuntimeSnapshot +import jp.co.soramitsu.fearless_utils.runtime.definitions.types.composite.Struct +import jp.co.soramitsu.fearless_utils.runtime.definitions.types.fromHex +import jp.co.soramitsu.fearless_utils.runtime.metadata.module +import jp.co.soramitsu.fearless_utils.runtime.metadata.storage +import jp.co.soramitsu.fearless_utils.runtime.metadata.storageKey +import jp.co.soramitsu.fearless_utils.scale.Schema +import jp.co.soramitsu.fearless_utils.scale.dataType.uint32 +import jp.co.soramitsu.fearless_utils.scale.sizedByteArray +import jp.co.soramitsu.fearless_utils.scale.uint128 +import jp.co.soramitsu.fearless_utils.ss58.SS58Encoder.toAccountId +import jp.co.soramitsu.fearless_utils.wsrpc.executeAsync +import jp.co.soramitsu.fearless_utils.wsrpc.mappers.nonNull +import jp.co.soramitsu.fearless_utils.wsrpc.mappers.pojo +import jp.co.soramitsu.fearless_utils.wsrpc.mappers.pojoList +import jp.co.soramitsu.fearless_utils.wsrpc.mappers.scale +import jp.co.soramitsu.fearless_utils.wsrpc.request.runtime.RuntimeRequest +import jp.co.soramitsu.fearless_utils.wsrpc.request.runtime.storage.GetStorageRequest +import jp.co.soramitsu.fearless_utils.wsrpc.subscription.response.SubscriptionChange import jp.co.soramitsu.liquiditypools.data.PoolDataDto import jp.co.soramitsu.liquiditypools.data.PoolsRepository import jp.co.soramitsu.liquiditypools.domain.model.BasicPoolData @@ -28,31 +49,11 @@ import jp.co.soramitsu.liquiditypools.impl.data.network.liquidityAdd import jp.co.soramitsu.liquiditypools.impl.data.network.register import jp.co.soramitsu.liquiditypools.impl.data.network.removeLiquidity import jp.co.soramitsu.liquiditypools.impl.util.PolkaswapFormulas +import jp.co.soramitsu.runtime.ext.accountIdOf import jp.co.soramitsu.runtime.ext.addressOf import jp.co.soramitsu.runtime.multiNetwork.ChainRegistry import jp.co.soramitsu.runtime.multiNetwork.chain.model.ChainId import jp.co.soramitsu.runtime.multiNetwork.chain.model.soraMainChainId -import jp.co.soramitsu.shared_utils.extensions.fromHex -import jp.co.soramitsu.shared_utils.extensions.toHexString -import jp.co.soramitsu.shared_utils.runtime.RuntimeSnapshot -import jp.co.soramitsu.shared_utils.runtime.definitions.types.composite.Struct -import jp.co.soramitsu.shared_utils.runtime.definitions.types.fromHex -import jp.co.soramitsu.shared_utils.runtime.metadata.module -import jp.co.soramitsu.shared_utils.runtime.metadata.storage -import jp.co.soramitsu.shared_utils.runtime.metadata.storageKey -import jp.co.soramitsu.shared_utils.scale.Schema -import jp.co.soramitsu.shared_utils.scale.dataType.uint32 -import jp.co.soramitsu.shared_utils.scale.sizedByteArray -import jp.co.soramitsu.shared_utils.scale.uint128 -import jp.co.soramitsu.shared_utils.ss58.SS58Encoder.toAccountId -import jp.co.soramitsu.shared_utils.wsrpc.executeAsync -import jp.co.soramitsu.shared_utils.wsrpc.mappers.nonNull -import jp.co.soramitsu.shared_utils.wsrpc.mappers.pojo -import jp.co.soramitsu.shared_utils.wsrpc.mappers.pojoList -import jp.co.soramitsu.shared_utils.wsrpc.mappers.scale -import jp.co.soramitsu.shared_utils.wsrpc.request.runtime.RuntimeRequest -import jp.co.soramitsu.shared_utils.wsrpc.request.runtime.storage.GetStorageRequest -import jp.co.soramitsu.shared_utils.wsrpc.subscription.response.SubscriptionChange import jp.co.soramitsu.wallet.impl.domain.model.amountFromPlanks import jp.co.soramitsu.wallet.impl.domain.model.planksFromAmount import kotlinx.coroutines.async @@ -325,8 +326,9 @@ class PoolsRepositoryImpl constructor( val amountToMin = PolkaswapFormulas.calculateMinAmount(tokenTargetAmount, slippageTolerance) val dexId = getPoolBaseTokenDexId(chainId, tokenBase.currencyId) val chain = chainRegistry.getChain(chainId) + val accountId = chain.accountIdOf(address) - val fee = extrinsicService.estimateFee(chain) { + val fee = extrinsicService.estimateFee(chain, accountId) { liquidityAdd( dexId = dexId, baseTokenId = tokenBase.currencyId, @@ -352,8 +354,9 @@ class PoolsRepositoryImpl constructor( val chain = chainRegistry.getChain(chainId) val baseTokenId = tokenBase.currencyId ?: return null val targetTokenId = tokenTarget.currencyId ?: return null + val accountId = accountRepository.getSelectedMetaAccount().substrateAccountId ?: return null - val fee = extrinsicService.estimateFee(chain) { + val fee = extrinsicService.estimateFee(chain, accountId) { removeLiquidity( dexId = getPoolBaseTokenDexId(chainId, baseTokenId), outputAssetIdA = baseTokenId, diff --git a/feature-liquiditypools-impl/src/main/java/jp/co/soramitsu/liquiditypools/impl/data/network/Extrinsic.kt b/feature-liquiditypools-impl/src/main/java/jp/co/soramitsu/liquiditypools/impl/data/network/Extrinsic.kt index 4a5809d440..6120c61664 100644 --- a/feature-liquiditypools-impl/src/main/java/jp/co/soramitsu/liquiditypools/impl/data/network/Extrinsic.kt +++ b/feature-liquiditypools-impl/src/main/java/jp/co/soramitsu/liquiditypools/impl/data/network/Extrinsic.kt @@ -1,9 +1,9 @@ package jp.co.soramitsu.liquiditypools.impl.data.network import jp.co.soramitsu.common.utils.Modules -import jp.co.soramitsu.shared_utils.extensions.fromHex -import jp.co.soramitsu.shared_utils.runtime.definitions.types.composite.Struct -import jp.co.soramitsu.shared_utils.runtime.extrinsic.ExtrinsicBuilder +import jp.co.soramitsu.fearless_utils.extensions.fromHex +import jp.co.soramitsu.fearless_utils.runtime.definitions.types.composite.Struct +import jp.co.soramitsu.fearless_utils.runtime.extrinsic.ExtrinsicBuilder import java.math.BigInteger fun ExtrinsicBuilder.register( diff --git a/feature-liquiditypools-impl/src/main/java/jp/co/soramitsu/liquiditypools/impl/presentation/allpools/AllPoolsScreen.kt b/feature-liquiditypools-impl/src/main/java/jp/co/soramitsu/liquiditypools/impl/presentation/allpools/AllPoolsScreen.kt index b71ea7c564..4df3b5c6cf 100644 --- a/feature-liquiditypools-impl/src/main/java/jp/co/soramitsu/liquiditypools/impl/presentation/allpools/AllPoolsScreen.kt +++ b/feature-liquiditypools-impl/src/main/java/jp/co/soramitsu/liquiditypools/impl/presentation/allpools/AllPoolsScreen.kt @@ -241,7 +241,7 @@ private fun PoolGroupHeader( @Composable private fun PreviewAllPoolsScreen() { val itemState = BasicPoolListItemState( - ids = "0" to "1", + ids = StringPair("0", "1"), token1Icon = "DEFAULT_ICON_URI", token2Icon = "DEFAULT_ICON_URI", text1 = "XOR-VAL", diff --git a/feature-liquiditypools-impl/src/main/java/jp/co/soramitsu/liquiditypools/impl/presentation/allpools/BasicPoolListItem.kt b/feature-liquiditypools-impl/src/main/java/jp/co/soramitsu/liquiditypools/impl/presentation/allpools/BasicPoolListItem.kt index d13a8cd971..f89bad3c81 100644 --- a/feature-liquiditypools-impl/src/main/java/jp/co/soramitsu/liquiditypools/impl/presentation/allpools/BasicPoolListItem.kt +++ b/feature-liquiditypools-impl/src/main/java/jp/co/soramitsu/liquiditypools/impl/presentation/allpools/BasicPoolListItem.kt @@ -101,7 +101,7 @@ fun BasicPoolListItem( contentDescription = null, loading = { Shimmer(Modifier.size(Size.ExtraSmall)) }, error = { - Icon(painterResource(id = R.drawable.ic_token_default), null, modifier = Modifier.size(size = 32.dp)) + Icon(painterResource(id = jp.co.soramitsu.common.R.drawable.ic_token_undefined), null, modifier = Modifier.size(size = 32.dp)) } ) } @@ -267,7 +267,7 @@ private fun PreviewBasicPoolListItem() { BasicPoolListItem( modifier = Modifier.background(transparent), state = BasicPoolListItemState( - ids = "0" to "1", + ids = StringPair("0", "1"), token1Icon = "DEFAULT_ICON_URI", token2Icon = "DEFAULT_ICON_URI", text1 = "XOR-VAL", @@ -279,7 +279,7 @@ private fun PreviewBasicPoolListItem() { BasicPoolListItem( modifier = Modifier.background(transparent), state = BasicPoolListItemState( - ids = "0" to "1", + ids = StringPair("0", "1"), token1Icon = "DEFAULT_ICON_URI", token2Icon = "DEFAULT_ICON_URI", text1 = "text1", @@ -291,7 +291,7 @@ private fun PreviewBasicPoolListItem() { BasicPoolListItem( modifier = Modifier.background(transparent), state = BasicPoolListItemState( - ids = "0" to "1", + ids = StringPair("0", "1"), token1Icon = "DEFAULT_ICON_URI", token2Icon = "DEFAULT_ICON_URI", text1 = "text1", @@ -303,7 +303,7 @@ private fun PreviewBasicPoolListItem() { BasicPoolListItem( modifier = Modifier.background(transparent), state = BasicPoolListItemState( - ids = "0" to "1", + ids = StringPair("0", "1"), token1Icon = "DEFAULT_ICON_URI", token2Icon = "DEFAULT_ICON_URI", text1 = "text1", diff --git a/feature-liquiditypools-impl/src/main/java/jp/co/soramitsu/liquiditypools/impl/presentation/pooldetails/PoolDetailsPresenter.kt b/feature-liquiditypools-impl/src/main/java/jp/co/soramitsu/liquiditypools/impl/presentation/pooldetails/PoolDetailsPresenter.kt index e56bc8bc09..cd4f4979fd 100644 --- a/feature-liquiditypools-impl/src/main/java/jp/co/soramitsu/liquiditypools/impl/presentation/pooldetails/PoolDetailsPresenter.kt +++ b/feature-liquiditypools-impl/src/main/java/jp/co/soramitsu/liquiditypools/impl/presentation/pooldetails/PoolDetailsPresenter.kt @@ -7,12 +7,12 @@ import jp.co.soramitsu.common.utils.applyFiatRate import jp.co.soramitsu.common.utils.formatCrypto import jp.co.soramitsu.common.utils.formatFiat import jp.co.soramitsu.common.utils.formatPercent +import jp.co.soramitsu.fearless_utils.extensions.fromHex import jp.co.soramitsu.liquiditypools.domain.interfaces.PoolsInteractor import jp.co.soramitsu.liquiditypools.domain.model.CommonPoolData import jp.co.soramitsu.liquiditypools.impl.presentation.CoroutinesStore import jp.co.soramitsu.liquiditypools.navigation.InternalPoolsRouter import jp.co.soramitsu.liquiditypools.navigation.LiquidityPoolsNavGraphRoute -import jp.co.soramitsu.shared_utils.extensions.fromHex import jp.co.soramitsu.wallet.impl.domain.interfaces.WalletInteractor import jp.co.soramitsu.wallet.impl.domain.model.Token import kotlinx.coroutines.CoroutineScope diff --git a/feature-liquiditypools-impl/src/main/java/jp/co/soramitsu/liquiditypools/impl/presentation/poollist/PoolListScreen.kt b/feature-liquiditypools-impl/src/main/java/jp/co/soramitsu/liquiditypools/impl/presentation/poollist/PoolListScreen.kt index a79a9a1405..78bb6fa5bd 100644 --- a/feature-liquiditypools-impl/src/main/java/jp/co/soramitsu/liquiditypools/impl/presentation/poollist/PoolListScreen.kt +++ b/feature-liquiditypools-impl/src/main/java/jp/co/soramitsu/liquiditypools/impl/presentation/poollist/PoolListScreen.kt @@ -93,7 +93,7 @@ fun PoolListScreen(state: PoolListState, callback: PoolListScreenInterface) { @Composable private fun PreviewPoolListScreen() { val itemState = BasicPoolListItemState( - ids = "0" to "1", + ids = StringPair("0", "1"), token1Icon = "DEFAULT_ICON_URI", token2Icon = "DEFAULT_ICON_URI", text1 = "XOR-VAL", diff --git a/feature-nft-impl/src/main/java/jp/co/soramitsu/nft/impl/domain/NFTInteractorImpl.kt b/feature-nft-impl/src/main/java/jp/co/soramitsu/nft/impl/domain/NFTInteractorImpl.kt index e78ae40205..23d7c4faa5 100644 --- a/feature-nft-impl/src/main/java/jp/co/soramitsu/nft/impl/domain/NFTInteractorImpl.kt +++ b/feature-nft-impl/src/main/java/jp/co/soramitsu/nft/impl/domain/NFTInteractorImpl.kt @@ -3,6 +3,7 @@ package jp.co.soramitsu.nft.impl.domain import jp.co.soramitsu.account.api.domain.interfaces.AccountRepository import jp.co.soramitsu.account.api.domain.model.address import jp.co.soramitsu.core.models.ChainId +import jp.co.soramitsu.fearless_utils.extensions.requireHexPrefix import jp.co.soramitsu.nft.data.NFTRepository import jp.co.soramitsu.nft.data.pagination.PaginationRequest import jp.co.soramitsu.nft.domain.NFTInteractor @@ -13,7 +14,6 @@ import jp.co.soramitsu.nft.impl.domain.models.nft.NFTImpl import jp.co.soramitsu.nft.impl.domain.usecase.collections.CollectionsFetchingUseCase import jp.co.soramitsu.nft.impl.domain.usecase.tokensbycontract.TokensFetchingUseCase import jp.co.soramitsu.runtime.multiNetwork.chain.ChainsRepository -import jp.co.soramitsu.shared_utils.extensions.requireHexPrefix import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.map import java.math.BigInteger diff --git a/feature-nft-impl/src/main/java/jp/co/soramitsu/nft/impl/domain/models/nft/CollectionImpl.kt b/feature-nft-impl/src/main/java/jp/co/soramitsu/nft/impl/domain/models/nft/CollectionImpl.kt index 1ea3b9bab3..0f0a39997e 100644 --- a/feature-nft-impl/src/main/java/jp/co/soramitsu/nft/impl/domain/models/nft/CollectionImpl.kt +++ b/feature-nft-impl/src/main/java/jp/co/soramitsu/nft/impl/domain/models/nft/CollectionImpl.kt @@ -2,9 +2,9 @@ package jp.co.soramitsu.nft.impl.domain.models.nft import jp.co.soramitsu.common.utils.formatting.shortenAddress import jp.co.soramitsu.core.models.ChainId +import jp.co.soramitsu.fearless_utils.extensions.requireHexPrefix import jp.co.soramitsu.nft.data.models.ContractInfo import jp.co.soramitsu.nft.domain.models.NFTCollection -import jp.co.soramitsu.shared_utils.extensions.requireHexPrefix class CollectionImpl( override val chainId: ChainId, diff --git a/feature-nft-impl/src/main/java/jp/co/soramitsu/nft/impl/domain/models/nft/CollectionWithTokensImpl.kt b/feature-nft-impl/src/main/java/jp/co/soramitsu/nft/impl/domain/models/nft/CollectionWithTokensImpl.kt index ed3bebbf9e..6efabb2433 100644 --- a/feature-nft-impl/src/main/java/jp/co/soramitsu/nft/impl/domain/models/nft/CollectionWithTokensImpl.kt +++ b/feature-nft-impl/src/main/java/jp/co/soramitsu/nft/impl/domain/models/nft/CollectionWithTokensImpl.kt @@ -2,11 +2,11 @@ package jp.co.soramitsu.nft.impl.domain.models.nft import jp.co.soramitsu.common.utils.formatting.shortenAddress import jp.co.soramitsu.core.models.ChainId +import jp.co.soramitsu.fearless_utils.extensions.requireHexPrefix import jp.co.soramitsu.nft.data.models.TokenInfo import jp.co.soramitsu.nft.data.pagination.PageBackStack import jp.co.soramitsu.nft.domain.models.NFT import jp.co.soramitsu.nft.domain.models.NFTCollection -import jp.co.soramitsu.shared_utils.extensions.requireHexPrefix class CollectionWithTokensImpl( private val response: PageBackStack.PageResult.ValidPage, diff --git a/feature-nft-impl/src/main/java/jp/co/soramitsu/nft/impl/domain/models/nft/NFTImpl.kt b/feature-nft-impl/src/main/java/jp/co/soramitsu/nft/impl/domain/models/nft/NFTImpl.kt index 4f6434b4eb..66d5a20baf 100644 --- a/feature-nft-impl/src/main/java/jp/co/soramitsu/nft/impl/domain/models/nft/NFTImpl.kt +++ b/feature-nft-impl/src/main/java/jp/co/soramitsu/nft/impl/domain/models/nft/NFTImpl.kt @@ -2,9 +2,9 @@ package jp.co.soramitsu.nft.impl.domain.models.nft import jp.co.soramitsu.common.utils.formatting.shortenAddress import jp.co.soramitsu.core.models.ChainId +import jp.co.soramitsu.fearless_utils.extensions.requireHexPrefix import jp.co.soramitsu.nft.data.models.TokenInfo import jp.co.soramitsu.nft.domain.models.NFT -import jp.co.soramitsu.shared_utils.extensions.requireHexPrefix import java.math.BigInteger class NFTImpl( diff --git a/feature-nft-impl/src/main/java/jp/co/soramitsu/nft/impl/domain/models/transfer/EIP1559Call.kt b/feature-nft-impl/src/main/java/jp/co/soramitsu/nft/impl/domain/models/transfer/EIP1559Call.kt index fae8650662..9a72f92ca4 100644 --- a/feature-nft-impl/src/main/java/jp/co/soramitsu/nft/impl/domain/models/transfer/EIP1559Call.kt +++ b/feature-nft-impl/src/main/java/jp/co/soramitsu/nft/impl/domain/models/transfer/EIP1559Call.kt @@ -1,10 +1,10 @@ package jp.co.soramitsu.nft.impl.domain.models.transfer +import jp.co.soramitsu.fearless_utils.extensions.requireHexPrefix import jp.co.soramitsu.nft.impl.domain.utils.getBaseFee import jp.co.soramitsu.nft.impl.domain.utils.getMaxPriorityFeePerGas import jp.co.soramitsu.nft.impl.domain.utils.nonNullWeb3j import jp.co.soramitsu.runtime.multiNetwork.connection.EthereumChainConnection -import jp.co.soramitsu.shared_utils.extensions.requireHexPrefix import java.math.BigInteger internal class EIP1559CallImpl private constructor( diff --git a/feature-nft-impl/src/main/java/jp/co/soramitsu/nft/impl/domain/usecase/eth/SendRawEthTransaction.kt b/feature-nft-impl/src/main/java/jp/co/soramitsu/nft/impl/domain/usecase/eth/SendRawEthTransaction.kt index 53685d398c..b8d4d1df72 100644 --- a/feature-nft-impl/src/main/java/jp/co/soramitsu/nft/impl/domain/usecase/eth/SendRawEthTransaction.kt +++ b/feature-nft-impl/src/main/java/jp/co/soramitsu/nft/impl/domain/usecase/eth/SendRawEthTransaction.kt @@ -1,10 +1,10 @@ package jp.co.soramitsu.nft.impl.domain.usecase.eth +import jp.co.soramitsu.fearless_utils.encrypt.keypair.Keypair +import jp.co.soramitsu.fearless_utils.extensions.toHexString import jp.co.soramitsu.nft.impl.domain.utils.map import jp.co.soramitsu.nft.impl.domain.utils.nonNullWeb3j import jp.co.soramitsu.runtime.multiNetwork.connection.EthereumChainConnection -import jp.co.soramitsu.shared_utils.encrypt.keypair.Keypair -import jp.co.soramitsu.shared_utils.extensions.toHexString import kotlinx.coroutines.future.await import org.web3j.crypto.Credentials import org.web3j.crypto.RawTransaction diff --git a/feature-nft-impl/src/main/java/jp/co/soramitsu/nft/impl/presentation/details/DetailsScreen.kt b/feature-nft-impl/src/main/java/jp/co/soramitsu/nft/impl/presentation/details/DetailsScreen.kt index 7d3e26fff2..c49ae015fd 100644 --- a/feature-nft-impl/src/main/java/jp/co/soramitsu/nft/impl/presentation/details/DetailsScreen.kt +++ b/feature-nft-impl/src/main/java/jp/co/soramitsu/nft/impl/presentation/details/DetailsScreen.kt @@ -445,9 +445,7 @@ fun NftCollectionScreenPreview() { NftDetailsScreen( previewState, object : NftDetailsScreenInterface { - override fun sendClicked() { - TODO("Not yet implemented") - } + override fun sendClicked() = Unit override fun shareClicked() = Unit diff --git a/feature-nft-impl/src/main/java/jp/co/soramitsu/nft/impl/presentation/details/NftDetailsPresenter.kt b/feature-nft-impl/src/main/java/jp/co/soramitsu/nft/impl/presentation/details/NftDetailsPresenter.kt index 06347f1300..cabf6c3751 100644 --- a/feature-nft-impl/src/main/java/jp/co/soramitsu/nft/impl/presentation/details/NftDetailsPresenter.kt +++ b/feature-nft-impl/src/main/java/jp/co/soramitsu/nft/impl/presentation/details/NftDetailsPresenter.kt @@ -6,13 +6,13 @@ import jp.co.soramitsu.common.address.createEthereumAddressModel import jp.co.soramitsu.common.resources.ClipboardManager import jp.co.soramitsu.common.resources.ResourceManager import jp.co.soramitsu.common.utils.formatting.shortenAddress +import jp.co.soramitsu.fearless_utils.extensions.toHexString import jp.co.soramitsu.feature_nft_impl.R import jp.co.soramitsu.nft.domain.NFTInteractor import jp.co.soramitsu.nft.impl.domain.utils.convertToShareMessage import jp.co.soramitsu.nft.impl.navigation.InternalNFTRouter import jp.co.soramitsu.nft.impl.presentation.CoroutinesStore import jp.co.soramitsu.nft.navigation.NFTNavGraphRoute -import jp.co.soramitsu.shared_utils.extensions.toHexString import jp.co.soramitsu.wallet.impl.domain.interfaces.WalletInteractor import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.ExperimentalCoroutinesApi diff --git a/feature-onboarding-impl/src/main/java/jp/co/soramitsu/onboarding/impl/welcome/WelcomeViewModel.kt b/feature-onboarding-impl/src/main/java/jp/co/soramitsu/onboarding/impl/welcome/WelcomeViewModel.kt index b586c4a502..3043d8e6c7 100644 --- a/feature-onboarding-impl/src/main/java/jp/co/soramitsu/onboarding/impl/welcome/WelcomeViewModel.kt +++ b/feature-onboarding-impl/src/main/java/jp/co/soramitsu/onboarding/impl/welcome/WelcomeViewModel.kt @@ -23,7 +23,7 @@ import jp.co.soramitsu.common.utils.requireException import jp.co.soramitsu.onboarding.api.domain.OnboardingInteractor import jp.co.soramitsu.onboarding.impl.OnboardingRouter import jp.co.soramitsu.onboarding.impl.welcome.WelcomeFragment.Companion.KEY_PAYLOAD -import jp.co.soramitsu.shared_utils.encrypt.mnemonic.Mnemonic +import jp.co.soramitsu.fearless_utils.encrypt.mnemonic.Mnemonic import kotlinx.coroutines.channels.BufferOverflow import kotlinx.coroutines.channels.Channel import kotlinx.coroutines.flow.MutableStateFlow diff --git a/feature-polkaswap-impl/src/main/kotlin/jp/co/soramitsu/polkaswap/impl/data/PolkaswapRepositoryImpl.kt b/feature-polkaswap-impl/src/main/kotlin/jp/co/soramitsu/polkaswap/impl/data/PolkaswapRepositoryImpl.kt index f21a2703d9..e9926fdc4a 100644 --- a/feature-polkaswap-impl/src/main/kotlin/jp/co/soramitsu/polkaswap/impl/data/PolkaswapRepositoryImpl.kt +++ b/feature-polkaswap-impl/src/main/kotlin/jp/co/soramitsu/polkaswap/impl/data/PolkaswapRepositoryImpl.kt @@ -21,16 +21,16 @@ import jp.co.soramitsu.runtime.multiNetwork.ChainRegistry import jp.co.soramitsu.runtime.multiNetwork.chain.model.Chain import jp.co.soramitsu.runtime.multiNetwork.chain.model.ChainId import jp.co.soramitsu.runtime.storage.source.StorageDataSource -import jp.co.soramitsu.shared_utils.extensions.fromHex -import jp.co.soramitsu.shared_utils.runtime.definitions.types.composite.Struct -import jp.co.soramitsu.shared_utils.runtime.metadata.storage -import jp.co.soramitsu.shared_utils.runtime.metadata.storageKey -import jp.co.soramitsu.shared_utils.wsrpc.exception.RpcException -import jp.co.soramitsu.shared_utils.wsrpc.executeAsync -import jp.co.soramitsu.shared_utils.wsrpc.mappers.nonNull -import jp.co.soramitsu.shared_utils.wsrpc.mappers.pojo -import jp.co.soramitsu.shared_utils.wsrpc.mappers.pojoList -import jp.co.soramitsu.shared_utils.wsrpc.request.runtime.RuntimeRequest +import jp.co.soramitsu.fearless_utils.extensions.fromHex +import jp.co.soramitsu.fearless_utils.runtime.definitions.types.composite.Struct +import jp.co.soramitsu.fearless_utils.runtime.metadata.storage +import jp.co.soramitsu.fearless_utils.runtime.metadata.storageKey +import jp.co.soramitsu.fearless_utils.wsrpc.exception.RpcException +import jp.co.soramitsu.fearless_utils.wsrpc.executeAsync +import jp.co.soramitsu.fearless_utils.wsrpc.mappers.nonNull +import jp.co.soramitsu.fearless_utils.wsrpc.mappers.pojo +import jp.co.soramitsu.fearless_utils.wsrpc.mappers.pojoList +import jp.co.soramitsu.fearless_utils.wsrpc.request.runtime.RuntimeRequest import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.flatMapLatest import kotlinx.coroutines.flow.flow @@ -167,7 +167,8 @@ class PolkaswapRepositoryImpl @Inject constructor( ): BigInteger { val chain = chainRegistry.getChain(chainId) - return extrinsicService.estimateFee(chain) { + val accountId = accountRepository.getSelectedMetaAccount().substrateAccountId!! + return extrinsicService.estimateFee(chain, accountId) { swap(dexId, inputAssetId, outputAssetId, amount, limit, filter, markets, desired) } } diff --git a/feature-polkaswap-impl/src/main/kotlin/jp/co/soramitsu/polkaswap/impl/data/network/blockchain/Extrinsic.kt b/feature-polkaswap-impl/src/main/kotlin/jp/co/soramitsu/polkaswap/impl/data/network/blockchain/Extrinsic.kt index 46b7f16664..476515bf3d 100644 --- a/feature-polkaswap-impl/src/main/kotlin/jp/co/soramitsu/polkaswap/impl/data/network/blockchain/Extrinsic.kt +++ b/feature-polkaswap-impl/src/main/kotlin/jp/co/soramitsu/polkaswap/impl/data/network/blockchain/Extrinsic.kt @@ -2,10 +2,10 @@ package jp.co.soramitsu.polkaswap.impl.data.network.blockchain import java.math.BigInteger import jp.co.soramitsu.polkaswap.api.models.WithDesired -import jp.co.soramitsu.shared_utils.extensions.fromHex -import jp.co.soramitsu.shared_utils.runtime.definitions.types.composite.DictEnum -import jp.co.soramitsu.shared_utils.runtime.definitions.types.composite.Struct -import jp.co.soramitsu.shared_utils.runtime.extrinsic.ExtrinsicBuilder +import jp.co.soramitsu.fearless_utils.extensions.fromHex +import jp.co.soramitsu.fearless_utils.runtime.definitions.types.composite.DictEnum +import jp.co.soramitsu.fearless_utils.runtime.definitions.types.composite.Struct +import jp.co.soramitsu.fearless_utils.runtime.extrinsic.ExtrinsicBuilder fun ExtrinsicBuilder.swap( dexId: Int, diff --git a/feature-polkaswap-impl/src/main/kotlin/jp/co/soramitsu/polkaswap/impl/data/network/blockchain/bindings/Polkaswap.kt b/feature-polkaswap-impl/src/main/kotlin/jp/co/soramitsu/polkaswap/impl/data/network/blockchain/bindings/Polkaswap.kt index 60628fe17b..08b4f32bd6 100644 --- a/feature-polkaswap-impl/src/main/kotlin/jp/co/soramitsu/polkaswap/impl/data/network/blockchain/bindings/Polkaswap.kt +++ b/feature-polkaswap-impl/src/main/kotlin/jp/co/soramitsu/polkaswap/impl/data/network/blockchain/bindings/Polkaswap.kt @@ -4,9 +4,9 @@ import jp.co.soramitsu.common.data.network.runtime.binding.UseCaseBinding import jp.co.soramitsu.common.data.network.runtime.binding.fromHexOrIncompatible import jp.co.soramitsu.common.data.network.runtime.binding.incompatible import jp.co.soramitsu.common.data.network.runtime.binding.storageReturnType -import jp.co.soramitsu.shared_utils.extensions.toHexString -import jp.co.soramitsu.shared_utils.runtime.RuntimeSnapshot -import jp.co.soramitsu.shared_utils.runtime.definitions.types.composite.Struct +import jp.co.soramitsu.fearless_utils.extensions.toHexString +import jp.co.soramitsu.fearless_utils.runtime.RuntimeSnapshot +import jp.co.soramitsu.fearless_utils.runtime.definitions.types.composite.Struct import java.math.BigInteger @UseCaseBinding diff --git a/feature-staking-api/src/main/java/jp/co/soramitsu/staking/api/domain/api/StakingRepository.kt b/feature-staking-api/src/main/java/jp/co/soramitsu/staking/api/domain/api/StakingRepository.kt index dd2ff67cca..d3ce304f8b 100644 --- a/feature-staking-api/src/main/java/jp/co/soramitsu/staking/api/domain/api/StakingRepository.kt +++ b/feature-staking-api/src/main/java/jp/co/soramitsu/staking/api/domain/api/StakingRepository.kt @@ -2,7 +2,7 @@ package jp.co.soramitsu.staking.api.domain.api import jp.co.soramitsu.common.data.network.runtime.binding.AccountInfo import jp.co.soramitsu.runtime.multiNetwork.chain.model.ChainId -import jp.co.soramitsu.shared_utils.runtime.AccountId +import jp.co.soramitsu.fearless_utils.runtime.AccountId import java.math.BigInteger interface StakingRepository { diff --git a/feature-staking-api/src/main/java/jp/co/soramitsu/staking/api/domain/model/DelegationScheduledRequest.kt b/feature-staking-api/src/main/java/jp/co/soramitsu/staking/api/domain/model/DelegationScheduledRequest.kt index 963ce47704..5996698bd8 100644 --- a/feature-staking-api/src/main/java/jp/co/soramitsu/staking/api/domain/model/DelegationScheduledRequest.kt +++ b/feature-staking-api/src/main/java/jp/co/soramitsu/staking/api/domain/model/DelegationScheduledRequest.kt @@ -2,7 +2,7 @@ package jp.co.soramitsu.staking.api.domain.model import androidx.annotation.StringRes import jp.co.soramitsu.feature_staking_api.R -import jp.co.soramitsu.shared_utils.runtime.AccountId +import jp.co.soramitsu.fearless_utils.runtime.AccountId import java.math.BigInteger data class DelegationScheduledRequest( diff --git a/feature-staking-api/src/main/java/jp/co/soramitsu/staking/api/domain/model/DelegatorState.kt b/feature-staking-api/src/main/java/jp/co/soramitsu/staking/api/domain/model/DelegatorState.kt index c5155bdb6c..08be28dc5b 100644 --- a/feature-staking-api/src/main/java/jp/co/soramitsu/staking/api/domain/model/DelegatorState.kt +++ b/feature-staking-api/src/main/java/jp/co/soramitsu/staking/api/domain/model/DelegatorState.kt @@ -1,6 +1,6 @@ package jp.co.soramitsu.staking.api.domain.model -import jp.co.soramitsu.shared_utils.runtime.AccountId +import jp.co.soramitsu.fearless_utils.runtime.AccountId import java.math.BigInteger data class DelegatorState( diff --git a/feature-staking-api/src/main/java/jp/co/soramitsu/staking/api/domain/model/Nominations.kt b/feature-staking-api/src/main/java/jp/co/soramitsu/staking/api/domain/model/Nominations.kt index afa78c93ac..8c3a75ec01 100644 --- a/feature-staking-api/src/main/java/jp/co/soramitsu/staking/api/domain/model/Nominations.kt +++ b/feature-staking-api/src/main/java/jp/co/soramitsu/staking/api/domain/model/Nominations.kt @@ -1,6 +1,6 @@ package jp.co.soramitsu.staking.api.domain.model -import jp.co.soramitsu.shared_utils.runtime.AccountId +import jp.co.soramitsu.fearless_utils.runtime.AccountId class Nominations( val targets: List, diff --git a/feature-staking-api/src/main/java/jp/co/soramitsu/staking/api/domain/model/PoolMember.kt b/feature-staking-api/src/main/java/jp/co/soramitsu/staking/api/domain/model/PoolMember.kt index e88b6fc0f0..b2a6d69989 100644 --- a/feature-staking-api/src/main/java/jp/co/soramitsu/staking/api/domain/model/PoolMember.kt +++ b/feature-staking-api/src/main/java/jp/co/soramitsu/staking/api/domain/model/PoolMember.kt @@ -1,7 +1,7 @@ package jp.co.soramitsu.staking.api.domain.model import android.os.Parcelable -import jp.co.soramitsu.shared_utils.runtime.AccountId +import jp.co.soramitsu.fearless_utils.runtime.AccountId import java.math.BigInteger data class PoolUnbonding( diff --git a/feature-staking-api/src/main/java/jp/co/soramitsu/staking/api/domain/model/RewardDestination.kt b/feature-staking-api/src/main/java/jp/co/soramitsu/staking/api/domain/model/RewardDestination.kt index 3541725758..e0cb2b1ed9 100644 --- a/feature-staking-api/src/main/java/jp/co/soramitsu/staking/api/domain/model/RewardDestination.kt +++ b/feature-staking-api/src/main/java/jp/co/soramitsu/staking/api/domain/model/RewardDestination.kt @@ -1,6 +1,6 @@ package jp.co.soramitsu.staking.api.domain.model -import jp.co.soramitsu.shared_utils.runtime.AccountId +import jp.co.soramitsu.fearless_utils.runtime.AccountId sealed class RewardDestination { diff --git a/feature-staking-api/src/main/java/jp/co/soramitsu/staking/api/domain/model/StakingLedger.kt b/feature-staking-api/src/main/java/jp/co/soramitsu/staking/api/domain/model/StakingLedger.kt index d00c6d3590..5811dcf502 100644 --- a/feature-staking-api/src/main/java/jp/co/soramitsu/staking/api/domain/model/StakingLedger.kt +++ b/feature-staking-api/src/main/java/jp/co/soramitsu/staking/api/domain/model/StakingLedger.kt @@ -1,7 +1,7 @@ package jp.co.soramitsu.staking.api.domain.model import jp.co.soramitsu.common.utils.sumByBigInteger -import jp.co.soramitsu.shared_utils.runtime.AccountId +import jp.co.soramitsu.fearless_utils.runtime.AccountId import java.math.BigInteger class StakingLedger( diff --git a/feature-staking-api/src/main/java/jp/co/soramitsu/staking/api/domain/model/StakingState.kt b/feature-staking-api/src/main/java/jp/co/soramitsu/staking/api/domain/model/StakingState.kt index 94a7dc09f6..e46861c70a 100644 --- a/feature-staking-api/src/main/java/jp/co/soramitsu/staking/api/domain/model/StakingState.kt +++ b/feature-staking-api/src/main/java/jp/co/soramitsu/staking/api/domain/model/StakingState.kt @@ -2,7 +2,7 @@ package jp.co.soramitsu.staking.api.domain.model import jp.co.soramitsu.runtime.ext.addressOf import jp.co.soramitsu.runtime.multiNetwork.chain.model.Chain -import jp.co.soramitsu.shared_utils.runtime.AccountId +import jp.co.soramitsu.fearless_utils.runtime.AccountId import java.math.BigDecimal import java.math.BigInteger diff --git a/feature-staking-impl/src/main/java/jp/co/soramitsu/staking/impl/data/mappers/MapRewardDestinationModelToRewardDestination.kt b/feature-staking-impl/src/main/java/jp/co/soramitsu/staking/impl/data/mappers/MapRewardDestinationModelToRewardDestination.kt index 56154b3de8..445972a23f 100644 --- a/feature-staking-impl/src/main/java/jp/co/soramitsu/staking/impl/data/mappers/MapRewardDestinationModelToRewardDestination.kt +++ b/feature-staking-impl/src/main/java/jp/co/soramitsu/staking/impl/data/mappers/MapRewardDestinationModelToRewardDestination.kt @@ -1,6 +1,6 @@ package jp.co.soramitsu.staking.impl.data.mappers -import jp.co.soramitsu.shared_utils.ss58.SS58Encoder.toAccountId +import jp.co.soramitsu.fearless_utils.ss58.SS58Encoder.toAccountId import jp.co.soramitsu.staking.api.domain.model.RewardDestination import jp.co.soramitsu.staking.impl.presentation.common.rewardDestination.RewardDestinationModel diff --git a/feature-staking-impl/src/main/java/jp/co/soramitsu/staking/impl/data/model/BondedPool.kt b/feature-staking-impl/src/main/java/jp/co/soramitsu/staking/impl/data/model/BondedPool.kt index 19a3b6f456..53a52204c0 100644 --- a/feature-staking-impl/src/main/java/jp/co/soramitsu/staking/impl/data/model/BondedPool.kt +++ b/feature-staking-impl/src/main/java/jp/co/soramitsu/staking/impl/data/model/BondedPool.kt @@ -1,6 +1,6 @@ package jp.co.soramitsu.staking.impl.data.model -import jp.co.soramitsu.shared_utils.runtime.AccountId +import jp.co.soramitsu.fearless_utils.runtime.AccountId import java.math.BigInteger data class BondedPool( diff --git a/feature-staking-impl/src/main/java/jp/co/soramitsu/staking/impl/data/network/blockhain/bindings/BindAtStakeOfCollator.kt b/feature-staking-impl/src/main/java/jp/co/soramitsu/staking/impl/data/network/blockhain/bindings/BindAtStakeOfCollator.kt index 8190ac55e4..2ae7802b80 100644 --- a/feature-staking-impl/src/main/java/jp/co/soramitsu/staking/impl/data/network/blockhain/bindings/BindAtStakeOfCollator.kt +++ b/feature-staking-impl/src/main/java/jp/co/soramitsu/staking/impl/data/network/blockhain/bindings/BindAtStakeOfCollator.kt @@ -6,10 +6,10 @@ import jp.co.soramitsu.common.data.network.runtime.binding.incompatible import jp.co.soramitsu.common.data.network.runtime.binding.requireType import jp.co.soramitsu.common.utils.parachainStaking import jp.co.soramitsu.core.runtime.storage.returnType -import jp.co.soramitsu.shared_utils.runtime.RuntimeSnapshot -import jp.co.soramitsu.shared_utils.runtime.definitions.types.composite.Struct -import jp.co.soramitsu.shared_utils.runtime.definitions.types.fromHexOrNull -import jp.co.soramitsu.shared_utils.runtime.metadata.storage +import jp.co.soramitsu.fearless_utils.runtime.RuntimeSnapshot +import jp.co.soramitsu.fearless_utils.runtime.definitions.types.composite.Struct +import jp.co.soramitsu.fearless_utils.runtime.definitions.types.fromHexOrNull +import jp.co.soramitsu.fearless_utils.runtime.metadata.storage import jp.co.soramitsu.staking.api.domain.model.AtStake import java.math.BigInteger diff --git a/feature-staking-impl/src/main/java/jp/co/soramitsu/staking/impl/data/network/blockhain/bindings/BindCandidateInfo.kt b/feature-staking-impl/src/main/java/jp/co/soramitsu/staking/impl/data/network/blockhain/bindings/BindCandidateInfo.kt index 3c4c9521eb..8faee529d1 100644 --- a/feature-staking-impl/src/main/java/jp/co/soramitsu/staking/impl/data/network/blockhain/bindings/BindCandidateInfo.kt +++ b/feature-staking-impl/src/main/java/jp/co/soramitsu/staking/impl/data/network/blockhain/bindings/BindCandidateInfo.kt @@ -6,11 +6,11 @@ import jp.co.soramitsu.common.data.network.runtime.binding.incompatible import jp.co.soramitsu.common.data.network.runtime.binding.requireType import jp.co.soramitsu.common.utils.parachainStaking import jp.co.soramitsu.core.runtime.storage.returnType -import jp.co.soramitsu.shared_utils.runtime.RuntimeSnapshot -import jp.co.soramitsu.shared_utils.runtime.definitions.types.composite.DictEnum -import jp.co.soramitsu.shared_utils.runtime.definitions.types.composite.Struct -import jp.co.soramitsu.shared_utils.runtime.definitions.types.fromHexOrNull -import jp.co.soramitsu.shared_utils.runtime.metadata.storage +import jp.co.soramitsu.fearless_utils.runtime.RuntimeSnapshot +import jp.co.soramitsu.fearless_utils.runtime.definitions.types.composite.DictEnum +import jp.co.soramitsu.fearless_utils.runtime.definitions.types.composite.Struct +import jp.co.soramitsu.fearless_utils.runtime.definitions.types.fromHexOrNull +import jp.co.soramitsu.fearless_utils.runtime.metadata.storage import jp.co.soramitsu.staking.api.domain.model.CandidateCapacity import jp.co.soramitsu.staking.api.domain.model.CandidateInfo import jp.co.soramitsu.staking.api.domain.model.CandidateInfoStatus diff --git a/feature-staking-impl/src/main/java/jp/co/soramitsu/staking/impl/data/network/blockhain/bindings/BindDelegatorState.kt b/feature-staking-impl/src/main/java/jp/co/soramitsu/staking/impl/data/network/blockhain/bindings/BindDelegatorState.kt index 706b6e3c27..25b6699171 100644 --- a/feature-staking-impl/src/main/java/jp/co/soramitsu/staking/impl/data/network/blockhain/bindings/BindDelegatorState.kt +++ b/feature-staking-impl/src/main/java/jp/co/soramitsu/staking/impl/data/network/blockhain/bindings/BindDelegatorState.kt @@ -7,12 +7,12 @@ import jp.co.soramitsu.common.data.network.runtime.binding.incompatible import jp.co.soramitsu.common.data.network.runtime.binding.requireType import jp.co.soramitsu.common.utils.parachainStaking import jp.co.soramitsu.core.runtime.storage.returnType -import jp.co.soramitsu.shared_utils.runtime.AccountId -import jp.co.soramitsu.shared_utils.runtime.RuntimeSnapshot -import jp.co.soramitsu.shared_utils.runtime.definitions.types.composite.DictEnum -import jp.co.soramitsu.shared_utils.runtime.definitions.types.composite.Struct -import jp.co.soramitsu.shared_utils.runtime.definitions.types.fromHexOrNull -import jp.co.soramitsu.shared_utils.runtime.metadata.storage +import jp.co.soramitsu.fearless_utils.runtime.AccountId +import jp.co.soramitsu.fearless_utils.runtime.RuntimeSnapshot +import jp.co.soramitsu.fearless_utils.runtime.definitions.types.composite.DictEnum +import jp.co.soramitsu.fearless_utils.runtime.definitions.types.composite.Struct +import jp.co.soramitsu.fearless_utils.runtime.definitions.types.fromHexOrNull +import jp.co.soramitsu.fearless_utils.runtime.metadata.storage import jp.co.soramitsu.staking.api.domain.model.Delegation import jp.co.soramitsu.staking.api.domain.model.DelegatorState import jp.co.soramitsu.staking.api.domain.model.DelegatorStateStatus diff --git a/feature-staking-impl/src/main/java/jp/co/soramitsu/staking/impl/data/network/blockhain/bindings/BindHistoryDepth.kt b/feature-staking-impl/src/main/java/jp/co/soramitsu/staking/impl/data/network/blockhain/bindings/BindHistoryDepth.kt index 283507ebc4..688f2953b1 100644 --- a/feature-staking-impl/src/main/java/jp/co/soramitsu/staking/impl/data/network/blockhain/bindings/BindHistoryDepth.kt +++ b/feature-staking-impl/src/main/java/jp/co/soramitsu/staking/impl/data/network/blockhain/bindings/BindHistoryDepth.kt @@ -4,7 +4,7 @@ import jp.co.soramitsu.common.data.network.runtime.binding.UseCaseBinding import jp.co.soramitsu.common.data.network.runtime.binding.bindNumber import jp.co.soramitsu.common.data.network.runtime.binding.fromHexOrIncompatible import jp.co.soramitsu.common.data.network.runtime.binding.storageReturnType -import jp.co.soramitsu.shared_utils.runtime.RuntimeSnapshot +import jp.co.soramitsu.fearless_utils.runtime.RuntimeSnapshot import java.math.BigInteger @UseCaseBinding diff --git a/feature-staking-impl/src/main/java/jp/co/soramitsu/staking/impl/data/network/blockhain/bindings/BindNominations.kt b/feature-staking-impl/src/main/java/jp/co/soramitsu/staking/impl/data/network/blockhain/bindings/BindNominations.kt index a9012c0675..95302a6bfb 100644 --- a/feature-staking-impl/src/main/java/jp/co/soramitsu/staking/impl/data/network/blockhain/bindings/BindNominations.kt +++ b/feature-staking-impl/src/main/java/jp/co/soramitsu/staking/impl/data/network/blockhain/bindings/BindNominations.kt @@ -6,11 +6,11 @@ import jp.co.soramitsu.common.data.network.runtime.binding.getTyped import jp.co.soramitsu.common.data.network.runtime.binding.requireType import jp.co.soramitsu.common.utils.staking import jp.co.soramitsu.core.runtime.storage.returnType -import jp.co.soramitsu.shared_utils.runtime.AccountId -import jp.co.soramitsu.shared_utils.runtime.RuntimeSnapshot -import jp.co.soramitsu.shared_utils.runtime.definitions.types.composite.Struct -import jp.co.soramitsu.shared_utils.runtime.definitions.types.fromHexOrNull -import jp.co.soramitsu.shared_utils.runtime.metadata.storage +import jp.co.soramitsu.fearless_utils.runtime.AccountId +import jp.co.soramitsu.fearless_utils.runtime.RuntimeSnapshot +import jp.co.soramitsu.fearless_utils.runtime.definitions.types.composite.Struct +import jp.co.soramitsu.fearless_utils.runtime.definitions.types.fromHexOrNull +import jp.co.soramitsu.fearless_utils.runtime.metadata.storage import jp.co.soramitsu.staking.api.domain.model.Nominations @UseCaseBinding diff --git a/feature-staking-impl/src/main/java/jp/co/soramitsu/staking/impl/data/network/blockhain/bindings/BindRewardDestination.kt b/feature-staking-impl/src/main/java/jp/co/soramitsu/staking/impl/data/network/blockhain/bindings/BindRewardDestination.kt index ba7e1292de..5bcb397fc2 100644 --- a/feature-staking-impl/src/main/java/jp/co/soramitsu/staking/impl/data/network/blockhain/bindings/BindRewardDestination.kt +++ b/feature-staking-impl/src/main/java/jp/co/soramitsu/staking/impl/data/network/blockhain/bindings/BindRewardDestination.kt @@ -5,10 +5,10 @@ import jp.co.soramitsu.common.data.network.runtime.binding.incompatible import jp.co.soramitsu.common.utils.staking import jp.co.soramitsu.core.runtime.storage.returnType import jp.co.soramitsu.core.utils.utilityAsset -import jp.co.soramitsu.shared_utils.runtime.RuntimeSnapshot -import jp.co.soramitsu.shared_utils.runtime.definitions.types.composite.DictEnum -import jp.co.soramitsu.shared_utils.runtime.definitions.types.fromHexOrNull -import jp.co.soramitsu.shared_utils.runtime.metadata.storage +import jp.co.soramitsu.fearless_utils.runtime.RuntimeSnapshot +import jp.co.soramitsu.fearless_utils.runtime.definitions.types.composite.DictEnum +import jp.co.soramitsu.fearless_utils.runtime.definitions.types.fromHexOrNull +import jp.co.soramitsu.fearless_utils.runtime.metadata.storage import jp.co.soramitsu.staking.api.data.SyntheticStakingType import jp.co.soramitsu.staking.api.data.syntheticStakingType import jp.co.soramitsu.staking.api.domain.model.RewardDestination diff --git a/feature-staking-impl/src/main/java/jp/co/soramitsu/staking/impl/data/network/blockhain/bindings/BindSlashingSpans.kt b/feature-staking-impl/src/main/java/jp/co/soramitsu/staking/impl/data/network/blockhain/bindings/BindSlashingSpans.kt index e465c84947..df71e154e0 100644 --- a/feature-staking-impl/src/main/java/jp/co/soramitsu/staking/impl/data/network/blockhain/bindings/BindSlashingSpans.kt +++ b/feature-staking-impl/src/main/java/jp/co/soramitsu/staking/impl/data/network/blockhain/bindings/BindSlashingSpans.kt @@ -4,10 +4,10 @@ import jp.co.soramitsu.common.data.network.runtime.binding.UseCaseBinding import jp.co.soramitsu.common.data.network.runtime.binding.getList import jp.co.soramitsu.common.data.network.runtime.binding.incompatible import jp.co.soramitsu.common.data.network.runtime.binding.storageReturnType -import jp.co.soramitsu.shared_utils.runtime.RuntimeSnapshot -import jp.co.soramitsu.shared_utils.runtime.definitions.types.Type -import jp.co.soramitsu.shared_utils.runtime.definitions.types.composite.Struct -import jp.co.soramitsu.shared_utils.runtime.definitions.types.fromHexOrNull +import jp.co.soramitsu.fearless_utils.runtime.RuntimeSnapshot +import jp.co.soramitsu.fearless_utils.runtime.definitions.types.Type +import jp.co.soramitsu.fearless_utils.runtime.definitions.types.composite.Struct +import jp.co.soramitsu.fearless_utils.runtime.definitions.types.fromHexOrNull import jp.co.soramitsu.staking.api.domain.model.SlashingSpans @UseCaseBinding diff --git a/feature-staking-impl/src/main/java/jp/co/soramitsu/staking/impl/data/network/blockhain/bindings/BindStaked.kt b/feature-staking-impl/src/main/java/jp/co/soramitsu/staking/impl/data/network/blockhain/bindings/BindStaked.kt index 4f3f684e7b..4c553f431b 100644 --- a/feature-staking-impl/src/main/java/jp/co/soramitsu/staking/impl/data/network/blockhain/bindings/BindStaked.kt +++ b/feature-staking-impl/src/main/java/jp/co/soramitsu/staking/impl/data/network/blockhain/bindings/BindStaked.kt @@ -4,7 +4,7 @@ import jp.co.soramitsu.common.data.network.runtime.binding.UseCaseBinding import jp.co.soramitsu.common.data.network.runtime.binding.bindNumber import jp.co.soramitsu.common.data.network.runtime.binding.fromHexOrIncompatible import jp.co.soramitsu.common.data.network.runtime.binding.storageReturnType -import jp.co.soramitsu.shared_utils.runtime.RuntimeSnapshot +import jp.co.soramitsu.fearless_utils.runtime.RuntimeSnapshot import java.math.BigInteger @UseCaseBinding diff --git a/feature-staking-impl/src/main/java/jp/co/soramitsu/staking/impl/data/network/blockhain/bindings/BindTotalIssuance.kt b/feature-staking-impl/src/main/java/jp/co/soramitsu/staking/impl/data/network/blockhain/bindings/BindTotalIssuance.kt index 7354ecd58e..f6cbf276b1 100644 --- a/feature-staking-impl/src/main/java/jp/co/soramitsu/staking/impl/data/network/blockhain/bindings/BindTotalIssuance.kt +++ b/feature-staking-impl/src/main/java/jp/co/soramitsu/staking/impl/data/network/blockhain/bindings/BindTotalIssuance.kt @@ -4,7 +4,7 @@ import jp.co.soramitsu.common.data.network.runtime.binding.UseCaseBinding import jp.co.soramitsu.common.data.network.runtime.binding.bindNumber import jp.co.soramitsu.common.data.network.runtime.binding.fromHexOrIncompatible import jp.co.soramitsu.common.data.network.runtime.binding.storageReturnType -import jp.co.soramitsu.shared_utils.runtime.RuntimeSnapshot +import jp.co.soramitsu.fearless_utils.runtime.RuntimeSnapshot import java.math.BigInteger /* diff --git a/feature-staking-impl/src/main/java/jp/co/soramitsu/staking/impl/data/network/blockhain/bindings/BindTotalValidatorEraReward.kt b/feature-staking-impl/src/main/java/jp/co/soramitsu/staking/impl/data/network/blockhain/bindings/BindTotalValidatorEraReward.kt index d9bc5bb9ff..cfae63748e 100644 --- a/feature-staking-impl/src/main/java/jp/co/soramitsu/staking/impl/data/network/blockhain/bindings/BindTotalValidatorEraReward.kt +++ b/feature-staking-impl/src/main/java/jp/co/soramitsu/staking/impl/data/network/blockhain/bindings/BindTotalValidatorEraReward.kt @@ -4,8 +4,8 @@ import jp.co.soramitsu.common.data.network.runtime.binding.UseCaseBinding import jp.co.soramitsu.common.data.network.runtime.binding.bindNumber import jp.co.soramitsu.common.data.network.runtime.binding.fromHexOrIncompatible import jp.co.soramitsu.common.data.network.runtime.binding.incompatible -import jp.co.soramitsu.shared_utils.runtime.RuntimeSnapshot -import jp.co.soramitsu.shared_utils.runtime.definitions.types.Type +import jp.co.soramitsu.fearless_utils.runtime.RuntimeSnapshot +import jp.co.soramitsu.fearless_utils.runtime.definitions.types.Type import java.math.BigInteger @UseCaseBinding diff --git a/feature-staking-impl/src/main/java/jp/co/soramitsu/staking/impl/data/network/blockhain/bindings/Constants.kt b/feature-staking-impl/src/main/java/jp/co/soramitsu/staking/impl/data/network/blockhain/bindings/Constants.kt index fdf107c35a..9552e1c112 100644 --- a/feature-staking-impl/src/main/java/jp/co/soramitsu/staking/impl/data/network/blockhain/bindings/Constants.kt +++ b/feature-staking-impl/src/main/java/jp/co/soramitsu/staking/impl/data/network/blockhain/bindings/Constants.kt @@ -2,8 +2,8 @@ package jp.co.soramitsu.staking.impl.data.network.blockhain.bindings import jp.co.soramitsu.common.data.network.runtime.binding.UseCaseBinding import jp.co.soramitsu.common.data.network.runtime.binding.bindNumberConstant -import jp.co.soramitsu.shared_utils.runtime.RuntimeSnapshot -import jp.co.soramitsu.shared_utils.runtime.metadata.module.Constant +import jp.co.soramitsu.fearless_utils.runtime.RuntimeSnapshot +import jp.co.soramitsu.fearless_utils.runtime.metadata.module.Constant import java.math.BigInteger /* diff --git a/feature-staking-impl/src/main/java/jp/co/soramitsu/staking/impl/data/network/blockhain/bindings/Data.kt b/feature-staking-impl/src/main/java/jp/co/soramitsu/staking/impl/data/network/blockhain/bindings/Data.kt index 5fb8938c3a..c9c2e5f20d 100644 --- a/feature-staking-impl/src/main/java/jp/co/soramitsu/staking/impl/data/network/blockhain/bindings/Data.kt +++ b/feature-staking-impl/src/main/java/jp/co/soramitsu/staking/impl/data/network/blockhain/bindings/Data.kt @@ -4,9 +4,9 @@ import jp.co.soramitsu.common.data.network.runtime.binding.HelperBinding import jp.co.soramitsu.common.data.network.runtime.binding.cast import jp.co.soramitsu.common.data.network.runtime.binding.incompatible import jp.co.soramitsu.common.data.network.runtime.binding.requireType -import jp.co.soramitsu.shared_utils.extensions.toHexString -import jp.co.soramitsu.shared_utils.runtime.definitions.types.composite.DictEnum -import jp.co.soramitsu.shared_utils.runtime.definitions.types.generics.Data as DataType +import jp.co.soramitsu.fearless_utils.extensions.toHexString +import jp.co.soramitsu.fearless_utils.runtime.definitions.types.composite.DictEnum +import jp.co.soramitsu.fearless_utils.runtime.definitions.types.generics.Data as DataType sealed class Data { abstract fun asString(): String? diff --git a/feature-staking-impl/src/main/java/jp/co/soramitsu/staking/impl/data/network/blockhain/bindings/Era.kt b/feature-staking-impl/src/main/java/jp/co/soramitsu/staking/impl/data/network/blockhain/bindings/Era.kt index d3abc4f525..fe5837b743 100644 --- a/feature-staking-impl/src/main/java/jp/co/soramitsu/staking/impl/data/network/blockhain/bindings/Era.kt +++ b/feature-staking-impl/src/main/java/jp/co/soramitsu/staking/impl/data/network/blockhain/bindings/Era.kt @@ -6,9 +6,9 @@ import jp.co.soramitsu.common.data.network.runtime.binding.bindNumber import jp.co.soramitsu.common.data.network.runtime.binding.getTyped import jp.co.soramitsu.common.data.network.runtime.binding.incompatible import jp.co.soramitsu.common.data.network.runtime.binding.storageReturnType -import jp.co.soramitsu.shared_utils.runtime.RuntimeSnapshot -import jp.co.soramitsu.shared_utils.runtime.definitions.types.composite.Struct -import jp.co.soramitsu.shared_utils.runtime.definitions.types.fromHexOrNull +import jp.co.soramitsu.fearless_utils.runtime.RuntimeSnapshot +import jp.co.soramitsu.fearless_utils.runtime.definitions.types.composite.Struct +import jp.co.soramitsu.fearless_utils.runtime.definitions.types.fromHexOrNull import jp.co.soramitsu.staking.api.domain.model.EraIndex import java.math.BigInteger diff --git a/feature-staking-impl/src/main/java/jp/co/soramitsu/staking/impl/data/network/blockhain/bindings/EraRewardPoints.kt b/feature-staking-impl/src/main/java/jp/co/soramitsu/staking/impl/data/network/blockhain/bindings/EraRewardPoints.kt index 9fb9575c3d..1121951d5c 100644 --- a/feature-staking-impl/src/main/java/jp/co/soramitsu/staking/impl/data/network/blockhain/bindings/EraRewardPoints.kt +++ b/feature-staking-impl/src/main/java/jp/co/soramitsu/staking/impl/data/network/blockhain/bindings/EraRewardPoints.kt @@ -8,11 +8,11 @@ import jp.co.soramitsu.common.data.network.runtime.binding.cast import jp.co.soramitsu.common.data.network.runtime.binding.getList import jp.co.soramitsu.common.data.network.runtime.binding.requireType import jp.co.soramitsu.common.utils.second -import jp.co.soramitsu.shared_utils.runtime.AccountId -import jp.co.soramitsu.shared_utils.runtime.RuntimeSnapshot -import jp.co.soramitsu.shared_utils.runtime.definitions.types.Type -import jp.co.soramitsu.shared_utils.runtime.definitions.types.composite.Struct -import jp.co.soramitsu.shared_utils.runtime.definitions.types.fromHexOrNull +import jp.co.soramitsu.fearless_utils.runtime.AccountId +import jp.co.soramitsu.fearless_utils.runtime.RuntimeSnapshot +import jp.co.soramitsu.fearless_utils.runtime.definitions.types.Type +import jp.co.soramitsu.fearless_utils.runtime.definitions.types.composite.Struct +import jp.co.soramitsu.fearless_utils.runtime.definitions.types.fromHexOrNull import java.math.BigInteger typealias RewardPoint = BigInteger diff --git a/feature-staking-impl/src/main/java/jp/co/soramitsu/staking/impl/data/network/blockhain/bindings/Exposure.kt b/feature-staking-impl/src/main/java/jp/co/soramitsu/staking/impl/data/network/blockhain/bindings/Exposure.kt index 67811a999e..b670805602 100644 --- a/feature-staking-impl/src/main/java/jp/co/soramitsu/staking/impl/data/network/blockhain/bindings/Exposure.kt +++ b/feature-staking-impl/src/main/java/jp/co/soramitsu/staking/impl/data/network/blockhain/bindings/Exposure.kt @@ -7,11 +7,11 @@ import jp.co.soramitsu.common.data.network.runtime.binding.incompatible import jp.co.soramitsu.common.data.network.runtime.binding.requireType import jp.co.soramitsu.common.utils.staking import jp.co.soramitsu.core.runtime.storage.returnType -import jp.co.soramitsu.shared_utils.runtime.RuntimeSnapshot -import jp.co.soramitsu.shared_utils.runtime.definitions.types.Type -import jp.co.soramitsu.shared_utils.runtime.definitions.types.composite.Struct -import jp.co.soramitsu.shared_utils.runtime.definitions.types.fromHexOrNull -import jp.co.soramitsu.shared_utils.runtime.metadata.storage +import jp.co.soramitsu.fearless_utils.runtime.RuntimeSnapshot +import jp.co.soramitsu.fearless_utils.runtime.definitions.types.Type +import jp.co.soramitsu.fearless_utils.runtime.definitions.types.composite.Struct +import jp.co.soramitsu.fearless_utils.runtime.definitions.types.fromHexOrNull +import jp.co.soramitsu.fearless_utils.runtime.metadata.storage import jp.co.soramitsu.staking.api.domain.model.Exposure import jp.co.soramitsu.staking.api.domain.model.ExposurePage import jp.co.soramitsu.staking.api.domain.model.IndividualExposure diff --git a/feature-staking-impl/src/main/java/jp/co/soramitsu/staking/impl/data/network/blockhain/bindings/Identity.kt b/feature-staking-impl/src/main/java/jp/co/soramitsu/staking/impl/data/network/blockhain/bindings/Identity.kt index 611202766e..80860ad325 100644 --- a/feature-staking-impl/src/main/java/jp/co/soramitsu/staking/impl/data/network/blockhain/bindings/Identity.kt +++ b/feature-staking-impl/src/main/java/jp/co/soramitsu/staking/impl/data/network/blockhain/bindings/Identity.kt @@ -5,11 +5,11 @@ import jp.co.soramitsu.common.data.network.runtime.binding.UseCaseBinding import jp.co.soramitsu.common.data.network.runtime.binding.cast import jp.co.soramitsu.common.data.network.runtime.binding.incompatible import jp.co.soramitsu.common.utils.second -import jp.co.soramitsu.shared_utils.extensions.toHexString -import jp.co.soramitsu.shared_utils.runtime.RuntimeSnapshot -import jp.co.soramitsu.shared_utils.runtime.definitions.types.Type -import jp.co.soramitsu.shared_utils.runtime.definitions.types.composite.Struct -import jp.co.soramitsu.shared_utils.runtime.definitions.types.fromHexOrNull +import jp.co.soramitsu.fearless_utils.extensions.toHexString +import jp.co.soramitsu.fearless_utils.runtime.RuntimeSnapshot +import jp.co.soramitsu.fearless_utils.runtime.definitions.types.Type +import jp.co.soramitsu.fearless_utils.runtime.definitions.types.composite.Struct +import jp.co.soramitsu.fearless_utils.runtime.definitions.types.fromHexOrNull import jp.co.soramitsu.staking.api.domain.model.Identity import jp.co.soramitsu.staking.api.domain.model.RootIdentity import jp.co.soramitsu.staking.api.domain.model.SuperOf diff --git a/feature-staking-impl/src/main/java/jp/co/soramitsu/staking/impl/data/network/blockhain/bindings/NominationPools.kt b/feature-staking-impl/src/main/java/jp/co/soramitsu/staking/impl/data/network/blockhain/bindings/NominationPools.kt index e4c74438cc..a42859c682 100644 --- a/feature-staking-impl/src/main/java/jp/co/soramitsu/staking/impl/data/network/blockhain/bindings/NominationPools.kt +++ b/feature-staking-impl/src/main/java/jp/co/soramitsu/staking/impl/data/network/blockhain/bindings/NominationPools.kt @@ -11,11 +11,11 @@ import jp.co.soramitsu.common.data.network.runtime.binding.storageReturnType import jp.co.soramitsu.common.utils.nominationPools import jp.co.soramitsu.common.utils.second import jp.co.soramitsu.core.runtime.storage.returnType -import jp.co.soramitsu.shared_utils.runtime.RuntimeSnapshot -import jp.co.soramitsu.shared_utils.runtime.definitions.types.composite.DictEnum -import jp.co.soramitsu.shared_utils.runtime.definitions.types.composite.Struct -import jp.co.soramitsu.shared_utils.runtime.definitions.types.fromHexOrNull -import jp.co.soramitsu.shared_utils.runtime.metadata.storage +import jp.co.soramitsu.fearless_utils.runtime.RuntimeSnapshot +import jp.co.soramitsu.fearless_utils.runtime.definitions.types.composite.DictEnum +import jp.co.soramitsu.fearless_utils.runtime.definitions.types.composite.Struct +import jp.co.soramitsu.fearless_utils.runtime.definitions.types.fromHexOrNull +import jp.co.soramitsu.fearless_utils.runtime.metadata.storage import jp.co.soramitsu.staking.impl.data.model.BondedPool import jp.co.soramitsu.staking.impl.data.model.BondedPoolState import jp.co.soramitsu.staking.impl.data.model.PoolMember diff --git a/feature-staking-impl/src/main/java/jp/co/soramitsu/staking/impl/data/network/blockhain/bindings/StakingLedger.kt b/feature-staking-impl/src/main/java/jp/co/soramitsu/staking/impl/data/network/blockhain/bindings/StakingLedger.kt index e5dedfdb3d..706b764855 100644 --- a/feature-staking-impl/src/main/java/jp/co/soramitsu/staking/impl/data/network/blockhain/bindings/StakingLedger.kt +++ b/feature-staking-impl/src/main/java/jp/co/soramitsu/staking/impl/data/network/blockhain/bindings/StakingLedger.kt @@ -8,10 +8,10 @@ import jp.co.soramitsu.common.data.network.runtime.binding.incompatible import jp.co.soramitsu.common.data.network.runtime.binding.requireType import jp.co.soramitsu.common.utils.staking import jp.co.soramitsu.core.runtime.storage.returnType -import jp.co.soramitsu.shared_utils.runtime.RuntimeSnapshot -import jp.co.soramitsu.shared_utils.runtime.definitions.types.composite.Struct -import jp.co.soramitsu.shared_utils.runtime.definitions.types.fromHexOrNull -import jp.co.soramitsu.shared_utils.runtime.metadata.storage +import jp.co.soramitsu.fearless_utils.runtime.RuntimeSnapshot +import jp.co.soramitsu.fearless_utils.runtime.definitions.types.composite.Struct +import jp.co.soramitsu.fearless_utils.runtime.definitions.types.fromHexOrNull +import jp.co.soramitsu.fearless_utils.runtime.metadata.storage import jp.co.soramitsu.staking.api.domain.model.EraIndex import jp.co.soramitsu.staking.api.domain.model.StakingLedger import jp.co.soramitsu.staking.api.domain.model.UnlockChunk diff --git a/feature-staking-impl/src/main/java/jp/co/soramitsu/staking/impl/data/network/blockhain/bindings/StakingMinMaxStorages.kt b/feature-staking-impl/src/main/java/jp/co/soramitsu/staking/impl/data/network/blockhain/bindings/StakingMinMaxStorages.kt index ba9b4584ff..b1636ad32e 100644 --- a/feature-staking-impl/src/main/java/jp/co/soramitsu/staking/impl/data/network/blockhain/bindings/StakingMinMaxStorages.kt +++ b/feature-staking-impl/src/main/java/jp/co/soramitsu/staking/impl/data/network/blockhain/bindings/StakingMinMaxStorages.kt @@ -8,15 +8,15 @@ import jp.co.soramitsu.common.data.network.runtime.binding.storageReturnType import jp.co.soramitsu.common.utils.orZero import jp.co.soramitsu.common.utils.parachainStaking import jp.co.soramitsu.core.runtime.storage.returnType -import jp.co.soramitsu.shared_utils.extensions.fromHex -import jp.co.soramitsu.shared_utils.extensions.fromUnsignedBytes -import jp.co.soramitsu.shared_utils.runtime.AccountId -import jp.co.soramitsu.shared_utils.runtime.RuntimeSnapshot -import jp.co.soramitsu.shared_utils.runtime.definitions.types.Type -import jp.co.soramitsu.shared_utils.runtime.definitions.types.composite.DictEnum -import jp.co.soramitsu.shared_utils.runtime.definitions.types.composite.Struct -import jp.co.soramitsu.shared_utils.runtime.definitions.types.fromHexOrNull -import jp.co.soramitsu.shared_utils.runtime.metadata.storage +import jp.co.soramitsu.fearless_utils.extensions.fromHex +import jp.co.soramitsu.fearless_utils.extensions.fromUnsignedBytes +import jp.co.soramitsu.fearless_utils.runtime.AccountId +import jp.co.soramitsu.fearless_utils.runtime.RuntimeSnapshot +import jp.co.soramitsu.fearless_utils.runtime.definitions.types.Type +import jp.co.soramitsu.fearless_utils.runtime.definitions.types.composite.DictEnum +import jp.co.soramitsu.fearless_utils.runtime.definitions.types.composite.Struct +import jp.co.soramitsu.fearless_utils.runtime.definitions.types.fromHexOrNull +import jp.co.soramitsu.fearless_utils.runtime.metadata.storage import jp.co.soramitsu.staking.api.domain.model.DelegationAction import jp.co.soramitsu.staking.api.domain.model.DelegationScheduledRequest import jp.co.soramitsu.staking.api.domain.model.Round diff --git a/feature-staking-impl/src/main/java/jp/co/soramitsu/staking/impl/data/network/blockhain/bindings/ValidatorPrefs.kt b/feature-staking-impl/src/main/java/jp/co/soramitsu/staking/impl/data/network/blockhain/bindings/ValidatorPrefs.kt index 7eb262f05d..8784498ea0 100644 --- a/feature-staking-impl/src/main/java/jp/co/soramitsu/staking/impl/data/network/blockhain/bindings/ValidatorPrefs.kt +++ b/feature-staking-impl/src/main/java/jp/co/soramitsu/staking/impl/data/network/blockhain/bindings/ValidatorPrefs.kt @@ -4,10 +4,10 @@ import jp.co.soramitsu.common.data.network.runtime.binding.HelperBinding import jp.co.soramitsu.common.data.network.runtime.binding.UseCaseBinding import jp.co.soramitsu.common.data.network.runtime.binding.getTyped import jp.co.soramitsu.common.data.network.runtime.binding.incompatible -import jp.co.soramitsu.shared_utils.runtime.RuntimeSnapshot -import jp.co.soramitsu.shared_utils.runtime.definitions.types.Type -import jp.co.soramitsu.shared_utils.runtime.definitions.types.composite.Struct -import jp.co.soramitsu.shared_utils.runtime.definitions.types.fromHexOrNull +import jp.co.soramitsu.fearless_utils.runtime.RuntimeSnapshot +import jp.co.soramitsu.fearless_utils.runtime.definitions.types.Type +import jp.co.soramitsu.fearless_utils.runtime.definitions.types.composite.Struct +import jp.co.soramitsu.fearless_utils.runtime.definitions.types.fromHexOrNull import jp.co.soramitsu.staking.api.domain.model.ValidatorPrefs import java.math.BigDecimal import java.math.BigInteger diff --git a/feature-staking-impl/src/main/java/jp/co/soramitsu/staking/impl/data/network/blockhain/calls/ExtrinsicBuilderExt.kt b/feature-staking-impl/src/main/java/jp/co/soramitsu/staking/impl/data/network/blockhain/calls/ExtrinsicBuilderExt.kt index 5481dca008..a49de9f3f4 100644 --- a/feature-staking-impl/src/main/java/jp/co/soramitsu/staking/impl/data/network/blockhain/calls/ExtrinsicBuilderExt.kt +++ b/feature-staking-impl/src/main/java/jp/co/soramitsu/staking/impl/data/network/blockhain/calls/ExtrinsicBuilderExt.kt @@ -3,10 +3,10 @@ package jp.co.soramitsu.staking.impl.data.network.blockhain.calls import java.math.BigInteger import jp.co.soramitsu.core.models.MultiAddress import jp.co.soramitsu.core.models.bindMultiAddress -import jp.co.soramitsu.shared_utils.extensions.fromHex -import jp.co.soramitsu.shared_utils.runtime.AccountId -import jp.co.soramitsu.shared_utils.runtime.definitions.types.composite.DictEnum -import jp.co.soramitsu.shared_utils.runtime.extrinsic.ExtrinsicBuilder +import jp.co.soramitsu.fearless_utils.extensions.fromHex +import jp.co.soramitsu.fearless_utils.runtime.AccountId +import jp.co.soramitsu.fearless_utils.runtime.definitions.types.composite.DictEnum +import jp.co.soramitsu.fearless_utils.runtime.extrinsic.ExtrinsicBuilder import jp.co.soramitsu.staking.api.domain.model.RewardDestination import jp.co.soramitsu.staking.impl.data.network.blockhain.bindings.bindRewardDestination diff --git a/feature-staking-impl/src/main/java/jp/co/soramitsu/staking/impl/data/network/blockhain/updaters/AccountNominationsUpdater.kt b/feature-staking-impl/src/main/java/jp/co/soramitsu/staking/impl/data/network/blockhain/updaters/AccountNominationsUpdater.kt index afee888899..32f5252f3f 100644 --- a/feature-staking-impl/src/main/java/jp/co/soramitsu/staking/impl/data/network/blockhain/updaters/AccountNominationsUpdater.kt +++ b/feature-staking-impl/src/main/java/jp/co/soramitsu/staking/impl/data/network/blockhain/updaters/AccountNominationsUpdater.kt @@ -4,9 +4,9 @@ import jp.co.soramitsu.common.utils.staking import jp.co.soramitsu.core.storage.StorageCache import jp.co.soramitsu.runtime.multiNetwork.ChainRegistry import jp.co.soramitsu.runtime.network.updaters.SingleStorageKeyUpdater -import jp.co.soramitsu.shared_utils.runtime.RuntimeSnapshot -import jp.co.soramitsu.shared_utils.runtime.metadata.storage -import jp.co.soramitsu.shared_utils.runtime.metadata.storageKey +import jp.co.soramitsu.fearless_utils.runtime.RuntimeSnapshot +import jp.co.soramitsu.fearless_utils.runtime.metadata.storage +import jp.co.soramitsu.fearless_utils.runtime.metadata.storageKey import jp.co.soramitsu.staking.api.data.StakingSharedState import jp.co.soramitsu.staking.impl.data.network.blockhain.updaters.base.StakingUpdater import jp.co.soramitsu.staking.impl.data.network.blockhain.updaters.scope.AccountStakingScope diff --git a/feature-staking-impl/src/main/java/jp/co/soramitsu/staking/impl/data/network/blockhain/updaters/AccountRewardDestinationUpdater.kt b/feature-staking-impl/src/main/java/jp/co/soramitsu/staking/impl/data/network/blockhain/updaters/AccountRewardDestinationUpdater.kt index 29abf4af51..4bf5a2c402 100644 --- a/feature-staking-impl/src/main/java/jp/co/soramitsu/staking/impl/data/network/blockhain/updaters/AccountRewardDestinationUpdater.kt +++ b/feature-staking-impl/src/main/java/jp/co/soramitsu/staking/impl/data/network/blockhain/updaters/AccountRewardDestinationUpdater.kt @@ -4,9 +4,9 @@ import jp.co.soramitsu.common.utils.staking import jp.co.soramitsu.core.storage.StorageCache import jp.co.soramitsu.runtime.multiNetwork.ChainRegistry import jp.co.soramitsu.runtime.network.updaters.SingleStorageKeyUpdater -import jp.co.soramitsu.shared_utils.runtime.RuntimeSnapshot -import jp.co.soramitsu.shared_utils.runtime.metadata.storage -import jp.co.soramitsu.shared_utils.runtime.metadata.storageKey +import jp.co.soramitsu.fearless_utils.runtime.RuntimeSnapshot +import jp.co.soramitsu.fearless_utils.runtime.metadata.storage +import jp.co.soramitsu.fearless_utils.runtime.metadata.storageKey import jp.co.soramitsu.staking.api.data.StakingSharedState import jp.co.soramitsu.staking.impl.data.network.blockhain.updaters.base.StakingUpdater import jp.co.soramitsu.staking.impl.data.network.blockhain.updaters.scope.AccountStakingScope diff --git a/feature-staking-impl/src/main/java/jp/co/soramitsu/staking/impl/data/network/blockhain/updaters/ActiveEraUpdater.kt b/feature-staking-impl/src/main/java/jp/co/soramitsu/staking/impl/data/network/blockhain/updaters/ActiveEraUpdater.kt index d945d4014f..153a1292f3 100644 --- a/feature-staking-impl/src/main/java/jp/co/soramitsu/staking/impl/data/network/blockhain/updaters/ActiveEraUpdater.kt +++ b/feature-staking-impl/src/main/java/jp/co/soramitsu/staking/impl/data/network/blockhain/updaters/ActiveEraUpdater.kt @@ -5,9 +5,9 @@ import jp.co.soramitsu.core.storage.StorageCache import jp.co.soramitsu.core.updater.GlobalUpdaterScope import jp.co.soramitsu.runtime.multiNetwork.ChainRegistry import jp.co.soramitsu.runtime.network.updaters.SingleStorageKeyUpdater -import jp.co.soramitsu.shared_utils.runtime.RuntimeSnapshot -import jp.co.soramitsu.shared_utils.runtime.metadata.storage -import jp.co.soramitsu.shared_utils.runtime.metadata.storageKey +import jp.co.soramitsu.fearless_utils.runtime.RuntimeSnapshot +import jp.co.soramitsu.fearless_utils.runtime.metadata.storage +import jp.co.soramitsu.fearless_utils.runtime.metadata.storageKey import jp.co.soramitsu.staking.api.data.StakingSharedState import jp.co.soramitsu.staking.impl.data.network.blockhain.updaters.base.StakingUpdater diff --git a/feature-staking-impl/src/main/java/jp/co/soramitsu/staking/impl/data/network/blockhain/updaters/Common.kt b/feature-staking-impl/src/main/java/jp/co/soramitsu/staking/impl/data/network/blockhain/updaters/Common.kt index f90ac1c82d..96d164921c 100644 --- a/feature-staking-impl/src/main/java/jp/co/soramitsu/staking/impl/data/network/blockhain/updaters/Common.kt +++ b/feature-staking-impl/src/main/java/jp/co/soramitsu/staking/impl/data/network/blockhain/updaters/Common.kt @@ -5,12 +5,12 @@ import jp.co.soramitsu.common.utils.Modules import jp.co.soramitsu.common.utils.parachainStaking import jp.co.soramitsu.core.model.StorageEntry import jp.co.soramitsu.core.storage.StorageCache -import jp.co.soramitsu.shared_utils.runtime.RuntimeSnapshot -import jp.co.soramitsu.shared_utils.runtime.metadata.RuntimeMetadata -import jp.co.soramitsu.shared_utils.runtime.metadata.moduleOrNull -import jp.co.soramitsu.shared_utils.runtime.metadata.storage -import jp.co.soramitsu.shared_utils.runtime.metadata.storageKey -import jp.co.soramitsu.shared_utils.wsrpc.SocketService +import jp.co.soramitsu.fearless_utils.runtime.RuntimeSnapshot +import jp.co.soramitsu.fearless_utils.runtime.metadata.RuntimeMetadata +import jp.co.soramitsu.fearless_utils.runtime.metadata.moduleOrNull +import jp.co.soramitsu.fearless_utils.runtime.metadata.storage +import jp.co.soramitsu.fearless_utils.runtime.metadata.storageKey +import jp.co.soramitsu.fearless_utils.wsrpc.SocketService import jp.co.soramitsu.staking.impl.data.network.blockhain.bindings.bindActiveEra import java.math.BigInteger import kotlinx.coroutines.flow.Flow diff --git a/feature-staking-impl/src/main/java/jp/co/soramitsu/staking/impl/data/network/blockhain/updaters/CounterForNominatorsUpdater.kt b/feature-staking-impl/src/main/java/jp/co/soramitsu/staking/impl/data/network/blockhain/updaters/CounterForNominatorsUpdater.kt index 6d1b879100..2ac3229415 100644 --- a/feature-staking-impl/src/main/java/jp/co/soramitsu/staking/impl/data/network/blockhain/updaters/CounterForNominatorsUpdater.kt +++ b/feature-staking-impl/src/main/java/jp/co/soramitsu/staking/impl/data/network/blockhain/updaters/CounterForNominatorsUpdater.kt @@ -5,10 +5,10 @@ import jp.co.soramitsu.core.storage.StorageCache import jp.co.soramitsu.core.updater.GlobalUpdaterScope import jp.co.soramitsu.runtime.multiNetwork.ChainRegistry import jp.co.soramitsu.runtime.network.updaters.SingleStorageKeyUpdater -import jp.co.soramitsu.shared_utils.runtime.RuntimeSnapshot -import jp.co.soramitsu.shared_utils.runtime.metadata.moduleOrNull -import jp.co.soramitsu.shared_utils.runtime.metadata.storageKey -import jp.co.soramitsu.shared_utils.runtime.metadata.storageOrNull +import jp.co.soramitsu.fearless_utils.runtime.RuntimeSnapshot +import jp.co.soramitsu.fearless_utils.runtime.metadata.moduleOrNull +import jp.co.soramitsu.fearless_utils.runtime.metadata.storageKey +import jp.co.soramitsu.fearless_utils.runtime.metadata.storageOrNull import jp.co.soramitsu.staking.api.data.StakingSharedState import jp.co.soramitsu.staking.impl.data.network.blockhain.updaters.base.StakingUpdater diff --git a/feature-staking-impl/src/main/java/jp/co/soramitsu/staking/impl/data/network/blockhain/updaters/CurrentEraUpdater.kt b/feature-staking-impl/src/main/java/jp/co/soramitsu/staking/impl/data/network/blockhain/updaters/CurrentEraUpdater.kt index 3b86f27de5..a0a4fa4c01 100644 --- a/feature-staking-impl/src/main/java/jp/co/soramitsu/staking/impl/data/network/blockhain/updaters/CurrentEraUpdater.kt +++ b/feature-staking-impl/src/main/java/jp/co/soramitsu/staking/impl/data/network/blockhain/updaters/CurrentEraUpdater.kt @@ -5,9 +5,9 @@ import jp.co.soramitsu.core.storage.StorageCache import jp.co.soramitsu.core.updater.GlobalUpdaterScope import jp.co.soramitsu.runtime.multiNetwork.ChainRegistry import jp.co.soramitsu.runtime.network.updaters.SingleStorageKeyUpdater -import jp.co.soramitsu.shared_utils.runtime.RuntimeSnapshot -import jp.co.soramitsu.shared_utils.runtime.metadata.storage -import jp.co.soramitsu.shared_utils.runtime.metadata.storageKey +import jp.co.soramitsu.fearless_utils.runtime.RuntimeSnapshot +import jp.co.soramitsu.fearless_utils.runtime.metadata.storage +import jp.co.soramitsu.fearless_utils.runtime.metadata.storageKey import jp.co.soramitsu.staking.api.data.StakingSharedState import jp.co.soramitsu.staking.impl.data.network.blockhain.updaters.base.StakingUpdater diff --git a/feature-staking-impl/src/main/java/jp/co/soramitsu/staking/impl/data/network/blockhain/updaters/DelegatorStateUpdater.kt b/feature-staking-impl/src/main/java/jp/co/soramitsu/staking/impl/data/network/blockhain/updaters/DelegatorStateUpdater.kt index 0515fbfac6..b7d15e92c0 100644 --- a/feature-staking-impl/src/main/java/jp/co/soramitsu/staking/impl/data/network/blockhain/updaters/DelegatorStateUpdater.kt +++ b/feature-staking-impl/src/main/java/jp/co/soramitsu/staking/impl/data/network/blockhain/updaters/DelegatorStateUpdater.kt @@ -6,9 +6,9 @@ import jp.co.soramitsu.core.updater.SubscriptionBuilder import jp.co.soramitsu.core.updater.Updater import jp.co.soramitsu.runtime.multiNetwork.ChainRegistry import jp.co.soramitsu.runtime.network.updaters.SingleStorageKeyUpdater -import jp.co.soramitsu.shared_utils.runtime.RuntimeSnapshot -import jp.co.soramitsu.shared_utils.runtime.metadata.storage -import jp.co.soramitsu.shared_utils.runtime.metadata.storageKey +import jp.co.soramitsu.fearless_utils.runtime.RuntimeSnapshot +import jp.co.soramitsu.fearless_utils.runtime.metadata.storage +import jp.co.soramitsu.fearless_utils.runtime.metadata.storageKey import jp.co.soramitsu.staking.api.data.StakingSharedState import jp.co.soramitsu.staking.impl.data.network.blockhain.updaters.base.ParachainStakingUpdater import jp.co.soramitsu.staking.impl.data.network.blockhain.updaters.scope.AccountStakingScope diff --git a/feature-staking-impl/src/main/java/jp/co/soramitsu/staking/impl/data/network/blockhain/updaters/HistoryDepthUpdater.kt b/feature-staking-impl/src/main/java/jp/co/soramitsu/staking/impl/data/network/blockhain/updaters/HistoryDepthUpdater.kt index a42b3fbec6..3d150aab87 100644 --- a/feature-staking-impl/src/main/java/jp/co/soramitsu/staking/impl/data/network/blockhain/updaters/HistoryDepthUpdater.kt +++ b/feature-staking-impl/src/main/java/jp/co/soramitsu/staking/impl/data/network/blockhain/updaters/HistoryDepthUpdater.kt @@ -6,9 +6,9 @@ import jp.co.soramitsu.core.storage.StorageCache import jp.co.soramitsu.core.updater.GlobalUpdaterScope import jp.co.soramitsu.runtime.multiNetwork.ChainRegistry import jp.co.soramitsu.runtime.network.updaters.SingleStorageKeyUpdater -import jp.co.soramitsu.shared_utils.runtime.RuntimeSnapshot -import jp.co.soramitsu.shared_utils.runtime.metadata.storage -import jp.co.soramitsu.shared_utils.runtime.metadata.storageKey +import jp.co.soramitsu.fearless_utils.runtime.RuntimeSnapshot +import jp.co.soramitsu.fearless_utils.runtime.metadata.storage +import jp.co.soramitsu.fearless_utils.runtime.metadata.storageKey import jp.co.soramitsu.staking.api.data.StakingSharedState import jp.co.soramitsu.staking.impl.data.network.blockhain.updaters.base.StakingUpdater diff --git a/feature-staking-impl/src/main/java/jp/co/soramitsu/staking/impl/data/network/blockhain/updaters/MaxNominatorsUpdater.kt b/feature-staking-impl/src/main/java/jp/co/soramitsu/staking/impl/data/network/blockhain/updaters/MaxNominatorsUpdater.kt index f08b014f53..2afbc9a286 100644 --- a/feature-staking-impl/src/main/java/jp/co/soramitsu/staking/impl/data/network/blockhain/updaters/MaxNominatorsUpdater.kt +++ b/feature-staking-impl/src/main/java/jp/co/soramitsu/staking/impl/data/network/blockhain/updaters/MaxNominatorsUpdater.kt @@ -6,9 +6,9 @@ import jp.co.soramitsu.core.storage.StorageCache import jp.co.soramitsu.core.updater.GlobalUpdaterScope import jp.co.soramitsu.runtime.multiNetwork.ChainRegistry import jp.co.soramitsu.runtime.network.updaters.SingleStorageKeyUpdater -import jp.co.soramitsu.shared_utils.runtime.RuntimeSnapshot -import jp.co.soramitsu.shared_utils.runtime.metadata.storageKey -import jp.co.soramitsu.shared_utils.runtime.metadata.storageOrNull +import jp.co.soramitsu.fearless_utils.runtime.RuntimeSnapshot +import jp.co.soramitsu.fearless_utils.runtime.metadata.storageKey +import jp.co.soramitsu.fearless_utils.runtime.metadata.storageOrNull import jp.co.soramitsu.staking.api.data.StakingSharedState import jp.co.soramitsu.staking.impl.data.network.blockhain.updaters.base.StakingUpdater diff --git a/feature-staking-impl/src/main/java/jp/co/soramitsu/staking/impl/data/network/blockhain/updaters/MinBondUpdater.kt b/feature-staking-impl/src/main/java/jp/co/soramitsu/staking/impl/data/network/blockhain/updaters/MinBondUpdater.kt index 8115365bc6..e2d5848e3a 100644 --- a/feature-staking-impl/src/main/java/jp/co/soramitsu/staking/impl/data/network/blockhain/updaters/MinBondUpdater.kt +++ b/feature-staking-impl/src/main/java/jp/co/soramitsu/staking/impl/data/network/blockhain/updaters/MinBondUpdater.kt @@ -5,9 +5,9 @@ import jp.co.soramitsu.core.storage.StorageCache import jp.co.soramitsu.core.updater.GlobalUpdaterScope import jp.co.soramitsu.runtime.multiNetwork.ChainRegistry import jp.co.soramitsu.runtime.network.updaters.SingleStorageKeyUpdater -import jp.co.soramitsu.shared_utils.runtime.RuntimeSnapshot -import jp.co.soramitsu.shared_utils.runtime.metadata.storageKey -import jp.co.soramitsu.shared_utils.runtime.metadata.storageOrNull +import jp.co.soramitsu.fearless_utils.runtime.RuntimeSnapshot +import jp.co.soramitsu.fearless_utils.runtime.metadata.storageKey +import jp.co.soramitsu.fearless_utils.runtime.metadata.storageOrNull import jp.co.soramitsu.staking.api.data.StakingSharedState import jp.co.soramitsu.staking.impl.data.network.blockhain.updaters.base.StakingUpdater diff --git a/feature-staking-impl/src/main/java/jp/co/soramitsu/staking/impl/data/network/blockhain/updaters/NominatorsUpdater.kt b/feature-staking-impl/src/main/java/jp/co/soramitsu/staking/impl/data/network/blockhain/updaters/NominatorsUpdater.kt index c30332b2f4..cb3305c58e 100644 --- a/feature-staking-impl/src/main/java/jp/co/soramitsu/staking/impl/data/network/blockhain/updaters/NominatorsUpdater.kt +++ b/feature-staking-impl/src/main/java/jp/co/soramitsu/staking/impl/data/network/blockhain/updaters/NominatorsUpdater.kt @@ -11,11 +11,11 @@ import jp.co.soramitsu.core.updater.GlobalScopeUpdater import jp.co.soramitsu.core.updater.SubscriptionBuilder import jp.co.soramitsu.core.updater.Updater import jp.co.soramitsu.runtime.multiNetwork.ChainRegistry -import jp.co.soramitsu.shared_utils.runtime.RuntimeSnapshot -import jp.co.soramitsu.shared_utils.runtime.metadata.storage -import jp.co.soramitsu.shared_utils.runtime.metadata.storageKey -import jp.co.soramitsu.shared_utils.runtime.metadata.storageOrNull -import jp.co.soramitsu.shared_utils.wsrpc.SocketService +import jp.co.soramitsu.fearless_utils.runtime.RuntimeSnapshot +import jp.co.soramitsu.fearless_utils.runtime.metadata.storage +import jp.co.soramitsu.fearless_utils.runtime.metadata.storageKey +import jp.co.soramitsu.fearless_utils.runtime.metadata.storageOrNull +import jp.co.soramitsu.fearless_utils.wsrpc.SocketService import jp.co.soramitsu.staking.api.data.StakingSharedState import jp.co.soramitsu.staking.impl.data.network.blockhain.updaters.base.StakingUpdater import kotlinx.coroutines.Dispatchers diff --git a/feature-staking-impl/src/main/java/jp/co/soramitsu/staking/impl/data/network/blockhain/updaters/StakingLedgerUpdater.kt b/feature-staking-impl/src/main/java/jp/co/soramitsu/staking/impl/data/network/blockhain/updaters/StakingLedgerUpdater.kt index 76167379da..53abd51f97 100644 --- a/feature-staking-impl/src/main/java/jp/co/soramitsu/staking/impl/data/network/blockhain/updaters/StakingLedgerUpdater.kt +++ b/feature-staking-impl/src/main/java/jp/co/soramitsu/staking/impl/data/network/blockhain/updaters/StakingLedgerUpdater.kt @@ -12,14 +12,14 @@ import jp.co.soramitsu.coredb.dao.AccountStakingDao import jp.co.soramitsu.coredb.model.AccountStakingLocal import jp.co.soramitsu.runtime.multiNetwork.ChainRegistry import jp.co.soramitsu.runtime.network.updaters.insert -import jp.co.soramitsu.shared_utils.runtime.AccountId -import jp.co.soramitsu.shared_utils.runtime.RuntimeSnapshot -import jp.co.soramitsu.shared_utils.runtime.metadata.storage -import jp.co.soramitsu.shared_utils.runtime.metadata.storageKey -import jp.co.soramitsu.shared_utils.wsrpc.SocketService -import jp.co.soramitsu.shared_utils.wsrpc.request.runtime.storage.SubscribeStorageRequest -import jp.co.soramitsu.shared_utils.wsrpc.request.runtime.storage.storageChange -import jp.co.soramitsu.shared_utils.wsrpc.subscriptionFlow +import jp.co.soramitsu.fearless_utils.runtime.AccountId +import jp.co.soramitsu.fearless_utils.runtime.RuntimeSnapshot +import jp.co.soramitsu.fearless_utils.runtime.metadata.storage +import jp.co.soramitsu.fearless_utils.runtime.metadata.storageKey +import jp.co.soramitsu.fearless_utils.wsrpc.SocketService +import jp.co.soramitsu.fearless_utils.wsrpc.request.runtime.storage.SubscribeStorageRequest +import jp.co.soramitsu.fearless_utils.wsrpc.request.runtime.storage.storageChange +import jp.co.soramitsu.fearless_utils.wsrpc.subscriptionFlow import jp.co.soramitsu.staking.api.data.StakingSharedState import jp.co.soramitsu.staking.api.domain.model.StakingLedger import jp.co.soramitsu.staking.api.domain.model.isRedeemableIn diff --git a/feature-staking-impl/src/main/java/jp/co/soramitsu/staking/impl/data/network/blockhain/updaters/TotalIssuanceUpdater.kt b/feature-staking-impl/src/main/java/jp/co/soramitsu/staking/impl/data/network/blockhain/updaters/TotalIssuanceUpdater.kt index 44afc71c56..2c0e8c990a 100644 --- a/feature-staking-impl/src/main/java/jp/co/soramitsu/staking/impl/data/network/blockhain/updaters/TotalIssuanceUpdater.kt +++ b/feature-staking-impl/src/main/java/jp/co/soramitsu/staking/impl/data/network/blockhain/updaters/TotalIssuanceUpdater.kt @@ -6,9 +6,9 @@ import jp.co.soramitsu.core.storage.StorageCache import jp.co.soramitsu.core.updater.GlobalUpdaterScope import jp.co.soramitsu.runtime.multiNetwork.ChainRegistry import jp.co.soramitsu.runtime.network.updaters.SingleStorageKeyUpdater -import jp.co.soramitsu.shared_utils.runtime.RuntimeSnapshot -import jp.co.soramitsu.shared_utils.runtime.metadata.storage -import jp.co.soramitsu.shared_utils.runtime.metadata.storageKey +import jp.co.soramitsu.fearless_utils.runtime.RuntimeSnapshot +import jp.co.soramitsu.fearless_utils.runtime.metadata.storage +import jp.co.soramitsu.fearless_utils.runtime.metadata.storageKey import jp.co.soramitsu.staking.api.data.StakingSharedState class TotalIssuanceUpdater( diff --git a/feature-staking-impl/src/main/java/jp/co/soramitsu/staking/impl/data/network/blockhain/updaters/ValidatorExposureUpdater.kt b/feature-staking-impl/src/main/java/jp/co/soramitsu/staking/impl/data/network/blockhain/updaters/ValidatorExposureUpdater.kt index fd85ca0ef2..cd11e029d1 100644 --- a/feature-staking-impl/src/main/java/jp/co/soramitsu/staking/impl/data/network/blockhain/updaters/ValidatorExposureUpdater.kt +++ b/feature-staking-impl/src/main/java/jp/co/soramitsu/staking/impl/data/network/blockhain/updaters/ValidatorExposureUpdater.kt @@ -7,10 +7,10 @@ import jp.co.soramitsu.core.updater.GlobalScopeUpdater import jp.co.soramitsu.core.updater.SubscriptionBuilder import jp.co.soramitsu.core.updater.Updater import jp.co.soramitsu.runtime.multiNetwork.ChainRegistry -import jp.co.soramitsu.shared_utils.runtime.RuntimeSnapshot -import jp.co.soramitsu.shared_utils.runtime.metadata.storage -import jp.co.soramitsu.shared_utils.runtime.metadata.storageKey -import jp.co.soramitsu.shared_utils.wsrpc.SocketService +import jp.co.soramitsu.fearless_utils.runtime.RuntimeSnapshot +import jp.co.soramitsu.fearless_utils.runtime.metadata.storage +import jp.co.soramitsu.fearless_utils.runtime.metadata.storageKey +import jp.co.soramitsu.fearless_utils.wsrpc.SocketService import jp.co.soramitsu.staking.api.data.StakingSharedState import jp.co.soramitsu.staking.impl.data.network.blockhain.updaters.base.StakingUpdater import kotlinx.coroutines.Dispatchers @@ -22,7 +22,7 @@ import kotlinx.coroutines.flow.onEach import java.math.BigInteger import jp.co.soramitsu.common.utils.stakingOrNull import jp.co.soramitsu.core.model.StorageEntry -import jp.co.soramitsu.shared_utils.runtime.metadata.storageOrNull +import jp.co.soramitsu.fearless_utils.runtime.metadata.storageOrNull class ValidatorExposureUpdater( private val bulkRetriever: BulkRetriever, diff --git a/feature-staking-impl/src/main/java/jp/co/soramitsu/staking/impl/data/network/blockhain/updaters/ValidatorPrefsUpdater.kt b/feature-staking-impl/src/main/java/jp/co/soramitsu/staking/impl/data/network/blockhain/updaters/ValidatorPrefsUpdater.kt index 2cd4dcdd9d..646d02917c 100644 --- a/feature-staking-impl/src/main/java/jp/co/soramitsu/staking/impl/data/network/blockhain/updaters/ValidatorPrefsUpdater.kt +++ b/feature-staking-impl/src/main/java/jp/co/soramitsu/staking/impl/data/network/blockhain/updaters/ValidatorPrefsUpdater.kt @@ -8,8 +8,8 @@ import jp.co.soramitsu.core.updater.GlobalScopeUpdater import jp.co.soramitsu.core.updater.SubscriptionBuilder import jp.co.soramitsu.core.updater.Updater import jp.co.soramitsu.runtime.multiNetwork.ChainRegistry -import jp.co.soramitsu.shared_utils.runtime.metadata.storage -import jp.co.soramitsu.shared_utils.runtime.metadata.storageKey +import jp.co.soramitsu.fearless_utils.runtime.metadata.storage +import jp.co.soramitsu.fearless_utils.runtime.metadata.storageKey import jp.co.soramitsu.staking.api.data.StakingSharedState import jp.co.soramitsu.staking.impl.data.network.blockhain.updaters.base.StakingUpdater import kotlinx.coroutines.Dispatchers diff --git a/feature-staking-impl/src/main/java/jp/co/soramitsu/staking/impl/data/network/blockhain/updaters/controller/AccountControllerBalanceUpdater.kt b/feature-staking-impl/src/main/java/jp/co/soramitsu/staking/impl/data/network/blockhain/updaters/controller/AccountControllerBalanceUpdater.kt index 7a00b7ab77..3d2750172c 100644 --- a/feature-staking-impl/src/main/java/jp/co/soramitsu/staking/impl/data/network/blockhain/updaters/controller/AccountControllerBalanceUpdater.kt +++ b/feature-staking-impl/src/main/java/jp/co/soramitsu/staking/impl/data/network/blockhain/updaters/controller/AccountControllerBalanceUpdater.kt @@ -5,8 +5,8 @@ import jp.co.soramitsu.common.utils.system import jp.co.soramitsu.core.updater.SubscriptionBuilder import jp.co.soramitsu.core.updater.Updater import jp.co.soramitsu.runtime.multiNetwork.ChainRegistry -import jp.co.soramitsu.shared_utils.runtime.metadata.storage -import jp.co.soramitsu.shared_utils.runtime.metadata.storageKey +import jp.co.soramitsu.fearless_utils.runtime.metadata.storage +import jp.co.soramitsu.fearless_utils.runtime.metadata.storageKey import jp.co.soramitsu.staking.api.data.StakingSharedState import jp.co.soramitsu.staking.impl.data.network.blockhain.updaters.scope.AccountStakingScope import jp.co.soramitsu.wallet.api.data.cache.AssetCache diff --git a/feature-staking-impl/src/main/java/jp/co/soramitsu/staking/impl/data/network/blockhain/updaters/historical/HistoricalTotalValidatorRewardUpdater.kt b/feature-staking-impl/src/main/java/jp/co/soramitsu/staking/impl/data/network/blockhain/updaters/historical/HistoricalTotalValidatorRewardUpdater.kt index cdcbbc6234..feccaea82c 100644 --- a/feature-staking-impl/src/main/java/jp/co/soramitsu/staking/impl/data/network/blockhain/updaters/historical/HistoricalTotalValidatorRewardUpdater.kt +++ b/feature-staking-impl/src/main/java/jp/co/soramitsu/staking/impl/data/network/blockhain/updaters/historical/HistoricalTotalValidatorRewardUpdater.kt @@ -1,9 +1,9 @@ package jp.co.soramitsu.staking.impl.data.network.blockhain.updaters.historical import jp.co.soramitsu.common.utils.staking -import jp.co.soramitsu.shared_utils.runtime.RuntimeSnapshot -import jp.co.soramitsu.shared_utils.runtime.metadata.storage -import jp.co.soramitsu.shared_utils.runtime.metadata.storageKey +import jp.co.soramitsu.fearless_utils.runtime.RuntimeSnapshot +import jp.co.soramitsu.fearless_utils.runtime.metadata.storage +import jp.co.soramitsu.fearless_utils.runtime.metadata.storageKey import java.math.BigInteger class HistoricalTotalValidatorRewardUpdater : HistoricalUpdater { diff --git a/feature-staking-impl/src/main/java/jp/co/soramitsu/staking/impl/data/network/blockhain/updaters/historical/HistoricalUpdateMediator.kt b/feature-staking-impl/src/main/java/jp/co/soramitsu/staking/impl/data/network/blockhain/updaters/historical/HistoricalUpdateMediator.kt index 1fa6a0e293..d8792dfc88 100644 --- a/feature-staking-impl/src/main/java/jp/co/soramitsu/staking/impl/data/network/blockhain/updaters/historical/HistoricalUpdateMediator.kt +++ b/feature-staking-impl/src/main/java/jp/co/soramitsu/staking/impl/data/network/blockhain/updaters/historical/HistoricalUpdateMediator.kt @@ -8,7 +8,7 @@ import jp.co.soramitsu.core.updater.SubscriptionBuilder import jp.co.soramitsu.core.updater.Updater import jp.co.soramitsu.runtime.multiNetwork.ChainRegistry import jp.co.soramitsu.runtime.multiNetwork.chain.model.ChainId -import jp.co.soramitsu.shared_utils.runtime.RuntimeSnapshot +import jp.co.soramitsu.fearless_utils.runtime.RuntimeSnapshot import jp.co.soramitsu.staking.api.data.StakingSharedState import jp.co.soramitsu.staking.impl.data.network.blockhain.updaters.fetchValuesToCache import jp.co.soramitsu.staking.impl.data.network.blockhain.updaters.observeActiveEraIndex diff --git a/feature-staking-impl/src/main/java/jp/co/soramitsu/staking/impl/data/network/blockhain/updaters/historical/HistoricalValidatorRewardPointsUpdater.kt b/feature-staking-impl/src/main/java/jp/co/soramitsu/staking/impl/data/network/blockhain/updaters/historical/HistoricalValidatorRewardPointsUpdater.kt index 134118869e..33a73419d3 100644 --- a/feature-staking-impl/src/main/java/jp/co/soramitsu/staking/impl/data/network/blockhain/updaters/historical/HistoricalValidatorRewardPointsUpdater.kt +++ b/feature-staking-impl/src/main/java/jp/co/soramitsu/staking/impl/data/network/blockhain/updaters/historical/HistoricalValidatorRewardPointsUpdater.kt @@ -1,9 +1,9 @@ package jp.co.soramitsu.staking.impl.data.network.blockhain.updaters.historical import jp.co.soramitsu.common.utils.staking -import jp.co.soramitsu.shared_utils.runtime.RuntimeSnapshot -import jp.co.soramitsu.shared_utils.runtime.metadata.storage -import jp.co.soramitsu.shared_utils.runtime.metadata.storageKey +import jp.co.soramitsu.fearless_utils.runtime.RuntimeSnapshot +import jp.co.soramitsu.fearless_utils.runtime.metadata.storage +import jp.co.soramitsu.fearless_utils.runtime.metadata.storageKey import java.math.BigInteger class HistoricalValidatorRewardPointsUpdater : HistoricalUpdater { diff --git a/feature-staking-impl/src/main/java/jp/co/soramitsu/staking/impl/data/network/subquery/request/StakingCollatorsApyRequest.kt b/feature-staking-impl/src/main/java/jp/co/soramitsu/staking/impl/data/network/subquery/request/StakingCollatorsApyRequest.kt index f518abad40..d01773aa06 100644 --- a/feature-staking-impl/src/main/java/jp/co/soramitsu/staking/impl/data/network/subquery/request/StakingCollatorsApyRequest.kt +++ b/feature-staking-impl/src/main/java/jp/co/soramitsu/staking/impl/data/network/subquery/request/StakingCollatorsApyRequest.kt @@ -1,6 +1,6 @@ package jp.co.soramitsu.staking.impl.data.network.subquery.request -import jp.co.soramitsu.shared_utils.extensions.toHexString +import jp.co.soramitsu.fearless_utils.extensions.toHexString class StakingCollatorsApyRequest(collatorIds: List, roundId: Int?) { val query = """ diff --git a/feature-staking-impl/src/main/java/jp/co/soramitsu/staking/impl/data/network/subquery/request/StakingDelegatorHistoryRequest.kt b/feature-staking-impl/src/main/java/jp/co/soramitsu/staking/impl/data/network/subquery/request/StakingDelegatorHistoryRequest.kt index 44204ab2e8..b67f385e27 100644 --- a/feature-staking-impl/src/main/java/jp/co/soramitsu/staking/impl/data/network/subquery/request/StakingDelegatorHistoryRequest.kt +++ b/feature-staking-impl/src/main/java/jp/co/soramitsu/staking/impl/data/network/subquery/request/StakingDelegatorHistoryRequest.kt @@ -1,6 +1,6 @@ package jp.co.soramitsu.staking.impl.data.network.subquery.request -import jp.co.soramitsu.shared_utils.extensions.requireHexPrefix +import jp.co.soramitsu.fearless_utils.extensions.requireHexPrefix class StakingDelegatorHistoryRequest(delegatorAddress: String, collatorAddress: String) { val query = """ diff --git a/feature-staking-impl/src/main/java/jp/co/soramitsu/staking/impl/data/network/subquery/request/SubsquidDelegatorHistoryRequest.kt b/feature-staking-impl/src/main/java/jp/co/soramitsu/staking/impl/data/network/subquery/request/SubsquidDelegatorHistoryRequest.kt index 8084809678..5aaeaedebd 100644 --- a/feature-staking-impl/src/main/java/jp/co/soramitsu/staking/impl/data/network/subquery/request/SubsquidDelegatorHistoryRequest.kt +++ b/feature-staking-impl/src/main/java/jp/co/soramitsu/staking/impl/data/network/subquery/request/SubsquidDelegatorHistoryRequest.kt @@ -1,6 +1,6 @@ package jp.co.soramitsu.staking.impl.data.network.subquery.request -import jp.co.soramitsu.shared_utils.extensions.requireHexPrefix +import jp.co.soramitsu.fearless_utils.extensions.requireHexPrefix class SubsquidDelegatorHistoryRequest(delegatorAddress: String, collatorAddress: String) { val query = """ diff --git a/feature-staking-impl/src/main/java/jp/co/soramitsu/staking/impl/data/repository/AccountMapStorageKeys.kt b/feature-staking-impl/src/main/java/jp/co/soramitsu/staking/impl/data/repository/AccountMapStorageKeys.kt index 3b76c14e9c..b337abaed2 100644 --- a/feature-staking-impl/src/main/java/jp/co/soramitsu/staking/impl/data/repository/AccountMapStorageKeys.kt +++ b/feature-staking-impl/src/main/java/jp/co/soramitsu/staking/impl/data/repository/AccountMapStorageKeys.kt @@ -1,9 +1,9 @@ package jp.co.soramitsu.staking.impl.data.repository -import jp.co.soramitsu.shared_utils.extensions.fromHex -import jp.co.soramitsu.shared_utils.runtime.RuntimeSnapshot -import jp.co.soramitsu.shared_utils.runtime.metadata.module.StorageEntry -import jp.co.soramitsu.shared_utils.runtime.metadata.storageKey +import jp.co.soramitsu.fearless_utils.extensions.fromHex +import jp.co.soramitsu.fearless_utils.runtime.RuntimeSnapshot +import jp.co.soramitsu.fearless_utils.runtime.metadata.module.StorageEntry +import jp.co.soramitsu.fearless_utils.runtime.metadata.storageKey fun StorageEntry.accountMapStorageKeys(runtime: RuntimeSnapshot, accountIdsHex: List): List { return accountIdsHex.map { storageKey(runtime, it.fromHex()) } diff --git a/feature-staking-impl/src/main/java/jp/co/soramitsu/staking/impl/data/repository/IdentityRepositoryImpl.kt b/feature-staking-impl/src/main/java/jp/co/soramitsu/staking/impl/data/repository/IdentityRepositoryImpl.kt index 06db76dd43..60cd709b9e 100644 --- a/feature-staking-impl/src/main/java/jp/co/soramitsu/staking/impl/data/repository/IdentityRepositoryImpl.kt +++ b/feature-staking-impl/src/main/java/jp/co/soramitsu/staking/impl/data/repository/IdentityRepositoryImpl.kt @@ -7,12 +7,12 @@ import jp.co.soramitsu.runtime.ext.accountFromMapKey import jp.co.soramitsu.runtime.ext.hexAccountIdOf import jp.co.soramitsu.runtime.multiNetwork.ChainRegistry import jp.co.soramitsu.runtime.multiNetwork.chain.model.Chain -import jp.co.soramitsu.shared_utils.extensions.toHexString -import jp.co.soramitsu.shared_utils.runtime.RuntimeSnapshot -import jp.co.soramitsu.shared_utils.runtime.definitions.types.Type -import jp.co.soramitsu.shared_utils.runtime.metadata.module -import jp.co.soramitsu.shared_utils.runtime.metadata.storage -import jp.co.soramitsu.shared_utils.wsrpc.SocketService +import jp.co.soramitsu.fearless_utils.extensions.toHexString +import jp.co.soramitsu.fearless_utils.runtime.RuntimeSnapshot +import jp.co.soramitsu.fearless_utils.runtime.definitions.types.Type +import jp.co.soramitsu.fearless_utils.runtime.metadata.module +import jp.co.soramitsu.fearless_utils.runtime.metadata.storage +import jp.co.soramitsu.fearless_utils.wsrpc.SocketService import jp.co.soramitsu.staking.api.domain.api.AccountAddressMap import jp.co.soramitsu.staking.api.domain.api.AccountIdMap import jp.co.soramitsu.staking.api.domain.api.IdentityRepository diff --git a/feature-staking-impl/src/main/java/jp/co/soramitsu/staking/impl/data/repository/PayoutRepository.kt b/feature-staking-impl/src/main/java/jp/co/soramitsu/staking/impl/data/repository/PayoutRepository.kt index 8c563cff49..fed50a9038 100644 --- a/feature-staking-impl/src/main/java/jp/co/soramitsu/staking/impl/data/repository/PayoutRepository.kt +++ b/feature-staking-impl/src/main/java/jp/co/soramitsu/staking/impl/data/repository/PayoutRepository.kt @@ -11,15 +11,15 @@ import jp.co.soramitsu.runtime.ext.accountIdOf import jp.co.soramitsu.runtime.multiNetwork.ChainRegistry import jp.co.soramitsu.runtime.multiNetwork.chain.model.Chain import jp.co.soramitsu.runtime.multiNetwork.chain.model.ChainId -import jp.co.soramitsu.shared_utils.extensions.fromHex -import jp.co.soramitsu.shared_utils.extensions.toHexString -import jp.co.soramitsu.shared_utils.runtime.AccountId -import jp.co.soramitsu.shared_utils.runtime.RuntimeSnapshot -import jp.co.soramitsu.shared_utils.runtime.metadata.module.StorageEntry -import jp.co.soramitsu.shared_utils.runtime.metadata.storage -import jp.co.soramitsu.shared_utils.runtime.metadata.storageKey -import jp.co.soramitsu.shared_utils.ss58.SS58Encoder.toAccountId -import jp.co.soramitsu.shared_utils.wsrpc.SocketService +import jp.co.soramitsu.fearless_utils.extensions.fromHex +import jp.co.soramitsu.fearless_utils.extensions.toHexString +import jp.co.soramitsu.fearless_utils.runtime.AccountId +import jp.co.soramitsu.fearless_utils.runtime.RuntimeSnapshot +import jp.co.soramitsu.fearless_utils.runtime.metadata.module.StorageEntry +import jp.co.soramitsu.fearless_utils.runtime.metadata.storage +import jp.co.soramitsu.fearless_utils.runtime.metadata.storageKey +import jp.co.soramitsu.fearless_utils.ss58.SS58Encoder.toAccountId +import jp.co.soramitsu.fearless_utils.wsrpc.SocketService import jp.co.soramitsu.staking.api.domain.model.LegacyExposure import jp.co.soramitsu.staking.api.domain.model.StakingLedger import jp.co.soramitsu.staking.api.domain.model.StakingState diff --git a/feature-staking-impl/src/main/java/jp/co/soramitsu/staking/impl/data/repository/StakingPoolApi.kt b/feature-staking-impl/src/main/java/jp/co/soramitsu/staking/impl/data/repository/StakingPoolApi.kt index 1c3c45eb77..364e54c3af 100644 --- a/feature-staking-impl/src/main/java/jp/co/soramitsu/staking/impl/data/repository/StakingPoolApi.kt +++ b/feature-staking-impl/src/main/java/jp/co/soramitsu/staking/impl/data/repository/StakingPoolApi.kt @@ -4,9 +4,9 @@ import jp.co.soramitsu.core.extrinsic.ExtrinsicService import jp.co.soramitsu.runtime.ext.accountIdOf import jp.co.soramitsu.runtime.ext.multiAddressOf import jp.co.soramitsu.runtime.multiNetwork.ChainRegistry -import jp.co.soramitsu.shared_utils.runtime.AccountId -import jp.co.soramitsu.shared_utils.runtime.definitions.types.composite.DictEnum -import jp.co.soramitsu.shared_utils.runtime.extrinsic.ExtrinsicBuilder +import jp.co.soramitsu.fearless_utils.runtime.AccountId +import jp.co.soramitsu.fearless_utils.runtime.definitions.types.composite.DictEnum +import jp.co.soramitsu.fearless_utils.runtime.extrinsic.ExtrinsicBuilder import jp.co.soramitsu.staking.api.data.StakingSharedState import jp.co.soramitsu.staking.impl.data.network.blockhain.calls.bondExtra import jp.co.soramitsu.staking.impl.data.network.blockhain.calls.claimPayout @@ -30,12 +30,15 @@ class StakingPoolApi( private val chainRegistry: ChainRegistry ) { suspend fun estimateJoinFee( + accountAddress: String, amountInPlanks: BigInteger, poolId: BigInteger ): BigInteger { return withContext(Dispatchers.IO) { val chain = stakingSharedState.chain() - extrinsicService.estimateFee(chain) { + val accountId = chain.accountIdOf(accountAddress) + + extrinsicService.estimateFee(chain, accountId) { joinPool(amountInPlanks, poolId) } } @@ -68,15 +71,16 @@ class StakingPoolApi( val rootMultiAddress = chain.multiAddressOf(rootAddress) val nominatorMultiAddress = chain.multiAddressOf(nominatorAddress) val stateTogglerMultiAddress = chain.multiAddressOf(stateTogglerAddress) + val root = chain.accountIdOf(rootAddress) val version = chainRegistry.getRemoteRuntimeVersion(chain.id)!! if (version < 9390) { - extrinsicService.estimateFee(chain, useBatchAll = true) { + extrinsicService.estimateFee(chain, root, useBatchAll = true) { createPoolStateToggler(amountInPlanks, rootMultiAddress, nominatorMultiAddress, stateTogglerMultiAddress) setPoolMetadata(poolId, name.encodeToByteArray()) } } else { - extrinsicService.estimateFee(chain, useBatchAll = true) { + extrinsicService.estimateFee(chain, root, useBatchAll = true) { createPoolBouncer(amountInPlanks, rootMultiAddress, nominatorMultiAddress, stateTogglerMultiAddress) setPoolMetadata(poolId, name.encodeToByteArray()) } @@ -116,13 +120,15 @@ class StakingPoolApi( } suspend fun estimateNominatePoolFee( + accountAddress: String, poolId: BigInteger, vararg validators: AccountId ): BigInteger { return withContext(Dispatchers.IO) { val chain = stakingSharedState.chain() + val accountId = chain.accountIdOf(accountAddress) - extrinsicService.estimateFee(chain) { + extrinsicService.estimateFee(chain, accountId) { nominatePool(poolId, validators.toList()) } } @@ -143,11 +149,12 @@ class StakingPoolApi( } } - suspend fun estimateClaimPayoutFee(): BigInteger { + suspend fun estimateClaimPayoutFee(accountAddress: String): BigInteger { return withContext(Dispatchers.IO) { val chain = stakingSharedState.chain() + val accountId = chain.accountIdOf(accountAddress) - extrinsicService.estimateFee(chain) { + extrinsicService.estimateFee(chain, accountId) { claimPayout() } } @@ -171,11 +178,11 @@ class StakingPoolApi( val accountId = chain.accountIdOf(accountAddress) // todo temporary fix until all runtimes will be updated try { - extrinsicService.estimateFee(chain) { + extrinsicService.estimateFee(chain, accountId) { withdrawUnbondedFromPool(multiAddress) } } catch (e: Exception) { - extrinsicService.estimateFee(chain) { + extrinsicService.estimateFee(chain, accountId) { withdrawUnbondedFromPool(accountId) } } @@ -214,11 +221,11 @@ class StakingPoolApi( val accountId = chain.accountIdOf(accountAddress) // todo temporary fix until all runtimes will be updated try { - extrinsicService.estimateFee(chain) { + extrinsicService.estimateFee(chain, accountId) { unbondFromPool(multiAddress, unbondingAmount) } } catch (e: Exception) { - extrinsicService.estimateFee(chain) { + extrinsicService.estimateFee(chain, accountId) { unbondFromPool(accountId, unbondingAmount) } } @@ -250,10 +257,12 @@ class StakingPoolApi( } } - suspend fun estimateBondExtraFee(extraAmount: BigInteger): BigInteger { + suspend fun estimateBondExtraFee(accountAddress: String, extraAmount: BigInteger): BigInteger { return withContext(Dispatchers.IO) { val chain = stakingSharedState.chain() - extrinsicService.estimateFee(chain) { + val accountId = chain.accountIdOf(accountAddress) + + extrinsicService.estimateFee(chain, accountId) { bondExtra(extraAmount) } } @@ -270,19 +279,20 @@ class StakingPoolApi( } } - suspend fun estimateEditPool(state: EditPoolFlowState): BigInteger { + suspend fun estimateEditPool(state: EditPoolFlowState, address: String): BigInteger { return withContext(Dispatchers.IO) { val poolId = state.poolId val chain = stakingSharedState.chain() + val accountId = chain.accountIdOf(address) val version = chainRegistry.getRemoteRuntimeVersion(chain.id)!! if (version < 9390) { - extrinsicService.estimateFee(chain) { + extrinsicService.estimateFee(chain, accountId) { state.newPoolName?.let { setPoolMetadata(poolId, it.encodeToByteArray()) } updateRolesStateToggler(state) } } else { - extrinsicService.estimateFee(chain) { + extrinsicService.estimateFee(chain, accountId) { state.newPoolName?.let { setPoolMetadata(poolId, it.encodeToByteArray()) } updateRolesBouncer(state) } diff --git a/feature-staking-impl/src/main/java/jp/co/soramitsu/staking/impl/data/repository/StakingPoolDataSource.kt b/feature-staking-impl/src/main/java/jp/co/soramitsu/staking/impl/data/repository/StakingPoolDataSource.kt index 18804feed2..446e39b61c 100644 --- a/feature-staking-impl/src/main/java/jp/co/soramitsu/staking/impl/data/repository/StakingPoolDataSource.kt +++ b/feature-staking-impl/src/main/java/jp/co/soramitsu/staking/impl/data/repository/StakingPoolDataSource.kt @@ -9,9 +9,9 @@ import jp.co.soramitsu.runtime.multiNetwork.ChainRegistry import jp.co.soramitsu.runtime.multiNetwork.chain.model.ChainId import jp.co.soramitsu.runtime.storage.source.StorageDataSource import jp.co.soramitsu.runtime.storage.source.queryNonNull -import jp.co.soramitsu.shared_utils.runtime.AccountId -import jp.co.soramitsu.shared_utils.runtime.metadata.storage -import jp.co.soramitsu.shared_utils.runtime.metadata.storageKey +import jp.co.soramitsu.fearless_utils.runtime.AccountId +import jp.co.soramitsu.fearless_utils.runtime.metadata.storage +import jp.co.soramitsu.fearless_utils.runtime.metadata.storageKey import jp.co.soramitsu.staking.impl.data.model.BondedPool import jp.co.soramitsu.staking.impl.data.model.PoolMember import jp.co.soramitsu.staking.impl.data.model.PoolRewards diff --git a/feature-staking-impl/src/main/java/jp/co/soramitsu/staking/impl/data/repository/StakingRepositoryImpl.kt b/feature-staking-impl/src/main/java/jp/co/soramitsu/staking/impl/data/repository/StakingRepositoryImpl.kt index 69ab7560cc..e718639d4e 100644 --- a/feature-staking-impl/src/main/java/jp/co/soramitsu/staking/impl/data/repository/StakingRepositoryImpl.kt +++ b/feature-staking-impl/src/main/java/jp/co/soramitsu/staking/impl/data/repository/StakingRepositoryImpl.kt @@ -6,9 +6,9 @@ import jp.co.soramitsu.common.utils.system import jp.co.soramitsu.runtime.multiNetwork.chain.model.ChainId import jp.co.soramitsu.runtime.storage.source.StorageDataSource import jp.co.soramitsu.runtime.storage.source.queryNonNull -import jp.co.soramitsu.shared_utils.runtime.AccountId -import jp.co.soramitsu.shared_utils.runtime.metadata.storage -import jp.co.soramitsu.shared_utils.runtime.metadata.storageKey +import jp.co.soramitsu.fearless_utils.runtime.AccountId +import jp.co.soramitsu.fearless_utils.runtime.metadata.storage +import jp.co.soramitsu.fearless_utils.runtime.metadata.storageKey import jp.co.soramitsu.staking.api.domain.api.StakingRepository import jp.co.soramitsu.staking.impl.data.network.blockhain.bindings.bindTotalIssuance import jp.co.soramitsu.wallet.api.data.cache.bindAccountInfoOrDefault diff --git a/feature-staking-impl/src/main/java/jp/co/soramitsu/staking/impl/data/repository/datasource/SubqueryStakingRewardsDataSource.kt b/feature-staking-impl/src/main/java/jp/co/soramitsu/staking/impl/data/repository/datasource/SubqueryStakingRewardsDataSource.kt index 9773c83667..f903360412 100644 --- a/feature-staking-impl/src/main/java/jp/co/soramitsu/staking/impl/data/repository/datasource/SubqueryStakingRewardsDataSource.kt +++ b/feature-staking-impl/src/main/java/jp/co/soramitsu/staking/impl/data/repository/datasource/SubqueryStakingRewardsDataSource.kt @@ -9,8 +9,8 @@ import jp.co.soramitsu.coredb.model.TotalRewardLocal import jp.co.soramitsu.runtime.multiNetwork.ChainRegistry import jp.co.soramitsu.runtime.multiNetwork.chain.model.Chain import jp.co.soramitsu.runtime.multiNetwork.chain.model.ChainId -import jp.co.soramitsu.shared_utils.extensions.toHexString -import jp.co.soramitsu.shared_utils.ss58.SS58Encoder.toAccountId +import jp.co.soramitsu.fearless_utils.extensions.toHexString +import jp.co.soramitsu.fearless_utils.ss58.SS58Encoder.toAccountId import jp.co.soramitsu.staking.api.data.SyntheticStakingType import jp.co.soramitsu.staking.api.data.syntheticStakingType import jp.co.soramitsu.staking.impl.data.mappers.mapSubqueryHistoryToTotalReward diff --git a/feature-staking-impl/src/main/java/jp/co/soramitsu/staking/impl/domain/StakingInteractor.kt b/feature-staking-impl/src/main/java/jp/co/soramitsu/staking/impl/domain/StakingInteractor.kt index ed98d668f1..4a94149aff 100644 --- a/feature-staking-impl/src/main/java/jp/co/soramitsu/staking/impl/domain/StakingInteractor.kt +++ b/feature-staking-impl/src/main/java/jp/co/soramitsu/staking/impl/domain/StakingInteractor.kt @@ -22,10 +22,10 @@ import jp.co.soramitsu.runtime.multiNetwork.ChainRegistry import jp.co.soramitsu.runtime.multiNetwork.chain.model.Chain import jp.co.soramitsu.runtime.multiNetwork.chain.model.ChainId import jp.co.soramitsu.runtime.multiNetwork.chain.model.polkadotChainId -import jp.co.soramitsu.shared_utils.runtime.AccountId -import jp.co.soramitsu.shared_utils.runtime.metadata.RuntimeMetadata -import jp.co.soramitsu.shared_utils.runtime.metadata.moduleOrNull -import jp.co.soramitsu.shared_utils.ss58.SS58Encoder.toAddress +import jp.co.soramitsu.fearless_utils.runtime.AccountId +import jp.co.soramitsu.fearless_utils.runtime.metadata.RuntimeMetadata +import jp.co.soramitsu.fearless_utils.runtime.metadata.moduleOrNull +import jp.co.soramitsu.fearless_utils.ss58.SS58Encoder.toAddress import jp.co.soramitsu.staking.api.data.StakingSharedState import jp.co.soramitsu.staking.api.domain.api.StakingRepository import jp.co.soramitsu.staking.api.domain.model.StakingAccount diff --git a/feature-staking-impl/src/main/java/jp/co/soramitsu/staking/impl/domain/StakingInteractorExt.kt b/feature-staking-impl/src/main/java/jp/co/soramitsu/staking/impl/domain/StakingInteractorExt.kt index b0de13f6dd..3da8726b3c 100644 --- a/feature-staking-impl/src/main/java/jp/co/soramitsu/staking/impl/domain/StakingInteractorExt.kt +++ b/feature-staking-impl/src/main/java/jp/co/soramitsu/staking/impl/domain/StakingInteractorExt.kt @@ -1,6 +1,6 @@ package jp.co.soramitsu.staking.impl.domain -import jp.co.soramitsu.shared_utils.runtime.AccountId +import jp.co.soramitsu.fearless_utils.runtime.AccountId import jp.co.soramitsu.staking.api.domain.model.IndividualExposure import kotlinx.coroutines.flow.first import java.math.BigInteger diff --git a/feature-staking-impl/src/main/java/jp/co/soramitsu/staking/impl/domain/alerts/Alert.kt b/feature-staking-impl/src/main/java/jp/co/soramitsu/staking/impl/domain/alerts/Alert.kt index 7b3f6b9ff4..a6f96bb7b3 100644 --- a/feature-staking-impl/src/main/java/jp/co/soramitsu/staking/impl/domain/alerts/Alert.kt +++ b/feature-staking-impl/src/main/java/jp/co/soramitsu/staking/impl/domain/alerts/Alert.kt @@ -1,6 +1,6 @@ package jp.co.soramitsu.staking.impl.domain.alerts -import jp.co.soramitsu.shared_utils.runtime.AccountId +import jp.co.soramitsu.fearless_utils.runtime.AccountId import jp.co.soramitsu.staking.api.domain.model.CollatorDelegation import jp.co.soramitsu.wallet.impl.domain.model.Token import java.math.BigDecimal diff --git a/feature-staking-impl/src/main/java/jp/co/soramitsu/staking/impl/domain/alerts/AlertsInteractor.kt b/feature-staking-impl/src/main/java/jp/co/soramitsu/staking/impl/domain/alerts/AlertsInteractor.kt index 833fd25a4c..29c098f4ab 100644 --- a/feature-staking-impl/src/main/java/jp/co/soramitsu/staking/impl/domain/alerts/AlertsInteractor.kt +++ b/feature-staking-impl/src/main/java/jp/co/soramitsu/staking/impl/domain/alerts/AlertsInteractor.kt @@ -4,9 +4,9 @@ import java.math.BigDecimal import java.math.BigInteger import jp.co.soramitsu.account.api.domain.interfaces.AccountRepository import jp.co.soramitsu.common.utils.orZero -import jp.co.soramitsu.shared_utils.extensions.fromHex -import jp.co.soramitsu.shared_utils.extensions.toHexString -import jp.co.soramitsu.shared_utils.runtime.AccountId +import jp.co.soramitsu.fearless_utils.extensions.fromHex +import jp.co.soramitsu.fearless_utils.extensions.toHexString +import jp.co.soramitsu.fearless_utils.runtime.AccountId import jp.co.soramitsu.staking.api.data.StakingSharedState import jp.co.soramitsu.staking.api.domain.model.LegacyExposure import jp.co.soramitsu.staking.api.domain.model.StakingState diff --git a/feature-staking-impl/src/main/java/jp/co/soramitsu/staking/impl/domain/payout/PayoutInteractor.kt b/feature-staking-impl/src/main/java/jp/co/soramitsu/staking/impl/domain/payout/PayoutInteractor.kt index f34941332f..ff5f7e89a4 100644 --- a/feature-staking-impl/src/main/java/jp/co/soramitsu/staking/impl/domain/payout/PayoutInteractor.kt +++ b/feature-staking-impl/src/main/java/jp/co/soramitsu/staking/impl/domain/payout/PayoutInteractor.kt @@ -2,7 +2,7 @@ package jp.co.soramitsu.staking.impl.domain.payout import jp.co.soramitsu.core.extrinsic.ExtrinsicService import jp.co.soramitsu.runtime.ext.accountIdOf -import jp.co.soramitsu.shared_utils.ss58.SS58Encoder.toAccountId +import jp.co.soramitsu.fearless_utils.ss58.SS58Encoder.toAccountId import jp.co.soramitsu.staking.api.data.StakingSharedState import jp.co.soramitsu.staking.impl.data.model.Payout import jp.co.soramitsu.staking.impl.data.network.blockhain.calls.payoutStakers @@ -18,7 +18,10 @@ class PayoutInteractor( suspend fun estimatePayoutFee(accountAddress: String, payouts: List): BigInteger { return withContext(Dispatchers.IO) { - extrinsicService.estimateFee(stakingSharedState.chain()) { + val chain = stakingSharedState.chain() + val accountId = chain.accountIdOf(accountAddress) + + extrinsicService.estimateFee(chain, accountId) { payouts.forEach { payoutStakers(it.era, it.validatorAddress.toAccountId()) } diff --git a/feature-staking-impl/src/main/java/jp/co/soramitsu/staking/impl/domain/rewards/ManualRewardCalculator.kt b/feature-staking-impl/src/main/java/jp/co/soramitsu/staking/impl/domain/rewards/ManualRewardCalculator.kt index 3367496128..1c74767f0d 100644 --- a/feature-staking-impl/src/main/java/jp/co/soramitsu/staking/impl/domain/rewards/ManualRewardCalculator.kt +++ b/feature-staking-impl/src/main/java/jp/co/soramitsu/staking/impl/domain/rewards/ManualRewardCalculator.kt @@ -7,7 +7,7 @@ import jp.co.soramitsu.common.utils.median import jp.co.soramitsu.common.utils.orZero import jp.co.soramitsu.common.utils.sumByBigInteger import jp.co.soramitsu.runtime.multiNetwork.chain.model.ChainId -import jp.co.soramitsu.shared_utils.extensions.toHexString +import jp.co.soramitsu.fearless_utils.extensions.toHexString import kotlin.math.pow import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext diff --git a/feature-staking-impl/src/main/java/jp/co/soramitsu/staking/impl/domain/rewards/ReefRewardCalculator.kt b/feature-staking-impl/src/main/java/jp/co/soramitsu/staking/impl/domain/rewards/ReefRewardCalculator.kt index 75e143947f..6ffa975aab 100644 --- a/feature-staking-impl/src/main/java/jp/co/soramitsu/staking/impl/domain/rewards/ReefRewardCalculator.kt +++ b/feature-staking-impl/src/main/java/jp/co/soramitsu/staking/impl/domain/rewards/ReefRewardCalculator.kt @@ -7,8 +7,8 @@ import jp.co.soramitsu.common.utils.fractionToPercentage import jp.co.soramitsu.common.utils.median import jp.co.soramitsu.core.models.Asset import jp.co.soramitsu.runtime.multiNetwork.chain.model.ChainId -import jp.co.soramitsu.shared_utils.extensions.fromHex -import jp.co.soramitsu.shared_utils.extensions.toHexString +import jp.co.soramitsu.fearless_utils.extensions.fromHex +import jp.co.soramitsu.fearless_utils.extensions.toHexString import jp.co.soramitsu.staking.impl.data.network.blockhain.bindings.EraRewardPoints import jp.co.soramitsu.staking.impl.data.repository.HistoricalMapping import jp.co.soramitsu.wallet.impl.domain.model.amountFromPlanks diff --git a/feature-staking-impl/src/main/java/jp/co/soramitsu/staking/impl/domain/rewards/RewardCalculatorFactory.kt b/feature-staking-impl/src/main/java/jp/co/soramitsu/staking/impl/domain/rewards/RewardCalculatorFactory.kt index ce082d6146..5fb9f56e92 100644 --- a/feature-staking-impl/src/main/java/jp/co/soramitsu/staking/impl/domain/rewards/RewardCalculatorFactory.kt +++ b/feature-staking-impl/src/main/java/jp/co/soramitsu/staking/impl/domain/rewards/RewardCalculatorFactory.kt @@ -5,8 +5,8 @@ import java.math.BigInteger import jp.co.soramitsu.common.data.network.runtime.binding.cast import jp.co.soramitsu.core.models.Asset import jp.co.soramitsu.runtime.multiNetwork.chain.model.ternoaChainId -import jp.co.soramitsu.shared_utils.extensions.toHexString -import jp.co.soramitsu.shared_utils.runtime.AccountId +import jp.co.soramitsu.fearless_utils.extensions.toHexString +import jp.co.soramitsu.fearless_utils.runtime.AccountId import jp.co.soramitsu.staking.api.data.SyntheticStakingType import jp.co.soramitsu.staking.api.data.syntheticStakingType import jp.co.soramitsu.staking.api.domain.api.StakingRepository diff --git a/feature-staking-impl/src/main/java/jp/co/soramitsu/staking/impl/domain/rewards/SoraRewardCalculator.kt b/feature-staking-impl/src/main/java/jp/co/soramitsu/staking/impl/domain/rewards/SoraRewardCalculator.kt index 07c67d7273..88d0f787f6 100644 --- a/feature-staking-impl/src/main/java/jp/co/soramitsu/staking/impl/domain/rewards/SoraRewardCalculator.kt +++ b/feature-staking-impl/src/main/java/jp/co/soramitsu/staking/impl/domain/rewards/SoraRewardCalculator.kt @@ -6,8 +6,8 @@ import jp.co.soramitsu.common.utils.fractionToPercentage import jp.co.soramitsu.common.utils.median import jp.co.soramitsu.core.models.Asset import jp.co.soramitsu.runtime.multiNetwork.chain.model.ChainId -import jp.co.soramitsu.shared_utils.extensions.fromHex -import jp.co.soramitsu.shared_utils.extensions.toHexString +import jp.co.soramitsu.fearless_utils.extensions.fromHex +import jp.co.soramitsu.fearless_utils.extensions.toHexString import jp.co.soramitsu.staking.impl.data.network.blockhain.bindings.EraRewardPoints import jp.co.soramitsu.staking.impl.data.repository.HistoricalMapping import jp.co.soramitsu.wallet.impl.domain.model.amountFromPlanks diff --git a/feature-staking-impl/src/main/java/jp/co/soramitsu/staking/impl/domain/rewards/SoraStakingRewardsScenario.kt b/feature-staking-impl/src/main/java/jp/co/soramitsu/staking/impl/domain/rewards/SoraStakingRewardsScenario.kt index c7ad892d27..cd1b8d61b7 100644 --- a/feature-staking-impl/src/main/java/jp/co/soramitsu/staking/impl/domain/rewards/SoraStakingRewardsScenario.kt +++ b/feature-staking-impl/src/main/java/jp/co/soramitsu/staking/impl/domain/rewards/SoraStakingRewardsScenario.kt @@ -8,7 +8,7 @@ import jp.co.soramitsu.core.rpc.RpcCalls import jp.co.soramitsu.core.rpc.calls.liquidityProxyQuote import jp.co.soramitsu.coredb.dao.TokenPriceDao import jp.co.soramitsu.runtime.multiNetwork.ChainRegistry -import jp.co.soramitsu.shared_utils.wsrpc.exception.RpcException +import jp.co.soramitsu.fearless_utils.wsrpc.exception.RpcException import jp.co.soramitsu.wallet.impl.domain.model.Token // Attention! Works only for the sora main net diff --git a/feature-staking-impl/src/main/java/jp/co/soramitsu/staking/impl/domain/rewards/SubqueryRewardCalculator.kt b/feature-staking-impl/src/main/java/jp/co/soramitsu/staking/impl/domain/rewards/SubqueryRewardCalculator.kt index 676f3aa624..edaf6427ac 100644 --- a/feature-staking-impl/src/main/java/jp/co/soramitsu/staking/impl/domain/rewards/SubqueryRewardCalculator.kt +++ b/feature-staking-impl/src/main/java/jp/co/soramitsu/staking/impl/domain/rewards/SubqueryRewardCalculator.kt @@ -6,8 +6,8 @@ import jp.co.soramitsu.common.utils.orZero import jp.co.soramitsu.common.utils.percentageToFraction import jp.co.soramitsu.runtime.multiNetwork.chain.model.Chain import jp.co.soramitsu.runtime.multiNetwork.chain.model.ChainId -import jp.co.soramitsu.shared_utils.extensions.fromHex -import jp.co.soramitsu.shared_utils.extensions.toHexString +import jp.co.soramitsu.fearless_utils.extensions.fromHex +import jp.co.soramitsu.fearless_utils.extensions.toHexString import jp.co.soramitsu.staking.api.domain.api.StakingRepository import jp.co.soramitsu.staking.impl.data.network.subquery.StakingApi import jp.co.soramitsu.staking.impl.data.network.subquery.request.StakingAllCollatorsApyRequest diff --git a/feature-staking-impl/src/main/java/jp/co/soramitsu/staking/impl/domain/rewards/TernoaRewardCalculator.kt b/feature-staking-impl/src/main/java/jp/co/soramitsu/staking/impl/domain/rewards/TernoaRewardCalculator.kt index 86305a0e31..bbdfa7bc46 100644 --- a/feature-staking-impl/src/main/java/jp/co/soramitsu/staking/impl/domain/rewards/TernoaRewardCalculator.kt +++ b/feature-staking-impl/src/main/java/jp/co/soramitsu/staking/impl/domain/rewards/TernoaRewardCalculator.kt @@ -4,7 +4,7 @@ import java.math.BigDecimal import jp.co.soramitsu.common.utils.fractionToPercentage import jp.co.soramitsu.core.models.Asset import jp.co.soramitsu.runtime.multiNetwork.chain.model.ChainId -import jp.co.soramitsu.shared_utils.extensions.toHexString +import jp.co.soramitsu.fearless_utils.extensions.toHexString import jp.co.soramitsu.wallet.impl.domain.model.amountFromPlanks import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext diff --git a/feature-staking-impl/src/main/java/jp/co/soramitsu/staking/impl/domain/setup/SetupStakingInteractor.kt b/feature-staking-impl/src/main/java/jp/co/soramitsu/staking/impl/domain/setup/SetupStakingInteractor.kt index cdb42dac5d..76dd367334 100644 --- a/feature-staking-impl/src/main/java/jp/co/soramitsu/staking/impl/domain/setup/SetupStakingInteractor.kt +++ b/feature-staking-impl/src/main/java/jp/co/soramitsu/staking/impl/domain/setup/SetupStakingInteractor.kt @@ -5,10 +5,10 @@ import jp.co.soramitsu.core.models.Asset import jp.co.soramitsu.runtime.ext.accountIdOf import jp.co.soramitsu.runtime.ext.multiAddressOf import jp.co.soramitsu.runtime.multiNetwork.chain.model.Chain -import jp.co.soramitsu.shared_utils.extensions.fromHex -import jp.co.soramitsu.shared_utils.extensions.toHexString -import jp.co.soramitsu.shared_utils.runtime.extrinsic.ExtrinsicBuilder -import jp.co.soramitsu.shared_utils.ss58.SS58Encoder.toAccountId +import jp.co.soramitsu.fearless_utils.extensions.fromHex +import jp.co.soramitsu.fearless_utils.extensions.toHexString +import jp.co.soramitsu.fearless_utils.runtime.extrinsic.ExtrinsicBuilder +import jp.co.soramitsu.fearless_utils.ss58.SS58Encoder.toAccountId import jp.co.soramitsu.staking.api.data.StakingSharedState import jp.co.soramitsu.staking.api.data.SyntheticStakingType import jp.co.soramitsu.staking.api.data.syntheticStakingType @@ -44,9 +44,11 @@ class SetupStakingInteractor( ) } - suspend fun estimateParachainFee(): BigInteger { + suspend fun estimateParachainFee(accountAddress: String): BigInteger { val (chain, asset) = stakingSharedState.assetWithChain.first() - return extrinsicService.estimateFee(chain) { + val accountId = chain.accountIdOf(accountAddress) + + return extrinsicService.estimateFee(chain, accountId) { val eth = fakeEthereumAddress() val fakeAmountInPlanks = asset.planksFromAmount(fakeAmount()) @@ -54,10 +56,16 @@ class SetupStakingInteractor( } } - suspend fun estimateFinalParachainFee(selectedCollator: Collator, amountInPlanks: BigInteger, delegationCount: Int): BigInteger { - val (chain, asset) = stakingSharedState.assetWithChain.first() + suspend fun estimateFinalParachainFee( + accountAddress: String, + selectedCollator: Collator, + amountInPlanks: BigInteger, + delegationCount: Int + ): BigInteger { + val (chain, _) = stakingSharedState.assetWithChain.first() + val accountId = chain.accountIdOf(accountAddress) - return extrinsicService.estimateFee(chain) { + return extrinsicService.estimateFee(chain, accountId) { delegate(selectedCollator.address.fromHex(), amountInPlanks, selectedCollator.delegationCount, delegationCount.toBigInteger()) } } @@ -68,8 +76,9 @@ class SetupStakingInteractor( bondPayload: BondPayload? ): BigInteger { val (chain, chainAsset) = stakingSharedState.assetWithChain.first() + val accountId = chain.accountIdOf(controllerAddress) - return extrinsicService.estimateFee(chain) { + return extrinsicService.estimateFee(chain, accountId) { formExtrinsic(chain, chainAsset, controllerAddress, validatorAccountIds, bondPayload) } } diff --git a/feature-staking-impl/src/main/java/jp/co/soramitsu/staking/impl/domain/staking/bond/BondMoreInteractor.kt b/feature-staking-impl/src/main/java/jp/co/soramitsu/staking/impl/domain/staking/bond/BondMoreInteractor.kt index 280cdfebf6..2757ef481c 100644 --- a/feature-staking-impl/src/main/java/jp/co/soramitsu/staking/impl/domain/staking/bond/BondMoreInteractor.kt +++ b/feature-staking-impl/src/main/java/jp/co/soramitsu/staking/impl/domain/staking/bond/BondMoreInteractor.kt @@ -2,7 +2,7 @@ package jp.co.soramitsu.staking.impl.domain.staking.bond import jp.co.soramitsu.core.extrinsic.ExtrinsicService import jp.co.soramitsu.runtime.ext.accountIdOf -import jp.co.soramitsu.shared_utils.runtime.extrinsic.ExtrinsicBuilder +import jp.co.soramitsu.fearless_utils.runtime.extrinsic.ExtrinsicBuilder import jp.co.soramitsu.staking.api.data.StakingSharedState import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext @@ -14,12 +14,14 @@ class BondMoreInteractor( ) { suspend fun estimateFee( + accountAddress: String, formExtrinsic: suspend ExtrinsicBuilder.() -> Unit ): BigInteger { return withContext(Dispatchers.IO) { val chain = stakingSharedState.chain() + val accountId = chain.accountIdOf(accountAddress) - extrinsicService.estimateFee(chain) { + extrinsicService.estimateFee(chain, accountId) { formExtrinsic.invoke(this) } } diff --git a/feature-staking-impl/src/main/java/jp/co/soramitsu/staking/impl/domain/staking/controller/ControllerInteractor.kt b/feature-staking-impl/src/main/java/jp/co/soramitsu/staking/impl/domain/staking/controller/ControllerInteractor.kt index ffef0ef7e9..87b0715138 100644 --- a/feature-staking-impl/src/main/java/jp/co/soramitsu/staking/impl/domain/staking/controller/ControllerInteractor.kt +++ b/feature-staking-impl/src/main/java/jp/co/soramitsu/staking/impl/domain/staking/controller/ControllerInteractor.kt @@ -8,7 +8,7 @@ import jp.co.soramitsu.runtime.ext.accountIdOf import jp.co.soramitsu.runtime.ext.multiAddressOf import jp.co.soramitsu.runtime.multiNetwork.chain.model.ChainId import jp.co.soramitsu.runtime.state.SingleAssetSharedState -import jp.co.soramitsu.shared_utils.ss58.SS58Encoder.toAccountId +import jp.co.soramitsu.fearless_utils.ss58.SS58Encoder.toAccountId import jp.co.soramitsu.staking.api.data.StakingSharedState import jp.co.soramitsu.staking.api.data.SyntheticStakingType import jp.co.soramitsu.staking.api.data.syntheticStakingType @@ -31,7 +31,7 @@ class ControllerInteractor( return metadata.staking().calls?.get("set_controller")?.arguments?.isEmpty() == true } - suspend fun estimateFee(controllerAccountAddress: String, chainId: ChainId? = null): BigInteger { + suspend fun estimateFee(stashAccountAddress: String, controllerAccountAddress: String, chainId: ChainId? = null): BigInteger { return withContext(Dispatchers.IO) { val (chain, asset) = if (chainId.isNullOrEmpty()) { sharedStakingSate.assetWithChain.first() @@ -39,7 +39,9 @@ class ControllerInteractor( val chain = stakingInteractor.getChain(chainId) SingleAssetSharedState.AssetWithChain(chain, requireNotNull(chain.utilityAsset)) } - extrinsicService.estimateFee(chain) { + val accountId = chain.accountIdOf(stashAccountAddress) + + extrinsicService.estimateFee(chain, accountId) { if (isControllerFeatureDeprecated(chain.id)) { setController() } else { diff --git a/feature-staking-impl/src/main/java/jp/co/soramitsu/staking/impl/domain/staking/rebond/RebondInteractor.kt b/feature-staking-impl/src/main/java/jp/co/soramitsu/staking/impl/domain/staking/rebond/RebondInteractor.kt index 801b824243..413e89497c 100644 --- a/feature-staking-impl/src/main/java/jp/co/soramitsu/staking/impl/domain/staking/rebond/RebondInteractor.kt +++ b/feature-staking-impl/src/main/java/jp/co/soramitsu/staking/impl/domain/staking/rebond/RebondInteractor.kt @@ -1,7 +1,7 @@ package jp.co.soramitsu.staking.impl.domain.staking.rebond import jp.co.soramitsu.core.extrinsic.ExtrinsicService -import jp.co.soramitsu.shared_utils.runtime.extrinsic.ExtrinsicBuilder +import jp.co.soramitsu.fearless_utils.runtime.extrinsic.ExtrinsicBuilder import jp.co.soramitsu.staking.api.data.StakingSharedState import jp.co.soramitsu.staking.api.domain.model.StakingState import kotlinx.coroutines.Dispatchers @@ -13,11 +13,9 @@ class RebondInteractor( private val sharedStakingSate: StakingSharedState ) { - suspend fun estimateFee(formExtrinsic: suspend ExtrinsicBuilder.() -> Unit): BigInteger { + suspend fun estimateFee(stashState: StakingState, formExtrinsic: suspend ExtrinsicBuilder.() -> Unit): BigInteger { return withContext(Dispatchers.IO) { - val chain = sharedStakingSate.chain() - - extrinsicService.estimateFee(chain) { + extrinsicService.estimateFee(stashState.chain, stashState.executionAddressId) { formExtrinsic.invoke(this) } } diff --git a/feature-staking-impl/src/main/java/jp/co/soramitsu/staking/impl/domain/staking/redeem/RedeemInteractor.kt b/feature-staking-impl/src/main/java/jp/co/soramitsu/staking/impl/domain/staking/redeem/RedeemInteractor.kt index 0f9d2bd527..0b446b576c 100644 --- a/feature-staking-impl/src/main/java/jp/co/soramitsu/staking/impl/domain/staking/redeem/RedeemInteractor.kt +++ b/feature-staking-impl/src/main/java/jp/co/soramitsu/staking/impl/domain/staking/redeem/RedeemInteractor.kt @@ -1,7 +1,7 @@ package jp.co.soramitsu.staking.impl.domain.staking.redeem import jp.co.soramitsu.core.extrinsic.ExtrinsicService -import jp.co.soramitsu.shared_utils.runtime.extrinsic.ExtrinsicBuilder +import jp.co.soramitsu.fearless_utils.runtime.extrinsic.ExtrinsicBuilder import jp.co.soramitsu.staking.api.domain.model.StakingState import jp.co.soramitsu.wallet.impl.domain.model.Asset import kotlinx.coroutines.Dispatchers @@ -17,7 +17,7 @@ class RedeemInteractor( formExtrinsic: suspend ExtrinsicBuilder.() -> Unit ): BigInteger { return withContext(Dispatchers.IO) { - extrinsicService.estimateFee(stashState.chain) { + extrinsicService.estimateFee(stashState.chain, stashState.executionAddressId) { formExtrinsic.invoke(this) } } diff --git a/feature-staking-impl/src/main/java/jp/co/soramitsu/staking/impl/domain/staking/rewardDestination/ChangeRewardDestinationInteractor.kt b/feature-staking-impl/src/main/java/jp/co/soramitsu/staking/impl/domain/staking/rewardDestination/ChangeRewardDestinationInteractor.kt index abdd0f1313..2f85193d4e 100644 --- a/feature-staking-impl/src/main/java/jp/co/soramitsu/staking/impl/domain/staking/rewardDestination/ChangeRewardDestinationInteractor.kt +++ b/feature-staking-impl/src/main/java/jp/co/soramitsu/staking/impl/domain/staking/rewardDestination/ChangeRewardDestinationInteractor.kt @@ -16,7 +16,7 @@ class ChangeRewardDestinationInteractor( stashState: StakingState.Stash, rewardDestination: RewardDestination ): BigInteger = withContext(Dispatchers.IO) { - extrinsicService.estimateFee(stashState.chain) { + extrinsicService.estimateFee(stashState.chain, stashState.controllerId) { setPayee(rewardDestination) } } diff --git a/feature-staking-impl/src/main/java/jp/co/soramitsu/staking/impl/domain/staking/unbond/UnbondInteractor.kt b/feature-staking-impl/src/main/java/jp/co/soramitsu/staking/impl/domain/staking/unbond/UnbondInteractor.kt index dc6265da58..bf2e84d00a 100644 --- a/feature-staking-impl/src/main/java/jp/co/soramitsu/staking/impl/domain/staking/unbond/UnbondInteractor.kt +++ b/feature-staking-impl/src/main/java/jp/co/soramitsu/staking/impl/domain/staking/unbond/UnbondInteractor.kt @@ -2,7 +2,7 @@ package jp.co.soramitsu.staking.impl.domain.staking.unbond import jp.co.soramitsu.common.utils.sumByBigInteger import jp.co.soramitsu.core.extrinsic.ExtrinsicService -import jp.co.soramitsu.shared_utils.runtime.extrinsic.ExtrinsicBuilder +import jp.co.soramitsu.fearless_utils.runtime.extrinsic.ExtrinsicBuilder import jp.co.soramitsu.staking.api.domain.model.StakingState import jp.co.soramitsu.staking.impl.domain.model.Unbonding import kotlinx.coroutines.Dispatchers @@ -18,7 +18,7 @@ class UnbondInteractor( formExtrinsic: suspend ExtrinsicBuilder.() -> Unit ): BigInteger { return withContext(Dispatchers.IO) { - extrinsicService.estimateFee(stashState.chain) { + extrinsicService.estimateFee(stashState.chain, stashState.executionAddressId) { formExtrinsic.invoke(this) } } diff --git a/feature-staking-impl/src/main/java/jp/co/soramitsu/staking/impl/domain/validations/unbond/CrossExistentialValidation.kt b/feature-staking-impl/src/main/java/jp/co/soramitsu/staking/impl/domain/validations/unbond/CrossExistentialValidation.kt index e1f4354ed8..287e4e373b 100644 --- a/feature-staking-impl/src/main/java/jp/co/soramitsu/staking/impl/domain/validations/unbond/CrossExistentialValidation.kt +++ b/feature-staking-impl/src/main/java/jp/co/soramitsu/staking/impl/domain/validations/unbond/CrossExistentialValidation.kt @@ -2,7 +2,7 @@ package jp.co.soramitsu.staking.impl.domain.validations.unbond import jp.co.soramitsu.common.validation.ValidationStatus import jp.co.soramitsu.common.validation.validOrWarning -import jp.co.soramitsu.shared_utils.extensions.fromHex +import jp.co.soramitsu.fearless_utils.extensions.fromHex import jp.co.soramitsu.staking.impl.scenarios.StakingScenarioInteractor class CrossExistentialValidation( diff --git a/feature-staking-impl/src/main/java/jp/co/soramitsu/staking/impl/domain/validators/ValidatorProvider.kt b/feature-staking-impl/src/main/java/jp/co/soramitsu/staking/impl/domain/validators/ValidatorProvider.kt index da17990b5d..f02cd59758 100644 --- a/feature-staking-impl/src/main/java/jp/co/soramitsu/staking/impl/domain/validators/ValidatorProvider.kt +++ b/feature-staking-impl/src/main/java/jp/co/soramitsu/staking/impl/domain/validators/ValidatorProvider.kt @@ -8,7 +8,7 @@ import jp.co.soramitsu.runtime.multiNetwork.chain.model.Chain import jp.co.soramitsu.runtime.multiNetwork.chain.model.reefChainId import jp.co.soramitsu.runtime.multiNetwork.chain.model.soraMainChainId import jp.co.soramitsu.runtime.multiNetwork.chain.model.ternoaChainId -import jp.co.soramitsu.shared_utils.extensions.fromHex +import jp.co.soramitsu.fearless_utils.extensions.fromHex import jp.co.soramitsu.staking.api.domain.api.IdentityRepository import jp.co.soramitsu.staking.api.domain.model.Validator import jp.co.soramitsu.staking.impl.data.repository.StakingConstantsRepository diff --git a/feature-staking-impl/src/main/java/jp/co/soramitsu/staking/impl/domain/validators/current/CurrentValidatorsInteractor.kt b/feature-staking-impl/src/main/java/jp/co/soramitsu/staking/impl/domain/validators/current/CurrentValidatorsInteractor.kt index d8db130aae..0885280419 100644 --- a/feature-staking-impl/src/main/java/jp/co/soramitsu/staking/impl/domain/validators/current/CurrentValidatorsInteractor.kt +++ b/feature-staking-impl/src/main/java/jp/co/soramitsu/staking/impl/domain/validators/current/CurrentValidatorsInteractor.kt @@ -3,8 +3,8 @@ package jp.co.soramitsu.staking.impl.domain.validators.current import jp.co.soramitsu.common.list.GroupedList import jp.co.soramitsu.common.list.emptyGroupedList import jp.co.soramitsu.runtime.multiNetwork.chain.model.Chain -import jp.co.soramitsu.shared_utils.extensions.toHexString -import jp.co.soramitsu.shared_utils.runtime.AccountId +import jp.co.soramitsu.fearless_utils.extensions.toHexString +import jp.co.soramitsu.fearless_utils.runtime.AccountId import jp.co.soramitsu.staking.api.domain.model.IndividualExposure import jp.co.soramitsu.staking.api.domain.model.NominatedValidator import jp.co.soramitsu.staking.api.domain.model.NominatedValidator.Status diff --git a/feature-staking-impl/src/main/java/jp/co/soramitsu/staking/impl/domain/validators/current/search/SearchCustomBlockProducerInteractor.kt b/feature-staking-impl/src/main/java/jp/co/soramitsu/staking/impl/domain/validators/current/search/SearchCustomBlockProducerInteractor.kt index 779f2a7a51..910ff127b5 100644 --- a/feature-staking-impl/src/main/java/jp/co/soramitsu/staking/impl/domain/validators/current/search/SearchCustomBlockProducerInteractor.kt +++ b/feature-staking-impl/src/main/java/jp/co/soramitsu/staking/impl/domain/validators/current/search/SearchCustomBlockProducerInteractor.kt @@ -12,7 +12,7 @@ import jp.co.soramitsu.common.utils.fractionToPercentage import jp.co.soramitsu.common.utils.toggle import jp.co.soramitsu.core.models.Asset import jp.co.soramitsu.core.utils.isValidAddress -import jp.co.soramitsu.shared_utils.extensions.requireHexPrefix +import jp.co.soramitsu.fearless_utils.extensions.requireHexPrefix import jp.co.soramitsu.staking.api.data.StakingSharedState import jp.co.soramitsu.staking.api.domain.model.Collator import jp.co.soramitsu.staking.api.domain.model.Validator diff --git a/feature-staking-impl/src/main/java/jp/co/soramitsu/staking/impl/presentation/common/StakingPoolSharedState.kt b/feature-staking-impl/src/main/java/jp/co/soramitsu/staking/impl/presentation/common/StakingPoolSharedState.kt index 6002d7f7a7..5d5120fbad 100644 --- a/feature-staking-impl/src/main/java/jp/co/soramitsu/staking/impl/presentation/common/StakingPoolSharedState.kt +++ b/feature-staking-impl/src/main/java/jp/co/soramitsu/staking/impl/presentation/common/StakingPoolSharedState.kt @@ -2,7 +2,7 @@ package jp.co.soramitsu.staking.impl.presentation.common import jp.co.soramitsu.runtime.ext.accountIdOf import jp.co.soramitsu.runtime.multiNetwork.chain.model.Chain -import jp.co.soramitsu.shared_utils.runtime.AccountId +import jp.co.soramitsu.fearless_utils.runtime.AccountId import jp.co.soramitsu.staking.api.domain.model.PoolInfo import jp.co.soramitsu.staking.api.domain.model.RoleInPool import jp.co.soramitsu.wallet.impl.domain.model.Asset diff --git a/feature-staking-impl/src/main/java/jp/co/soramitsu/staking/impl/presentation/confirm/ConfirmStakingViewModel.kt b/feature-staking-impl/src/main/java/jp/co/soramitsu/staking/impl/presentation/confirm/ConfirmStakingViewModel.kt index 6e4683c65c..bce3e005fd 100644 --- a/feature-staking-impl/src/main/java/jp/co/soramitsu/staking/impl/presentation/confirm/ConfirmStakingViewModel.kt +++ b/feature-staking-impl/src/main/java/jp/co/soramitsu/staking/impl/presentation/confirm/ConfirmStakingViewModel.kt @@ -243,13 +243,14 @@ class ConfirmStakingViewModel @Inject constructor( is SetupStakingProcess.ReadyToSubmit.Parachain -> { val collator = currentProcessState.payload.blockProducers.first() val amount = bondPayload?.amount ?: error("Amount cant be null") - val delegationCount = when (val state = scenarioInteractor.stakingStateFlow().first()) { - is StakingState.Parachain.Delegator -> state.delegations.size + val stakingState = scenarioInteractor.stakingStateFlow().first() + val delegationCount = when (stakingState) { + is StakingState.Parachain.Delegator -> stakingState.delegations.size is StakingState.Parachain.Collator -> 0 // todo add collators support is StakingState.Parachain.None -> 0 else -> 0 } - setupStakingInteractor.estimateFinalParachainFee(collator, it.planksFromAmount(amount), delegationCount) + setupStakingInteractor.estimateFinalParachainFee(stakingState.accountAddress, collator, it.planksFromAmount(amount), delegationCount) } } }, diff --git a/feature-staking-impl/src/main/java/jp/co/soramitsu/staking/impl/presentation/confirm/pool/join/ConfirmJoinPoolViewModel.kt b/feature-staking-impl/src/main/java/jp/co/soramitsu/staking/impl/presentation/confirm/pool/join/ConfirmJoinPoolViewModel.kt index 154d671e80..f5b1f88fa5 100644 --- a/feature-staking-impl/src/main/java/jp/co/soramitsu/staking/impl/presentation/confirm/pool/join/ConfirmJoinPoolViewModel.kt +++ b/feature-staking-impl/src/main/java/jp/co/soramitsu/staking/impl/presentation/confirm/pool/join/ConfirmJoinPoolViewModel.kt @@ -69,7 +69,7 @@ class ConfirmJoinPoolViewModel @Inject constructor( private val feeViewStateFlow = jp.co.soramitsu.common.utils.flowOf { val amountInPlanks = asset.token.planksFromAmount(amount) - val feeInPlanks = poolInteractor.estimateJoinFee(amountInPlanks, selectedPool.poolId) + val feeInPlanks = poolInteractor.estimateJoinFee(address, amountInPlanks, selectedPool.poolId) val fee = asset.token.amountFromPlanks(feeInPlanks) val feeFormatted = fee.formatCryptoDetail(asset.token.configuration.symbol) val feeFiat = fee.formatFiat(asset.token.fiatSymbol) diff --git a/feature-staking-impl/src/main/java/jp/co/soramitsu/staking/impl/presentation/mappers/Validator.kt b/feature-staking-impl/src/main/java/jp/co/soramitsu/staking/impl/presentation/mappers/Validator.kt index e131aa5a82..5f57a08a31 100644 --- a/feature-staking-impl/src/main/java/jp/co/soramitsu/staking/impl/presentation/mappers/Validator.kt +++ b/feature-staking-impl/src/main/java/jp/co/soramitsu/staking/impl/presentation/mappers/Validator.kt @@ -13,7 +13,7 @@ import jp.co.soramitsu.common.utils.fractionToPercentage import jp.co.soramitsu.feature_staking_impl.R import jp.co.soramitsu.runtime.ext.addressOf import jp.co.soramitsu.runtime.multiNetwork.chain.model.Chain -import jp.co.soramitsu.shared_utils.extensions.fromHex +import jp.co.soramitsu.fearless_utils.extensions.fromHex import jp.co.soramitsu.staking.api.domain.model.NominatedValidator import jp.co.soramitsu.staking.api.domain.model.Validator import jp.co.soramitsu.staking.impl.domain.recommendations.settings.sortings.BlockProducersSorting diff --git a/feature-staking-impl/src/main/java/jp/co/soramitsu/staking/impl/presentation/payouts/confirm/ConfirmPayoutViewModel.kt b/feature-staking-impl/src/main/java/jp/co/soramitsu/staking/impl/presentation/payouts/confirm/ConfirmPayoutViewModel.kt index 562f0e91ff..f90f464743 100644 --- a/feature-staking-impl/src/main/java/jp/co/soramitsu/staking/impl/presentation/payouts/confirm/ConfirmPayoutViewModel.kt +++ b/feature-staking-impl/src/main/java/jp/co/soramitsu/staking/impl/presentation/payouts/confirm/ConfirmPayoutViewModel.kt @@ -25,8 +25,8 @@ import jp.co.soramitsu.common.validation.progressConsumer import jp.co.soramitsu.feature_staking_impl.R import jp.co.soramitsu.runtime.multiNetwork.ChainRegistry import jp.co.soramitsu.runtime.multiNetwork.chain.model.getSupportedAddressExplorers -import jp.co.soramitsu.shared_utils.ss58.SS58Encoder.addressByte -import jp.co.soramitsu.shared_utils.ss58.SS58Encoder.toAddress +import jp.co.soramitsu.fearless_utils.ss58.SS58Encoder.addressByte +import jp.co.soramitsu.fearless_utils.ss58.SS58Encoder.toAddress import jp.co.soramitsu.staking.api.data.SyntheticStakingType import jp.co.soramitsu.staking.api.data.syntheticStakingType import jp.co.soramitsu.staking.api.domain.model.RewardDestination diff --git a/feature-staking-impl/src/main/java/jp/co/soramitsu/staking/impl/presentation/pools/PoolInfoViewModel.kt b/feature-staking-impl/src/main/java/jp/co/soramitsu/staking/impl/presentation/pools/PoolInfoViewModel.kt index 7566d6853f..3788c2a976 100644 --- a/feature-staking-impl/src/main/java/jp/co/soramitsu/staking/impl/presentation/pools/PoolInfoViewModel.kt +++ b/feature-staking-impl/src/main/java/jp/co/soramitsu/staking/impl/presentation/pools/PoolInfoViewModel.kt @@ -16,9 +16,9 @@ import jp.co.soramitsu.feature_staking_impl.R import jp.co.soramitsu.runtime.ext.accountFromMapKey import jp.co.soramitsu.runtime.ext.accountIdOf import jp.co.soramitsu.runtime.multiNetwork.chain.model.Chain -import jp.co.soramitsu.shared_utils.extensions.toHexString -import jp.co.soramitsu.shared_utils.runtime.AccountId -import jp.co.soramitsu.shared_utils.ss58.SS58Encoder.toAddress +import jp.co.soramitsu.fearless_utils.extensions.toHexString +import jp.co.soramitsu.fearless_utils.runtime.AccountId +import jp.co.soramitsu.fearless_utils.ss58.SS58Encoder.toAddress import jp.co.soramitsu.staking.api.domain.model.NominationPoolState import jp.co.soramitsu.staking.api.domain.model.PoolInfo import jp.co.soramitsu.staking.impl.presentation.StakingRouter diff --git a/feature-staking-impl/src/main/java/jp/co/soramitsu/staking/impl/presentation/pools/edit/EditPoolConfirmViewModel.kt b/feature-staking-impl/src/main/java/jp/co/soramitsu/staking/impl/presentation/pools/edit/EditPoolConfirmViewModel.kt index cac031a73c..6c2994ea61 100644 --- a/feature-staking-impl/src/main/java/jp/co/soramitsu/staking/impl/presentation/pools/edit/EditPoolConfirmViewModel.kt +++ b/feature-staking-impl/src/main/java/jp/co/soramitsu/staking/impl/presentation/pools/edit/EditPoolConfirmViewModel.kt @@ -5,8 +5,8 @@ import dagger.hilt.android.lifecycle.HiltViewModel import jp.co.soramitsu.common.compose.component.TitleValueViewState import jp.co.soramitsu.common.resources.ResourceManager import jp.co.soramitsu.feature_staking_impl.R -import jp.co.soramitsu.shared_utils.runtime.AccountId -import jp.co.soramitsu.shared_utils.ss58.SS58Encoder.toAddress +import jp.co.soramitsu.fearless_utils.runtime.AccountId +import jp.co.soramitsu.fearless_utils.ss58.SS58Encoder.toAddress import jp.co.soramitsu.staking.impl.presentation.StakingConfirmViewModel import jp.co.soramitsu.staking.impl.presentation.StakingRouter import jp.co.soramitsu.staking.impl.presentation.common.StakingPoolSharedStateProvider @@ -40,7 +40,7 @@ class EditPoolConfirmViewModel @Inject constructor( customIcon = R.drawable.ic_vector, accountNameProvider = { null }, feeEstimator = { - stakingPoolInteractor.estimateEditFee(poolSharedStateProvider.requireEditPoolState) + stakingPoolInteractor.estimateEditFee(poolSharedStateProvider.requireEditPoolState, poolSharedStateProvider.requireMainState.requireAddress) }, executeOperation = { address, _ -> stakingPoolInteractor.edit(poolSharedStateProvider.requireEditPoolState, address) diff --git a/feature-staking-impl/src/main/java/jp/co/soramitsu/staking/impl/presentation/pools/edit/EditPoolViewModel.kt b/feature-staking-impl/src/main/java/jp/co/soramitsu/staking/impl/presentation/pools/edit/EditPoolViewModel.kt index e1758da037..cb9a5a5187 100644 --- a/feature-staking-impl/src/main/java/jp/co/soramitsu/staking/impl/presentation/pools/edit/EditPoolViewModel.kt +++ b/feature-staking-impl/src/main/java/jp/co/soramitsu/staking/impl/presentation/pools/edit/EditPoolViewModel.kt @@ -5,7 +5,7 @@ import dagger.hilt.android.lifecycle.HiltViewModel import jp.co.soramitsu.account.api.domain.model.accountId import jp.co.soramitsu.common.base.BaseViewModel import jp.co.soramitsu.common.navigation.payload.WalletSelectorPayload -import jp.co.soramitsu.shared_utils.ss58.SS58Encoder.toAddress +import jp.co.soramitsu.fearless_utils.ss58.SS58Encoder.toAddress import jp.co.soramitsu.staking.impl.domain.StakingInteractor import jp.co.soramitsu.staking.impl.presentation.StakingRouter import jp.co.soramitsu.staking.impl.presentation.common.StakingPoolSharedStateProvider diff --git a/feature-staking-impl/src/main/java/jp/co/soramitsu/staking/impl/presentation/setup/SetupStakingViewModel.kt b/feature-staking-impl/src/main/java/jp/co/soramitsu/staking/impl/presentation/setup/SetupStakingViewModel.kt index 94041638b1..bb1e51549f 100644 --- a/feature-staking-impl/src/main/java/jp/co/soramitsu/staking/impl/presentation/setup/SetupStakingViewModel.kt +++ b/feature-staking-impl/src/main/java/jp/co/soramitsu/staking/impl/presentation/setup/SetupStakingViewModel.kt @@ -152,7 +152,9 @@ class SetupStakingViewModel @Inject constructor( assetFlow.first().availableForStaking }, calculateFee = { if (asset.type == StakingType.PARACHAIN) { - setupStakingInteractor.estimateParachainFee() + interactor.getSelectedAccountProjection()?.address?.let { address -> + setupStakingInteractor.estimateParachainFee(address) + }.orZero() } else { interactor.getSelectedAccountProjection()?.address?.let { address -> setupStakingInteractor.estimateMaxSetupStakingFee(address) @@ -188,7 +190,9 @@ class SetupStakingViewModel @Inject constructor( coroutineScope = viewModelScope, feeConstructor = { when (stakingSharedState.selectionItem.first().type) { - StakingType.PARACHAIN -> setupStakingInteractor.estimateParachainFee() + StakingType.PARACHAIN -> interactor.getSelectedAccountProjection()?.address?.let { address -> + setupStakingInteractor.estimateParachainFee(address) + }.orZero() StakingType.RELAYCHAIN -> interactor.getSelectedAccountProjection()?.address?.let { address -> setupStakingInteractor.estimateMaxSetupStakingFee(address) }.orZero() diff --git a/feature-staking-impl/src/main/java/jp/co/soramitsu/staking/impl/presentation/setup/pool/create/CreatePoolSetupViewModel.kt b/feature-staking-impl/src/main/java/jp/co/soramitsu/staking/impl/presentation/setup/pool/create/CreatePoolSetupViewModel.kt index f7bfa49c4d..21711be3a3 100644 --- a/feature-staking-impl/src/main/java/jp/co/soramitsu/staking/impl/presentation/setup/pool/create/CreatePoolSetupViewModel.kt +++ b/feature-staking-impl/src/main/java/jp/co/soramitsu/staking/impl/presentation/setup/pool/create/CreatePoolSetupViewModel.kt @@ -27,7 +27,7 @@ import jp.co.soramitsu.common.validation.MinPoolCreationThresholdException import jp.co.soramitsu.common.validation.StakeInsufficientBalanceException import jp.co.soramitsu.feature_staking_impl.R import jp.co.soramitsu.runtime.multiNetwork.chain.model.Chain -import jp.co.soramitsu.shared_utils.runtime.AccountId +import jp.co.soramitsu.fearless_utils.runtime.AccountId import jp.co.soramitsu.staking.impl.domain.StakingInteractor import jp.co.soramitsu.staking.impl.presentation.StakingRouter import jp.co.soramitsu.staking.impl.presentation.common.StakingPoolSharedStateProvider diff --git a/feature-staking-impl/src/main/java/jp/co/soramitsu/staking/impl/presentation/setup/pool/join/SetupStakingPoolViewModel.kt b/feature-staking-impl/src/main/java/jp/co/soramitsu/staking/impl/presentation/setup/pool/join/SetupStakingPoolViewModel.kt index 1d1cdb1eea..12152c4376 100644 --- a/feature-staking-impl/src/main/java/jp/co/soramitsu/staking/impl/presentation/setup/pool/join/SetupStakingPoolViewModel.kt +++ b/feature-staking-impl/src/main/java/jp/co/soramitsu/staking/impl/presentation/setup/pool/join/SetupStakingPoolViewModel.kt @@ -64,6 +64,7 @@ class SetupStakingPoolViewModel @Inject constructor( private val chain: Chain private val asset: Asset + private val address: String private val initialAmount: BigDecimal private var quickInputs: Map = emptyMap() @@ -72,6 +73,7 @@ class SetupStakingPoolViewModel @Inject constructor( chain = requireNotNull(mainState.chain) asset = requireNotNull(mainState.asset) + address = mainState.requireAddress initialAmount = mainState.requireAmount viewModelScope.launch { @@ -79,7 +81,7 @@ class SetupStakingPoolViewModel @Inject constructor( chain.id, asset.token.configuration.id, calculateAvailableAmount = { asset.transferable }, - calculateFee = { stakingPoolInteractor.estimateJoinFee(it) }) + calculateFee = { stakingPoolInteractor.estimateJoinFee(address, it) }) } } @@ -120,7 +122,7 @@ class SetupStakingPoolViewModel @Inject constructor( private val feeInPlanksFlow = enteredAmountFlow.map { amount -> val inPlanks = asset.token.planksFromAmount(amount) - stakingPoolInteractor.estimateJoinFee(inPlanks) + stakingPoolInteractor.estimateJoinFee(address, inPlanks) }.inBackground().stateIn(viewModelScope, SharingStarted.Eagerly, null) private val feeInfoViewStateFlow: Flow = feeInPlanksFlow.filterNotNull().map { feeInPlanks -> diff --git a/feature-staking-impl/src/main/java/jp/co/soramitsu/staking/impl/presentation/staking/balance/StakingBalanceViewModel.kt b/feature-staking-impl/src/main/java/jp/co/soramitsu/staking/impl/presentation/staking/balance/StakingBalanceViewModel.kt index 617130b5d9..756f4f4bcf 100644 --- a/feature-staking-impl/src/main/java/jp/co/soramitsu/staking/impl/presentation/staking/balance/StakingBalanceViewModel.kt +++ b/feature-staking-impl/src/main/java/jp/co/soramitsu/staking/impl/presentation/staking/balance/StakingBalanceViewModel.kt @@ -12,7 +12,7 @@ import jp.co.soramitsu.common.utils.inBackground import jp.co.soramitsu.common.utils.isZero import jp.co.soramitsu.common.validation.ValidationExecutor import jp.co.soramitsu.core.models.Asset -import jp.co.soramitsu.shared_utils.extensions.fromHex +import jp.co.soramitsu.fearless_utils.extensions.fromHex import jp.co.soramitsu.staking.api.domain.model.StakingState import jp.co.soramitsu.staking.impl.domain.StakingInteractor import jp.co.soramitsu.staking.impl.domain.model.Unbonding diff --git a/feature-staking-impl/src/main/java/jp/co/soramitsu/staking/impl/presentation/staking/bond/confirm/ConfirmPoolBondMoreViewModel.kt b/feature-staking-impl/src/main/java/jp/co/soramitsu/staking/impl/presentation/staking/bond/confirm/ConfirmPoolBondMoreViewModel.kt index 7ddb80cc21..6260e83738 100644 --- a/feature-staking-impl/src/main/java/jp/co/soramitsu/staking/impl/presentation/staking/bond/confirm/ConfirmPoolBondMoreViewModel.kt +++ b/feature-staking-impl/src/main/java/jp/co/soramitsu/staking/impl/presentation/staking/bond/confirm/ConfirmPoolBondMoreViewModel.kt @@ -30,7 +30,7 @@ class ConfirmPoolBondMoreViewModel @Inject constructor( resourceManager = resourceManager, asset = requireNotNull(poolSharedStateProvider.mainState.get()?.asset), amountInPlanks = requireNotNull(poolSharedStateProvider.manageState.get()?.amountInPlanks), - feeEstimator = { amount -> stakingPoolInteractor.estimateBondMoreFee(requireNotNull(amount)) }, + feeEstimator = { amount -> stakingPoolInteractor.estimateBondMoreFee(poolSharedStateProvider.requireMainState.requireAddress, requireNotNull(amount)) }, executeOperation = { address, amount -> stakingPoolInteractor.bondMore(address, requireNotNull(amount)) }, onOperationSuccess = { router.returnToManagePoolStake() }, accountNameProvider = { diff --git a/feature-staking-impl/src/main/java/jp/co/soramitsu/staking/impl/presentation/staking/bond/select/PoolBondMoreViewModel.kt b/feature-staking-impl/src/main/java/jp/co/soramitsu/staking/impl/presentation/staking/bond/select/PoolBondMoreViewModel.kt index f813e96d4a..fa1816e8db 100644 --- a/feature-staking-impl/src/main/java/jp/co/soramitsu/staking/impl/presentation/staking/bond/select/PoolBondMoreViewModel.kt +++ b/feature-staking-impl/src/main/java/jp/co/soramitsu/staking/impl/presentation/staking/bond/select/PoolBondMoreViewModel.kt @@ -27,7 +27,9 @@ class PoolBondMoreViewModel @Inject constructor( asset = requireNotNull(stakingPoolSharedStateProvider.mainState.get()?.asset), resourceManager = resourceManager, quickInputsUseCase = quickInputsUseCase, - feeEstimator = stakingPoolInteractor::estimateBondMoreFee, + feeEstimator = { amount -> + stakingPoolInteractor.estimateBondMoreFee(stakingPoolSharedStateProvider.requireMainState.requireAddress, amount) + }, onNextStep = { amount -> stakingPoolSharedStateProvider.manageState.get()?.copy(amountInPlanks = amount)?.let { stakingPoolSharedStateProvider.manageState.set(it) } router.openPoolConfirmBondMore() diff --git a/feature-staking-impl/src/main/java/jp/co/soramitsu/staking/impl/presentation/staking/bond/select/SelectBondMoreViewModel.kt b/feature-staking-impl/src/main/java/jp/co/soramitsu/staking/impl/presentation/staking/bond/select/SelectBondMoreViewModel.kt index 3bb2858786..edac64cec0 100644 --- a/feature-staking-impl/src/main/java/jp/co/soramitsu/staking/impl/presentation/staking/bond/select/SelectBondMoreViewModel.kt +++ b/feature-staking-impl/src/main/java/jp/co/soramitsu/staking/impl/presentation/staking/bond/select/SelectBondMoreViewModel.kt @@ -133,7 +133,7 @@ class SelectBondMoreViewModel @Inject constructor( stakingScenarioInteractor.getAvailableForBondMoreBalance() }, calculateFee = { amountInPlanks -> - bondMoreInteractor.estimateFee { + bondMoreInteractor.estimateFee(stashAddress()) { stakingScenarioInteractor.stakeMore( this, amountInPlanks, @@ -172,7 +172,7 @@ class SelectBondMoreViewModel @Inject constructor( feeConstructor = { token -> val amountInPlanks = token.planksFromAmount(amount) - bondMoreInteractor.estimateFee { + bondMoreInteractor.estimateFee(stashAddress()) { stakingScenarioInteractor.stakeMore(this, amountInPlanks, payload.collatorAddress) } }, diff --git a/feature-staking-impl/src/main/java/jp/co/soramitsu/staking/impl/presentation/staking/claim/ConfirmPoolClaimViewModel.kt b/feature-staking-impl/src/main/java/jp/co/soramitsu/staking/impl/presentation/staking/claim/ConfirmPoolClaimViewModel.kt index 7ab83942d7..2fc8a75096 100644 --- a/feature-staking-impl/src/main/java/jp/co/soramitsu/staking/impl/presentation/staking/claim/ConfirmPoolClaimViewModel.kt +++ b/feature-staking-impl/src/main/java/jp/co/soramitsu/staking/impl/presentation/staking/claim/ConfirmPoolClaimViewModel.kt @@ -31,7 +31,7 @@ class ConfirmPoolClaimViewModel @Inject constructor( resourceManager = resourceManager, asset = requireNotNull(poolSharedStateProvider.mainState.get()?.asset), amountInPlanks = requireNotNull(poolSharedStateProvider.manageState.get()?.claimableInPlanks), - feeEstimator = { stakingPoolInteractor.estimateClaimFee() }, + feeEstimator = { stakingPoolInteractor.estimateClaimFee(poolSharedStateProvider.requireMainState.requireAddress) }, executeOperation = { address, _ -> stakingPoolInteractor.claim(address) }, onOperationSuccess = { router.returnToManagePoolStake() }, accountNameProvider = { stakingPoolInteractor.getAccountName(it) }, diff --git a/feature-staking-impl/src/main/java/jp/co/soramitsu/staking/impl/presentation/staking/claim/PoolClaimViewModel.kt b/feature-staking-impl/src/main/java/jp/co/soramitsu/staking/impl/presentation/staking/claim/PoolClaimViewModel.kt index f09787547a..b8f994bf8e 100644 --- a/feature-staking-impl/src/main/java/jp/co/soramitsu/staking/impl/presentation/staking/claim/PoolClaimViewModel.kt +++ b/feature-staking-impl/src/main/java/jp/co/soramitsu/staking/impl/presentation/staking/claim/PoolClaimViewModel.kt @@ -27,7 +27,7 @@ class PoolClaimViewModel @Inject constructor( }, isInputActive = false, resourceManager = resourceManager, - feeEstimator = { stakingPoolInteractor.estimateClaimFee() }, + feeEstimator = { stakingPoolInteractor.estimateClaimFee(stakingPoolSharedStateProvider.requireMainState.requireAddress) }, onNextStep = { router.openPoolConfirmClaim() }, buttonValidation = { true }, errorAlertPresenter = { diff --git a/feature-staking-impl/src/main/java/jp/co/soramitsu/staking/impl/presentation/staking/controller/set/SetControllerViewModel.kt b/feature-staking-impl/src/main/java/jp/co/soramitsu/staking/impl/presentation/staking/controller/set/SetControllerViewModel.kt index 9641658c53..3f72bcf72e 100644 --- a/feature-staking-impl/src/main/java/jp/co/soramitsu/staking/impl/presentation/staking/controller/set/SetControllerViewModel.kt +++ b/feature-staking-impl/src/main/java/jp/co/soramitsu/staking/impl/presentation/staking/controller/set/SetControllerViewModel.kt @@ -167,7 +167,8 @@ class SetControllerViewModel @Inject constructor( val asset = requireNotNull(stakingInteractor.getUtilityAsset(chain)) val feeResult = runCatching { - interactor.estimateFee(controllerAddress(), chain.id) + val stakingState = accountStakingFlow.first() + interactor.estimateFee(stakingState.stashAddress, controllerAddress(), chain.id) } val value = if (feeResult.isSuccess) { diff --git a/feature-staking-impl/src/main/java/jp/co/soramitsu/staking/impl/presentation/staking/main/StakingViewModel.kt b/feature-staking-impl/src/main/java/jp/co/soramitsu/staking/impl/presentation/staking/main/StakingViewModel.kt index 8b2b4f9102..1106bf1ffd 100644 --- a/feature-staking-impl/src/main/java/jp/co/soramitsu/staking/impl/presentation/staking/main/StakingViewModel.kt +++ b/feature-staking-impl/src/main/java/jp/co/soramitsu/staking/impl/presentation/staking/main/StakingViewModel.kt @@ -262,7 +262,9 @@ class StakingViewModel @Inject constructor( calculateFee = { when (selectionItem.type) { StakingType.PARACHAIN -> { - setupStakingInteractor.estimateParachainFee() + interactor.getSelectedAccountProjection()?.address?.let { address -> + setupStakingInteractor.estimateParachainFee(address) + }.orZero() } else -> { interactor.getSelectedAccountProjection()?.address?.let { address -> diff --git a/feature-staking-impl/src/main/java/jp/co/soramitsu/staking/impl/presentation/staking/main/StakingViewStateOld.kt b/feature-staking-impl/src/main/java/jp/co/soramitsu/staking/impl/presentation/staking/main/StakingViewStateOld.kt index 1209d1b9b2..f788fbecbc 100644 --- a/feature-staking-impl/src/main/java/jp/co/soramitsu/staking/impl/presentation/staking/main/StakingViewStateOld.kt +++ b/feature-staking-impl/src/main/java/jp/co/soramitsu/staking/impl/presentation/staking/main/StakingViewStateOld.kt @@ -19,8 +19,8 @@ import jp.co.soramitsu.common.validation.ValidationExecutor import jp.co.soramitsu.feature_staking_impl.R import jp.co.soramitsu.runtime.ext.accountFromMapKey import jp.co.soramitsu.runtime.multiNetwork.chain.model.Chain -import jp.co.soramitsu.shared_utils.extensions.toHexString -import jp.co.soramitsu.shared_utils.runtime.AccountId +import jp.co.soramitsu.fearless_utils.extensions.toHexString +import jp.co.soramitsu.fearless_utils.runtime.AccountId import jp.co.soramitsu.staking.api.domain.model.CandidateInfo import jp.co.soramitsu.staking.api.domain.model.CandidateInfoStatus import jp.co.soramitsu.staking.api.domain.model.Round diff --git a/feature-staking-impl/src/main/java/jp/co/soramitsu/staking/impl/presentation/staking/main/scenarios/StakingParachainScenarioViewModel.kt b/feature-staking-impl/src/main/java/jp/co/soramitsu/staking/impl/presentation/staking/main/scenarios/StakingParachainScenarioViewModel.kt index 6133e4b466..0950d182b9 100644 --- a/feature-staking-impl/src/main/java/jp/co/soramitsu/staking/impl/presentation/staking/main/scenarios/StakingParachainScenarioViewModel.kt +++ b/feature-staking-impl/src/main/java/jp/co/soramitsu/staking/impl/presentation/staking/main/scenarios/StakingParachainScenarioViewModel.kt @@ -10,9 +10,9 @@ import jp.co.soramitsu.common.utils.withLoading import jp.co.soramitsu.common.validation.CompositeValidation import jp.co.soramitsu.common.validation.ValidationSystem import jp.co.soramitsu.feature_staking_impl.R -import jp.co.soramitsu.shared_utils.extensions.fromHex -import jp.co.soramitsu.shared_utils.extensions.requireHexPrefix -import jp.co.soramitsu.shared_utils.extensions.toHexString +import jp.co.soramitsu.fearless_utils.extensions.fromHex +import jp.co.soramitsu.fearless_utils.extensions.requireHexPrefix +import jp.co.soramitsu.fearless_utils.extensions.toHexString import jp.co.soramitsu.staking.api.domain.model.DelegatorStateStatus import jp.co.soramitsu.staking.api.domain.model.StakingState import jp.co.soramitsu.staking.impl.data.repository.datasource.ParachainStakingStoriesDataSourceImpl diff --git a/feature-staking-impl/src/main/java/jp/co/soramitsu/staking/impl/presentation/staking/main/scenarios/StakingRelaychainScenarioViewModel.kt b/feature-staking-impl/src/main/java/jp/co/soramitsu/staking/impl/presentation/staking/main/scenarios/StakingRelaychainScenarioViewModel.kt index 0a643b6ffe..5baad04986 100644 --- a/feature-staking-impl/src/main/java/jp/co/soramitsu/staking/impl/presentation/staking/main/scenarios/StakingRelaychainScenarioViewModel.kt +++ b/feature-staking-impl/src/main/java/jp/co/soramitsu/staking/impl/presentation/staking/main/scenarios/StakingRelaychainScenarioViewModel.kt @@ -14,7 +14,7 @@ import jp.co.soramitsu.common.validation.ValidationSystem import jp.co.soramitsu.core.models.Asset import jp.co.soramitsu.feature_staking_impl.R import jp.co.soramitsu.runtime.multiNetwork.chain.model.polkadotChainId -import jp.co.soramitsu.shared_utils.extensions.toHexString +import jp.co.soramitsu.fearless_utils.extensions.toHexString import jp.co.soramitsu.staking.api.data.StakingSharedState import jp.co.soramitsu.staking.api.domain.model.StakingState import jp.co.soramitsu.staking.impl.data.repository.datasource.StakingStoriesDataSource diff --git a/feature-staking-impl/src/main/java/jp/co/soramitsu/staking/impl/presentation/staking/main/scenarios/StakingScenarioRepository.kt b/feature-staking-impl/src/main/java/jp/co/soramitsu/staking/impl/presentation/staking/main/scenarios/StakingScenarioRepository.kt index 577c169712..49b49fac0b 100644 --- a/feature-staking-impl/src/main/java/jp/co/soramitsu/staking/impl/presentation/staking/main/scenarios/StakingScenarioRepository.kt +++ b/feature-staking-impl/src/main/java/jp/co/soramitsu/staking/impl/presentation/staking/main/scenarios/StakingScenarioRepository.kt @@ -2,7 +2,7 @@ package jp.co.soramitsu.staking.impl.presentation.staking.main.scenarios import jp.co.soramitsu.core.models.Asset import jp.co.soramitsu.runtime.multiNetwork.chain.model.Chain -import jp.co.soramitsu.shared_utils.runtime.AccountId +import jp.co.soramitsu.fearless_utils.runtime.AccountId import jp.co.soramitsu.staking.api.domain.model.StakingState import kotlinx.coroutines.flow.Flow diff --git a/feature-staking-impl/src/main/java/jp/co/soramitsu/staking/impl/presentation/staking/rebond/confirm/ConfirmRebondViewModel.kt b/feature-staking-impl/src/main/java/jp/co/soramitsu/staking/impl/presentation/staking/rebond/confirm/ConfirmRebondViewModel.kt index 69240f239b..cc5657c700 100644 --- a/feature-staking-impl/src/main/java/jp/co/soramitsu/staking/impl/presentation/staking/rebond/confirm/ConfirmRebondViewModel.kt +++ b/feature-staking-impl/src/main/java/jp/co/soramitsu/staking/impl/presentation/staking/rebond/confirm/ConfirmRebondViewModel.kt @@ -129,7 +129,9 @@ class ConfirmRebondViewModel @Inject constructor( coroutineScope = viewModelScope, feeConstructor = { token -> val amountInPlanks = token.planksFromAmount(payload.amount) - rebondInteractor.estimateFee { + val stakingState = accountStakingFlow.first() + + rebondInteractor.estimateFee(stakingState) { stakingScenarioInteractor.rebond( this, amountInPlanks, diff --git a/feature-staking-impl/src/main/java/jp/co/soramitsu/staking/impl/presentation/staking/rebond/custom/CustomRebondViewModel.kt b/feature-staking-impl/src/main/java/jp/co/soramitsu/staking/impl/presentation/staking/rebond/custom/CustomRebondViewModel.kt index a7800d5355..76eb13cfd5 100644 --- a/feature-staking-impl/src/main/java/jp/co/soramitsu/staking/impl/presentation/staking/rebond/custom/CustomRebondViewModel.kt +++ b/feature-staking-impl/src/main/java/jp/co/soramitsu/staking/impl/presentation/staking/rebond/custom/CustomRebondViewModel.kt @@ -62,6 +62,9 @@ class CustomRebondViewModel @Inject constructor( private val assetFlow = interactor.currentAssetFlow() .share() + private val accountStakingFlow = stakingScenarioInteractor.selectedAccountStakingStateFlow() + .share() + val assetModelFlow = assetFlow .map { mapAssetToAssetModel(it, resourceManager, Asset::unbonding, R.string.staking_unbonding_format) } .inBackground() @@ -102,7 +105,9 @@ class CustomRebondViewModel @Inject constructor( coroutineScope = viewModelScope, feeConstructor = { token -> val amountInPlanks = token.planksFromAmount(amount) - rebondInteractor.estimateFee { + val stakingState = accountStakingFlow.first() + + rebondInteractor.estimateFee(stakingState) { stakingScenarioInteractor.rebond( this, amountInPlanks, diff --git a/feature-staking-impl/src/main/java/jp/co/soramitsu/staking/impl/presentation/staking/redeem/RedeemViewModel.kt b/feature-staking-impl/src/main/java/jp/co/soramitsu/staking/impl/presentation/staking/redeem/RedeemViewModel.kt index 5fe6b97adb..0d6c7cbedf 100644 --- a/feature-staking-impl/src/main/java/jp/co/soramitsu/staking/impl/presentation/staking/redeem/RedeemViewModel.kt +++ b/feature-staking-impl/src/main/java/jp/co/soramitsu/staking/impl/presentation/staking/redeem/RedeemViewModel.kt @@ -24,7 +24,7 @@ import jp.co.soramitsu.common.validation.ValidationExecutor import jp.co.soramitsu.common.validation.progressConsumer import jp.co.soramitsu.runtime.multiNetwork.ChainRegistry import jp.co.soramitsu.runtime.multiNetwork.chain.model.getSupportedAddressExplorers -import jp.co.soramitsu.shared_utils.extensions.fromHex +import jp.co.soramitsu.fearless_utils.extensions.fromHex import jp.co.soramitsu.staking.api.domain.model.StakingState import jp.co.soramitsu.staking.impl.domain.StakingInteractor import jp.co.soramitsu.staking.impl.domain.staking.redeem.RedeemInteractor diff --git a/feature-staking-impl/src/main/java/jp/co/soramitsu/staking/impl/presentation/staking/unbond/select/SelectUnbondViewModel.kt b/feature-staking-impl/src/main/java/jp/co/soramitsu/staking/impl/presentation/staking/unbond/select/SelectUnbondViewModel.kt index 54da60f02a..0e56bac8a2 100644 --- a/feature-staking-impl/src/main/java/jp/co/soramitsu/staking/impl/presentation/staking/unbond/select/SelectUnbondViewModel.kt +++ b/feature-staking-impl/src/main/java/jp/co/soramitsu/staking/impl/presentation/staking/unbond/select/SelectUnbondViewModel.kt @@ -17,7 +17,7 @@ import jp.co.soramitsu.common.utils.requireException import jp.co.soramitsu.common.validation.ValidationExecutor import jp.co.soramitsu.common.validation.progressConsumer import jp.co.soramitsu.feature_staking_impl.R -import jp.co.soramitsu.shared_utils.extensions.fromHex +import jp.co.soramitsu.fearless_utils.extensions.fromHex import jp.co.soramitsu.staking.impl.domain.StakingInteractor import jp.co.soramitsu.staking.impl.domain.staking.unbond.UnbondInteractor import jp.co.soramitsu.staking.impl.domain.validations.unbond.UnbondValidationPayload diff --git a/feature-staking-impl/src/main/java/jp/co/soramitsu/staking/impl/presentation/validators/change/custom/search/SearchCustomValidatorsViewModel.kt b/feature-staking-impl/src/main/java/jp/co/soramitsu/staking/impl/presentation/validators/change/custom/search/SearchCustomValidatorsViewModel.kt index 2f4bd03911..ef0167aa67 100644 --- a/feature-staking-impl/src/main/java/jp/co/soramitsu/staking/impl/presentation/validators/change/custom/search/SearchCustomValidatorsViewModel.kt +++ b/feature-staking-impl/src/main/java/jp/co/soramitsu/staking/impl/presentation/validators/change/custom/search/SearchCustomValidatorsViewModel.kt @@ -10,9 +10,9 @@ import jp.co.soramitsu.common.resources.ResourceManager import jp.co.soramitsu.common.utils.inBackground import jp.co.soramitsu.core.models.Asset import jp.co.soramitsu.feature_staking_impl.R -import jp.co.soramitsu.shared_utils.extensions.fromHex -import jp.co.soramitsu.shared_utils.extensions.requireHexPrefix -import jp.co.soramitsu.shared_utils.extensions.toHexString +import jp.co.soramitsu.fearless_utils.extensions.fromHex +import jp.co.soramitsu.fearless_utils.extensions.requireHexPrefix +import jp.co.soramitsu.fearless_utils.extensions.toHexString import jp.co.soramitsu.staking.impl.domain.StakingInteractor import jp.co.soramitsu.staking.impl.domain.validators.current.search.BlockedValidatorException import jp.co.soramitsu.staking.impl.domain.validators.current.search.SearchCustomBlockProducerInteractor diff --git a/feature-staking-impl/src/main/java/jp/co/soramitsu/staking/impl/presentation/validators/compose/ConfirmSelectValidatorsViewModel.kt b/feature-staking-impl/src/main/java/jp/co/soramitsu/staking/impl/presentation/validators/compose/ConfirmSelectValidatorsViewModel.kt index 86512fff86..360c4fca51 100644 --- a/feature-staking-impl/src/main/java/jp/co/soramitsu/staking/impl/presentation/validators/compose/ConfirmSelectValidatorsViewModel.kt +++ b/feature-staking-impl/src/main/java/jp/co/soramitsu/staking/impl/presentation/validators/compose/ConfirmSelectValidatorsViewModel.kt @@ -38,7 +38,7 @@ class ConfirmSelectValidatorsViewModel @Inject constructor( val poolId = poolSharedStateProvider.requireSelectValidatorsState.requirePoolId val validators = poolSharedStateProvider.requireSelectValidatorsState.selectedValidators.toTypedArray() require(validators.isNotEmpty()) - stakingPoolInteractor.estimateNominateFee(poolId, *validators) + stakingPoolInteractor.estimateNominateFee(poolSharedStateProvider.requireMainState.requireAddress, poolId, *validators) }, executeOperation = { address, _ -> val poolId = poolSharedStateProvider.requireSelectValidatorsState.requirePoolId diff --git a/feature-staking-impl/src/main/java/jp/co/soramitsu/staking/impl/presentation/validators/compose/SelectValidatorsViewModel.kt b/feature-staking-impl/src/main/java/jp/co/soramitsu/staking/impl/presentation/validators/compose/SelectValidatorsViewModel.kt index ee1844e71e..cecbdb9530 100644 --- a/feature-staking-impl/src/main/java/jp/co/soramitsu/staking/impl/presentation/validators/compose/SelectValidatorsViewModel.kt +++ b/feature-staking-impl/src/main/java/jp/co/soramitsu/staking/impl/presentation/validators/compose/SelectValidatorsViewModel.kt @@ -13,8 +13,8 @@ import jp.co.soramitsu.common.utils.invoke import jp.co.soramitsu.common.utils.lazyAsync import jp.co.soramitsu.feature_staking_impl.R import jp.co.soramitsu.runtime.multiNetwork.chain.model.Chain -import jp.co.soramitsu.shared_utils.extensions.fromHex -import jp.co.soramitsu.shared_utils.extensions.toHexString +import jp.co.soramitsu.fearless_utils.extensions.fromHex +import jp.co.soramitsu.fearless_utils.extensions.toHexString import jp.co.soramitsu.staking.api.domain.model.Validator import jp.co.soramitsu.staking.impl.domain.StakingInteractor import jp.co.soramitsu.staking.impl.domain.recommendations.ValidatorRecommendatorFactory diff --git a/feature-staking-impl/src/main/java/jp/co/soramitsu/staking/impl/presentation/validators/compose/SelectedValidatorsViewModel.kt b/feature-staking-impl/src/main/java/jp/co/soramitsu/staking/impl/presentation/validators/compose/SelectedValidatorsViewModel.kt index 563271d701..379e95ea83 100644 --- a/feature-staking-impl/src/main/java/jp/co/soramitsu/staking/impl/presentation/validators/compose/SelectedValidatorsViewModel.kt +++ b/feature-staking-impl/src/main/java/jp/co/soramitsu/staking/impl/presentation/validators/compose/SelectedValidatorsViewModel.kt @@ -9,7 +9,7 @@ import jp.co.soramitsu.common.presentation.LoadingState import jp.co.soramitsu.common.resources.ResourceManager import jp.co.soramitsu.feature_staking_impl.R import jp.co.soramitsu.runtime.multiNetwork.chain.model.Chain -import jp.co.soramitsu.shared_utils.runtime.AccountId +import jp.co.soramitsu.fearless_utils.runtime.AccountId import jp.co.soramitsu.staking.api.domain.model.NominatedValidator import jp.co.soramitsu.staking.impl.domain.StakingInteractor import jp.co.soramitsu.staking.impl.domain.recommendations.settings.sortings.BlockProducersSorting diff --git a/feature-staking-impl/src/main/java/jp/co/soramitsu/staking/impl/presentation/validators/current/CurrentValidatorsViewModel.kt b/feature-staking-impl/src/main/java/jp/co/soramitsu/staking/impl/presentation/validators/current/CurrentValidatorsViewModel.kt index 5d713813ae..a72291b6d3 100644 --- a/feature-staking-impl/src/main/java/jp/co/soramitsu/staking/impl/presentation/validators/current/CurrentValidatorsViewModel.kt +++ b/feature-staking-impl/src/main/java/jp/co/soramitsu/staking/impl/presentation/validators/current/CurrentValidatorsViewModel.kt @@ -13,7 +13,7 @@ import jp.co.soramitsu.common.utils.withLoading import jp.co.soramitsu.feature_staking_impl.R import jp.co.soramitsu.runtime.ext.addressOf import jp.co.soramitsu.runtime.multiNetwork.chain.model.Chain -import jp.co.soramitsu.shared_utils.extensions.fromHex +import jp.co.soramitsu.fearless_utils.extensions.fromHex import jp.co.soramitsu.staking.api.domain.model.NominatedValidator import jp.co.soramitsu.staking.api.domain.model.StakingState import jp.co.soramitsu.staking.impl.domain.StakingInteractor diff --git a/feature-staking-impl/src/main/java/jp/co/soramitsu/staking/impl/presentation/validators/details/CollatorDetailsViewModel.kt b/feature-staking-impl/src/main/java/jp/co/soramitsu/staking/impl/presentation/validators/details/CollatorDetailsViewModel.kt index 08e419d1f2..50891ae9b0 100644 --- a/feature-staking-impl/src/main/java/jp/co/soramitsu/staking/impl/presentation/validators/details/CollatorDetailsViewModel.kt +++ b/feature-staking-impl/src/main/java/jp/co/soramitsu/staking/impl/presentation/validators/details/CollatorDetailsViewModel.kt @@ -20,7 +20,7 @@ import jp.co.soramitsu.common.utils.inBackground import jp.co.soramitsu.feature_staking_impl.R import jp.co.soramitsu.runtime.multiNetwork.ChainRegistry import jp.co.soramitsu.runtime.multiNetwork.chain.model.getSupportedAddressExplorers -import jp.co.soramitsu.shared_utils.extensions.fromHex +import jp.co.soramitsu.fearless_utils.extensions.fromHex import jp.co.soramitsu.staking.api.domain.model.CandidateInfoStatus import jp.co.soramitsu.staking.impl.domain.StakingInteractor import jp.co.soramitsu.staking.impl.domain.rewards.RewardCalculatorFactory diff --git a/feature-staking-impl/src/main/java/jp/co/soramitsu/staking/impl/scenarios/StakingPoolInteractor.kt b/feature-staking-impl/src/main/java/jp/co/soramitsu/staking/impl/scenarios/StakingPoolInteractor.kt index fa6185c8eb..78096deb26 100644 --- a/feature-staking-impl/src/main/java/jp/co/soramitsu/staking/impl/scenarios/StakingPoolInteractor.kt +++ b/feature-staking-impl/src/main/java/jp/co/soramitsu/staking/impl/scenarios/StakingPoolInteractor.kt @@ -11,10 +11,10 @@ import jp.co.soramitsu.runtime.ext.accountFromMapKey import jp.co.soramitsu.runtime.ext.accountIdOf import jp.co.soramitsu.runtime.multiNetwork.chain.model.Chain import jp.co.soramitsu.runtime.multiNetwork.chain.model.ChainId -import jp.co.soramitsu.shared_utils.extensions.toHexString -import jp.co.soramitsu.shared_utils.runtime.AccountId -import jp.co.soramitsu.shared_utils.ss58.SS58Encoder -import jp.co.soramitsu.shared_utils.ss58.SS58Encoder.toAccountId +import jp.co.soramitsu.fearless_utils.extensions.toHexString +import jp.co.soramitsu.fearless_utils.runtime.AccountId +import jp.co.soramitsu.fearless_utils.ss58.SS58Encoder +import jp.co.soramitsu.fearless_utils.ss58.SS58Encoder.toAccountId import jp.co.soramitsu.staking.api.domain.api.IdentityRepository import jp.co.soramitsu.staking.api.domain.model.Identity import jp.co.soramitsu.staking.api.domain.model.NominatedValidator @@ -275,11 +275,11 @@ class StakingPoolInteractor( suspend fun getLastPoolId(chainId: ChainId) = dataSource.lastPoolId(chainId) - suspend fun estimateJoinFee(amount: BigInteger, poolId: BigInteger = BigInteger.ZERO) = api.estimateJoinFee(amount, poolId) + suspend fun estimateJoinFee(address: String, amount: BigInteger, poolId: BigInteger = BigInteger.ZERO) = api.estimateJoinFee(address, amount, poolId) suspend fun joinPool(address: String, amount: BigInteger, poolId: BigInteger) = api.joinPool(address, amount, poolId) - suspend fun estimateBondMoreFee(amount: BigInteger) = api.estimateBondExtraFee(amount) + suspend fun estimateBondMoreFee(address: String, amount: BigInteger) = api.estimateBondExtraFee(address, amount) suspend fun bondMore(address: String, amount: BigInteger) = api.bondExtra(address, amount) @@ -291,7 +291,7 @@ class StakingPoolInteractor( suspend fun redeem(address: String) = api.withdrawUnbonded(address) - suspend fun estimateClaimFee() = api.estimateClaimPayoutFee() + suspend fun estimateClaimFee(address: String) = api.estimateClaimPayoutFee(address) suspend fun claim(address: String) = api.claimPayout(address) @@ -314,9 +314,10 @@ class StakingPoolInteractor( ) = api.createPool(name, poolId, amountInPlanks, rootAddress, nominatorAddress, stateToggler) suspend fun estimateNominateFee( + address: String, poolId: BigInteger, vararg validators: AccountId - ) = api.estimateNominatePoolFee(poolId, *validators) + ) = api.estimateNominatePoolFee(address, poolId, *validators) suspend fun nominate( poolId: BigInteger, @@ -324,7 +325,7 @@ class StakingPoolInteractor( vararg validators: AccountId ) = api.nominatePool(poolId, accountAddress, *validators) - suspend fun estimateEditFee(state: EditPoolFlowState) = api.estimateEditPool(state) + suspend fun estimateEditFee(state: EditPoolFlowState, address: String) = api.estimateEditPool(state, address) suspend fun edit(state: EditPoolFlowState, address: String) = api.editPool(state, address) } diff --git a/feature-staking-impl/src/main/java/jp/co/soramitsu/staking/impl/scenarios/StakingScenarioInteractor.kt b/feature-staking-impl/src/main/java/jp/co/soramitsu/staking/impl/scenarios/StakingScenarioInteractor.kt index b89646b716..7433a6c6b1 100644 --- a/feature-staking-impl/src/main/java/jp/co/soramitsu/staking/impl/scenarios/StakingScenarioInteractor.kt +++ b/feature-staking-impl/src/main/java/jp/co/soramitsu/staking/impl/scenarios/StakingScenarioInteractor.kt @@ -3,8 +3,8 @@ package jp.co.soramitsu.staking.impl.scenarios import jp.co.soramitsu.common.address.AddressModel import jp.co.soramitsu.common.validation.ValidationSystem import jp.co.soramitsu.runtime.multiNetwork.chain.model.ChainId -import jp.co.soramitsu.shared_utils.runtime.AccountId -import jp.co.soramitsu.shared_utils.runtime.extrinsic.ExtrinsicBuilder +import jp.co.soramitsu.fearless_utils.runtime.AccountId +import jp.co.soramitsu.fearless_utils.runtime.extrinsic.ExtrinsicBuilder import jp.co.soramitsu.staking.api.domain.model.RewardDestination import jp.co.soramitsu.staking.api.domain.model.StakingLedger import jp.co.soramitsu.staking.api.domain.model.StakingState diff --git a/feature-staking-impl/src/main/java/jp/co/soramitsu/staking/impl/scenarios/parachain/StakingParachainScenarioInteractor.kt b/feature-staking-impl/src/main/java/jp/co/soramitsu/staking/impl/scenarios/parachain/StakingParachainScenarioInteractor.kt index fd07d03c31..a53b587f6a 100644 --- a/feature-staking-impl/src/main/java/jp/co/soramitsu/staking/impl/scenarios/parachain/StakingParachainScenarioInteractor.kt +++ b/feature-staking-impl/src/main/java/jp/co/soramitsu/staking/impl/scenarios/parachain/StakingParachainScenarioInteractor.kt @@ -20,11 +20,11 @@ import jp.co.soramitsu.runtime.ext.accountIdOf import jp.co.soramitsu.runtime.multiNetwork.chain.model.ChainId import jp.co.soramitsu.runtime.multiNetwork.chain.model.polkadotChainId import jp.co.soramitsu.runtime.state.SingleAssetSharedState -import jp.co.soramitsu.shared_utils.extensions.fromHex -import jp.co.soramitsu.shared_utils.extensions.requireHexPrefix -import jp.co.soramitsu.shared_utils.extensions.toHexString -import jp.co.soramitsu.shared_utils.runtime.AccountId -import jp.co.soramitsu.shared_utils.runtime.extrinsic.ExtrinsicBuilder +import jp.co.soramitsu.fearless_utils.extensions.fromHex +import jp.co.soramitsu.fearless_utils.extensions.requireHexPrefix +import jp.co.soramitsu.fearless_utils.extensions.toHexString +import jp.co.soramitsu.fearless_utils.runtime.AccountId +import jp.co.soramitsu.fearless_utils.runtime.extrinsic.ExtrinsicBuilder import jp.co.soramitsu.staking.api.data.StakingSharedState import jp.co.soramitsu.staking.api.domain.api.AccountIdMap import jp.co.soramitsu.staking.api.domain.api.IdentityRepository diff --git a/feature-staking-impl/src/main/java/jp/co/soramitsu/staking/impl/scenarios/parachain/StakingParachainScenarioRepository.kt b/feature-staking-impl/src/main/java/jp/co/soramitsu/staking/impl/scenarios/parachain/StakingParachainScenarioRepository.kt index c0a9638de0..9d384003a3 100644 --- a/feature-staking-impl/src/main/java/jp/co/soramitsu/staking/impl/scenarios/parachain/StakingParachainScenarioRepository.kt +++ b/feature-staking-impl/src/main/java/jp/co/soramitsu/staking/impl/scenarios/parachain/StakingParachainScenarioRepository.kt @@ -10,13 +10,13 @@ import jp.co.soramitsu.core.runtime.storage.returnType import jp.co.soramitsu.runtime.multiNetwork.chain.model.Chain import jp.co.soramitsu.runtime.multiNetwork.chain.model.ChainId import jp.co.soramitsu.runtime.storage.source.StorageDataSource -import jp.co.soramitsu.shared_utils.extensions.toHexString -import jp.co.soramitsu.shared_utils.runtime.AccountId -import jp.co.soramitsu.shared_utils.runtime.RuntimeSnapshot -import jp.co.soramitsu.shared_utils.runtime.definitions.types.composite.Struct -import jp.co.soramitsu.shared_utils.runtime.definitions.types.fromHexOrNull -import jp.co.soramitsu.shared_utils.runtime.metadata.storage -import jp.co.soramitsu.shared_utils.runtime.metadata.storageKey +import jp.co.soramitsu.fearless_utils.extensions.toHexString +import jp.co.soramitsu.fearless_utils.runtime.AccountId +import jp.co.soramitsu.fearless_utils.runtime.RuntimeSnapshot +import jp.co.soramitsu.fearless_utils.runtime.definitions.types.composite.Struct +import jp.co.soramitsu.fearless_utils.runtime.definitions.types.fromHexOrNull +import jp.co.soramitsu.fearless_utils.runtime.metadata.storage +import jp.co.soramitsu.fearless_utils.runtime.metadata.storageKey import jp.co.soramitsu.staking.api.domain.api.AccountIdMap import jp.co.soramitsu.staking.api.domain.model.AtStake import jp.co.soramitsu.staking.api.domain.model.CandidateInfo diff --git a/feature-staking-impl/src/main/java/jp/co/soramitsu/staking/impl/scenarios/relaychain/StakingRelayChainScenarioInteractor.kt b/feature-staking-impl/src/main/java/jp/co/soramitsu/staking/impl/scenarios/relaychain/StakingRelayChainScenarioInteractor.kt index cdcb41274c..23c6984aec 100644 --- a/feature-staking-impl/src/main/java/jp/co/soramitsu/staking/impl/scenarios/relaychain/StakingRelayChainScenarioInteractor.kt +++ b/feature-staking-impl/src/main/java/jp/co/soramitsu/staking/impl/scenarios/relaychain/StakingRelayChainScenarioInteractor.kt @@ -23,14 +23,14 @@ import jp.co.soramitsu.runtime.multiNetwork.chain.model.ChainId import jp.co.soramitsu.runtime.multiNetwork.chain.model.soraMainChainId import jp.co.soramitsu.runtime.multiNetwork.chain.model.soraTestChainId import jp.co.soramitsu.runtime.state.SingleAssetSharedState -import jp.co.soramitsu.shared_utils.extensions.toHexString -import jp.co.soramitsu.shared_utils.runtime.AccountId -import jp.co.soramitsu.shared_utils.runtime.extrinsic.ExtrinsicBuilder -import jp.co.soramitsu.shared_utils.runtime.metadata.storage -import jp.co.soramitsu.shared_utils.runtime.metadata.storageKey -import jp.co.soramitsu.shared_utils.wsrpc.executeAsync -import jp.co.soramitsu.shared_utils.wsrpc.mappers.pojo -import jp.co.soramitsu.shared_utils.wsrpc.request.runtime.storage.GetStorageRequest +import jp.co.soramitsu.fearless_utils.extensions.toHexString +import jp.co.soramitsu.fearless_utils.runtime.AccountId +import jp.co.soramitsu.fearless_utils.runtime.extrinsic.ExtrinsicBuilder +import jp.co.soramitsu.fearless_utils.runtime.metadata.storage +import jp.co.soramitsu.fearless_utils.runtime.metadata.storageKey +import jp.co.soramitsu.fearless_utils.wsrpc.executeAsync +import jp.co.soramitsu.fearless_utils.wsrpc.mappers.pojo +import jp.co.soramitsu.fearless_utils.wsrpc.request.runtime.storage.GetStorageRequest import jp.co.soramitsu.staking.api.data.StakingAssetSelection import jp.co.soramitsu.staking.api.data.StakingSharedState import jp.co.soramitsu.staking.api.domain.api.AccountIdMap diff --git a/feature-staking-impl/src/main/java/jp/co/soramitsu/staking/impl/scenarios/relaychain/StakingRelayChainScenarioRepository.kt b/feature-staking-impl/src/main/java/jp/co/soramitsu/staking/impl/scenarios/relaychain/StakingRelayChainScenarioRepository.kt index 133cc22f47..98e387a7c9 100644 --- a/feature-staking-impl/src/main/java/jp/co/soramitsu/staking/impl/scenarios/relaychain/StakingRelayChainScenarioRepository.kt +++ b/feature-staking-impl/src/main/java/jp/co/soramitsu/staking/impl/scenarios/relaychain/StakingRelayChainScenarioRepository.kt @@ -28,18 +28,18 @@ import jp.co.soramitsu.runtime.multiNetwork.chain.model.Chain import jp.co.soramitsu.runtime.multiNetwork.chain.model.ChainId import jp.co.soramitsu.runtime.storage.source.StorageDataSource import jp.co.soramitsu.runtime.storage.source.queryNonNull -import jp.co.soramitsu.shared_utils.extensions.fromHex -import jp.co.soramitsu.shared_utils.extensions.toHexString -import jp.co.soramitsu.shared_utils.runtime.AccountId -import jp.co.soramitsu.shared_utils.runtime.RuntimeSnapshot -import jp.co.soramitsu.shared_utils.runtime.definitions.types.fromByteArrayOrNull -import jp.co.soramitsu.shared_utils.runtime.definitions.types.fromHex -import jp.co.soramitsu.shared_utils.runtime.metadata.module.StorageEntry -import jp.co.soramitsu.shared_utils.runtime.metadata.moduleOrNull -import jp.co.soramitsu.shared_utils.runtime.metadata.storage -import jp.co.soramitsu.shared_utils.runtime.metadata.storageKey -import jp.co.soramitsu.shared_utils.runtime.metadata.storageOrNull -import jp.co.soramitsu.shared_utils.ss58.SS58Encoder.toAccountId +import jp.co.soramitsu.fearless_utils.extensions.fromHex +import jp.co.soramitsu.fearless_utils.extensions.toHexString +import jp.co.soramitsu.fearless_utils.runtime.AccountId +import jp.co.soramitsu.fearless_utils.runtime.RuntimeSnapshot +import jp.co.soramitsu.fearless_utils.runtime.definitions.types.fromByteArrayOrNull +import jp.co.soramitsu.fearless_utils.runtime.definitions.types.fromHex +import jp.co.soramitsu.fearless_utils.runtime.metadata.module.StorageEntry +import jp.co.soramitsu.fearless_utils.runtime.metadata.moduleOrNull +import jp.co.soramitsu.fearless_utils.runtime.metadata.storage +import jp.co.soramitsu.fearless_utils.runtime.metadata.storageKey +import jp.co.soramitsu.fearless_utils.runtime.metadata.storageOrNull +import jp.co.soramitsu.fearless_utils.ss58.SS58Encoder.toAccountId import jp.co.soramitsu.staking.api.domain.api.AccountIdMap import jp.co.soramitsu.staking.api.domain.model.EraIndex import jp.co.soramitsu.staking.api.domain.model.Exposure diff --git a/feature-staking-impl/src/test/java/jp/co/soramitsu/staking/impl/AnonymousFeeEstimateGuardTest.kt b/feature-staking-impl/src/test/java/jp/co/soramitsu/staking/impl/AnonymousFeeEstimateGuardTest.kt new file mode 100644 index 0000000000..10ca33395b --- /dev/null +++ b/feature-staking-impl/src/test/java/jp/co/soramitsu/staking/impl/AnonymousFeeEstimateGuardTest.kt @@ -0,0 +1,21 @@ +package jp.co.soramitsu.staking.impl + +import java.io.File +import org.junit.Assert.assertTrue +import org.junit.Test + +class AnonymousFeeEstimateGuardTest { + + @Test + fun `staking production code does not use anonymous extrinsic fee estimates`() { + val sourceRoot = File("src/main/java") + val anonymousEstimate = Regex("""extrinsicService\s*\.\s*estimateFee\s*\(\s*chain\s*(?:,\s*false\s*)?\)\s*\{""") + val offenders = sourceRoot.walkTopDown() + .filter { it.isFile && it.extension == "kt" } + .filter { anonymousEstimate.containsMatchIn(it.readText()) } + .map { it.relativeTo(sourceRoot).path } + .toList() + + assertTrue("Anonymous fee estimates must use a real signer account: $offenders", offenders.isEmpty()) + } +} diff --git a/feature-tonconnect-api/src/main/java/jp/co/soramitsu/tonconnect/api/domain/TonConnectRepository.kt b/feature-tonconnect-api/src/main/java/jp/co/soramitsu/tonconnect/api/domain/TonConnectRepository.kt index df13e51fef..645ff3c2e9 100644 --- a/feature-tonconnect-api/src/main/java/jp/co/soramitsu/tonconnect/api/domain/TonConnectRepository.kt +++ b/feature-tonconnect-api/src/main/java/jp/co/soramitsu/tonconnect/api/domain/TonConnectRepository.kt @@ -2,7 +2,7 @@ package jp.co.soramitsu.tonconnect.api.domain import jp.co.soramitsu.coredb.model.ConnectionSource import jp.co.soramitsu.coredb.model.TonConnectionLocal -import jp.co.soramitsu.shared_utils.encrypt.keypair.Keypair +import jp.co.soramitsu.fearless_utils.encrypt.keypair.Keypair import jp.co.soramitsu.tonconnect.api.model.TonDappConnection import kotlinx.coroutines.flow.Flow diff --git a/feature-tonconnect-impl/src/main/java/jp/co/soramitsu/tonconnect/impl/data/TonConnectRepositoryImpl.kt b/feature-tonconnect-impl/src/main/java/jp/co/soramitsu/tonconnect/impl/data/TonConnectRepositoryImpl.kt index 6230208d89..4e605302ad 100644 --- a/feature-tonconnect-impl/src/main/java/jp/co/soramitsu/tonconnect/impl/data/TonConnectRepositoryImpl.kt +++ b/feature-tonconnect-impl/src/main/java/jp/co/soramitsu/tonconnect/impl/data/TonConnectRepositoryImpl.kt @@ -7,8 +7,8 @@ import jp.co.soramitsu.common.utils.invoke import jp.co.soramitsu.coredb.dao.TonConnectDao import jp.co.soramitsu.coredb.model.ConnectionSource import jp.co.soramitsu.coredb.model.TonConnectionLocal -import jp.co.soramitsu.shared_utils.encrypt.keypair.Keypair -import jp.co.soramitsu.shared_utils.scale.toHexString +import jp.co.soramitsu.fearless_utils.encrypt.keypair.Keypair +import jp.co.soramitsu.fearless_utils.scale.toHexString import jp.co.soramitsu.tonconnect.api.domain.TonConnectRepository import jp.co.soramitsu.tonconnect.api.model.TonDappConnection import kotlinx.coroutines.flow.Flow diff --git a/feature-tonconnect-impl/src/main/java/jp/co/soramitsu/tonconnect/impl/domain/TonConnectInteractorImpl.kt b/feature-tonconnect-impl/src/main/java/jp/co/soramitsu/tonconnect/impl/domain/TonConnectInteractorImpl.kt index 36df090367..b43c9b7c49 100644 --- a/feature-tonconnect-impl/src/main/java/jp/co/soramitsu/tonconnect/impl/domain/TonConnectInteractorImpl.kt +++ b/feature-tonconnect-impl/src/main/java/jp/co/soramitsu/tonconnect/impl/domain/TonConnectInteractorImpl.kt @@ -11,6 +11,10 @@ import jp.co.soramitsu.common.utils.tonAccountId import jp.co.soramitsu.core.extrinsic.keypair_provider.KeypairProvider import jp.co.soramitsu.coredb.model.ConnectionSource import jp.co.soramitsu.coredb.model.TonConnectionLocal +import jp.co.soramitsu.fearless_utils.encrypt.json.copyBytes +import jp.co.soramitsu.fearless_utils.encrypt.xsalsa20poly1305.Keys +import jp.co.soramitsu.fearless_utils.encrypt.xsalsa20poly1305.SecretBox +import jp.co.soramitsu.fearless_utils.extensions.toHexString import jp.co.soramitsu.runtime.multiNetwork.chain.ChainsRepository import jp.co.soramitsu.runtime.multiNetwork.chain.model.Chain import jp.co.soramitsu.runtime.multiNetwork.chain.model.tonMainnetChainId @@ -30,10 +34,6 @@ import jp.co.soramitsu.runtime.multiNetwork.chain.ton.storeOpCode import jp.co.soramitsu.runtime.multiNetwork.chain.ton.storeQueryId import jp.co.soramitsu.runtime.multiNetwork.chain.ton.storeSeqAndValidUntil import jp.co.soramitsu.runtime.multiNetwork.chain.ton.storeStringTail -import jp.co.soramitsu.shared_utils.encrypt.json.copyBytes -import jp.co.soramitsu.shared_utils.encrypt.xsalsa20poly1305.Keys -import jp.co.soramitsu.shared_utils.encrypt.xsalsa20poly1305.SecretBox -import jp.co.soramitsu.shared_utils.extensions.toHexString import jp.co.soramitsu.tonconnect.api.domain.TonConnectInteractor import jp.co.soramitsu.tonconnect.api.domain.TonConnectRepository import jp.co.soramitsu.tonconnect.api.model.AppEntity diff --git a/feature-tonconnect-impl/src/main/java/jp/co/soramitsu/tonconnect/impl/presentation/discoverdapp/DiscoverDappViewModel.kt b/feature-tonconnect-impl/src/main/java/jp/co/soramitsu/tonconnect/impl/presentation/discoverdapp/DiscoverDappViewModel.kt index cee8855b2b..f2e05089c2 100644 --- a/feature-tonconnect-impl/src/main/java/jp/co/soramitsu/tonconnect/impl/presentation/discoverdapp/DiscoverDappViewModel.kt +++ b/feature-tonconnect-impl/src/main/java/jp/co/soramitsu/tonconnect/impl/presentation/discoverdapp/DiscoverDappViewModel.kt @@ -17,9 +17,9 @@ import jp.co.soramitsu.common.utils.flowOf import jp.co.soramitsu.common.utils.inBackground import jp.co.soramitsu.common.utils.mapList import jp.co.soramitsu.coredb.model.ConnectionSource +import jp.co.soramitsu.fearless_utils.extensions.toHexString import jp.co.soramitsu.feature_tonconnect_impl.R import jp.co.soramitsu.runtime.multiNetwork.chain.model.ChainId -import jp.co.soramitsu.shared_utils.extensions.toHexString import jp.co.soramitsu.tonconnect.api.domain.TonConnectInteractor import jp.co.soramitsu.tonconnect.api.model.DappConfig import jp.co.soramitsu.wallet.impl.data.network.blockchain.updaters.BalanceUpdateTrigger diff --git a/feature-tonconnect-impl/src/main/java/jp/co/soramitsu/tonconnect/impl/presentation/tonsignrequest/TonSignRequestViewModel.kt b/feature-tonconnect-impl/src/main/java/jp/co/soramitsu/tonconnect/impl/presentation/tonsignrequest/TonSignRequestViewModel.kt index 4a2076bda5..e9328b4031 100644 --- a/feature-tonconnect-impl/src/main/java/jp/co/soramitsu/tonconnect/impl/presentation/tonsignrequest/TonSignRequestViewModel.kt +++ b/feature-tonconnect-impl/src/main/java/jp/co/soramitsu/tonconnect/impl/presentation/tonsignrequest/TonSignRequestViewModel.kt @@ -4,7 +4,6 @@ import androidx.lifecycle.SavedStateHandle import androidx.lifecycle.viewModelScope import com.google.gson.Gson import dagger.hilt.android.lifecycle.HiltViewModel -import io.ktor.http.Url import jp.co.soramitsu.account.api.domain.interfaces.AccountRepository import jp.co.soramitsu.account.api.domain.interfaces.TotalBalanceUseCase import jp.co.soramitsu.common.address.AddressIconGenerator @@ -159,7 +158,7 @@ class TonSignRequestViewModel @Inject constructor( ), TitleValueViewState( resourceManager.getString(R.string.common_host), - kotlin.runCatching { Url(dApp.url.orEmpty()).host }.getOrNull() + kotlin.runCatching { URL(dApp.url.orEmpty()).host }.getOrNull() ), TitleValueViewState( resourceManager.getString(R.string.common_network), diff --git a/feature-wallet-api/src/main/java/jp/co/soramitsu/wallet/api/data/cache/AssetCache.kt b/feature-wallet-api/src/main/java/jp/co/soramitsu/wallet/api/data/cache/AssetCache.kt index 221fb063a6..212fe986c4 100644 --- a/feature-wallet-api/src/main/java/jp/co/soramitsu/wallet/api/data/cache/AssetCache.kt +++ b/feature-wallet-api/src/main/java/jp/co/soramitsu/wallet/api/data/cache/AssetCache.kt @@ -7,7 +7,7 @@ import jp.co.soramitsu.coredb.dao.AssetDao import jp.co.soramitsu.coredb.dao.AssetReadOnlyCache import jp.co.soramitsu.coredb.dao.emptyAccountIdValue import jp.co.soramitsu.coredb.model.AssetLocal -import jp.co.soramitsu.shared_utils.runtime.AccountId +import jp.co.soramitsu.fearless_utils.runtime.AccountId import kotlinx.coroutines.CoroutineDispatcher import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext diff --git a/feature-wallet-api/src/main/java/jp/co/soramitsu/wallet/api/data/cache/AssetCacheExt.kt b/feature-wallet-api/src/main/java/jp/co/soramitsu/wallet/api/data/cache/AssetCacheExt.kt index 3cc75959d8..9ea2949241 100644 --- a/feature-wallet-api/src/main/java/jp/co/soramitsu/wallet/api/data/cache/AssetCacheExt.kt +++ b/feature-wallet-api/src/main/java/jp/co/soramitsu/wallet/api/data/cache/AssetCacheExt.kt @@ -14,8 +14,8 @@ import jp.co.soramitsu.common.data.network.runtime.binding.bindOrmlTokensAccount import jp.co.soramitsu.common.utils.orZero import jp.co.soramitsu.core.models.Asset import jp.co.soramitsu.coredb.model.AssetLocal -import jp.co.soramitsu.shared_utils.runtime.AccountId -import jp.co.soramitsu.shared_utils.runtime.RuntimeSnapshot +import jp.co.soramitsu.fearless_utils.runtime.AccountId +import jp.co.soramitsu.fearless_utils.runtime.RuntimeSnapshot import java.math.BigInteger suspend fun AssetCache.updateAsset( diff --git a/feature-wallet-api/src/main/java/jp/co/soramitsu/wallet/impl/domain/implementations/ExistentialDepositUseCaseImpl.kt b/feature-wallet-api/src/main/java/jp/co/soramitsu/wallet/impl/domain/implementations/ExistentialDepositUseCaseImpl.kt index 3c17054546..bdbff761a1 100644 --- a/feature-wallet-api/src/main/java/jp/co/soramitsu/wallet/impl/domain/implementations/ExistentialDepositUseCaseImpl.kt +++ b/feature-wallet-api/src/main/java/jp/co/soramitsu/wallet/impl/domain/implementations/ExistentialDepositUseCaseImpl.kt @@ -17,11 +17,11 @@ import jp.co.soramitsu.core.runtime.storage.returnType import jp.co.soramitsu.core.utils.orZero import jp.co.soramitsu.runtime.multiNetwork.ChainRegistry import jp.co.soramitsu.runtime.storage.source.StorageDataSource -import jp.co.soramitsu.shared_utils.runtime.definitions.types.composite.Struct -import jp.co.soramitsu.shared_utils.runtime.definitions.types.fromHexOrNull -import jp.co.soramitsu.shared_utils.runtime.metadata.module -import jp.co.soramitsu.shared_utils.runtime.metadata.storage -import jp.co.soramitsu.shared_utils.runtime.metadata.storageKey +import jp.co.soramitsu.fearless_utils.runtime.definitions.types.composite.Struct +import jp.co.soramitsu.fearless_utils.runtime.definitions.types.fromHexOrNull +import jp.co.soramitsu.fearless_utils.runtime.metadata.module +import jp.co.soramitsu.fearless_utils.runtime.metadata.storage +import jp.co.soramitsu.fearless_utils.runtime.metadata.storageKey import jp.co.soramitsu.wallet.api.domain.ExistentialDepositUseCase import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext diff --git a/feature-wallet-api/src/main/java/jp/co/soramitsu/wallet/impl/domain/interfaces/WalletInteractor.kt b/feature-wallet-api/src/main/java/jp/co/soramitsu/wallet/impl/domain/interfaces/WalletInteractor.kt index a58906efd4..5f5d448b8f 100644 --- a/feature-wallet-api/src/main/java/jp/co/soramitsu/wallet/impl/domain/interfaces/WalletInteractor.kt +++ b/feature-wallet-api/src/main/java/jp/co/soramitsu/wallet/impl/domain/interfaces/WalletInteractor.kt @@ -12,7 +12,7 @@ import jp.co.soramitsu.common.model.AssetBooleanState import jp.co.soramitsu.core.models.ChainId import jp.co.soramitsu.coredb.model.AddressBookContact import jp.co.soramitsu.runtime.multiNetwork.chain.model.Chain -import jp.co.soramitsu.shared_utils.runtime.AccountId +import jp.co.soramitsu.fearless_utils.runtime.AccountId import jp.co.soramitsu.wallet.impl.domain.model.Asset import jp.co.soramitsu.wallet.impl.domain.model.AssetWithStatus import jp.co.soramitsu.wallet.impl.domain.model.ControllerDeprecationWarning diff --git a/feature-wallet-api/src/main/java/jp/co/soramitsu/wallet/impl/domain/interfaces/WalletRepository.kt b/feature-wallet-api/src/main/java/jp/co/soramitsu/wallet/impl/domain/interfaces/WalletRepository.kt index 0d0e5ccb60..14fbee8f31 100644 --- a/feature-wallet-api/src/main/java/jp/co/soramitsu/wallet/impl/domain/interfaces/WalletRepository.kt +++ b/feature-wallet-api/src/main/java/jp/co/soramitsu/wallet/impl/domain/interfaces/WalletRepository.kt @@ -11,8 +11,8 @@ import jp.co.soramitsu.coredb.model.AssetUpdateItem import jp.co.soramitsu.coredb.model.PhishingLocal import jp.co.soramitsu.runtime.multiNetwork.chain.model.Chain import jp.co.soramitsu.runtime.multiNetwork.chain.model.ChainId -import jp.co.soramitsu.shared_utils.runtime.AccountId -import jp.co.soramitsu.shared_utils.runtime.extrinsic.ExtrinsicBuilder +import jp.co.soramitsu.fearless_utils.runtime.AccountId +import jp.co.soramitsu.fearless_utils.runtime.extrinsic.ExtrinsicBuilder import jp.co.soramitsu.wallet.impl.domain.model.Asset import jp.co.soramitsu.wallet.impl.domain.model.AssetWithStatus import jp.co.soramitsu.wallet.impl.domain.model.Transfer diff --git a/feature-wallet-api/src/main/java/jp/co/soramitsu/wallet/impl/domain/model/Asset.kt b/feature-wallet-api/src/main/java/jp/co/soramitsu/wallet/impl/domain/model/Asset.kt index fbcef29df4..21557d51c4 100644 --- a/feature-wallet-api/src/main/java/jp/co/soramitsu/wallet/impl/domain/model/Asset.kt +++ b/feature-wallet-api/src/main/java/jp/co/soramitsu/wallet/impl/domain/model/Asset.kt @@ -8,7 +8,7 @@ import jp.co.soramitsu.common.utils.lessThan import jp.co.soramitsu.common.utils.orZero import jp.co.soramitsu.common.utils.positiveOrNull import jp.co.soramitsu.core.utils.utilityAsset -import jp.co.soramitsu.shared_utils.runtime.AccountId +import jp.co.soramitsu.fearless_utils.runtime.AccountId import java.math.BigDecimal import java.math.BigInteger import jp.co.soramitsu.core.models.Asset as CoreAsset diff --git a/feature-wallet-impl/build.gradle b/feature-wallet-impl/build.gradle index 72eccf1d9b..cc56ecad87 100644 --- a/feature-wallet-impl/build.gradle +++ b/feature-wallet-impl/build.gradle @@ -16,26 +16,26 @@ android { testInstrumentationRunner "android.support.test.runner.AndroidJUnitRunner" - buildConfigField "String", "RAMP_TOKEN", "\"4bk3yhfrg99fer764bo7egrqmxbfene7gbrpwmp3\"" + buildConfigField "String", "RAMP_TOKEN", readOptionalSecretInQuotes("RAMP_TOKEN_DEBUG") buildConfigField "String", "RAMP_HOST", "\"ri-widget-staging.firebaseapp.com\"" buildConfigField "String", "COINBASE_HOST", "\"pay.coinbase.com/buy/select-asset\"" - buildConfigField "String", "COINBASE_APP_ID", "\"396933a1-26e6-4b65-b442-637d260e05c2\"" + buildConfigField "String", "COINBASE_APP_ID", readOptionalSecretInQuotes("COINBASE_APP_ID") buildConfigField "String", "MOONPAY_PRIVATE_KEY", readSecretInQuotes("MOONPAY_TEST_SECRET") buildConfigField "String", "MOONPAY_HOST", "\"buy-staging.moonpay.com\"" - buildConfigField "String", "MOONPAY_PUBLIC_KEY", "\"pk_test_DMRuyL6Nf1qc9OzjPBmCFBeCGkFwiZs0\"" + buildConfigField "String", "MOONPAY_PUBLIC_KEY", readOptionalSecretInQuotes("MOONPAY_TEST_PUBLIC_KEY") buildConfigField "String", "SCAM_DETECTION_CONFIG", "\"https://raw.githubusercontent.com/soramitsu/shared-features-utils/master/scamDetection/Polkadot_Hot_Wallet_Attributions.csv\"" } buildTypes { release { - buildConfigField "String", "RAMP_TOKEN", "\"3quzr4e6wdyccndec8jzjebzar5kxxzfy2f3us5k\"" + buildConfigField "String", "RAMP_TOKEN", readOptionalSecretInQuotes("RAMP_TOKEN_RELEASE") buildConfigField "String", "RAMP_HOST", "\"app.ramp.network\"" buildConfigField "String", "MOONPAY_PRIVATE_KEY", readSecretInQuotes("MOONPAY_PRODUCTION_SECRET") - buildConfigField "String", "MOONPAY_PUBLIC_KEY", "\"pk_live_Boi6Rl107p7XuJWBL8GJRzGWlmUSoxbz\"" + buildConfigField "String", "MOONPAY_PUBLIC_KEY", readOptionalSecretInQuotes("MOONPAY_PRODUCTION_PUBLIC_KEY") buildConfigField "String", "MOONPAY_HOST", "\"buy.moonpay.com\"" } } @@ -59,7 +59,6 @@ android { } dependencies { - implementation fileTree(dir: 'libs', include: ['*.jar']) implementation project(':core-db') implementation project(':common') implementation project(':feature-wallet-api') diff --git a/feature-wallet-impl/libs/pushpayment-core-sdk-2.0.6.jar b/feature-wallet-impl/libs/pushpayment-core-sdk-2.0.6.jar deleted file mode 100644 index ebc7c90ee0..0000000000 Binary files a/feature-wallet-impl/libs/pushpayment-core-sdk-2.0.6.jar and /dev/null differ diff --git a/feature-wallet-impl/src/main/java/jp/co/soramitsu/wallet/impl/data/historySource/AtletaHistorySource.kt b/feature-wallet-impl/src/main/java/jp/co/soramitsu/wallet/impl/data/historySource/AtletaHistorySource.kt index 887bd92977..b08b2293dc 100644 --- a/feature-wallet-impl/src/main/java/jp/co/soramitsu/wallet/impl/data/historySource/AtletaHistorySource.kt +++ b/feature-wallet-impl/src/main/java/jp/co/soramitsu/wallet/impl/data/historySource/AtletaHistorySource.kt @@ -7,7 +7,7 @@ import java.util.Locale import jp.co.soramitsu.common.data.model.CursorPage import jp.co.soramitsu.core.models.Asset import jp.co.soramitsu.runtime.multiNetwork.chain.model.Chain -import jp.co.soramitsu.shared_utils.runtime.AccountId +import jp.co.soramitsu.fearless_utils.runtime.AccountId import jp.co.soramitsu.wallet.impl.data.network.subquery.OperationsHistoryApi import jp.co.soramitsu.wallet.impl.domain.interfaces.TransactionFilter import jp.co.soramitsu.wallet.impl.domain.model.Operation diff --git a/feature-wallet-impl/src/main/java/jp/co/soramitsu/wallet/impl/data/historySource/BitcoinHistorySource.kt b/feature-wallet-impl/src/main/java/jp/co/soramitsu/wallet/impl/data/historySource/BitcoinHistorySource.kt new file mode 100644 index 0000000000..f3510449ea --- /dev/null +++ b/feature-wallet-impl/src/main/java/jp/co/soramitsu/wallet/impl/data/historySource/BitcoinHistorySource.kt @@ -0,0 +1,92 @@ +package jp.co.soramitsu.wallet.impl.data.historySource + +import java.math.BigInteger +import jp.co.soramitsu.common.data.model.CursorPage +import jp.co.soramitsu.common.data.network.bitcoin.BitcoinIndexerRoutes +import jp.co.soramitsu.common.data.network.bitcoin.BitcoinTransactionHistoryException +import jp.co.soramitsu.common.data.network.bitcoin.BitcoinTransactionHistorySync +import jp.co.soramitsu.core.models.Asset +import jp.co.soramitsu.runtime.multiNetwork.chain.model.Chain +import jp.co.soramitsu.fearless_utils.runtime.AccountId +import jp.co.soramitsu.wallet.impl.domain.interfaces.TransactionFilter +import jp.co.soramitsu.wallet.impl.domain.model.Operation +import kotlin.time.DurationUnit +import kotlin.time.toDuration + +class BitcoinHistorySource( + private val historySync: BitcoinTransactionHistorySync, + private val historyUrl: String +) : HistorySource { + + override suspend fun getOperations( + pageSize: Int, + cursor: String?, + filters: Set, + accountId: AccountId, + chain: Chain, + chainAsset: Asset, + accountAddress: String + ): CursorPage { + if (pageSize <= 0 || TransactionFilter.TRANSFER !in filters || !chainAsset.symbol.equals(BITCOIN_SYMBOL, ignoreCase = true)) { + return CursorPage(null, emptyList()) + } + + return runCatching { + val network = if (chain.isTestNet) { + BitcoinIndexerRoutes.Network.Testnet + } else { + BitcoinIndexerRoutes.Network.Mainnet + } + val page = if (cursor.isNullOrBlank()) { + historySync.historyWindow( + address = accountAddress, + network = network, + baseUrl = historyUrl + ) + } else { + historySync.history( + address = accountAddress, + network = network, + baseUrl = historyUrl, + lastSeenTxid = cursor + ) + } + val visibleEntries = page.entries.take(pageSize) + val operations = visibleEntries.map { entry -> + Operation( + id = entry.txid, + address = accountAddress, + time = entry.timestamp.toDuration(DurationUnit.SECONDS).inWholeMilliseconds, + chainAsset = chainAsset, + type = Operation.Type.Transfer( + hash = entry.txid, + myAddress = accountAddress, + amount = BigInteger.valueOf(entry.amountSats), + receiver = entry.to, + sender = entry.from, + status = if (entry.confirmed) Operation.Status.COMPLETED else Operation.Status.PENDING, + fee = BigInteger.valueOf(entry.feeSats) + ) + ) + } + + CursorPage( + nextCursor = when { + visibleEntries.isEmpty() -> null + visibleEntries.size < page.entries.size -> visibleEntries.last().txid + else -> page.nextLastSeenTxid + }, + items = operations + ) + }.getOrElse { error -> + when (error) { + is BitcoinTransactionHistoryException -> CursorPage(null, emptyList()) + else -> CursorPage(null, emptyList()) + } + } + } + + private companion object { + const val BITCOIN_SYMBOL = "BTC" + } +} diff --git a/feature-wallet-impl/src/main/java/jp/co/soramitsu/wallet/impl/data/historySource/BlockscoutHistorySource.kt b/feature-wallet-impl/src/main/java/jp/co/soramitsu/wallet/impl/data/historySource/BlockscoutHistorySource.kt index f84805fa7a..b101676aab 100644 --- a/feature-wallet-impl/src/main/java/jp/co/soramitsu/wallet/impl/data/historySource/BlockscoutHistorySource.kt +++ b/feature-wallet-impl/src/main/java/jp/co/soramitsu/wallet/impl/data/historySource/BlockscoutHistorySource.kt @@ -9,8 +9,8 @@ import jp.co.soramitsu.common.utils.orZero import jp.co.soramitsu.core.models.Asset import jp.co.soramitsu.core.models.ChainAssetType import jp.co.soramitsu.runtime.multiNetwork.chain.model.Chain -import jp.co.soramitsu.shared_utils.extensions.toHexString -import jp.co.soramitsu.shared_utils.runtime.AccountId +import jp.co.soramitsu.fearless_utils.extensions.toHexString +import jp.co.soramitsu.fearless_utils.runtime.AccountId import jp.co.soramitsu.wallet.impl.data.network.subquery.OperationsHistoryApi import jp.co.soramitsu.wallet.impl.domain.interfaces.TransactionFilter import jp.co.soramitsu.wallet.impl.domain.model.Operation diff --git a/feature-wallet-impl/src/main/java/jp/co/soramitsu/wallet/impl/data/historySource/EtherscanHistorySource.kt b/feature-wallet-impl/src/main/java/jp/co/soramitsu/wallet/impl/data/historySource/EtherscanHistorySource.kt index a505c93ff1..cf2ed3b625 100644 --- a/feature-wallet-impl/src/main/java/jp/co/soramitsu/wallet/impl/data/historySource/EtherscanHistorySource.kt +++ b/feature-wallet-impl/src/main/java/jp/co/soramitsu/wallet/impl/data/historySource/EtherscanHistorySource.kt @@ -13,8 +13,8 @@ import jp.co.soramitsu.runtime.multiNetwork.chain.model.ethereumChainId import jp.co.soramitsu.runtime.multiNetwork.chain.model.goerliChainId import jp.co.soramitsu.runtime.multiNetwork.chain.model.polygonChainId import jp.co.soramitsu.runtime.multiNetwork.chain.model.polygonTestnetChainId -import jp.co.soramitsu.shared_utils.extensions.toHexString -import jp.co.soramitsu.shared_utils.runtime.AccountId +import jp.co.soramitsu.fearless_utils.extensions.toHexString +import jp.co.soramitsu.fearless_utils.runtime.AccountId import jp.co.soramitsu.wallet.impl.data.network.subquery.OperationsHistoryApi import jp.co.soramitsu.wallet.impl.domain.interfaces.TransactionFilter import jp.co.soramitsu.wallet.impl.domain.model.Operation diff --git a/feature-wallet-impl/src/main/java/jp/co/soramitsu/wallet/impl/data/historySource/FireHistorySource.kt b/feature-wallet-impl/src/main/java/jp/co/soramitsu/wallet/impl/data/historySource/FireHistorySource.kt index 17b04cc7d5..4134a993d4 100644 --- a/feature-wallet-impl/src/main/java/jp/co/soramitsu/wallet/impl/data/historySource/FireHistorySource.kt +++ b/feature-wallet-impl/src/main/java/jp/co/soramitsu/wallet/impl/data/historySource/FireHistorySource.kt @@ -7,7 +7,7 @@ import java.util.Locale import jp.co.soramitsu.common.data.model.CursorPage import jp.co.soramitsu.core.models.Asset import jp.co.soramitsu.runtime.multiNetwork.chain.model.Chain -import jp.co.soramitsu.shared_utils.runtime.AccountId +import jp.co.soramitsu.fearless_utils.runtime.AccountId import jp.co.soramitsu.wallet.impl.data.network.subquery.OperationsHistoryApi import jp.co.soramitsu.wallet.impl.domain.interfaces.TransactionFilter import jp.co.soramitsu.wallet.impl.domain.model.Operation diff --git a/feature-wallet-impl/src/main/java/jp/co/soramitsu/wallet/impl/data/historySource/GiantsquidHistorySource.kt b/feature-wallet-impl/src/main/java/jp/co/soramitsu/wallet/impl/data/historySource/GiantsquidHistorySource.kt index edcd3a1487..d17643b2cb 100644 --- a/feature-wallet-impl/src/main/java/jp/co/soramitsu/wallet/impl/data/historySource/GiantsquidHistorySource.kt +++ b/feature-wallet-impl/src/main/java/jp/co/soramitsu/wallet/impl/data/historySource/GiantsquidHistorySource.kt @@ -5,7 +5,7 @@ import jp.co.soramitsu.common.data.model.CursorPage import jp.co.soramitsu.common.utils.orZero import jp.co.soramitsu.core.models.Asset import jp.co.soramitsu.runtime.multiNetwork.chain.model.Chain -import jp.co.soramitsu.shared_utils.runtime.AccountId +import jp.co.soramitsu.fearless_utils.runtime.AccountId import jp.co.soramitsu.wallet.impl.data.network.model.request.GiantsquidHistoryRequest import jp.co.soramitsu.wallet.impl.data.network.subquery.OperationsHistoryApi import jp.co.soramitsu.wallet.impl.domain.interfaces.TransactionFilter diff --git a/feature-wallet-impl/src/main/java/jp/co/soramitsu/wallet/impl/data/historySource/HistorySource.kt b/feature-wallet-impl/src/main/java/jp/co/soramitsu/wallet/impl/data/historySource/HistorySource.kt index 3ed8ad8e0f..3c9cdd0ec8 100644 --- a/feature-wallet-impl/src/main/java/jp/co/soramitsu/wallet/impl/data/historySource/HistorySource.kt +++ b/feature-wallet-impl/src/main/java/jp/co/soramitsu/wallet/impl/data/historySource/HistorySource.kt @@ -3,7 +3,7 @@ package jp.co.soramitsu.wallet.impl.data.historySource import jp.co.soramitsu.common.data.model.CursorPage import jp.co.soramitsu.core.models.Asset import jp.co.soramitsu.runtime.multiNetwork.chain.model.Chain -import jp.co.soramitsu.shared_utils.runtime.AccountId +import jp.co.soramitsu.fearless_utils.runtime.AccountId import jp.co.soramitsu.wallet.impl.domain.interfaces.TransactionFilter import jp.co.soramitsu.wallet.impl.domain.model.Operation diff --git a/feature-wallet-impl/src/main/java/jp/co/soramitsu/wallet/impl/data/historySource/HistorySourceProvider.kt b/feature-wallet-impl/src/main/java/jp/co/soramitsu/wallet/impl/data/historySource/HistorySourceProvider.kt index b17985192a..ea6ec4784c 100644 --- a/feature-wallet-impl/src/main/java/jp/co/soramitsu/wallet/impl/data/historySource/HistorySourceProvider.kt +++ b/feature-wallet-impl/src/main/java/jp/co/soramitsu/wallet/impl/data/historySource/HistorySourceProvider.kt @@ -1,17 +1,22 @@ package jp.co.soramitsu.wallet.impl.data.historySource +import jp.co.soramitsu.common.data.network.bitcoin.BitcoinTransactionHistorySync +import jp.co.soramitsu.common.data.network.iroha.IrohaToriiClient +import jp.co.soramitsu.common.data.network.solana.SolanaTransactionHistorySync import jp.co.soramitsu.runtime.multiNetwork.ChainRegistry import jp.co.soramitsu.runtime.multiNetwork.chain.model.Chain import jp.co.soramitsu.runtime.multiNetwork.chain.remote.TonRemoteSource import jp.co.soramitsu.wallet.impl.data.network.subquery.OperationsHistoryApi -import jp.co.soramitsu.xnetworking.lib.datasources.txhistory.api.TxHistoryRepository import jp.co.soramitsu.xnetworking.lib.datasources.txhistory.api.adapters.HistoryInfoRemoteLoader class HistorySourceProvider( private val walletOperationsApi: OperationsHistoryApi, private val chainRegistry: ChainRegistry, private val historyInfoRemoteLoader: HistoryInfoRemoteLoader, - private val tonRemoteSource: TonRemoteSource + private val tonRemoteSource: TonRemoteSource, + private val bitcoinTransactionHistorySync: BitcoinTransactionHistorySync, + private val solanaTransactionHistorySync: SolanaTransactionHistorySync, + private val irohaToriiClient: IrohaToriiClient ) { operator fun invoke(historyUrl: String, historyType: Chain.ExternalApi.Section.Type): HistorySource? { return when (historyType) { @@ -28,6 +33,9 @@ class HistorySourceProvider( Chain.ExternalApi.Section.Type.VICSCAN -> VicscanHistorySource(walletOperationsApi, historyUrl) Chain.ExternalApi.Section.Type.ZCHAINS -> ZchainsHistorySource(walletOperationsApi, historyUrl) Chain.ExternalApi.Section.Type.TON -> TonHistorySource(tonRemoteSource, historyUrl) + Chain.ExternalApi.Section.Type.BITCOIN -> BitcoinHistorySource(bitcoinTransactionHistorySync, historyUrl) + Chain.ExternalApi.Section.Type.SOLANA -> SolanaHistorySource(solanaTransactionHistorySync, historyUrl) + Chain.ExternalApi.Section.Type.IROHA -> IrohaHistorySource(irohaToriiClient, historyUrl) else -> null } } diff --git a/feature-wallet-impl/src/main/java/jp/co/soramitsu/wallet/impl/data/historySource/IrohaHistorySource.kt b/feature-wallet-impl/src/main/java/jp/co/soramitsu/wallet/impl/data/historySource/IrohaHistorySource.kt new file mode 100644 index 0000000000..279a477745 --- /dev/null +++ b/feature-wallet-impl/src/main/java/jp/co/soramitsu/wallet/impl/data/historySource/IrohaHistorySource.kt @@ -0,0 +1,209 @@ +package jp.co.soramitsu.wallet.impl.data.historySource + +import java.math.BigInteger +import java.time.Instant +import jp.co.soramitsu.common.data.model.CursorPage +import jp.co.soramitsu.common.data.network.iroha.IrohaMcpJsonRpcRequest +import jp.co.soramitsu.common.data.network.iroha.IrohaToriiClient +import jp.co.soramitsu.common.data.network.iroha.IrohaToriiRoutes +import jp.co.soramitsu.core.models.Asset +import jp.co.soramitsu.fearless_utils.runtime.AccountId +import jp.co.soramitsu.runtime.ext.normalizedIrohaAddress +import jp.co.soramitsu.runtime.ext.universalWalletIrohaNetwork +import jp.co.soramitsu.runtime.multiNetwork.chain.model.Chain +import jp.co.soramitsu.wallet.impl.domain.interfaces.TransactionFilter +import jp.co.soramitsu.wallet.impl.domain.model.Operation + +class IrohaHistorySource( + private val toriiClient: IrohaToriiClient, + private val historyUrl: String +) : HistorySource { + + override suspend fun getOperations( + pageSize: Int, + cursor: String?, + filters: Set, + accountId: AccountId, + chain: Chain, + chainAsset: Asset, + accountAddress: String + ): CursorPage { + if (pageSize <= 0 || TransactionFilter.TRANSFER !in filters) { + return CursorPage(null, emptyList()) + } + + val network = chain.universalWalletIrohaNetwork() ?: return CursorPage(null, emptyList()) + val normalizedAddress = chain.normalizedIrohaAddress(accountAddress) ?: return CursorPage(null, emptyList()) + val page = cursor?.toIntOrNull()?.takeIf { it >= 0 } ?: 0 + val limit = pageSize.coerceAtMost(IrohaToriiRoutes.MAX_LIMIT) + + return runCatching { + val response = toriiClient.mcpJsonRpc( + request = IrohaMcpJsonRpcRequest( + id = "history-$page", + method = "tools/call", + params = mapOf( + "name" to "iroha.instructions.list", + "arguments" to mapOf( + "account" to normalizedAddress, + "asset_id" to chainAsset.id, + "kind" to "Transfer", + "page" to page, + "per_page" to limit, + "transaction_status" to "committed", + "accept" to "application/json" + ) + ) + ), + network = network, + baseUrl = historyUrl.takeIf(String::isNotBlank) + ) + + if (response.error != null) return@runCatching CursorPage(null, emptyList()) + + val items = response.result.extractInstructionItems() + val operations = items.flatMapIndexed { index, item -> + item.toTransferOperations( + index = index, + accountAddress = normalizedAddress, + chainAsset = chainAsset + ) + } + + CursorPage( + nextCursor = if (items.size >= limit) (page + 1).toString() else null, + items = operations + ) + }.getOrDefault(CursorPage(null, emptyList())) + } + + private fun Any?.extractInstructionItems(): List> { + val result = this as? Map<*, *> ?: return emptyList() + val body = result["body"] as? Map<*, *> ?: result + val items = body["items"] as? List<*> ?: return emptyList() + + return items.mapNotNull { it as? Map<*, *> } + } + + private fun Map<*, *>.toTransferOperations( + index: Int, + accountAddress: String, + chainAsset: Asset + ): List { + val hash = stringValue("transaction_hash", "transactionHash", "hash") ?: return emptyList() + val timestamp = timestampMillis(stringValue("created_at", "createdAt", "timestamp")) ?: return emptyList() + val payload = (((this["box"] as? Map<*, *>)?.get("json") as? Map<*, *>)?.get("payload") as? Map<*, *>) + ?: return emptyList() + val success = !stringValue("transaction_status", "transactionStatus", "status").equals("Rejected", ignoreCase = true) + val transfers = payload.transferPayloads(accountAddress, chainAsset.id) + + return transfers.mapIndexed { transferIndex, transfer -> + val id = if (transfers.size == 1) hash else "$hash:${index + transferIndex}" + Operation( + id = id, + address = accountAddress, + time = timestamp, + chainAsset = chainAsset, + type = Operation.Type.Transfer( + hash = hash, + myAddress = accountAddress, + amount = transfer.amount, + receiver = transfer.to, + sender = transfer.from, + status = Operation.Status.fromSuccess(success), + fee = BigInteger.ZERO + ) + ) + } + } + + private fun Map<*, *>.transferPayloads(accountAddress: String, assetId: String): List { + val variant = stringValue("variant") + val value = this["value"] + + return when (variant) { + "Asset" -> listOfNotNull((value as? Map<*, *>)?.assetTransfer(accountAddress, assetId)) + "AssetBatch" -> (value as? Map<*, *>) + ?.firstList("entries", "transfers", "items") + ?.mapNotNull { (it as? Map<*, *>)?.assetTransfer(accountAddress, assetId) } + .orEmpty() + else -> emptyList() + } + } + + private fun Map<*, *>.assetTransfer(accountAddress: String, assetId: String): IrohaTransferPayload? { + val source = stringValue("source", "source_id", "asset", "asset_id") + val destination = stringValue("destination", "destination_id", "to", "account_id") ?: return null + val amount = normalizeIrohaAmount(this["object"] ?: this["amount"] ?: this["quantity"] ?: this["value"]) ?: return null + + if (source != null && !source.irohaAssetMatches(assetId)) return null + + val sourceAccount = stringValue("source_account", "from", "account") ?: source.extractIrohaAssetAccount() + val incoming = destination.equals(accountAddress, ignoreCase = true) + val outgoing = sourceAccount?.equals(accountAddress, ignoreCase = true) == true || source?.contains(accountAddress) == true + + if (!incoming && !outgoing) return null + + return IrohaTransferPayload( + amount = amount, + from = if (outgoing) accountAddress else sourceAccount.orEmpty(), + to = if (incoming) accountAddress else destination + ) + } + + private fun normalizeIrohaAmount(value: Any?): BigInteger? { + return when (value) { + is String -> value.trim().takeIf { UNSIGNED_INTEGER.matches(it) }?.let(::BigInteger) + is Number -> value.toLong().takeIf { value.toDouble() == it.toDouble() && it > 0 }?.let(BigInteger::valueOf) + is Map<*, *> -> { + val scale = value["scale"] + if (scale != null && scale.toString() != "0") return null + normalizeIrohaAmount(value["value"] ?: value["amount"] ?: value["mantissa"]) + } + else -> null + }?.takeIf { it.signum() > 0 } + } + + private fun timestampMillis(value: String?): Long? { + val timestamp = value?.trim()?.takeIf(String::isNotEmpty) ?: return null + if (timestamp.all(Char::isDigit)) { + val raw = timestamp.toLongOrNull() ?: return null + return if (raw < MILLIS_THRESHOLD) raw * 1000 else raw + } + + return runCatching { Instant.parse(timestamp).toEpochMilli() }.getOrNull() + } + + private fun Map<*, *>.stringValue(vararg keys: String): String? { + return keys.firstNotNullOfOrNull { key -> + (this[key] as? String)?.trim()?.takeIf(String::isNotEmpty) + } + } + + private fun Map<*, *>.firstList(vararg keys: String): List<*>? { + return keys.firstNotNullOfOrNull { key -> this[key] as? List<*> } + } + + private fun String?.extractIrohaAssetAccount(): String? { + val source = this ?: return null + val separatorIndex = source.lastIndexOf('#') + + return source.takeIf { separatorIndex >= 0 && separatorIndex < source.lastIndex } + ?.substring(separatorIndex + 1) + } + + private fun String.irohaAssetMatches(assetId: String): Boolean { + return this == assetId || startsWith("$assetId#") + } + + private data class IrohaTransferPayload( + val amount: BigInteger, + val from: String, + val to: String + ) + + private companion object { + const val MILLIS_THRESHOLD = 10_000_000_000L + val UNSIGNED_INTEGER = Regex("^[1-9][0-9]*$") + } +} diff --git a/feature-wallet-impl/src/main/java/jp/co/soramitsu/wallet/impl/data/historySource/KlaytnHistorySource.kt b/feature-wallet-impl/src/main/java/jp/co/soramitsu/wallet/impl/data/historySource/KlaytnHistorySource.kt index 43867990ac..7db736449d 100644 --- a/feature-wallet-impl/src/main/java/jp/co/soramitsu/wallet/impl/data/historySource/KlaytnHistorySource.kt +++ b/feature-wallet-impl/src/main/java/jp/co/soramitsu/wallet/impl/data/historySource/KlaytnHistorySource.kt @@ -3,7 +3,7 @@ package jp.co.soramitsu.wallet.impl.data.historySource import jp.co.soramitsu.common.data.model.CursorPage import jp.co.soramitsu.core.models.Asset import jp.co.soramitsu.runtime.multiNetwork.chain.model.Chain -import jp.co.soramitsu.shared_utils.runtime.AccountId +import jp.co.soramitsu.fearless_utils.runtime.AccountId import jp.co.soramitsu.wallet.impl.data.network.subquery.OperationsHistoryApi import jp.co.soramitsu.wallet.impl.domain.interfaces.TransactionFilter import jp.co.soramitsu.wallet.impl.domain.model.Operation diff --git a/feature-wallet-impl/src/main/java/jp/co/soramitsu/wallet/impl/data/historySource/OkLinkHistorySource.kt b/feature-wallet-impl/src/main/java/jp/co/soramitsu/wallet/impl/data/historySource/OkLinkHistorySource.kt index 54e5cd7bc4..81f3319f92 100644 --- a/feature-wallet-impl/src/main/java/jp/co/soramitsu/wallet/impl/data/historySource/OkLinkHistorySource.kt +++ b/feature-wallet-impl/src/main/java/jp/co/soramitsu/wallet/impl/data/historySource/OkLinkHistorySource.kt @@ -3,8 +3,8 @@ package jp.co.soramitsu.wallet.impl.data.historySource import jp.co.soramitsu.common.data.model.CursorPage import jp.co.soramitsu.core.models.Asset import jp.co.soramitsu.runtime.multiNetwork.chain.model.Chain -import jp.co.soramitsu.shared_utils.extensions.toHexString -import jp.co.soramitsu.shared_utils.runtime.AccountId +import jp.co.soramitsu.fearless_utils.extensions.toHexString +import jp.co.soramitsu.fearless_utils.runtime.AccountId import jp.co.soramitsu.wallet.impl.data.network.subquery.OperationsHistoryApi import jp.co.soramitsu.wallet.impl.domain.interfaces.TransactionFilter import jp.co.soramitsu.wallet.impl.domain.model.Operation diff --git a/feature-wallet-impl/src/main/java/jp/co/soramitsu/wallet/impl/data/historySource/ReefHistorySource.kt b/feature-wallet-impl/src/main/java/jp/co/soramitsu/wallet/impl/data/historySource/ReefHistorySource.kt index 12f86777d5..e60b748fdc 100644 --- a/feature-wallet-impl/src/main/java/jp/co/soramitsu/wallet/impl/data/historySource/ReefHistorySource.kt +++ b/feature-wallet-impl/src/main/java/jp/co/soramitsu/wallet/impl/data/historySource/ReefHistorySource.kt @@ -8,7 +8,7 @@ import jp.co.soramitsu.common.data.model.CursorPage import jp.co.soramitsu.common.utils.orZero import jp.co.soramitsu.core.models.Asset import jp.co.soramitsu.runtime.multiNetwork.chain.model.Chain -import jp.co.soramitsu.shared_utils.runtime.AccountId +import jp.co.soramitsu.fearless_utils.runtime.AccountId import jp.co.soramitsu.wallet.impl.data.network.model.request.ReefRequestBuilder import jp.co.soramitsu.wallet.impl.data.network.subquery.OperationsHistoryApi import jp.co.soramitsu.wallet.impl.domain.interfaces.TransactionFilter diff --git a/feature-wallet-impl/src/main/java/jp/co/soramitsu/wallet/impl/data/historySource/SolanaHistorySource.kt b/feature-wallet-impl/src/main/java/jp/co/soramitsu/wallet/impl/data/historySource/SolanaHistorySource.kt new file mode 100644 index 0000000000..7ed7619e4d --- /dev/null +++ b/feature-wallet-impl/src/main/java/jp/co/soramitsu/wallet/impl/data/historySource/SolanaHistorySource.kt @@ -0,0 +1,138 @@ +package jp.co.soramitsu.wallet.impl.data.historySource + +import java.math.BigInteger +import jp.co.soramitsu.common.data.model.CursorPage +import jp.co.soramitsu.common.data.network.solana.SolanaIndexerRoutes +import jp.co.soramitsu.common.data.network.solana.SolanaTransactionHistoryException +import jp.co.soramitsu.common.data.network.solana.SolanaTransactionHistorySync +import jp.co.soramitsu.common.model.UniversalWalletIndexedOperationType +import jp.co.soramitsu.common.model.UniversalWalletIndexedTransaction +import jp.co.soramitsu.common.model.UniversalWalletIndexedTransactionDirection +import jp.co.soramitsu.common.model.UniversalWalletIndexedTransactionStatus +import jp.co.soramitsu.common.model.UniversalWalletRegistry +import jp.co.soramitsu.core.models.Asset +import jp.co.soramitsu.fearless_utils.runtime.AccountId +import jp.co.soramitsu.runtime.ext.universalWalletSolanaIndexerNetwork +import jp.co.soramitsu.runtime.multiNetwork.chain.model.Chain +import jp.co.soramitsu.wallet.impl.domain.interfaces.TransactionFilter +import jp.co.soramitsu.wallet.impl.domain.model.Operation + +class SolanaHistorySource( + private val historySync: SolanaTransactionHistorySync, + private val historyUrl: String +) : HistorySource { + + override suspend fun getOperations( + pageSize: Int, + cursor: String?, + filters: Set, + accountId: AccountId, + chain: Chain, + chainAsset: Asset, + accountAddress: String + ): CursorPage { + if (pageSize <= 0 || TransactionFilter.TRANSFER !in filters) { + return CursorPage(null, emptyList()) + } + + val network = chain.universalWalletSolanaIndexerNetwork() ?: return CursorPage(null, emptyList()) + val historyAsset = chainAsset.solanaHistoryAsset(network) ?: return CursorPage(null, emptyList()) + val limit = pageSize.coerceAtMost(SolanaIndexerRoutes.MAX_LIMIT) + + return runCatching { + val page = historySync.history( + wallet = accountAddress, + assetId = historyAsset.assetId, + isNative = historyAsset.isNative, + network = network, + baseUrl = historyUrl, + before = cursor, + limit = limit + ) + val operations = page.transactions.mapNotNull { transaction -> + transaction.toTransferOperation(accountAddress, chainAsset) + } + + CursorPage( + nextCursor = page.pageInfo.nextCursor, + items = operations + ) + }.getOrElse { error -> + when (error) { + is SolanaTransactionHistoryException, + is SolanaIndexerRoutes.SolanaIndexerRouteException -> CursorPage(null, emptyList()) + else -> CursorPage(null, emptyList()) + } + } + } + + private fun UniversalWalletIndexedTransaction.toTransferOperation( + accountAddress: String, + chainAsset: Asset + ): Operation? { + if (operationType != UniversalWalletIndexedOperationType.Transfer) return null + + val amount = amount?.toUnsignedBigIntegerOrNull() ?: return null + val fee = feeAmount?.toUnsignedBigIntegerOrNull()?.takeIf { chainAsset.isNativeSolanaAsset() } + val time = timestampMillis ?: return null + val status = when (status) { + UniversalWalletIndexedTransactionStatus.Pending -> Operation.Status.PENDING + UniversalWalletIndexedTransactionStatus.Confirmed -> Operation.Status.COMPLETED + UniversalWalletIndexedTransactionStatus.Failed -> Operation.Status.FAILED + } + val counterparty = counterpartyAddress.orEmpty() + val (sender, receiver) = when (direction) { + UniversalWalletIndexedTransactionDirection.Outgoing -> accountAddress to counterparty + UniversalWalletIndexedTransactionDirection.Incoming -> counterparty to accountAddress + UniversalWalletIndexedTransactionDirection.Self, + UniversalWalletIndexedTransactionDirection.Unknown -> return null + } + + return Operation( + id = transactionId, + address = accountAddress, + time = time, + chainAsset = chainAsset, + type = Operation.Type.Transfer( + hash = transactionId, + myAddress = accountAddress, + amount = amount, + receiver = receiver, + sender = sender, + status = status, + fee = fee + ) + ) + } + + private fun Asset.solanaHistoryAsset(network: UniversalWalletRegistry.SolanaNetwork): SolanaHistoryAsset? { + if (isNativeSolanaAsset()) { + return SolanaHistoryAsset(network.nativeAsset.id, isNative = true) + } + + val mint = listOfNotNull(id, currencyId) + .firstOrNull { it.isNotBlank() && it != network.nativeAsset.id } + ?: return null + + return SolanaHistoryAsset(mint, isNative = false) + } + + private fun Asset.isNativeSolanaAsset(): Boolean { + return isNative == true && symbol.equals(SOLANA_SYMBOL, ignoreCase = true) + } + + private fun String.toUnsignedBigIntegerOrNull(): BigInteger? { + return runCatching { BigInteger(this) } + .getOrNull() + ?.takeIf { it.signum() >= 0 } + } + + private companion object { + const val SOLANA_SYMBOL = "SOL" + } + + private data class SolanaHistoryAsset( + val assetId: String, + val isNative: Boolean + ) +} diff --git a/feature-wallet-impl/src/main/java/jp/co/soramitsu/wallet/impl/data/historySource/SoraHistorySource.kt b/feature-wallet-impl/src/main/java/jp/co/soramitsu/wallet/impl/data/historySource/SoraHistorySource.kt index 21c450912e..1490066227 100644 --- a/feature-wallet-impl/src/main/java/jp/co/soramitsu/wallet/impl/data/historySource/SoraHistorySource.kt +++ b/feature-wallet-impl/src/main/java/jp/co/soramitsu/wallet/impl/data/historySource/SoraHistorySource.kt @@ -3,7 +3,7 @@ package jp.co.soramitsu.wallet.impl.data.historySource import jp.co.soramitsu.common.data.model.CursorPage import jp.co.soramitsu.core.models.Asset import jp.co.soramitsu.runtime.multiNetwork.chain.model.Chain -import jp.co.soramitsu.shared_utils.runtime.AccountId +import jp.co.soramitsu.fearless_utils.runtime.AccountId import jp.co.soramitsu.wallet.impl.data.mappers.toOperation import jp.co.soramitsu.wallet.impl.domain.interfaces.TransactionFilter import jp.co.soramitsu.wallet.impl.domain.model.Operation diff --git a/feature-wallet-impl/src/main/java/jp/co/soramitsu/wallet/impl/data/historySource/SubqueryHistorySource.kt b/feature-wallet-impl/src/main/java/jp/co/soramitsu/wallet/impl/data/historySource/SubqueryHistorySource.kt index ab72f00fc1..cd4a2fba38 100644 --- a/feature-wallet-impl/src/main/java/jp/co/soramitsu/wallet/impl/data/historySource/SubqueryHistorySource.kt +++ b/feature-wallet-impl/src/main/java/jp/co/soramitsu/wallet/impl/data/historySource/SubqueryHistorySource.kt @@ -4,11 +4,11 @@ import jp.co.soramitsu.common.data.model.CursorPage import jp.co.soramitsu.core.models.Asset import jp.co.soramitsu.runtime.multiNetwork.ChainRegistry import jp.co.soramitsu.runtime.multiNetwork.chain.model.Chain -import jp.co.soramitsu.shared_utils.extensions.toHexString -import jp.co.soramitsu.shared_utils.runtime.AccountId -import jp.co.soramitsu.shared_utils.runtime.definitions.types.composite.Alias -import jp.co.soramitsu.shared_utils.runtime.definitions.types.composite.DictEnum -import jp.co.soramitsu.shared_utils.runtime.definitions.types.useScaleWriter +import jp.co.soramitsu.fearless_utils.extensions.toHexString +import jp.co.soramitsu.fearless_utils.runtime.AccountId +import jp.co.soramitsu.fearless_utils.runtime.definitions.types.composite.Alias +import jp.co.soramitsu.fearless_utils.runtime.definitions.types.composite.DictEnum +import jp.co.soramitsu.fearless_utils.runtime.definitions.types.useScaleWriter import jp.co.soramitsu.wallet.impl.data.mappers.mapNodeToOperation import jp.co.soramitsu.wallet.impl.data.network.model.request.SubqueryHistoryRequest import jp.co.soramitsu.wallet.impl.data.network.subquery.OperationsHistoryApi diff --git a/feature-wallet-impl/src/main/java/jp/co/soramitsu/wallet/impl/data/historySource/SubsquidHistorySource.kt b/feature-wallet-impl/src/main/java/jp/co/soramitsu/wallet/impl/data/historySource/SubsquidHistorySource.kt index c899518fbc..c6c59f3139 100644 --- a/feature-wallet-impl/src/main/java/jp/co/soramitsu/wallet/impl/data/historySource/SubsquidHistorySource.kt +++ b/feature-wallet-impl/src/main/java/jp/co/soramitsu/wallet/impl/data/historySource/SubsquidHistorySource.kt @@ -4,7 +4,7 @@ import jp.co.soramitsu.common.data.model.CursorPage import jp.co.soramitsu.common.utils.orZero import jp.co.soramitsu.core.models.Asset import jp.co.soramitsu.runtime.multiNetwork.chain.model.Chain -import jp.co.soramitsu.shared_utils.runtime.AccountId +import jp.co.soramitsu.fearless_utils.runtime.AccountId import jp.co.soramitsu.wallet.impl.data.network.model.request.SubsquidHistoryRequest import jp.co.soramitsu.wallet.impl.data.network.subquery.OperationsHistoryApi import jp.co.soramitsu.wallet.impl.domain.interfaces.TransactionFilter diff --git a/feature-wallet-impl/src/main/java/jp/co/soramitsu/wallet/impl/data/historySource/TonHistorySource.kt b/feature-wallet-impl/src/main/java/jp/co/soramitsu/wallet/impl/data/historySource/TonHistorySource.kt index f807394113..3b4c4229a0 100644 --- a/feature-wallet-impl/src/main/java/jp/co/soramitsu/wallet/impl/data/historySource/TonHistorySource.kt +++ b/feature-wallet-impl/src/main/java/jp/co/soramitsu/wallet/impl/data/historySource/TonHistorySource.kt @@ -8,7 +8,7 @@ import jp.co.soramitsu.core.models.ChainAssetType import jp.co.soramitsu.runtime.multiNetwork.chain.model.Chain import jp.co.soramitsu.runtime.multiNetwork.chain.remote.TonRemoteSource import jp.co.soramitsu.runtime.multiNetwork.chain.ton.V4R2WalletContract -import jp.co.soramitsu.shared_utils.runtime.AccountId +import jp.co.soramitsu.fearless_utils.runtime.AccountId import jp.co.soramitsu.wallet.impl.domain.interfaces.TransactionFilter import jp.co.soramitsu.wallet.impl.domain.model.Operation import kotlin.math.abs diff --git a/feature-wallet-impl/src/main/java/jp/co/soramitsu/wallet/impl/data/historySource/VicscanHistorySource.kt b/feature-wallet-impl/src/main/java/jp/co/soramitsu/wallet/impl/data/historySource/VicscanHistorySource.kt index ac772f5b6d..044b973058 100644 --- a/feature-wallet-impl/src/main/java/jp/co/soramitsu/wallet/impl/data/historySource/VicscanHistorySource.kt +++ b/feature-wallet-impl/src/main/java/jp/co/soramitsu/wallet/impl/data/historySource/VicscanHistorySource.kt @@ -3,7 +3,7 @@ package jp.co.soramitsu.wallet.impl.data.historySource import jp.co.soramitsu.common.data.model.CursorPage import jp.co.soramitsu.core.models.Asset import jp.co.soramitsu.runtime.multiNetwork.chain.model.Chain -import jp.co.soramitsu.shared_utils.runtime.AccountId +import jp.co.soramitsu.fearless_utils.runtime.AccountId import jp.co.soramitsu.wallet.impl.data.network.subquery.OperationsHistoryApi import jp.co.soramitsu.wallet.impl.domain.interfaces.TransactionFilter import jp.co.soramitsu.wallet.impl.domain.model.Operation diff --git a/feature-wallet-impl/src/main/java/jp/co/soramitsu/wallet/impl/data/historySource/ZchainsHistorySource.kt b/feature-wallet-impl/src/main/java/jp/co/soramitsu/wallet/impl/data/historySource/ZchainsHistorySource.kt index 24484a34f5..ddd31fc185 100644 --- a/feature-wallet-impl/src/main/java/jp/co/soramitsu/wallet/impl/data/historySource/ZchainsHistorySource.kt +++ b/feature-wallet-impl/src/main/java/jp/co/soramitsu/wallet/impl/data/historySource/ZchainsHistorySource.kt @@ -3,7 +3,7 @@ package jp.co.soramitsu.wallet.impl.data.historySource import jp.co.soramitsu.common.data.model.CursorPage import jp.co.soramitsu.core.models.Asset import jp.co.soramitsu.runtime.multiNetwork.chain.model.Chain -import jp.co.soramitsu.shared_utils.runtime.AccountId +import jp.co.soramitsu.fearless_utils.runtime.AccountId import jp.co.soramitsu.wallet.impl.data.network.subquery.OperationsHistoryApi import jp.co.soramitsu.wallet.impl.domain.interfaces.TransactionFilter import jp.co.soramitsu.wallet.impl.domain.model.Operation diff --git a/feature-wallet-impl/src/main/java/jp/co/soramitsu/wallet/impl/data/network/blockchain/EthereumRemoteSource.kt b/feature-wallet-impl/src/main/java/jp/co/soramitsu/wallet/impl/data/network/blockchain/EthereumRemoteSource.kt index df10f38048..9f65d3cd09 100644 --- a/feature-wallet-impl/src/main/java/jp/co/soramitsu/wallet/impl/data/network/blockchain/EthereumRemoteSource.kt +++ b/feature-wallet-impl/src/main/java/jp/co/soramitsu/wallet/impl/data/network/blockchain/EthereumRemoteSource.kt @@ -11,8 +11,8 @@ import jp.co.soramitsu.runtime.multiNetwork.chain.model.Chain import jp.co.soramitsu.runtime.multiNetwork.chain.model.ChainId import jp.co.soramitsu.runtime.multiNetwork.connection.EthereumChainConnection import jp.co.soramitsu.runtime.multiNetwork.connection.EthereumConnectionPool -import jp.co.soramitsu.shared_utils.extensions.toHexString -import jp.co.soramitsu.shared_utils.runtime.AccountId +import jp.co.soramitsu.fearless_utils.extensions.toHexString +import jp.co.soramitsu.fearless_utils.runtime.AccountId import jp.co.soramitsu.wallet.impl.data.network.model.response.NewHeadsNotificationExtended import jp.co.soramitsu.wallet.impl.domain.model.Transfer import kotlinx.coroutines.Dispatchers diff --git a/feature-wallet-impl/src/main/java/jp/co/soramitsu/wallet/impl/data/network/blockchain/EthereumTransactionBuilder.kt b/feature-wallet-impl/src/main/java/jp/co/soramitsu/wallet/impl/data/network/blockchain/EthereumTransactionBuilder.kt index bc444c6eea..bf1e6bc634 100644 --- a/feature-wallet-impl/src/main/java/jp/co/soramitsu/wallet/impl/data/network/blockchain/EthereumTransactionBuilder.kt +++ b/feature-wallet-impl/src/main/java/jp/co/soramitsu/wallet/impl/data/network/blockchain/EthereumTransactionBuilder.kt @@ -5,7 +5,7 @@ import jp.co.soramitsu.common.utils.failure import jp.co.soramitsu.core.models.Asset import jp.co.soramitsu.core.models.ChainAssetType import jp.co.soramitsu.runtime.multiNetwork.connection.EthereumChainConnection -import jp.co.soramitsu.shared_utils.extensions.requireHexPrefix +import jp.co.soramitsu.fearless_utils.extensions.requireHexPrefix import jp.co.soramitsu.wallet.impl.data.network.model.EvmTransfer import jp.co.soramitsu.wallet.impl.domain.model.Transfer import org.web3j.abi.FunctionEncoder diff --git a/feature-wallet-impl/src/main/java/jp/co/soramitsu/wallet/impl/data/network/blockchain/SubstrateRemoteSource.kt b/feature-wallet-impl/src/main/java/jp/co/soramitsu/wallet/impl/data/network/blockchain/SubstrateRemoteSource.kt index c9952b40bf..5fb3fc4157 100644 --- a/feature-wallet-impl/src/main/java/jp/co/soramitsu/wallet/impl/data/network/blockchain/SubstrateRemoteSource.kt +++ b/feature-wallet-impl/src/main/java/jp/co/soramitsu/wallet/impl/data/network/blockchain/SubstrateRemoteSource.kt @@ -8,8 +8,8 @@ import jp.co.soramitsu.common.data.network.runtime.binding.ExtrinsicStatusEvent import jp.co.soramitsu.core.models.Asset import jp.co.soramitsu.runtime.multiNetwork.chain.model.Chain import jp.co.soramitsu.runtime.multiNetwork.chain.model.ChainId -import jp.co.soramitsu.shared_utils.runtime.AccountId -import jp.co.soramitsu.shared_utils.runtime.extrinsic.ExtrinsicBuilder +import jp.co.soramitsu.fearless_utils.runtime.AccountId +import jp.co.soramitsu.fearless_utils.runtime.extrinsic.ExtrinsicBuilder import jp.co.soramitsu.wallet.impl.data.network.blockchain.bindings.TransferExtrinsic import jp.co.soramitsu.wallet.impl.domain.model.Transfer diff --git a/feature-wallet-impl/src/main/java/jp/co/soramitsu/wallet/impl/data/network/blockchain/WssSubstrateSource.kt b/feature-wallet-impl/src/main/java/jp/co/soramitsu/wallet/impl/data/network/blockchain/WssSubstrateSource.kt index a58d0c7199..b26d277243 100644 --- a/feature-wallet-impl/src/main/java/jp/co/soramitsu/wallet/impl/data/network/blockchain/WssSubstrateSource.kt +++ b/feature-wallet-impl/src/main/java/jp/co/soramitsu/wallet/impl/data/network/blockchain/WssSubstrateSource.kt @@ -34,18 +34,18 @@ import jp.co.soramitsu.runtime.multiNetwork.chain.model.ChainId import jp.co.soramitsu.runtime.multiNetwork.chain.model.bokoloCashTokenId import jp.co.soramitsu.runtime.storage.source.StorageDataSource import jp.co.soramitsu.runtime.storage.source.queryNonNull -import jp.co.soramitsu.shared_utils.extensions.fromHex -import jp.co.soramitsu.shared_utils.runtime.AccountId -import jp.co.soramitsu.shared_utils.runtime.RuntimeSnapshot -import jp.co.soramitsu.shared_utils.runtime.definitions.registry.TypeRegistry -import jp.co.soramitsu.shared_utils.runtime.definitions.types.composite.DictEnum -import jp.co.soramitsu.shared_utils.runtime.definitions.types.composite.Struct -import jp.co.soramitsu.shared_utils.runtime.definitions.types.fromHexOrNull -import jp.co.soramitsu.shared_utils.runtime.definitions.types.primitives.FixedByteArray -import jp.co.soramitsu.shared_utils.runtime.extrinsic.ExtrinsicBuilder -import jp.co.soramitsu.shared_utils.runtime.metadata.module -import jp.co.soramitsu.shared_utils.runtime.metadata.storage -import jp.co.soramitsu.shared_utils.runtime.metadata.storageKey +import jp.co.soramitsu.fearless_utils.extensions.fromHex +import jp.co.soramitsu.fearless_utils.runtime.AccountId +import jp.co.soramitsu.fearless_utils.runtime.RuntimeSnapshot +import jp.co.soramitsu.fearless_utils.runtime.definitions.registry.TypeRegistry +import jp.co.soramitsu.fearless_utils.runtime.definitions.types.composite.DictEnum +import jp.co.soramitsu.fearless_utils.runtime.definitions.types.composite.Struct +import jp.co.soramitsu.fearless_utils.runtime.definitions.types.fromHexOrNull +import jp.co.soramitsu.fearless_utils.runtime.definitions.types.primitives.FixedByteArray +import jp.co.soramitsu.fearless_utils.runtime.extrinsic.ExtrinsicBuilder +import jp.co.soramitsu.fearless_utils.runtime.metadata.module +import jp.co.soramitsu.fearless_utils.runtime.metadata.storage +import jp.co.soramitsu.fearless_utils.runtime.metadata.storageKey import jp.co.soramitsu.wallet.api.data.cache.bindAccountInfoOrDefault import jp.co.soramitsu.wallet.api.data.cache.bindAssetsAccountData import jp.co.soramitsu.wallet.api.data.cache.bindEquilibriumAccountData @@ -189,9 +189,15 @@ class WssSubstrateSource( additional: (suspend ExtrinsicBuilder.() -> Unit)?, batchAll: Boolean ): BigInteger { + val transferParams = transfer.additionalParams as? SubstrateTransferParams + val accountId = chain.accountIdOf(transfer.sender) + return extrinsicService.estimateFee( chain = chain, + accountId = accountId, useBatchAll = batchAll, + tip = transferParams?.tipInPlanks, + appId = transferParams?.appId, formExtrinsic = { transfer(chain, transfer, this.runtime.typeRegistry) additional?.invoke(this) diff --git a/feature-wallet-impl/src/main/java/jp/co/soramitsu/wallet/impl/data/network/blockchain/balance/BalanceLoaderProvider.kt b/feature-wallet-impl/src/main/java/jp/co/soramitsu/wallet/impl/data/network/blockchain/balance/BalanceLoaderProvider.kt index 90b647e42f..758ae734e7 100644 --- a/feature-wallet-impl/src/main/java/jp/co/soramitsu/wallet/impl/data/network/blockchain/balance/BalanceLoaderProvider.kt +++ b/feature-wallet-impl/src/main/java/jp/co/soramitsu/wallet/impl/data/network/blockchain/balance/BalanceLoaderProvider.kt @@ -1,9 +1,15 @@ package jp.co.soramitsu.wallet.impl.data.network.blockchain.balance +import jp.co.soramitsu.common.data.network.bitcoin.BitcoinIndexerClient +import jp.co.soramitsu.common.data.network.iroha.IrohaToriiClient +import jp.co.soramitsu.common.data.network.solana.SolanaBalanceSync import jp.co.soramitsu.core.models.ChainAssetType import jp.co.soramitsu.core.models.Ecosystem import jp.co.soramitsu.core.utils.utilityAsset import jp.co.soramitsu.coredb.dao.OperationDao +import jp.co.soramitsu.runtime.ext.isUniversalWalletBitcoin +import jp.co.soramitsu.runtime.ext.isUniversalWalletIroha +import jp.co.soramitsu.runtime.ext.isUniversalWalletSolana import jp.co.soramitsu.runtime.multiNetwork.ChainRegistry import jp.co.soramitsu.runtime.multiNetwork.chain.ChainsRepository import jp.co.soramitsu.runtime.multiNetwork.chain.TonSyncDataRepository @@ -23,12 +29,18 @@ class BalanceLoaderProvider( private val tonRemoteSource: TonRemoteSource, private val chainsRepository: ChainsRepository, private val tonSyncDataRepository: TonSyncDataRepository, + private val bitcoinIndexerClient: BitcoinIndexerClient, + private val solanaBalanceSync: SolanaBalanceSync, + private val irohaToriiClient: IrohaToriiClient ): BalanceLoader.Provider { override fun invoke(chain: Chain): BalanceLoader { val isEquilibriumTypeChain = chain.utilityAsset != null && chain.utilityAsset!!.typeExtra == ChainAssetType.Equilibrium return when { + chain.isUniversalWalletBitcoin() -> BitcoinBalanceLoader(chain, bitcoinIndexerClient) + chain.isUniversalWalletSolana() -> SolanaBalanceLoader(chain, solanaBalanceSync) + chain.isUniversalWalletIroha() -> IrohaBalanceLoader(chain, irohaToriiClient) chain.ecosystem == Ecosystem.Ton -> TonBalanceLoader(chain, tonSyncDataRepository, chainsRepository) chain.ecosystem == Ecosystem.Ethereum -> EthereumBalanceLoader(chain, ethereumRemoteSource) @@ -38,4 +50,4 @@ class BalanceLoaderProvider( else -> throw IllegalStateException("Cannot find BalanceLoader for ${chain.name}") } } -} \ No newline at end of file +} diff --git a/feature-wallet-impl/src/main/java/jp/co/soramitsu/wallet/impl/data/network/blockchain/balance/BitcoinBalanceLoader.kt b/feature-wallet-impl/src/main/java/jp/co/soramitsu/wallet/impl/data/network/blockchain/balance/BitcoinBalanceLoader.kt new file mode 100644 index 0000000000..e0ee36be28 --- /dev/null +++ b/feature-wallet-impl/src/main/java/jp/co/soramitsu/wallet/impl/data/network/blockchain/balance/BitcoinBalanceLoader.kt @@ -0,0 +1,115 @@ +package jp.co.soramitsu.wallet.impl.data.network.blockchain.balance + +import android.annotation.SuppressLint +import android.util.Log +import jp.co.soramitsu.account.api.domain.model.MetaAccount +import jp.co.soramitsu.account.api.domain.model.accountId +import jp.co.soramitsu.account.api.domain.model.address +import jp.co.soramitsu.common.data.network.bitcoin.BitcoinEsploraAddress +import jp.co.soramitsu.common.data.network.bitcoin.BitcoinIndexerClient +import jp.co.soramitsu.core.models.Asset +import jp.co.soramitsu.coredb.model.AssetBalanceUpdateItem +import jp.co.soramitsu.runtime.ext.normalizedBitcoinAddress +import jp.co.soramitsu.runtime.ext.universalWalletBitcoinIndexerNetwork +import jp.co.soramitsu.runtime.multiNetwork.chain.model.Chain +import jp.co.soramitsu.wallet.api.data.BalanceLoader +import jp.co.soramitsu.wallet.impl.data.network.blockchain.updaters.BalanceUpdateTrigger +import kotlinx.coroutines.async +import kotlinx.coroutines.awaitAll +import kotlinx.coroutines.coroutineScope +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.channelFlow +import kotlinx.coroutines.flow.flatMapLatest +import kotlinx.coroutines.flow.onStart +import kotlinx.coroutines.supervisorScope +import java.math.BigInteger + +@SuppressLint("LogNotTimber") +class BitcoinBalanceLoader( + chain: Chain, + private val bitcoinIndexerClient: BitcoinIndexerClient +) : BalanceLoader(chain) { + + private val trigger = BalanceUpdateTrigger.observe() + private val tag = "BitcoinBalanceLoader (${chain.name})" + + override suspend fun loadBalance(metaAccounts: Set): List { + return supervisorScope { + val balancesDeferred = metaAccounts.map { metaAccount -> + async { + loadNativeBalance(metaAccount) + } + } + + balancesDeferred.awaitAll().filterNotNull() + } + } + + @OptIn(ExperimentalCoroutinesApi::class) + override fun subscribeBalance(metaAccount: MetaAccount): Flow { + return trigger.onStart { emit(null) }.flatMapLatest { triggeredChainId -> + channelFlow { + val specificChainTriggered = triggeredChainId != null + val currentChainTriggered = triggeredChainId == chain.id + + if (specificChainTriggered && currentChainTriggered.not()) return@channelFlow + + coroutineScope { + val balance = loadNativeBalance(metaAccount) ?: return@coroutineScope + val asset = nativeBitcoinAsset() ?: return@coroutineScope + send(BalanceLoaderAction.UpdateOrInsertBalance(balance, asset)) + } + } + } + } + + private suspend fun loadNativeBalance(metaAccount: MetaAccount): AssetBalanceUpdateItem? { + val asset = nativeBitcoinAsset() ?: return null + val accountId = metaAccount.accountId(chain) ?: return null + val address = metaAccount.address(chain)?.let(chain::normalizedBitcoinAddress) ?: return null + val network = chain.universalWalletBitcoinIndexerNetwork() ?: return null + val baseUrl = chain.externalApi?.history?.url + + val balance = runCatching { + val response = bitcoinIndexerClient.address( + address = address, + network = network, + baseUrl = baseUrl + ) + response.totalSatsOrNull() + }.onFailure { + Log.d(tag, "address balance failed: $it") + }.getOrNull() ?: return null + + return AssetBalanceUpdateItem( + metaId = metaAccount.id, + chainId = chain.id, + accountId = accountId, + id = asset.id, + freeInPlanks = balance + ) + } + + private fun nativeBitcoinAsset(): Asset? { + return chain.assets.firstOrNull { asset -> + asset.isNative == true && asset.symbol.equals(BITCOIN_SYMBOL, ignoreCase = true) + } ?: chain.assets.firstOrNull { asset -> + asset.isUtility && asset.symbol.equals(BITCOIN_SYMBOL, ignoreCase = true) + } + } + + private fun BitcoinEsploraAddress.totalSatsOrNull(): BigInteger? { + val confirmed = confirmedSats + val mempool = mempoolSats + if (confirmed < 0 || mempool < 0) { + return null + } + + return BigInteger.valueOf(confirmed).add(BigInteger.valueOf(mempool)) + } + + private companion object { + const val BITCOIN_SYMBOL = "BTC" + } +} diff --git a/feature-wallet-impl/src/main/java/jp/co/soramitsu/wallet/impl/data/network/blockchain/balance/EquilibriumBalanceLoader.kt b/feature-wallet-impl/src/main/java/jp/co/soramitsu/wallet/impl/data/network/blockchain/balance/EquilibriumBalanceLoader.kt index 33dd6b6776..d78d019985 100644 --- a/feature-wallet-impl/src/main/java/jp/co/soramitsu/wallet/impl/data/network/blockchain/balance/EquilibriumBalanceLoader.kt +++ b/feature-wallet-impl/src/main/java/jp/co/soramitsu/wallet/impl/data/network/blockchain/balance/EquilibriumBalanceLoader.kt @@ -12,10 +12,10 @@ import jp.co.soramitsu.runtime.multiNetwork.ChainRegistry import jp.co.soramitsu.runtime.multiNetwork.chain.model.Chain import jp.co.soramitsu.runtime.network.subscriptionFlowCatching import jp.co.soramitsu.runtime.storage.source.RemoteStorageSource -import jp.co.soramitsu.shared_utils.runtime.RuntimeSnapshot -import jp.co.soramitsu.shared_utils.runtime.metadata.storage -import jp.co.soramitsu.shared_utils.runtime.metadata.storageKey -import jp.co.soramitsu.shared_utils.wsrpc.request.runtime.storage.storageChange +import jp.co.soramitsu.fearless_utils.runtime.RuntimeSnapshot +import jp.co.soramitsu.fearless_utils.runtime.metadata.storage +import jp.co.soramitsu.fearless_utils.runtime.metadata.storageKey +import jp.co.soramitsu.fearless_utils.wsrpc.request.runtime.storage.storageChange import jp.co.soramitsu.wallet.api.data.BalanceLoader import jp.co.soramitsu.wallet.api.data.cache.bindEquilibriumAccountData import jp.co.soramitsu.wallet.impl.data.network.blockchain.updaters.SubscribeBalanceRequest diff --git a/feature-wallet-impl/src/main/java/jp/co/soramitsu/wallet/impl/data/network/blockchain/balance/IrohaBalanceLoader.kt b/feature-wallet-impl/src/main/java/jp/co/soramitsu/wallet/impl/data/network/blockchain/balance/IrohaBalanceLoader.kt new file mode 100644 index 0000000000..5122ce28e4 --- /dev/null +++ b/feature-wallet-impl/src/main/java/jp/co/soramitsu/wallet/impl/data/network/blockchain/balance/IrohaBalanceLoader.kt @@ -0,0 +1,140 @@ +package jp.co.soramitsu.wallet.impl.data.network.blockchain.balance + +import android.annotation.SuppressLint +import android.util.Log +import java.math.BigDecimal +import java.math.BigInteger +import jp.co.soramitsu.account.api.domain.model.MetaAccount +import jp.co.soramitsu.account.api.domain.model.accountId +import jp.co.soramitsu.account.api.domain.model.address +import jp.co.soramitsu.common.data.network.iroha.IrohaAccountAssetListItem +import jp.co.soramitsu.common.data.network.iroha.IrohaToriiClient +import jp.co.soramitsu.common.data.network.iroha.IrohaToriiRoutes +import jp.co.soramitsu.core.models.Asset +import jp.co.soramitsu.coredb.model.AssetBalanceUpdateItem +import jp.co.soramitsu.runtime.ext.irohaAddressFromPublicKey +import jp.co.soramitsu.runtime.ext.normalizedIrohaAddress +import jp.co.soramitsu.runtime.ext.universalWalletIrohaNetwork +import jp.co.soramitsu.runtime.multiNetwork.chain.model.Chain +import jp.co.soramitsu.wallet.api.data.BalanceLoader +import jp.co.soramitsu.wallet.impl.data.network.blockchain.updaters.BalanceUpdateTrigger +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.async +import kotlinx.coroutines.awaitAll +import kotlinx.coroutines.coroutineScope +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.channelFlow +import kotlinx.coroutines.flow.flatMapLatest +import kotlinx.coroutines.flow.onStart +import kotlinx.coroutines.supervisorScope + +@SuppressLint("LogNotTimber") +class IrohaBalanceLoader( + chain: Chain, + private val toriiClient: IrohaToriiClient +) : BalanceLoader(chain) { + + private val trigger = BalanceUpdateTrigger.observe() + private val tag = "IrohaBalanceLoader (${chain.name})" + + override suspend fun loadBalance(metaAccounts: Set): List { + return supervisorScope { + metaAccounts.map { metaAccount -> + async { + loadAssetBalances(metaAccount).map { it.balance } + } + }.awaitAll().flatten() + } + } + + @OptIn(ExperimentalCoroutinesApi::class) + override fun subscribeBalance(metaAccount: MetaAccount): Flow { + return trigger.onStart { emit(null) }.flatMapLatest { triggeredChainId -> + channelFlow { + val specificChainTriggered = triggeredChainId != null + val currentChainTriggered = triggeredChainId == chain.id + + if (specificChainTriggered && currentChainTriggered.not()) return@channelFlow + + coroutineScope { + loadAssetBalances(metaAccount).forEach { update -> + send(BalanceLoaderAction.UpdateOrInsertBalance(update.balance, update.asset)) + } + } + } + } + } + + private suspend fun loadAssetBalances(metaAccount: MetaAccount): List { + val accountId = metaAccount.accountId(chain) ?: return emptyList() + val address = metaAccount.address(chain)?.let(chain::normalizedIrohaAddress) + ?: chain.irohaAddressFromPublicKey(accountId) + ?: return emptyList() + val network = chain.universalWalletIrohaNetwork() ?: return emptyList() + val baseUrl = chain.externalApi?.history + ?.takeIf { it.type == Chain.ExternalApi.Section.Type.IROHA } + ?.url + + val response = runCatching { + toriiClient.accountAssets( + accountId = address, + baseUrl = baseUrl, + limit = IrohaToriiRoutes.MAX_LIMIT, + countMode = IrohaToriiRoutes.CountMode.Bounded, + network = network + ) + }.onFailure { + Log.d(tag, "balance load failed: $it") + }.getOrNull() ?: return emptyList() + + return chain.assets.mapNotNull { asset -> + val freeInPlanks = response.items + .filter { it.matchesAccount(address) && it.matchesAsset(asset) } + .mapNotNull { it.quantity.toPlanksOrNull(asset.precision) } + .takeIf { it.isNotEmpty() } + ?.fold(BigInteger.ZERO, BigInteger::add) + ?: return@mapNotNull null + + IrohaAssetBalanceUpdate( + balance = AssetBalanceUpdateItem( + metaId = metaAccount.id, + chainId = chain.id, + accountId = accountId, + id = asset.id, + freeInPlanks = freeInPlanks + ), + asset = asset + ) + } + } + + private fun IrohaAccountAssetListItem.matchesAccount(address: String): Boolean { + val itemAccount = accountId?.takeIf(String::isNotBlank) ?: return true + return chain.normalizedIrohaAddress(itemAccount) == address + } + + private fun IrohaAccountAssetListItem.matchesAsset(chainAsset: Asset): Boolean { + val assetIds = setOfNotNull(chainAsset.id, chainAsset.currencyId) + val itemIds = setOfNotNull(asset, assetId, assetName, assetAlias) + + return itemIds.any { it in assetIds } + } + + private fun String.toPlanksOrNull(precision: Int): BigInteger? { + val normalized = trim() + if (!DECIMAL_QUANTITY.matches(normalized)) return null + + return runCatching { + BigDecimal(normalized).movePointRight(precision).toBigIntegerExact() + }.getOrNull()?.takeIf { it.signum() >= 0 } + } + + private data class IrohaAssetBalanceUpdate( + val balance: AssetBalanceUpdateItem, + val asset: Asset + ) + + private companion object { + val DECIMAL_QUANTITY = Regex("^(0|[1-9][0-9]*)(\\.[0-9]+)?$") + } +} diff --git a/feature-wallet-impl/src/main/java/jp/co/soramitsu/wallet/impl/data/network/blockchain/balance/SolanaBalanceLoader.kt b/feature-wallet-impl/src/main/java/jp/co/soramitsu/wallet/impl/data/network/blockchain/balance/SolanaBalanceLoader.kt new file mode 100644 index 0000000000..a7a0837f2d --- /dev/null +++ b/feature-wallet-impl/src/main/java/jp/co/soramitsu/wallet/impl/data/network/blockchain/balance/SolanaBalanceLoader.kt @@ -0,0 +1,138 @@ +package jp.co.soramitsu.wallet.impl.data.network.blockchain.balance + +import android.annotation.SuppressLint +import android.util.Log +import java.math.BigInteger +import jp.co.soramitsu.account.api.domain.model.MetaAccount +import jp.co.soramitsu.account.api.domain.model.accountId +import jp.co.soramitsu.account.api.domain.model.address +import jp.co.soramitsu.common.data.network.solana.SolanaBalanceSync +import jp.co.soramitsu.common.data.network.solana.SolanaBalanceSyncResult +import jp.co.soramitsu.common.model.UniversalWalletIndexedAssetBalance +import jp.co.soramitsu.core.models.Asset +import jp.co.soramitsu.coredb.model.AssetBalanceUpdateItem +import jp.co.soramitsu.runtime.ext.normalizedSolanaAddress +import jp.co.soramitsu.runtime.ext.universalWalletSolanaIndexerNetwork +import jp.co.soramitsu.runtime.multiNetwork.chain.model.Chain +import jp.co.soramitsu.wallet.api.data.BalanceLoader +import jp.co.soramitsu.wallet.impl.data.network.blockchain.updaters.BalanceUpdateTrigger +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.async +import kotlinx.coroutines.awaitAll +import kotlinx.coroutines.coroutineScope +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.channelFlow +import kotlinx.coroutines.flow.flatMapLatest +import kotlinx.coroutines.flow.onStart +import kotlinx.coroutines.supervisorScope + +@SuppressLint("LogNotTimber") +class SolanaBalanceLoader( + chain: Chain, + private val solanaBalanceSync: SolanaBalanceSync +) : BalanceLoader(chain) { + + private val trigger = BalanceUpdateTrigger.observe() + private val tag = "SolanaBalanceLoader (${chain.name})" + + override suspend fun loadBalance(metaAccounts: Set): List { + return supervisorScope { + val balancesDeferred = metaAccounts.map { metaAccount -> + async { + loadAssetBalances(metaAccount).map { it.balance } + } + } + + balancesDeferred.awaitAll().flatten() + } + } + + @OptIn(ExperimentalCoroutinesApi::class) + override fun subscribeBalance(metaAccount: MetaAccount): Flow { + return trigger.onStart { emit(null) }.flatMapLatest { triggeredChainId -> + channelFlow { + val specificChainTriggered = triggeredChainId != null + val currentChainTriggered = triggeredChainId == chain.id + + if (specificChainTriggered && currentChainTriggered.not()) return@channelFlow + + coroutineScope { + loadAssetBalances(metaAccount).forEach { update -> + send(BalanceLoaderAction.UpdateOrInsertBalance(update.balance, update.asset)) + } + } + } + } + } + + private suspend fun loadAssetBalances(metaAccount: MetaAccount): List { + val accountId = metaAccount.accountId(chain) ?: return emptyList() + val address = metaAccount.address(chain)?.let(chain::normalizedSolanaAddress) ?: return emptyList() + val network = chain.universalWalletSolanaIndexerNetwork() ?: return emptyList() + val baseUrl = chain.externalApi?.history?.url + + val result = runCatching { + solanaBalanceSync.balances( + wallet = address, + network = network, + baseUrl = baseUrl, + includeTokenMetadata = false + ) + }.onFailure { + Log.d(tag, "balance load failed: $it") + }.getOrNull() ?: return emptyList() + + return chain.assets.mapNotNull { asset -> + val indexedBalance = indexedBalanceForAsset(asset, network, result) ?: return@mapNotNull null + val freeInPlanks = indexedBalance.amount.toUnsignedBigIntegerOrNull() ?: return@mapNotNull null + SolanaAssetBalanceUpdate( + balance = AssetBalanceUpdateItem( + metaId = metaAccount.id, + chainId = chain.id, + accountId = accountId, + id = asset.id, + freeInPlanks = freeInPlanks + ), + asset = asset + ) + } + } + + private fun indexedBalanceForAsset( + asset: Asset, + network: jp.co.soramitsu.common.model.UniversalWalletRegistry.SolanaNetwork, + result: SolanaBalanceSyncResult + ): UniversalWalletIndexedAssetBalance? { + if (isNativeSolanaAsset(asset, network)) { + return result.nativeBalance.takeIf { it.decimals == asset.precision } + } + + val assetIds = setOfNotNull(asset.id, asset.currencyId) + return result.tokenBalances.firstOrNull { balance -> + !balance.isNative && + balance.decimals == asset.precision && + (balance.assetId in assetIds || balance.contractAddress in assetIds) + } + } + + private fun isNativeSolanaAsset( + asset: Asset, + network: jp.co.soramitsu.common.model.UniversalWalletRegistry.SolanaNetwork + ): Boolean { + return asset.id.equals(network.nativeAsset.id, ignoreCase = true) && + asset.symbol.equals(network.nativeAsset.symbol, ignoreCase = true) && + asset.precision == network.nativeAsset.decimals && + asset.isNative == true + } + + private fun String.toUnsignedBigIntegerOrNull(): BigInteger? { + return runCatching { BigInteger(this) } + .getOrNull() + ?.takeIf { it.signum() >= 0 } + } + + private data class SolanaAssetBalanceUpdate( + val balance: AssetBalanceUpdateItem, + val asset: Asset + ) +} diff --git a/feature-wallet-impl/src/main/java/jp/co/soramitsu/wallet/impl/data/network/blockchain/balance/SubstrateBalanceLoader.kt b/feature-wallet-impl/src/main/java/jp/co/soramitsu/wallet/impl/data/network/blockchain/balance/SubstrateBalanceLoader.kt index 4a0642cffb..cc8ded2e12 100644 --- a/feature-wallet-impl/src/main/java/jp/co/soramitsu/wallet/impl/data/network/blockchain/balance/SubstrateBalanceLoader.kt +++ b/feature-wallet-impl/src/main/java/jp/co/soramitsu/wallet/impl/data/network/blockchain/balance/SubstrateBalanceLoader.kt @@ -22,13 +22,13 @@ import jp.co.soramitsu.runtime.ext.addressOf import jp.co.soramitsu.runtime.multiNetwork.ChainRegistry import jp.co.soramitsu.runtime.multiNetwork.chain.model.Chain import jp.co.soramitsu.runtime.storage.source.RemoteStorageSource -import jp.co.soramitsu.shared_utils.runtime.AccountId -import jp.co.soramitsu.shared_utils.runtime.RuntimeSnapshot -import jp.co.soramitsu.shared_utils.runtime.metadata.module -import jp.co.soramitsu.shared_utils.runtime.metadata.storage -import jp.co.soramitsu.shared_utils.runtime.metadata.storageKey -import jp.co.soramitsu.shared_utils.wsrpc.request.runtime.storage.storageChange -import jp.co.soramitsu.shared_utils.wsrpc.subscriptionFlow +import jp.co.soramitsu.fearless_utils.runtime.AccountId +import jp.co.soramitsu.fearless_utils.runtime.RuntimeSnapshot +import jp.co.soramitsu.fearless_utils.runtime.metadata.module +import jp.co.soramitsu.fearless_utils.runtime.metadata.storage +import jp.co.soramitsu.fearless_utils.runtime.metadata.storageKey +import jp.co.soramitsu.fearless_utils.wsrpc.request.runtime.storage.storageChange +import jp.co.soramitsu.fearless_utils.wsrpc.subscriptionFlow import jp.co.soramitsu.wallet.api.data.BalanceLoader import jp.co.soramitsu.wallet.api.data.cache.bindAccountInfoOrDefault import jp.co.soramitsu.wallet.api.data.cache.bindAssetsAccountData diff --git a/feature-wallet-impl/src/main/java/jp/co/soramitsu/wallet/impl/data/network/blockchain/bindings/TransferExtrinsic.kt b/feature-wallet-impl/src/main/java/jp/co/soramitsu/wallet/impl/data/network/blockchain/bindings/TransferExtrinsic.kt index 10ab79d58c..beade3e2e8 100644 --- a/feature-wallet-impl/src/main/java/jp/co/soramitsu/wallet/impl/data/network/blockchain/bindings/TransferExtrinsic.kt +++ b/feature-wallet-impl/src/main/java/jp/co/soramitsu/wallet/impl/data/network/blockchain/bindings/TransferExtrinsic.kt @@ -7,9 +7,9 @@ import jp.co.soramitsu.common.data.network.runtime.binding.fromHexOrIncompatible import jp.co.soramitsu.common.data.network.runtime.binding.incompatible import jp.co.soramitsu.common.utils.balances import jp.co.soramitsu.common.utils.extrinsicHash -import jp.co.soramitsu.shared_utils.runtime.RuntimeSnapshot -import jp.co.soramitsu.shared_utils.runtime.definitions.types.generics.Extrinsic -import jp.co.soramitsu.shared_utils.runtime.metadata.call +import jp.co.soramitsu.fearless_utils.runtime.RuntimeSnapshot +import jp.co.soramitsu.fearless_utils.runtime.definitions.types.generics.Extrinsic +import jp.co.soramitsu.fearless_utils.runtime.metadata.call import java.math.BigInteger class TransferExtrinsic( diff --git a/feature-wallet-impl/src/main/java/jp/co/soramitsu/wallet/impl/data/network/blockchain/extrinsic/ExtrinsicBuilderExt.kt b/feature-wallet-impl/src/main/java/jp/co/soramitsu/wallet/impl/data/network/blockchain/extrinsic/ExtrinsicBuilderExt.kt index 0f7d301f62..afa9076b20 100644 --- a/feature-wallet-impl/src/main/java/jp/co/soramitsu/wallet/impl/data/network/blockchain/extrinsic/ExtrinsicBuilderExt.kt +++ b/feature-wallet-impl/src/main/java/jp/co/soramitsu/wallet/impl/data/network/blockchain/extrinsic/ExtrinsicBuilderExt.kt @@ -1,6 +1,6 @@ package jp.co.soramitsu.wallet.impl.data.network.blockchain.extrinsic -import jp.co.soramitsu.shared_utils.runtime.extrinsic.ExtrinsicBuilder +import jp.co.soramitsu.fearless_utils.runtime.extrinsic.ExtrinsicBuilder fun ExtrinsicBuilder.remark(remark: String): ExtrinsicBuilder = call( diff --git a/feature-wallet-impl/src/main/java/jp/co/soramitsu/wallet/impl/data/network/blockchain/updaters/BalancesUpdateSystem.kt b/feature-wallet-impl/src/main/java/jp/co/soramitsu/wallet/impl/data/network/blockchain/updaters/BalancesUpdateSystem.kt index 18506d070e..c3e7daa635 100644 --- a/feature-wallet-impl/src/main/java/jp/co/soramitsu/wallet/impl/data/network/blockchain/updaters/BalancesUpdateSystem.kt +++ b/feature-wallet-impl/src/main/java/jp/co/soramitsu/wallet/impl/data/network/blockchain/updaters/BalancesUpdateSystem.kt @@ -2,6 +2,7 @@ package jp.co.soramitsu.wallet.impl.data.network.blockchain.updaters import android.annotation.SuppressLint import android.util.Log +import jp.co.soramitsu.account.api.domain.model.hasChainAccount import jp.co.soramitsu.account.api.domain.model.hasEthereum import jp.co.soramitsu.account.api.domain.model.hasSubstrate import jp.co.soramitsu.account.api.domain.model.hasTon @@ -12,8 +13,11 @@ import jp.co.soramitsu.core.updater.Updater import jp.co.soramitsu.coredb.dao.AssetDao import jp.co.soramitsu.coredb.dao.MetaAccountDao import jp.co.soramitsu.coredb.model.AssetLocal +import jp.co.soramitsu.runtime.ext.isUniversalWalletBitcoin +import jp.co.soramitsu.runtime.ext.isUniversalWalletIroha +import jp.co.soramitsu.runtime.ext.isUniversalWalletSolana import jp.co.soramitsu.runtime.multiNetwork.ChainRegistry -import jp.co.soramitsu.shared_utils.wsrpc.request.runtime.RuntimeRequest +import jp.co.soramitsu.fearless_utils.wsrpc.request.runtime.RuntimeRequest import jp.co.soramitsu.wallet.api.data.BalanceLoader import kotlinx.coroutines.CoroutineExceptionHandler import kotlinx.coroutines.CoroutineScope @@ -57,7 +61,10 @@ class BalancesUpdateSystem( .map { (chains, metaAccount) -> scope.launch { val supportedChains = chains.filter { - it.ecosystem == Ecosystem.Ton && metaAccount.hasTon || + it.isUniversalWalletBitcoin() && metaAccount.hasChainAccount(it.id) || + it.isUniversalWalletSolana() && metaAccount.hasChainAccount(it.id) || + it.isUniversalWalletIroha() && metaAccount.hasChainAccount(it.id) || + it.ecosystem == Ecosystem.Ton && metaAccount.hasTon || it.ecosystem == Ecosystem.Ethereum && metaAccount.hasEthereum || it.ecosystem == Ecosystem.Substrate && metaAccount.hasSubstrate } @@ -101,4 +108,4 @@ class SubscribeBalanceRequest(storageKeys: List) : RuntimeRequest( "state_subscribeStorage", listOf(storageKeys), 0 -) \ No newline at end of file +) diff --git a/feature-wallet-impl/src/main/java/jp/co/soramitsu/wallet/impl/data/repository/HistoryRepository.kt b/feature-wallet-impl/src/main/java/jp/co/soramitsu/wallet/impl/data/repository/HistoryRepository.kt index adfc71ccef..1d90c4bf5b 100644 --- a/feature-wallet-impl/src/main/java/jp/co/soramitsu/wallet/impl/data/repository/HistoryRepository.kt +++ b/feature-wallet-impl/src/main/java/jp/co/soramitsu/wallet/impl/data/repository/HistoryRepository.kt @@ -6,10 +6,19 @@ import jp.co.soramitsu.core.models.Asset import jp.co.soramitsu.coredb.dao.OperationDao import jp.co.soramitsu.coredb.model.OperationLocal import jp.co.soramitsu.runtime.ext.addressOf +import jp.co.soramitsu.runtime.ext.bitcoinAddressFromPublicKey +import jp.co.soramitsu.runtime.ext.irohaAddressFromPublicKey +import jp.co.soramitsu.runtime.ext.isUniversalWalletBitcoin +import jp.co.soramitsu.runtime.ext.isUniversalWalletIroha +import jp.co.soramitsu.runtime.ext.isUniversalWalletSolana +import jp.co.soramitsu.runtime.ext.normalizedBitcoinAddress +import jp.co.soramitsu.runtime.ext.normalizedIrohaAddress +import jp.co.soramitsu.runtime.ext.normalizedSolanaAddress +import jp.co.soramitsu.runtime.ext.solanaAddressFromPublicKey import jp.co.soramitsu.runtime.multiNetwork.chain.model.Chain import jp.co.soramitsu.runtime.multiNetwork.chain.model.ChainId import jp.co.soramitsu.runtime.multiNetwork.chain.model.soraMainChainId -import jp.co.soramitsu.shared_utils.runtime.AccountId +import jp.co.soramitsu.fearless_utils.runtime.AccountId import jp.co.soramitsu.wallet.impl.data.historySource.HistorySourceProvider import jp.co.soramitsu.wallet.impl.data.mappers.mapOperationLocalToOperation import jp.co.soramitsu.wallet.impl.data.mappers.mapOperationToOperationLocalDb @@ -45,7 +54,8 @@ class HistoryRepository( filters: Set, accountId: AccountId, chain: Chain, - chainAsset: Asset + chainAsset: Asset, + accountAddress: String? = null ): CursorPage { return withContext(Dispatchers.Default) { val historyUrl = chain.externalApi?.history?.url @@ -62,7 +72,10 @@ class HistoryRepository( throw HistoryNotSupportedException() } - val accountAddress = chain.addressOf(accountId) + val resolvedAccountAddress = resolveAccountAddress(chain, accountId, accountAddress) ?: return@withContext CursorPage( + null, + emptyList() + ) val historySource = historySourceProvider(historyUrl, historyType) val operations = historySource?.getOperations( @@ -72,7 +85,7 @@ class HistoryRepository( accountId, chain, chainAsset, - accountAddress + resolvedAccountAddress ) return@withContext operations ?: CursorPage( null, @@ -86,9 +99,13 @@ class HistoryRepository( filters: Set, accountId: AccountId, chain: Chain, - chainAsset: Asset + chainAsset: Asset, + accountAddress: String? = null ): CursorPage { - val accountAddress = chain.addressOf(accountId) + val resolvedAccountAddress = resolveAccountAddress(chain, accountId, accountAddress) ?: return CursorPage( + null, + emptyList() + ) val elements: MutableList = mutableListOf() var page: CursorPage = CursorPage(null, emptyList()) @@ -97,7 +114,7 @@ class HistoryRepository( while (elements.size <= pageSize.div(2) && hasNextPage) { page = kotlin.runCatching { - getOperations(pageSize, cursor = nextCursor, filters, accountId, chain, chainAsset) + getOperations(pageSize, cursor = nextCursor, filters, accountId, chain, chainAsset, resolvedAccountAddress) }.getOrDefault(CursorPage(null, emptyList())) nextCursor = page.nextCursor hasNextPage = nextCursor != null && page.items.isNotEmpty() @@ -108,7 +125,7 @@ class HistoryRepository( ) }) } - operationDao.insertFromSubquery(accountAddress, chain.id, chainAsset.id, elements) + operationDao.insertFromSubquery(resolvedAccountAddress, chain.id, chainAsset.id, elements) cursorStorage.saveCursor(chain.id, chainAsset.id, accountId, page.nextCursor) return page @@ -117,10 +134,14 @@ class HistoryRepository( fun operationsFirstPageFlow( accountId: AccountId, chain: Chain, - chainAsset: Asset + chainAsset: Asset, + accountAddress: String? = null ): Flow> { - val accountAddress = chain.addressOf(accountId) - return operationDao.observe(accountAddress, chain.id, chainAsset.id) + val resolvedAccountAddress = resolveAccountAddress(chain, accountId, accountAddress) ?: return flow { + emit(CursorPage(null, emptyList())) + } + + return operationDao.observe(resolvedAccountAddress, chain.id, chainAsset.id) .mapList { mapOperationLocalToOperation(it, chainAsset, chain) } @@ -131,6 +152,22 @@ class HistoryRepository( } } + private fun resolveAccountAddress( + chain: Chain, + accountId: AccountId, + suppliedAddress: String? + ): String? { + return if (chain.isUniversalWalletBitcoin()) { + suppliedAddress?.let(chain::normalizedBitcoinAddress) ?: chain.bitcoinAddressFromPublicKey(accountId) + } else if (chain.isUniversalWalletSolana()) { + suppliedAddress?.let(chain::normalizedSolanaAddress) ?: chain.solanaAddressFromPublicKey(accountId) + } else if (chain.isUniversalWalletIroha()) { + suppliedAddress?.let(chain::normalizedIrohaAddress) ?: chain.irohaAddressFromPublicKey(accountId) + } else { + suppliedAddress ?: chain.addressOf(accountId) + } + } + @OptIn(ExperimentalCoroutinesApi::class) fun getOperationAddressWithChainIdFlow(chainId: ChainId, limit: Int?): Flow> { return flow { emit(currentAccountAddress.invoke(chainId)) } diff --git a/feature-wallet-impl/src/main/java/jp/co/soramitsu/wallet/impl/data/repository/WalletRepositoryImpl.kt b/feature-wallet-impl/src/main/java/jp/co/soramitsu/wallet/impl/data/repository/WalletRepositoryImpl.kt index 9da4bf4793..45a55576a0 100644 --- a/feature-wallet-impl/src/main/java/jp/co/soramitsu/wallet/impl/data/repository/WalletRepositoryImpl.kt +++ b/feature-wallet-impl/src/main/java/jp/co/soramitsu/wallet/impl/data/repository/WalletRepositoryImpl.kt @@ -39,15 +39,15 @@ import jp.co.soramitsu.runtime.multiNetwork.chain.model.Chain import jp.co.soramitsu.runtime.multiNetwork.chain.model.ChainId import jp.co.soramitsu.runtime.multiNetwork.chain.remote.TonRemoteSource import jp.co.soramitsu.runtime.storage.source.StorageDataSource -import jp.co.soramitsu.shared_utils.runtime.AccountId -import jp.co.soramitsu.shared_utils.runtime.RuntimeSnapshot -import jp.co.soramitsu.shared_utils.runtime.definitions.types.Type -import jp.co.soramitsu.shared_utils.runtime.definitions.types.composite.Struct -import jp.co.soramitsu.shared_utils.runtime.definitions.types.fromHexOrNull -import jp.co.soramitsu.shared_utils.runtime.extrinsic.ExtrinsicBuilder -import jp.co.soramitsu.shared_utils.runtime.metadata.moduleOrNull -import jp.co.soramitsu.shared_utils.runtime.metadata.storage -import jp.co.soramitsu.shared_utils.runtime.metadata.storageKey +import jp.co.soramitsu.fearless_utils.runtime.AccountId +import jp.co.soramitsu.fearless_utils.runtime.RuntimeSnapshot +import jp.co.soramitsu.fearless_utils.runtime.definitions.types.Type +import jp.co.soramitsu.fearless_utils.runtime.definitions.types.composite.Struct +import jp.co.soramitsu.fearless_utils.runtime.definitions.types.fromHexOrNull +import jp.co.soramitsu.fearless_utils.runtime.extrinsic.ExtrinsicBuilder +import jp.co.soramitsu.fearless_utils.runtime.metadata.moduleOrNull +import jp.co.soramitsu.fearless_utils.runtime.metadata.storage +import jp.co.soramitsu.fearless_utils.runtime.metadata.storageKey import jp.co.soramitsu.wallet.impl.data.mappers.mapAssetLocalToAsset import jp.co.soramitsu.wallet.impl.data.network.blockchain.EthereumRemoteSource import jp.co.soramitsu.wallet.impl.data.network.blockchain.SubstrateRemoteSource @@ -460,9 +460,14 @@ class WalletRepositoryImpl( override suspend fun estimateClaimRewardsFee(chainId: ChainId): BigInteger { return withContext(Dispatchers.IO) { val chain = chainsRepository.getChain(chainId) + val accountId = accountRepository.getSelectedMetaAccount().accountId(chain) - extrinsicService.estimateFee(chain) { - claimRewards() + if (accountId == null) { + BigInteger.ZERO + } else { + extrinsicService.estimateFee(chain, accountId) { + claimRewards() + } } } } @@ -488,4 +493,3 @@ fun ExtrinsicBuilder.claimRewards(): ExtrinsicBuilder { this } } - diff --git a/feature-wallet-impl/src/main/java/jp/co/soramitsu/wallet/impl/data/repository/tranfser/TonTransferService.kt b/feature-wallet-impl/src/main/java/jp/co/soramitsu/wallet/impl/data/repository/tranfser/TonTransferService.kt index 4a907e6818..614310b34a 100644 --- a/feature-wallet-impl/src/main/java/jp/co/soramitsu/wallet/impl/data/repository/tranfser/TonTransferService.kt +++ b/feature-wallet-impl/src/main/java/jp/co/soramitsu/wallet/impl/data/repository/tranfser/TonTransferService.kt @@ -77,22 +77,23 @@ class TonTransferService( @SuppressLint("LogNotTimber") override suspend fun getTransferFee(transfer: Transfer): BigDecimal = withContext(Dispatchers.Default + SupervisorJob()) { val selectedMetaAccount = async { accountRepository.getSelectedMetaAccount() } + val metaAccount = selectedMetaAccount.await() + val tonPublicKey = metaAccount.tonPublicKey ?: throw IllegalStateException(KEYPAIR_REQUIRED_MESSAGE) + val senderAccountId = tonPublicKey.tonAccountId(chain.isTestNet) - val senderAccountId = - selectedMetaAccount.await().tonPublicKey?.tonAccountId(chain.isTestNet) ?: throw IllegalStateException(KEYPAIR_REQUIRED_MESSAGE) + requireMatchingSender(transfer, tonPublicKey, senderAccountId) val utilityAsset = chain.utilityAsset ?: throw IllegalStateException( "Can't find utility asset for ${chain.name}" ) - val accountId = selectedMetaAccount.await().tonPublicKey ?: throw IllegalStateException(KEYPAIR_REQUIRED_MESSAGE) - val tonAsset = assetDao.getAsset(selectedMetaAccount.await().id, accountId, chain.id, utilityAsset.id) + val tonAsset = assetDao.getAsset(metaAccount.id, tonPublicKey, chain.id, utilityAsset.id) if (transfer.chainAsset.type == ChainAssetType.Jetton && tonAsset?.asset?.freeInPlanks.lessThanOrEquals(BigInteger.ZERO)) { throw RuntimeException("Can't calculate fee: Not enough tokens for fee") } - val senderSmartContract = V4R2WalletContract(selectedMetaAccount.await().tonPublicKey!!) + val senderSmartContract = V4R2WalletContract(tonPublicKey) val seqnoDeferred = async { tonRemoteSource.getSeqno(chain, senderAccountId) } @@ -173,7 +174,7 @@ class TonTransferService( } catch (e: Throwable) { val account = tonRemoteSource.loadAccountData( chain, - selectedMetaAccount.await().tonPublicKey!!.tonAccountId(chain.isTestNet) + senderAccountId ) val initializedAccount = account.status != AccountStatus.uninit && account.status != AccountStatus.nonexist @@ -198,13 +199,17 @@ class TonTransferService( override suspend fun transfer(transfer: Transfer): String = withContext(Dispatchers.Default) { val selectedMetaAccount = async { accountRepository.getSelectedMetaAccount() } - val senderAccountId = - selectedMetaAccount.await().tonPublicKey?.tonAccountId(chain.isTestNet) ?: throw IllegalStateException(KEYPAIR_REQUIRED_MESSAGE) - val senderSmartContract = V4R2WalletContract(selectedMetaAccount.await().tonPublicKey!!) + val metaAccount = selectedMetaAccount.await() + val tonPublicKey = metaAccount.tonPublicKey ?: throw IllegalStateException(KEYPAIR_REQUIRED_MESSAGE) + val senderAccountId = tonPublicKey.tonAccountId(chain.isTestNet) + + requireMatchingSender(transfer, tonPublicKey, senderAccountId) + + val senderSmartContract = V4R2WalletContract(tonPublicKey) val seqnoDeferred = async { tonRemoteSource.getSeqno(chain, senderAccountId) } val keypair = - keyPairRepository.getKeypairFor(chain, selectedMetaAccount.await().tonPublicKey!!) + keyPairRepository.getKeypairFor(chain, tonPublicKey) val privateKey = PrivateKeyEd25519.of(keypair.privateKey) val seqno = seqnoDeferred.await() @@ -276,6 +281,18 @@ class TonTransferService( return@withContext hashHex } + private fun requireMatchingSender(transfer: Transfer, tonPublicKey: ByteArray, senderAccountId: String) { + val sender = transfer.sender.trim() + val matchesSelectedAccount = + sender == tonPublicKey.v4r2tonAddress(chain.isTestNet) || + sender == tonPublicKey.v4r2tonAddress(chain.isTestNet, bounceable = true) || + sender.equals(senderAccountId, ignoreCase = true) + + require(matchesSelectedAccount) { + "Transfer sender does not match selected TON account" + } + } + private suspend fun createUnsignedBody( transfer: Transfer, stateInit: StateInit?, diff --git a/feature-wallet-impl/src/main/java/jp/co/soramitsu/wallet/impl/data/repository/tranfser/TransferService.kt b/feature-wallet-impl/src/main/java/jp/co/soramitsu/wallet/impl/data/repository/tranfser/TransferService.kt index 5d2bd38683..0f8c4d7f6b 100644 --- a/feature-wallet-impl/src/main/java/jp/co/soramitsu/wallet/impl/data/repository/tranfser/TransferService.kt +++ b/feature-wallet-impl/src/main/java/jp/co/soramitsu/wallet/impl/data/repository/tranfser/TransferService.kt @@ -1,19 +1,56 @@ package jp.co.soramitsu.wallet.impl.data.repository.tranfser import jp.co.soramitsu.account.api.domain.interfaces.AccountRepository +import jp.co.soramitsu.account.api.domain.model.MetaAccount +import jp.co.soramitsu.account.api.domain.model.address +import jp.co.soramitsu.common.data.network.bitcoin.BitcoinIndexerClient +import jp.co.soramitsu.common.data.network.bitcoin.BitcoinIndexerRoutes +import jp.co.soramitsu.common.data.network.bitcoin.BitcoinSendPlanner +import jp.co.soramitsu.common.data.network.bitcoin.BitcoinSendRequest +import jp.co.soramitsu.common.data.network.bitcoin.BitcoinSendService +import jp.co.soramitsu.common.data.network.bitcoin.BitcoinUtxoSource +import jp.co.soramitsu.common.data.network.iroha.IrohaToriiClient +import jp.co.soramitsu.common.data.network.solana.SolanaBalanceSync +import jp.co.soramitsu.common.data.network.solana.SolanaBalanceSyncSendBalanceProvider +import jp.co.soramitsu.common.data.network.solana.SolanaRpcClient +import jp.co.soramitsu.common.data.network.solana.SolanaSendRequest +import jp.co.soramitsu.common.data.network.solana.SolanaSendService +import jp.co.soramitsu.common.data.network.solana.SolanaTokenProgram +import jp.co.soramitsu.common.data.network.solana.SolanaTokenSendRequest +import jp.co.soramitsu.common.data.network.solana.SolanaTransferTransactionBuilder +import jp.co.soramitsu.common.model.UniversalWalletIndexedAssetBalance +import jp.co.soramitsu.common.model.UniversalWalletDerivationPaths +import jp.co.soramitsu.common.model.UniversalWalletRegistry +import jp.co.soramitsu.common.data.secrets.v3.EthereumSecrets +import jp.co.soramitsu.common.data.secrets.v3.SubstrateSecrets +import jp.co.soramitsu.common.data.secrets.v3.TonSecrets +import jp.co.soramitsu.common.utils.BitcoinKeyDerivation +import jp.co.soramitsu.common.utils.IrohaAddressCodec +import jp.co.soramitsu.common.utils.IrohaKeyDerivation +import jp.co.soramitsu.common.utils.SolanaKeyDerivation import jp.co.soramitsu.common.utils.requireValue import jp.co.soramitsu.core.extrinsic.keypair_provider.KeypairProvider +import jp.co.soramitsu.core.models.Asset import jp.co.soramitsu.core.models.Ecosystem import jp.co.soramitsu.core.utils.utilityAsset import jp.co.soramitsu.coredb.dao.AssetDao +import jp.co.soramitsu.fearless_utils.encrypt.mnemonic.MnemonicCreator +import jp.co.soramitsu.fearless_utils.extensions.fromHex +import jp.co.soramitsu.fearless_utils.extensions.toHexString +import jp.co.soramitsu.fearless_utils.ss58.SS58Encoder.toAccountId +import jp.co.soramitsu.runtime.ext.isUniversalWalletBitcoin +import jp.co.soramitsu.runtime.ext.isUniversalWalletIroha +import jp.co.soramitsu.runtime.ext.isUniversalWalletSolana +import jp.co.soramitsu.runtime.ext.normalizedBitcoinAddress +import jp.co.soramitsu.runtime.ext.normalizedIrohaAddress +import jp.co.soramitsu.runtime.ext.normalizedSolanaAddress +import jp.co.soramitsu.runtime.ext.universalWalletBitcoinIndexerNetwork +import jp.co.soramitsu.runtime.ext.universalWalletIrohaNetwork +import jp.co.soramitsu.runtime.ext.universalWalletSolanaIndexerNetwork import jp.co.soramitsu.runtime.multiNetwork.chain.model.Chain import jp.co.soramitsu.runtime.multiNetwork.chain.remote.TonRemoteSource -import jp.co.soramitsu.shared_utils.extensions.fromHex -import jp.co.soramitsu.shared_utils.extensions.toHexString -import jp.co.soramitsu.shared_utils.ss58.SS58Encoder.toAccountId import jp.co.soramitsu.wallet.impl.data.network.blockchain.EthereumRemoteSource import jp.co.soramitsu.wallet.impl.data.network.blockchain.SubstrateRemoteSource -import jp.co.soramitsu.wallet.impl.domain.interfaces.WalletRepository import jp.co.soramitsu.wallet.impl.domain.model.SubstrateTransferParams import jp.co.soramitsu.wallet.impl.domain.model.Transfer import jp.co.soramitsu.wallet.impl.domain.model.amountFromPlanks @@ -22,6 +59,7 @@ import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.flow import kotlinx.coroutines.flow.map import java.math.BigDecimal +import java.math.BigInteger class TransferServiceProvider( private val substrateSource: SubstrateRemoteSource, @@ -29,10 +67,36 @@ class TransferServiceProvider( private val keyPairRepository: KeypairProvider, private val accountRepository: AccountRepository, private val tonRemoteSource: TonRemoteSource, - private val assetDao: AssetDao + private val assetDao: AssetDao, + private val bitcoinIndexerClient: BitcoinIndexerClient, + private val solanaRpcClient: SolanaRpcClient, + private val solanaBalanceSync: SolanaBalanceSync, + private val irohaToriiClient: IrohaToriiClient, + private val irohaTransferSigner: IrohaTransferSigner = UnavailableIrohaTransferSigner ) { fun provide(chain: Chain): TransferService { - return when (chain.ecosystem) { + return if (chain.isUniversalWalletBitcoin()) { + BitcoinTransferService( + chain = chain, + accountRepository = accountRepository, + sendPlanner = BitcoinSendPlanner(bitcoinIndexerClient), + sendService = BitcoinSendService(bitcoinIndexerClient) + ) + } else if (chain.isUniversalWalletSolana()) { + SolanaTransferService( + chain = chain, + accountRepository = accountRepository, + rpcClient = solanaRpcClient, + balanceSync = solanaBalanceSync + ) + } else if (chain.isUniversalWalletIroha()) { + IrohaTransferService( + chain = chain, + accountRepository = accountRepository, + toriiClient = irohaToriiClient, + signer = irohaTransferSigner + ) + } else when (chain.ecosystem) { Ecosystem.EthereumBased, Ecosystem.Substrate -> SubstrateTransferService( chain, substrateSource @@ -54,6 +118,629 @@ interface TransferService { suspend fun transfer(transfer: Transfer): String } +class BitcoinTransferService( + private val chain: Chain, + private val accountRepository: AccountRepository, + private val sendPlanner: BitcoinSendPlanner, + private val sendService: BitcoinSendService +) : TransferService { + override suspend fun getTransferFee(transfer: Transfer): BigDecimal { + val context = resolveContext(transfer) + val plan = sendPlanner.plan( + amountSats = transfer.amountSats(), + sources = listOf(BitcoinUtxoSource(context.sourceAddress)), + recipientAddress = transfer.recipient, + changeAddress = context.sourceAddress, + network = context.network, + baseUrl = context.baseUrl + ) + + return transfer.chainAsset.amountFromPlanks(BigInteger.valueOf(plan.feeSats)) + } + + override fun observeTransferFee(transfer: Transfer): Flow { + return flow { emit(getTransferFee(transfer)) } + } + + override suspend fun transfer(transfer: Transfer): String { + val context = resolveContext(transfer) + val mnemonic = resolveRootMnemonic(context.metaAccount.id) + requireMnemonicMatchesSelectedWallet(mnemonic, context) + val result = sendService.send( + BitcoinSendRequest( + mnemonic = mnemonic, + amountSats = transfer.amountSats(), + sources = listOf(BitcoinUtxoSource(context.sourceAddress)), + recipientAddress = transfer.recipient, + changeAddress = context.sourceAddress, + network = context.network, + baseUrl = context.baseUrl + ) + ) + + return result.broadcastTxid + } + + private suspend fun resolveContext(transfer: Transfer): BitcoinTransferContext { + val network = chain.universalWalletBitcoinIndexerNetwork() + ?: throw unsupported("Bitcoin network is not configured for ${chain.name}") + val metaAccount = accountRepository.getSelectedMetaAccount() + val sourceAddress = metaAccount.address(chain)?.let(chain::normalizedBitcoinAddress) + ?: throw unsupported("Selected wallet has no Bitcoin account for ${chain.name}") + val senderAddress = chain.normalizedBitcoinAddress(transfer.sender) + ?: throw unsupported("Bitcoin sender address is invalid for ${chain.name}") + + if (senderAddress != sourceAddress) { + throw unsupported("Bitcoin sender does not match selected wallet for ${chain.name}") + } + + return BitcoinTransferContext( + metaAccount = metaAccount, + sourceAddress = sourceAddress, + network = network, + baseUrl = chain.externalApi?.history + ?.takeIf { it.type == Chain.ExternalApi.Section.Type.BITCOIN } + ?.url + ) + } + + private suspend fun resolveRootMnemonic(metaId: Long): String { + accountRepository.getSubstrateSecrets(metaId) + ?.get(SubstrateSecrets.Entropy) + ?.let { return MnemonicCreator.fromEntropy(it.clone()).words } + + accountRepository.getEthereumSecrets(metaId) + ?.get(EthereumSecrets.Entropy) + ?.let { return MnemonicCreator.fromEntropy(it.clone()).words } + + accountRepository.getTonSecrets(metaId) + ?.get(TonSecrets.Seed) + ?.decodeToString() + ?.normalizeMnemonic() + ?.let { return it } + + throw unsupported("Bitcoin transfers require mnemonic root material for ${chain.name}") + } + + private fun requireMnemonicMatchesSelectedWallet( + mnemonic: String, + context: BitcoinTransferContext + ) { + val derivedAddress = runCatching { + BitcoinKeyDerivation.deriveAccount( + mnemonic = mnemonic, + network = context.network.bitcoinKeyDerivationNetwork() + ).firstReceiveAddress + }.getOrElse { + throw unsupported("Bitcoin mnemonic root material is invalid for ${chain.name}") + } + + if (!derivedAddress.equals(context.sourceAddress, ignoreCase = true)) { + throw unsupported("Bitcoin mnemonic does not match selected wallet for ${chain.name}") + } + } + + private fun BitcoinIndexerRoutes.Network.bitcoinKeyDerivationNetwork(): BitcoinKeyDerivation.Network { + return when (this) { + BitcoinIndexerRoutes.Network.Mainnet -> BitcoinKeyDerivation.Network.Mainnet + BitcoinIndexerRoutes.Network.Testnet -> BitcoinKeyDerivation.Network.Testnet + } + } + + private fun Transfer.amountSats(): Long { + return try { + amountInPlanks.longValueExact() + } catch (_: ArithmeticException) { + throw unsupported("Bitcoin transfer amount is outside the supported satoshi range") + } + } + + private fun String.normalizeMnemonic(): String? { + val words = trim().split(Regex("\\s+")).filter(String::isNotBlank) + return words.takeIf { it.isNotEmpty() }?.joinToString(" ") + } + + private fun unsupported(message: String): IllegalStateException { + return IllegalStateException(message) + } + + private data class BitcoinTransferContext( + val metaAccount: MetaAccount, + val sourceAddress: String, + val network: BitcoinIndexerRoutes.Network, + val baseUrl: String? + ) +} + +class SolanaTransferService( + private val chain: Chain, + private val accountRepository: AccountRepository, + private val rpcClient: SolanaRpcClient, + private val balanceSync: SolanaBalanceSync +) : TransferService { + override suspend fun getTransferFee(transfer: Transfer): BigDecimal { + val context = resolveContext(transfer) + return if (transfer.chainAsset.isSolanaNativeAsset(context.network)) { + getNativeTransferFee(transfer, context) + } else { + getTokenTransferFee(transfer, context) + } + } + + private suspend fun getNativeTransferFee( + transfer: Transfer, + context: SolanaTransferContext + ): BigDecimal { + val lamports = transfer.amountLamports() + SolanaTransferTransactionBuilder.requireLamports(lamports) + val blockhash = rpcClient.latestBlockhash(rpcUrl = context.rpcUrl) + val unsigned = SolanaTransferTransactionBuilder.buildNativeTransfer( + senderAddress = context.sourceAddress, + recipientAddress = transfer.recipient, + lamports = lamports, + recentBlockhash = blockhash.value.blockhash + ) + val fee = rpcClient.feeForMessage( + messageBase64 = unsigned.messageBase64, + rpcUrl = context.rpcUrl + ).value ?: throw unsupported("Solana transfer fee is unavailable for ${chain.name}") + + return solanaFeeAmountFromLamports(fee, context.network) + } + + private suspend fun getTokenTransferFee( + transfer: Transfer, + context: SolanaTransferContext + ): BigDecimal { + val token = resolveTokenTransferContext(transfer, context) + val destinationTokenAccount = SolanaTransferTransactionBuilder.deriveAssociatedTokenAccountAddress( + walletAddress = transfer.recipient, + mintAddress = token.mintAddress, + tokenProgram = SolanaTokenProgram.SplToken + ) + val destinationTokenAccountExists = rpcClient.accountExists( + address = destinationTokenAccount, + rpcUrl = context.rpcUrl + ) + val blockhash = rpcClient.latestBlockhash(rpcUrl = context.rpcUrl) + val unsigned = SolanaTransferTransactionBuilder.buildTokenSendChecked( + ownerAddress = context.sourceAddress, + sourceTokenAccount = token.sourceTokenAccount, + destinationTokenAccount = destinationTokenAccount.takeIf { destinationTokenAccountExists }, + mintAddress = token.mintAddress, + rawAmount = token.rawAmount, + decimals = token.decimals, + recentBlockhash = blockhash.value.blockhash, + tokenProgram = SolanaTokenProgram.SplToken, + destinationWalletAddress = transfer.recipient.takeUnless { destinationTokenAccountExists } + ) + val fee = rpcClient.feeForMessage( + messageBase64 = unsigned.messageBase64, + rpcUrl = context.rpcUrl + ).value ?: throw unsupported("Solana token transfer fee is unavailable for ${chain.name}") + + return solanaFeeAmountFromLamports(fee, context.network) + } + + override fun observeTransferFee(transfer: Transfer): Flow { + return flow { emit(getTransferFee(transfer)) } + } + + override suspend fun transfer(transfer: Transfer): String { + val context = resolveContext(transfer) + val sendService = SolanaSendService( + rpcClient = rpcClient, + balanceProvider = SolanaBalanceSyncSendBalanceProvider( + balanceSync = balanceSync, + network = context.network, + baseUrl = context.indexerBaseUrl + ) + ) + + return if (transfer.chainAsset.isSolanaNativeAsset(context.network)) { + transferNative(transfer, context, sendService) + } else { + transferToken(transfer, context, sendService) + } + } + + private suspend fun transferNative( + transfer: Transfer, + context: SolanaTransferContext, + sendService: SolanaSendService + ): String { + val mnemonic = resolveRootMnemonic(context.metaAccount.id) + requireMnemonicMatchesSelectedWallet(mnemonic, context) + val result = sendService.send( + SolanaSendRequest( + mnemonic = mnemonic, + recipientAddress = transfer.recipient, + lamports = transfer.amountLamports(), + rpcUrl = context.rpcUrl + ) + ) + + return result.signature + } + + private suspend fun transferToken( + transfer: Transfer, + context: SolanaTransferContext, + sendService: SolanaSendService + ): String { + val mnemonic = resolveRootMnemonic(context.metaAccount.id) + requireMnemonicMatchesSelectedWallet(mnemonic, context) + val token = resolveTokenTransferContext(transfer, context) + val result = sendService.sendTokenTransfer( + SolanaTokenSendRequest( + mnemonic = mnemonic, + sourceTokenAccount = token.sourceTokenAccount, + mintAddress = token.mintAddress, + rawAmount = token.rawAmount, + decimals = token.decimals, + tokenProgram = SolanaTokenProgram.SplToken, + destinationWalletAddress = transfer.recipient, + rpcUrl = context.rpcUrl + ) + ) + + return result.signature + } + + private suspend fun resolveContext(transfer: Transfer): SolanaTransferContext { + val network = chain.universalWalletSolanaIndexerNetwork() + ?: throw unsupported("Solana network is not configured for ${chain.name}") + val metaAccount = accountRepository.getSelectedMetaAccount() + val sourceAddress = metaAccount.address(chain)?.let(chain::normalizedSolanaAddress) + ?: throw unsupported("Selected wallet has no Solana account for ${chain.name}") + val senderAddress = chain.normalizedSolanaAddress(transfer.sender) + ?: throw unsupported("Solana sender address is invalid for ${chain.name}") + + if (senderAddress != sourceAddress) { + throw unsupported("Solana sender does not match selected wallet for ${chain.name}") + } + + return SolanaTransferContext( + metaAccount = metaAccount, + sourceAddress = sourceAddress, + network = network, + indexerBaseUrl = chain.externalApi?.history + ?.takeIf { it.type == Chain.ExternalApi.Section.Type.SOLANA } + ?.url, + rpcUrl = chain.nodes.firstOrNull { it.isActive }?.url + ?: chain.nodes.firstOrNull { it.isDefault }?.url + ?: network.rpcUrl + ) + } + + private suspend fun resolveRootMnemonic(metaId: Long): String { + accountRepository.getSubstrateSecrets(metaId) + ?.get(SubstrateSecrets.Entropy) + ?.let { return MnemonicCreator.fromEntropy(it.clone()).words } + + accountRepository.getEthereumSecrets(metaId) + ?.get(EthereumSecrets.Entropy) + ?.let { return MnemonicCreator.fromEntropy(it.clone()).words } + + accountRepository.getTonSecrets(metaId) + ?.get(TonSecrets.Seed) + ?.decodeToString() + ?.normalizeMnemonic() + ?.let { return it } + + throw unsupported("Solana transfers require mnemonic root material for ${chain.name}") + } + + private fun requireMnemonicMatchesSelectedWallet( + mnemonic: String, + context: SolanaTransferContext + ) { + val derivedAddress = runCatching { + SolanaKeyDerivation.deriveAccount(mnemonic).address + }.getOrElse { + throw unsupported("Solana mnemonic root material is invalid for ${chain.name}") + } + + if (derivedAddress != context.sourceAddress) { + throw unsupported("Solana mnemonic does not match selected wallet for ${chain.name}") + } + } + + private fun Transfer.amountLamports(): Long { + return amountRawUnits("lamport") + } + + private fun Transfer.amountTokenUnits(): Long { + return amountRawUnits("token raw unit") + } + + private fun Transfer.amountRawUnits(unitName: String): Long { + return try { + amountInPlanks.longValueExact() + } catch (_: ArithmeticException) { + throw unsupported("Solana transfer amount is outside the supported $unitName range") + } + } + + private suspend fun resolveTokenTransferContext( + transfer: Transfer, + context: SolanaTransferContext + ): SolanaTokenTransferContext { + val rawAmount = transfer.amountTokenUnits() + val balance = balanceSync.balances( + wallet = context.sourceAddress, + network = context.network, + baseUrl = context.indexerBaseUrl, + includeTokenMetadata = false + ).tokenBalances.firstOrNull { it.matchesAsset(transfer.chainAsset) } + ?: throw unsupported("Solana token balance is unavailable for ${transfer.chainAsset.symbol} on ${chain.name}") + + if (balance.tokenProgram != SPL_TOKEN_PROGRAM_NAME) { + throw unsupported("Solana token transfers currently support only SPL Token assets on ${chain.name}") + } + + val sourceTokenAccount = balance.tokenAccountId?.takeIf(String::isNotBlank) + ?: throw unsupported("Solana token source account is unavailable for ${transfer.chainAsset.symbol} on ${chain.name}") + val mintAddress = balance.contractAddress?.takeIf(String::isNotBlank) + ?: balance.assetId.takeIf(String::isNotBlank) + ?: throw unsupported("Solana token mint is unavailable for ${transfer.chainAsset.symbol} on ${chain.name}") + + return SolanaTokenTransferContext( + sourceTokenAccount = sourceTokenAccount, + mintAddress = mintAddress, + decimals = balance.decimals, + rawAmount = rawAmount + ) + } + + private fun UniversalWalletIndexedAssetBalance.matchesAsset(asset: Asset): Boolean { + if (isNative || decimals != asset.precision) { + return false + } + + val assetIds = asset.solanaTokenIdentifiers() + return assetIds.any { it == assetId || it == contractAddress } + } + + private fun Asset.solanaTokenIdentifiers(): Set { + return listOf(id, currencyId) + .mapNotNull { it?.takeIf(String::isNotBlank) } + .toSet() + } + + private fun Asset.isSolanaNativeAsset(network: UniversalWalletRegistry.SolanaNetwork): Boolean { + return isNative == true && + id == network.nativeAsset.id && + symbol == network.nativeAsset.symbol && + precision == network.nativeAsset.decimals + } + + private fun solanaFeeAmountFromLamports( + lamports: Long, + network: UniversalWalletRegistry.SolanaNetwork + ): BigDecimal { + return chain.utilityAsset + ?.takeIf { it.isSolanaNativeAsset(network) } + ?.amountFromPlanks(BigInteger.valueOf(lamports)) + ?: BigDecimal.valueOf(lamports).movePointLeft(network.nativeAsset.decimals) + } + + private fun String.normalizeMnemonic(): String? { + val words = trim().split(Regex("\\s+")).filter(String::isNotBlank) + return words.takeIf { it.isNotEmpty() }?.joinToString(" ") + } + + private fun unsupported(message: String): IllegalStateException { + return IllegalStateException(message) + } + + private data class SolanaTransferContext( + val metaAccount: MetaAccount, + val sourceAddress: String, + val network: jp.co.soramitsu.common.model.UniversalWalletRegistry.SolanaNetwork, + val indexerBaseUrl: String?, + val rpcUrl: String + ) + + private data class SolanaTokenTransferContext( + val sourceTokenAccount: String, + val mintAddress: String, + val decimals: Int, + val rawAmount: Long + ) + + private companion object { + const val SPL_TOKEN_PROGRAM_NAME = "spl-token" + } +} + +interface IrohaTransferSigner { + suspend fun buildAndSignTransfer(request: IrohaTransferSigningRequest): IrohaSignedTransfer +} + +object UnavailableIrohaTransferSigner : IrohaTransferSigner { + override suspend fun buildAndSignTransfer(request: IrohaTransferSigningRequest): IrohaSignedTransfer { + throw IllegalStateException("Iroha transfer signing codec is unavailable") + } +} + +data class IrohaTransferSigningRequest( + val amount: String, + val assetDefinitionId: String, + val authority: String, + val chainId: String, + val derivationPath: String, + val destinationAccountId: String, + val mnemonicOrSeed: String, + val network: String, + val signingPublicKeyHex: String, + val sourceAccountId: String, + val sourceAssetId: String +) + +class IrohaSignedTransfer( + val signedTransaction: ByteArray, + val transactionHashHex: String? = null +) + +class IrohaTransferService( + private val chain: Chain, + private val accountRepository: AccountRepository, + private val toriiClient: IrohaToriiClient, + private val signer: IrohaTransferSigner +) : TransferService { + override suspend fun getTransferFee(transfer: Transfer): BigDecimal { + resolveContext(transfer) + + return BigDecimal.ZERO + } + + override fun observeTransferFee(transfer: Transfer): Flow { + return flow { emit(getTransferFee(transfer)) } + } + + override suspend fun transfer(transfer: Transfer): String { + val context = resolveContext(transfer) + val signedTransfer = signer.buildAndSignTransfer(context.signingRequest) + if (signedTransfer.signedTransaction.isEmpty()) { + throw unsupported("Iroha transfer signer returned an empty transaction for ${chain.name}") + } + + val receipt = toriiClient.submitTransaction( + noritoBytes = signedTransfer.signedTransaction, + baseUrl = context.toriiBaseUrl + ) + + return signedTransfer.transactionHashHex + ?: receipt.payload.signedTransactionHash + ?: receipt.payload.txHash + } + + private suspend fun resolveContext(transfer: Transfer): IrohaTransferContext { + val network = chain.universalWalletIrohaNetwork() + ?: throw unsupported("Iroha network is not configured for ${chain.name}") + val toriiBaseUrl = chain.externalApi?.history + ?.takeIf { it.type == Chain.ExternalApi.Section.Type.IROHA } + ?.url + ?.takeIf(String::isNotBlank) + ?: network.toriiBaseUrl + ?: throw unsupported("Iroha Torii endpoint is not configured for ${chain.name}") + val metaAccount = accountRepository.getSelectedMetaAccount() + val sourceAddress = metaAccount.address(chain)?.let(chain::normalizedIrohaAddress) + ?: throw unsupported("Selected wallet has no Iroha account for ${chain.name}") + val sourceDetails = IrohaAddressCodec.parse(sourceAddress, network.chainDiscriminant) + val senderAddress = chain.normalizedIrohaAddress(transfer.sender) + ?: throw unsupported("Iroha sender address is invalid for ${chain.name}") + + if (senderAddress != sourceAddress) { + throw unsupported("Iroha sender does not match selected wallet for ${chain.name}") + } + + val destinationAddress = chain.normalizedIrohaAddress(transfer.recipient) + ?: throw unsupported("Iroha recipient address is invalid for ${chain.name}") + val amount = transfer.amount.toPlainString().normalizeIrohaTransferAmount() + val assetDefinitionId = transfer.chainAsset.id.normalizeIrohaAssetDefinitionId() + val mnemonic = resolveRootMnemonic(metaAccount.id) + val derivedAddress = runCatching { + IrohaKeyDerivation.deriveAddress( + mnemonic = mnemonic, + chainDiscriminant = network.chainDiscriminant + ).i105 + }.getOrElse { + throw unsupported("Iroha mnemonic root material is invalid for ${chain.name}") + } + + if (derivedAddress != sourceAddress) { + throw unsupported("Iroha mnemonic does not match selected wallet for ${chain.name}") + } + + return IrohaTransferContext( + toriiBaseUrl = toriiBaseUrl, + signingRequest = IrohaTransferSigningRequest( + amount = amount, + assetDefinitionId = assetDefinitionId, + authority = sourceAddress, + chainId = network.chainId, + derivationPath = UniversalWalletDerivationPaths.IROHA_DEFAULT, + destinationAccountId = destinationAddress, + mnemonicOrSeed = mnemonic, + network = network.irohaTransferNetworkKey(), + signingPublicKeyHex = sourceDetails.publicKeyHex, + sourceAccountId = sourceAddress, + sourceAssetId = "$assetDefinitionId#$sourceAddress" + ) + ) + } + + private suspend fun resolveRootMnemonic(metaId: Long): String { + accountRepository.getSubstrateSecrets(metaId) + ?.get(SubstrateSecrets.Entropy) + ?.let { return MnemonicCreator.fromEntropy(it.clone()).words } + + accountRepository.getEthereumSecrets(metaId) + ?.get(EthereumSecrets.Entropy) + ?.let { return MnemonicCreator.fromEntropy(it.clone()).words } + + accountRepository.getTonSecrets(metaId) + ?.get(TonSecrets.Seed) + ?.decodeToString() + ?.normalizeMnemonic() + ?.let { return it } + + throw unsupported("Iroha transfers require mnemonic root material for ${chain.name}") + } + + private fun String.normalizeMnemonic(): String? { + val words = trim().split(Regex("\\s+")).filter(String::isNotBlank) + return words.takeIf { it.isNotEmpty() }?.joinToString(" ") + } + + private fun String.normalizeIrohaTransferAmount(): String { + if (!IROHA_AMOUNT_PATTERN.matches(this)) { + throw unsupported("Iroha transfer amount is invalid for ${chain.name}") + } + + val parts = split('.') + val whole = parts[0].toBigInteger() + val fraction = parts.getOrNull(1).orEmpty() + if (whole == BigInteger.ZERO && fraction.all { it == '0' }) { + throw unsupported("Iroha transfer amount must be greater than zero for ${chain.name}") + } + + return this + } + + private fun String.normalizeIrohaAssetDefinitionId(): String { + if (!IROHA_ASSET_DEFINITION_ID_PATTERN.matches(this)) { + throw unsupported("Iroha asset definition id is invalid for ${chain.name}") + } + + return this + } + + private fun UniversalWalletRegistry.IrohaNetwork.irohaTransferNetworkKey(): String { + return when (this) { + UniversalWalletRegistry.taira -> "taira" + UniversalWalletRegistry.nexus -> "nexus" + else -> id + } + } + + private fun unsupported(message: String): IllegalStateException { + return IllegalStateException(message) + } + + private data class IrohaTransferContext( + val toriiBaseUrl: String, + val signingRequest: IrohaTransferSigningRequest + ) + + private companion object { + val IROHA_AMOUNT_PATTERN = Regex("(?:0|[1-9]\\d*)(?:\\.\\d{1,28})?") + val IROHA_ASSET_DEFINITION_ID_PATTERN = Regex("[^\\s%/?:#]+#[^\\s%/?:#]+") + } +} + class SubstrateTransferService( private val chain: Chain, private val substrateSource: SubstrateRemoteSource @@ -106,4 +793,4 @@ class EthereumTransferService( return ethereumRemoteSource.performTransfer(chain, transfer, privateKey.toHexString(true)) .requireValue() // handle error } -} \ No newline at end of file +} diff --git a/feature-wallet-impl/src/main/java/jp/co/soramitsu/wallet/impl/data/storage/TransferCursorStorage.kt b/feature-wallet-impl/src/main/java/jp/co/soramitsu/wallet/impl/data/storage/TransferCursorStorage.kt index 32278743cc..dfd29e0359 100644 --- a/feature-wallet-impl/src/main/java/jp/co/soramitsu/wallet/impl/data/storage/TransferCursorStorage.kt +++ b/feature-wallet-impl/src/main/java/jp/co/soramitsu/wallet/impl/data/storage/TransferCursorStorage.kt @@ -2,8 +2,8 @@ package jp.co.soramitsu.wallet.impl.data.storage import jp.co.soramitsu.common.data.storage.Preferences import jp.co.soramitsu.runtime.multiNetwork.chain.model.ChainId -import jp.co.soramitsu.shared_utils.extensions.toHexString -import jp.co.soramitsu.shared_utils.runtime.AccountId +import jp.co.soramitsu.fearless_utils.extensions.toHexString +import jp.co.soramitsu.fearless_utils.runtime.AccountId import kotlinx.coroutines.flow.filterNotNull import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.map diff --git a/feature-wallet-impl/src/main/java/jp/co/soramitsu/wallet/impl/di/WalletFeatureModule.kt b/feature-wallet-impl/src/main/java/jp/co/soramitsu/wallet/impl/di/WalletFeatureModule.kt index 210b5531f9..8093cb92ae 100644 --- a/feature-wallet-impl/src/main/java/jp/co/soramitsu/wallet/impl/di/WalletFeatureModule.kt +++ b/feature-wallet-impl/src/main/java/jp/co/soramitsu/wallet/impl/di/WalletFeatureModule.kt @@ -14,9 +14,15 @@ import jp.co.soramitsu.account.impl.presentation.account.mixin.impl.AccountListi import jp.co.soramitsu.common.address.AddressIconGenerator import jp.co.soramitsu.common.data.network.HttpExceptionHandler import jp.co.soramitsu.common.data.network.NetworkApiCreator +import jp.co.soramitsu.common.data.network.bitcoin.BitcoinIndexerClient +import jp.co.soramitsu.common.data.network.bitcoin.BitcoinTransactionHistorySync import jp.co.soramitsu.common.data.network.coingecko.CoingeckoApi import jp.co.soramitsu.common.data.network.config.RemoteConfigFetcher +import jp.co.soramitsu.common.data.network.iroha.IrohaToriiClient import jp.co.soramitsu.common.data.network.nomis.NomisApi +import jp.co.soramitsu.common.data.network.solana.SolanaBalanceSync +import jp.co.soramitsu.common.data.network.solana.SolanaRpcClient +import jp.co.soramitsu.common.data.network.solana.SolanaTransactionHistorySync import jp.co.soramitsu.common.data.storage.Preferences import jp.co.soramitsu.common.domain.GetAvailableFiatCurrencies import jp.co.soramitsu.common.domain.NetworkStateService @@ -24,6 +30,7 @@ import jp.co.soramitsu.common.domain.SelectedFiat import jp.co.soramitsu.common.interfaces.FileProvider import jp.co.soramitsu.common.resources.ResourceManager import jp.co.soramitsu.common.utils.QrBitmapDecoder +import jp.co.soramitsu.core.extrinsic.ExtrinsicBuilderFactory import jp.co.soramitsu.core.extrinsic.ExtrinsicService import jp.co.soramitsu.core.extrinsic.keypair_provider.KeypairProvider import jp.co.soramitsu.core.rpc.RpcCalls @@ -99,6 +106,8 @@ import jp.co.soramitsu.wallet.impl.presentation.balance.assetActions.buy.BuyMixi import jp.co.soramitsu.wallet.impl.presentation.balance.assetActions.buy.BuyMixinProvider import jp.co.soramitsu.wallet.impl.presentation.send.SendSharedState import jp.co.soramitsu.wallet.impl.presentation.transaction.filter.HistoryFiltersProvider +import jp.co.soramitsu.xcm.ExtrinsicServiceXcmSubmitter +import jp.co.soramitsu.xcm.SubstrateXcmTransferEngine import jp.co.soramitsu.xcm.XcmService import jp.co.soramitsu.xcm.domain.XcmEntitiesFetcher import jp.co.soramitsu.xnetworking.lib.datasources.chainsconfig.api.ConfigDAO @@ -257,12 +266,18 @@ class WalletFeatureModule { walletOperationsHistoryApi: OperationsHistoryApi, chainRegistry: ChainRegistry, historyInfoRemoteLoader: HistoryInfoRemoteLoader, - tonRemoteSource: TonRemoteSource + tonRemoteSource: TonRemoteSource, + bitcoinTransactionHistorySync: BitcoinTransactionHistorySync, + solanaTransactionHistorySync: SolanaTransactionHistorySync, + irohaToriiClient: IrohaToriiClient ) = HistorySourceProvider( walletOperationsHistoryApi, chainRegistry, historyInfoRemoteLoader, tonRemoteSource, + bitcoinTransactionHistorySync, + solanaTransactionHistorySync, + irohaToriiClient ) @Provides @@ -350,8 +365,20 @@ class WalletFeatureModule { @Provides @Singleton - fun provideXcmService(chainRegistry: ChainRegistry): XcmService { - return XcmService(chainRegistry) + fun provideXcmService( + chainRegistry: ChainRegistry, + rpcCalls: RpcCalls, + extrinsicBuilderFactory: ExtrinsicBuilderFactory + ): XcmService { + return XcmService( + chainRegistry, + SubstrateXcmTransferEngine( + ExtrinsicServiceXcmSubmitter( + rpcCalls = rpcCalls, + extrinsicBuilderFactory = extrinsicBuilderFactory + ) + ) + ) } @Provides @@ -387,8 +414,8 @@ class WalletFeatureModule { ): ChainInteractor = ChainInteractor(chainDao, xcmEntitiesFetcher) @Provides - fun provideXcmEntitiesFetcher(): XcmEntitiesFetcher { - return XcmEntitiesFetcher() + fun provideXcmEntitiesFetcher(chainRegistry: ChainRegistry): XcmEntitiesFetcher { + return XcmEntitiesFetcher(chainRegistry) } @Provides @@ -444,7 +471,10 @@ class WalletFeatureModule { operationDao: OperationDao, tonRemoteSource: TonRemoteSource, chainsRepository: ChainsRepository, - tonSyncDataRepository: TonSyncDataRepository + tonSyncDataRepository: TonSyncDataRepository, + bitcoinIndexerClient: BitcoinIndexerClient, + solanaBalanceSync: SolanaBalanceSync, + irohaToriiClient: IrohaToriiClient ): BalanceLoader.Provider { return BalanceLoaderProvider( chainRegistry, @@ -454,7 +484,10 @@ class WalletFeatureModule { operationDao, tonRemoteSource, chainsRepository, - tonSyncDataRepository + tonSyncDataRepository, + bitcoinIndexerClient, + solanaBalanceSync, + irohaToriiClient ) } @@ -597,7 +630,11 @@ class WalletFeatureModule { keyPairRepository: KeypairProvider, accountRepository: AccountRepository, tonRemoteSource: TonRemoteSource, - assetDao: AssetDao + assetDao: AssetDao, + bitcoinIndexerClient: BitcoinIndexerClient, + solanaRpcClient: SolanaRpcClient, + solanaBalanceSync: SolanaBalanceSync, + irohaToriiClient: IrohaToriiClient ): TransferServiceProvider { return TransferServiceProvider( substrateSource, @@ -605,7 +642,11 @@ class WalletFeatureModule { keyPairRepository, accountRepository, tonRemoteSource, - assetDao + assetDao, + bitcoinIndexerClient, + solanaRpcClient, + solanaBalanceSync, + irohaToriiClient ) } } diff --git a/feature-wallet-impl/src/main/java/jp/co/soramitsu/wallet/impl/domain/QuickInputsUseCaseImpl.kt b/feature-wallet-impl/src/main/java/jp/co/soramitsu/wallet/impl/domain/QuickInputsUseCaseImpl.kt index f838722bcb..7ebc3b8030 100644 --- a/feature-wallet-impl/src/main/java/jp/co/soramitsu/wallet/impl/domain/QuickInputsUseCaseImpl.kt +++ b/feature-wallet-impl/src/main/java/jp/co/soramitsu/wallet/impl/domain/QuickInputsUseCaseImpl.kt @@ -157,6 +157,7 @@ class QuickInputsUseCaseImpl( val quickAmounts = inputValues.map { input -> async { val destinationFee = xcmInteractor.getDestinationFee( + originChainId = originChainId, destinationChainId = destinationChainId, tokenConfiguration = chainAsset ) ?: BigDecimal.ZERO @@ -253,4 +254,4 @@ class QuickInputsUseCaseImpl( inputValues.zip(quickAmounts).toMap() } -} \ No newline at end of file +} diff --git a/feature-wallet-impl/src/main/java/jp/co/soramitsu/wallet/impl/domain/WalletInteractorImpl.kt b/feature-wallet-impl/src/main/java/jp/co/soramitsu/wallet/impl/domain/WalletInteractorImpl.kt index fa4cd9b034..b063e0b755 100644 --- a/feature-wallet-impl/src/main/java/jp/co/soramitsu/wallet/impl/domain/WalletInteractorImpl.kt +++ b/feature-wallet-impl/src/main/java/jp/co/soramitsu/wallet/impl/domain/WalletInteractorImpl.kt @@ -1,9 +1,6 @@ package jp.co.soramitsu.wallet.impl.domain -import android.net.Uri import android.util.Log -import com.mastercard.mpqr.pushpayment.model.PushPaymentData -import com.mastercard.mpqr.pushpayment.parser.Parser import jp.co.soramitsu.account.api.domain.interfaces.AccountRepository import jp.co.soramitsu.account.api.domain.model.LightMetaAccount import jp.co.soramitsu.account.api.domain.model.MetaAccount @@ -36,10 +33,10 @@ import jp.co.soramitsu.runtime.multiNetwork.chain.ChainsRepository import jp.co.soramitsu.runtime.multiNetwork.chain.model.Chain import jp.co.soramitsu.runtime.multiNetwork.chain.model.isPolkadotOrKusama import jp.co.soramitsu.runtime.multiNetwork.chain.model.polkadotChainId -import jp.co.soramitsu.shared_utils.extensions.toHexString -import jp.co.soramitsu.shared_utils.runtime.AccountId -import jp.co.soramitsu.shared_utils.runtime.metadata.moduleOrNull -import jp.co.soramitsu.shared_utils.ss58.SS58Encoder.toAddress +import jp.co.soramitsu.fearless_utils.extensions.toHexString +import jp.co.soramitsu.fearless_utils.runtime.AccountId +import jp.co.soramitsu.fearless_utils.runtime.metadata.moduleOrNull +import jp.co.soramitsu.fearless_utils.ss58.SS58Encoder.toAddress import jp.co.soramitsu.wallet.impl.data.network.blockchain.updaters.BalanceUpdateTrigger import jp.co.soramitsu.wallet.impl.data.repository.HistoryRepository import jp.co.soramitsu.wallet.impl.data.repository.isSupported @@ -60,6 +57,7 @@ import jp.co.soramitsu.wallet.impl.domain.model.QrContentSora import jp.co.soramitsu.wallet.impl.domain.model.Transfer import jp.co.soramitsu.wallet.impl.domain.model.WalletAccount import jp.co.soramitsu.wallet.impl.domain.model.toPhishingModel +import jp.co.soramitsu.wallet.impl.domain.qr.CbdcQrParser import jp.co.soramitsu.xcm.domain.XcmEntitiesFetcher import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.ExperimentalCoroutinesApi @@ -76,7 +74,6 @@ import kotlinx.coroutines.withContext import kotlinx.coroutines.withTimeoutOrNull import java.math.BigDecimal import java.math.BigInteger -import java.net.URLDecoder import kotlin.coroutines.CoroutineContext import jp.co.soramitsu.core.models.Asset as CoreAsset @@ -85,8 +82,6 @@ const val QR_PREFIX_WALLET_CONNECT = "wc" const val QR_PREFIX_TON_CONNECT = "tc" private const val PREFS_WALLET_SELECTED_CHAIN_ID = "wallet_selected_chain_id" private const val CHAIN_SELECT_FILTER_APPLIED = "chain_select_filter_applied" -private const val ACCOUNT_ID_MIN_TAG = 26 -private const val ACCOUNT_ID_MAX_TAG = 51 private const val ASSET_SORTING_KEY = "ASSET_SORTING_KEY" private const val ASSET_MANAGEMENT_INTRO_PASSED_KEY = "ASSET_MANAGEMENT_INTRO_PASSED_KEY" @@ -193,8 +188,9 @@ class WalletInteractorImpl( }.flatMapLatest { metaAccount -> val (chain, chainAsset) = chainRegistry.chainWithAsset(chainId, chainAssetId) val accountId = metaAccount.accountId(chain)!! + val accountAddress = metaAccount.address(chain) - historyRepository.operationsFirstPageFlow(accountId, chain, chainAsset).withIndex() + historyRepository.operationsFirstPageFlow(accountId, chain, chainAsset, accountAddress).withIndex() .map { (index, cursorPage) -> OperationsPageChange(cursorPage, accountChanged = index == 0) } @@ -211,13 +207,15 @@ class WalletInteractorImpl( val metaAccount = accountRepository.getSelectedMetaAccount() val (chain, chainAsset) = chainRegistry.chainWithAsset(chainId, chainAssetId) val accountId = metaAccount.accountId(chain)!! + val accountAddress = metaAccount.address(chain) historyRepository.syncOperationsFirstPage( pageSize, filters, accountId, chain, - chainAsset + chainAsset, + accountAddress ) } } @@ -233,6 +231,7 @@ class WalletInteractorImpl( val metaAccount = accountRepository.getSelectedMetaAccount() val (chain, chainAsset) = chainsRepository.chainWithAsset(chainId, chainAssetId) val accountId = metaAccount.accountId(chain)!! + val accountAddress = metaAccount.address(chain) historyRepository.getOperations( pageSize, @@ -240,7 +239,8 @@ class WalletInteractorImpl( filters, accountId, chain, - chainAsset + chainAsset, + accountAddress ) } } @@ -334,25 +334,7 @@ class WalletInteractorImpl( } override suspend fun tryReadCBDCAddressFormat(content: String): QrContentCBDC? { - val qrParamValue = - runCatching { Uri.parse(content).getQueryParameter("qr") }.getOrNull() ?: return null - val mastercardPushPaymentString = URLDecoder.decode(qrParamValue, "UTF-8") - - val pushPaymentData = Parser.parseWithoutTagValidation(mastercardPushPaymentString) - val transactionAmount = - if (pushPaymentData.transactionAmount != null && pushPaymentData.transactionAmount > 0) { - pushPaymentData.transactionAmount.toBigDecimal() - } else { - BigDecimal.ZERO - } - return QrContentCBDC( - transactionAmount = transactionAmount, - transactionCurrencyCode = pushPaymentData.transactionCurrencyCode, - description = pushPaymentData.additionalData?.purpose, - name = pushPaymentData.merchantName, - billNumber = pushPaymentData.additionalData?.billNumber, - recipientId = getAccountId(pushPaymentData) - ) + return CbdcQrParser.parse(content) } override fun extractTonAddress(input: String): String? = kotlin.runCatching { @@ -371,16 +353,6 @@ class WalletInteractorImpl( BigDecimal(amountStr) }.getOrNull() - private fun getAccountId(item: PushPaymentData): String { - for (i in ACCOUNT_ID_MIN_TAG..ACCOUNT_ID_MAX_TAG) { - item.getMAIData("$i")?.aid?.let { - return it - } - } - - throw IllegalArgumentException("ACCOUNT_ID not found") - } - override fun tryReadSoraFormat(content: String): QrContentSora? { // substrate:[user address]:[user public key]:[user name]:[token id]: val list = content.split(":") diff --git a/feature-wallet-impl/src/main/java/jp/co/soramitsu/wallet/impl/domain/XcmInteractor.kt b/feature-wallet-impl/src/main/java/jp/co/soramitsu/wallet/impl/domain/XcmInteractor.kt index 5198282af9..52c23f77fb 100644 --- a/feature-wallet-impl/src/main/java/jp/co/soramitsu/wallet/impl/domain/XcmInteractor.kt +++ b/feature-wallet-impl/src/main/java/jp/co/soramitsu/wallet/impl/domain/XcmInteractor.kt @@ -16,6 +16,7 @@ import jp.co.soramitsu.core.models.Asset import jp.co.soramitsu.core.models.ChainId import jp.co.soramitsu.core.models.ChainIdWithMetadata import jp.co.soramitsu.core.utils.removedXcPrefix +import jp.co.soramitsu.core.utils.utilityAsset import jp.co.soramitsu.runtime.ext.accountIdOf import jp.co.soramitsu.runtime.ext.fakeAddress import jp.co.soramitsu.runtime.multiNetwork.ChainRegistry @@ -127,11 +128,13 @@ class XcmInteractor( } suspend fun getDestinationFee( + originChainId: ChainId, destinationChainId: ChainId, tokenConfiguration: Asset ): BigDecimal? { return runCatching { xcmService.getXcmDestinationFee( + originChainId = originChainId, destinationChainId = destinationChainId, asset = tokenConfiguration ) @@ -145,12 +148,14 @@ class XcmInteractor( amount: BigDecimal ): BigDecimal? { return runCatching { - val chain = chainRegistry.getChain(destinationNetworkId) + val originChain = chainRegistry.getChain(originNetworkId) + val destinationChain = chainRegistry.getChain(destinationNetworkId) xcmService.getXcmOriginFee( - originChainId = originNetworkId, + originChain = originChain, destinationChainId = destinationNetworkId, asset = asset, - address = chain.fakeAddress(), + originFeeAsset = originChain.utilityAsset ?: asset, + address = destinationChain.fakeAddress(), amount = asset.getPlanksFromAmountForOriginFee(amount) ) }.getOrNull() diff --git a/feature-wallet-impl/src/main/java/jp/co/soramitsu/wallet/impl/domain/beacon/BeaconInteractor.kt b/feature-wallet-impl/src/main/java/jp/co/soramitsu/wallet/impl/domain/beacon/BeaconInteractor.kt index 2b97f70557..99f1794c8d 100644 --- a/feature-wallet-impl/src/main/java/jp/co/soramitsu/wallet/impl/domain/beacon/BeaconInteractor.kt +++ b/feature-wallet-impl/src/main/java/jp/co/soramitsu/wallet/impl/domain/beacon/BeaconInteractor.kt @@ -30,18 +30,19 @@ import jp.co.soramitsu.common.utils.isTransfer import jp.co.soramitsu.common.utils.substrateAccountId import jp.co.soramitsu.core.crypto.mapCryptoTypeToEncryption import jp.co.soramitsu.core.extrinsic.ExtrinsicService +import jp.co.soramitsu.runtime.ext.accountIdOf import jp.co.soramitsu.runtime.multiNetwork.ChainRegistry import jp.co.soramitsu.runtime.multiNetwork.chain.model.Chain -import jp.co.soramitsu.shared_utils.extensions.fromHex -import jp.co.soramitsu.shared_utils.extensions.requireHexPrefix -import jp.co.soramitsu.shared_utils.extensions.toHexString -import jp.co.soramitsu.shared_utils.hash.Hasher.blake2b256 -import jp.co.soramitsu.shared_utils.runtime.definitions.types.composite.DictEnum -import jp.co.soramitsu.shared_utils.runtime.definitions.types.fromHex -import jp.co.soramitsu.shared_utils.runtime.definitions.types.generics.GenericCall -import jp.co.soramitsu.shared_utils.runtime.definitions.types.useScaleWriter -import jp.co.soramitsu.shared_utils.scale.utils.directWrite -import jp.co.soramitsu.shared_utils.ss58.SS58Encoder.toAddress +import jp.co.soramitsu.fearless_utils.extensions.fromHex +import jp.co.soramitsu.fearless_utils.extensions.requireHexPrefix +import jp.co.soramitsu.fearless_utils.extensions.toHexString +import jp.co.soramitsu.fearless_utils.hash.Hasher.blake2b256 +import jp.co.soramitsu.fearless_utils.runtime.definitions.types.composite.DictEnum +import jp.co.soramitsu.fearless_utils.runtime.definitions.types.fromHex +import jp.co.soramitsu.fearless_utils.runtime.definitions.types.generics.GenericCall +import jp.co.soramitsu.fearless_utils.runtime.definitions.types.useScaleWriter +import jp.co.soramitsu.fearless_utils.scale.utils.directWrite +import jp.co.soramitsu.fearless_utils.ss58.SS58Encoder.toAddress import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.SupervisorJob @@ -320,7 +321,11 @@ class BeaconInteractor( suspend fun estimateFee(operation: SignableOperation): BigInteger { val chainId = getBeaconRegisteredNetwork() ?: return BigInteger.ZERO val chain = chainRegistry.getChain(chainId) - return extrinsicService.estimateFee(chain, false) { + val account = accountRepository.getSelectedMetaAccount() + val address = account.address(chain) ?: return BigInteger.ZERO + val accountId = chain.accountIdOf(address) + + return extrinsicService.estimateFee(chain, accountId) { call(operation.module, operation.call, operation.args) } } diff --git a/feature-wallet-impl/src/main/java/jp/co/soramitsu/wallet/impl/domain/qr/CbdcQrParser.kt b/feature-wallet-impl/src/main/java/jp/co/soramitsu/wallet/impl/domain/qr/CbdcQrParser.kt new file mode 100644 index 0000000000..18c1618012 --- /dev/null +++ b/feature-wallet-impl/src/main/java/jp/co/soramitsu/wallet/impl/domain/qr/CbdcQrParser.kt @@ -0,0 +1,117 @@ +package jp.co.soramitsu.wallet.impl.domain.qr + +import jp.co.soramitsu.wallet.impl.domain.model.QrContentCBDC +import java.math.BigDecimal +import java.net.URLDecoder +import java.nio.charset.StandardCharsets + +object CbdcQrParser { + private const val QUERY_PARAMETER = "qr" + private const val MAX_QR_LENGTH = 4096 + private const val MAX_TLV_NODES = 128 + private const val ACCOUNT_ID_MIN_TAG = 26 + private const val ACCOUNT_ID_MAX_TAG = 51 + + private const val TAG_TRANSACTION_AMOUNT = "54" + private const val TAG_TRANSACTION_CURRENCY = "53" + private const val TAG_MERCHANT_NAME = "59" + private const val TAG_ADDITIONAL_DATA = "62" + private const val TAG_ADDITIONAL_BILL_NUMBER = "01" + private const val TAG_ADDITIONAL_PURPOSE = "08" + private const val TAG_MERCHANT_ACCOUNT_AID = "00" + + fun parse(content: String): QrContentCBDC? = runCatching { + val payload = extractQueryParameter(content, QUERY_PARAMETER) ?: return null + if (payload.isBlank() || payload.length > MAX_QR_LENGTH) return null + + val root = parseTlv(payload) + val transactionAmount = root[TAG_TRANSACTION_AMOUNT] + ?.takeIf { it.length <= 32 } + ?.let(::BigDecimal) + ?.takeIf { it > BigDecimal.ZERO } + ?: BigDecimal.ZERO + + val currency = root[TAG_TRANSACTION_CURRENCY] + ?.takeIf { it.isNotBlank() && it.length <= 8 } + ?: return null + + val merchantName = root[TAG_MERCHANT_NAME] + ?.takeIf { it.isNotBlank() && it.length <= 128 } + ?: return null + + val additionalData = root[TAG_ADDITIONAL_DATA]?.let(::parseTlv).orEmpty() + + QrContentCBDC( + transactionAmount = transactionAmount, + transactionCurrencyCode = currency, + description = additionalData[TAG_ADDITIONAL_PURPOSE]?.takeIf { it.length <= 128 }, + name = merchantName, + billNumber = additionalData[TAG_ADDITIONAL_BILL_NUMBER]?.takeIf { it.length <= 128 }, + recipientId = requireMerchantAccountId(root) + ) + }.getOrNull() + + private fun requireMerchantAccountId(root: Map): String { + for (tag in ACCOUNT_ID_MIN_TAG..ACCOUNT_ID_MAX_TAG) { + val accountInfo = root[tag.toString()] ?: continue + val aid = parseTlv(accountInfo)[TAG_MERCHANT_ACCOUNT_AID] + if (!aid.isNullOrBlank() && aid.length <= 256) { + return aid + } + } + + error("CBDC recipient account id not found") + } + + private fun parseTlv(payload: String): Map { + var offset = 0 + var nodes = 0 + val result = linkedMapOf() + + while (offset < payload.length) { + if (nodes++ >= MAX_TLV_NODES) error("Too many TLV nodes") + if (offset + 4 > payload.length) error("Incomplete TLV header") + + val tag = payload.substring(offset, offset + 2) + val lengthText = payload.substring(offset + 2, offset + 4) + if (!tag.all(Char::isDigit) || !lengthText.all(Char::isDigit)) { + error("Invalid TLV header") + } + + val length = lengthText.toInt() + val valueStart = offset + 4 + val valueEnd = valueStart + length + if (valueEnd > payload.length) error("TLV value exceeds payload") + + result[tag] = payload.substring(valueStart, valueEnd) + offset = valueEnd + } + + return result + } + + private fun extractQueryParameter(content: String, name: String): String? { + val queryStart = content.indexOf('?').takeIf { it >= 0 }?.plus(1) ?: return null + val fragmentStart = content.indexOf('#', startIndex = queryStart).takeIf { it >= 0 } ?: content.length + val query = content.substring(queryStart, fragmentStart) + + return query.split('&') + .asSequence() + .mapNotNull { parameter -> + val separator = parameter.indexOf('=') + if (separator <= 0) { + null + } else { + val key = urlDecode(parameter.substring(0, separator)) + val value = parameter.substring(separator + 1) + key to urlDecode(value) + } + } + .firstOrNull { (key, _) -> key == name } + ?.second + } + + private fun urlDecode(value: String): String { + return URLDecoder.decode(value, StandardCharsets.UTF_8.name()) + } +} diff --git a/feature-wallet-impl/src/main/java/jp/co/soramitsu/wallet/impl/presentation/balance/detail/claimreward/ClaimRewardsViewModel.kt b/feature-wallet-impl/src/main/java/jp/co/soramitsu/wallet/impl/presentation/balance/detail/claimreward/ClaimRewardsViewModel.kt index 3baf3c0cbf..46684f47b8 100644 --- a/feature-wallet-impl/src/main/java/jp/co/soramitsu/wallet/impl/presentation/balance/detail/claimreward/ClaimRewardsViewModel.kt +++ b/feature-wallet-impl/src/main/java/jp/co/soramitsu/wallet/impl/presentation/balance/detail/claimreward/ClaimRewardsViewModel.kt @@ -22,7 +22,7 @@ import jp.co.soramitsu.core.utils.amountFromPlanks import jp.co.soramitsu.core.utils.utilityAsset import jp.co.soramitsu.feature_wallet_impl.R import jp.co.soramitsu.runtime.multiNetwork.chain.model.ChainId -import jp.co.soramitsu.shared_utils.hash.isPositive +import jp.co.soramitsu.fearless_utils.hash.isPositive import jp.co.soramitsu.wallet.impl.data.mappers.mapAssetToAssetModel import jp.co.soramitsu.wallet.impl.domain.interfaces.WalletInteractor import jp.co.soramitsu.wallet.impl.presentation.WalletRouter diff --git a/feature-wallet-impl/src/main/java/jp/co/soramitsu/wallet/impl/presentation/cross_chain/confirm/CrossChainConfirmViewModel.kt b/feature-wallet-impl/src/main/java/jp/co/soramitsu/wallet/impl/presentation/cross_chain/confirm/CrossChainConfirmViewModel.kt index ef3e2b085c..bddceb6e07 100644 --- a/feature-wallet-impl/src/main/java/jp/co/soramitsu/wallet/impl/presentation/cross_chain/confirm/CrossChainConfirmViewModel.kt +++ b/feature-wallet-impl/src/main/java/jp/co/soramitsu/wallet/impl/presentation/cross_chain/confirm/CrossChainConfirmViewModel.kt @@ -247,10 +247,11 @@ class CrossChainConfirmViewModel @Inject constructor( val destinationChain = destinationNetworkFlow.value ?: return@launch val asset = originAssetFlow.firstOrNull() ?: return@launch val token = asset.token.configuration + val utilityAsset = utilityAssetFlow.firstOrNull() ?: return@launch val rawAmountInPlanks = token.planksFromAmount(transferDraft.amount) val destinationFeeInPlanks = token.planksFromAmount(transferDraft.destinationFee) - val originFee = token.planksFromAmount(transferDraft.originFee) + val originFee = utilityAsset.token.configuration.planksFromAmount(transferDraft.originFee) val recipientAddress = transferDraft.recipientAddress val selfAddress = currentAccountAddress(asset.token.configuration.chainId) ?: return@launch diff --git a/feature-wallet-impl/src/main/java/jp/co/soramitsu/wallet/impl/presentation/cross_chain/setup/CrossChainSetupViewModel.kt b/feature-wallet-impl/src/main/java/jp/co/soramitsu/wallet/impl/presentation/cross_chain/setup/CrossChainSetupViewModel.kt index 0fdb6786c7..33475ac51d 100644 --- a/feature-wallet-impl/src/main/java/jp/co/soramitsu/wallet/impl/presentation/cross_chain/setup/CrossChainSetupViewModel.kt +++ b/feature-wallet-impl/src/main/java/jp/co/soramitsu/wallet/impl/presentation/cross_chain/setup/CrossChainSetupViewModel.kt @@ -219,13 +219,16 @@ class CrossChainSetupViewModel @Inject constructor( private val hasDestinationFeeAmountFlow = MutableStateFlow(false) private val destinationFeeAmountFlow: StateFlow = combine( + chainAssetsManager.originChainIdFlow, chainAssetsManager.destinationChainIdFlow, assetFlow - ) { _destinationChainId, _asset -> + ) { _originChainId, _destinationChainId, _asset -> hasDestinationFeeAmountFlow.value = false + val originChainId = _originChainId ?: return@combine null val destinationChainId = _destinationChainId ?: return@combine null val tokenConfiguration = _asset?.token?.configuration ?: return@combine null val fee = xcmInteractor.getDestinationFee( + originChainId = originChainId, destinationChainId = destinationChainId, tokenConfiguration = tokenConfiguration ) diff --git a/feature-wallet-impl/src/main/java/jp/co/soramitsu/wallet/impl/presentation/model/OperationParcelizeModel.kt b/feature-wallet-impl/src/main/java/jp/co/soramitsu/wallet/impl/presentation/model/OperationParcelizeModel.kt index 9027f03f61..984edcb28b 100644 --- a/feature-wallet-impl/src/main/java/jp/co/soramitsu/wallet/impl/presentation/model/OperationParcelizeModel.kt +++ b/feature-wallet-impl/src/main/java/jp/co/soramitsu/wallet/impl/presentation/model/OperationParcelizeModel.kt @@ -5,6 +5,7 @@ import java.math.BigInteger import jp.co.soramitsu.core.models.Asset import jp.co.soramitsu.wallet.impl.domain.model.Operation import kotlinx.parcelize.Parcelize +import kotlinx.parcelize.RawValue sealed class OperationParcelizeModel : Parcelable { @@ -50,8 +51,8 @@ sealed class OperationParcelizeModel : Parcelable { val hash: String, val time: Long, val module: String, - val chainAsset: Asset, - val targetAsset: Asset?, + val chainAsset: @RawValue Asset, + val targetAsset: @RawValue Asset?, val baseAssetAmount: BigInteger, val liquidityProviderFee: BigInteger, val selectedMarket: String?, diff --git a/feature-wallet-impl/src/main/java/jp/co/soramitsu/wallet/impl/presentation/transaction/detail/transfer/TransferDetailsScreen.kt b/feature-wallet-impl/src/main/java/jp/co/soramitsu/wallet/impl/presentation/transaction/detail/transfer/TransferDetailsScreen.kt index d9a5d6fa69..7699408bb8 100644 --- a/feature-wallet-impl/src/main/java/jp/co/soramitsu/wallet/impl/presentation/transaction/detail/transfer/TransferDetailsScreen.kt +++ b/feature-wallet-impl/src/main/java/jp/co/soramitsu/wallet/impl/presentation/transaction/detail/transfer/TransferDetailsScreen.kt @@ -32,7 +32,7 @@ interface TransactionDetailsCallbacks { fun TransferDetailScreen(state: TransferDetailsState, callback: TransactionDetailsCallbacks) { Column { MarginVertical(8.dp) - Toolbar(ToolbarViewState(stringResource(R.string.common_details), R.drawable.ic_cross), onNavigationClick = callback::onNavigationClick) + Toolbar(ToolbarViewState(stringResource(R.string.common_details), R.drawable.ic_cross_24), onNavigationClick = callback::onNavigationClick) Column(modifier = Modifier.padding(horizontal = 16.dp)) { MarginVertical(16.dp) DisabledTextInput(state.hash.hint, state.hash.text, endIcon = state.hash.endIcon, callback::onHashClick) @@ -99,4 +99,4 @@ fun TransferDetailsPreview() { override fun onToClick() = Unit } ) -} \ No newline at end of file +} diff --git a/feature-wallet-impl/src/test/java/jp/co/soramitsu/wallet/impl/AnonymousFeeEstimateGuardTest.kt b/feature-wallet-impl/src/test/java/jp/co/soramitsu/wallet/impl/AnonymousFeeEstimateGuardTest.kt new file mode 100644 index 0000000000..a26d6c6e8e --- /dev/null +++ b/feature-wallet-impl/src/test/java/jp/co/soramitsu/wallet/impl/AnonymousFeeEstimateGuardTest.kt @@ -0,0 +1,21 @@ +package jp.co.soramitsu.wallet.impl + +import java.io.File +import org.junit.Assert.assertTrue +import org.junit.Test + +class AnonymousFeeEstimateGuardTest { + + @Test + fun `wallet production code does not use anonymous extrinsic fee estimates`() { + val sourceRoot = File("src/main/java") + val anonymousEstimate = Regex("""extrinsicService\s*\.\s*estimateFee\s*\(\s*chain\s*(?:,\s*false\s*)?\)\s*\{""") + val offenders = sourceRoot.walkTopDown() + .filter { it.isFile && it.extension == "kt" } + .filter { anonymousEstimate.containsMatchIn(it.readText()) } + .map { it.relativeTo(sourceRoot).path } + .toList() + + assertTrue("Anonymous fee estimates must use a real signer account: $offenders", offenders.isEmpty()) + } +} diff --git a/feature-wallet-impl/src/test/java/jp/co/soramitsu/wallet/impl/data/historySource/BitcoinHistorySourceTest.kt b/feature-wallet-impl/src/test/java/jp/co/soramitsu/wallet/impl/data/historySource/BitcoinHistorySourceTest.kt new file mode 100644 index 0000000000..7d72779a38 --- /dev/null +++ b/feature-wallet-impl/src/test/java/jp/co/soramitsu/wallet/impl/data/historySource/BitcoinHistorySourceTest.kt @@ -0,0 +1,331 @@ +package jp.co.soramitsu.wallet.impl.data.historySource + +import com.google.gson.JsonPrimitive +import java.math.BigInteger +import jp.co.soramitsu.common.data.network.bitcoin.BitcoinEsploraAddress +import jp.co.soramitsu.common.data.network.bitcoin.BitcoinEsploraStats +import jp.co.soramitsu.common.data.network.bitcoin.BitcoinEsploraTransaction +import jp.co.soramitsu.common.data.network.bitcoin.BitcoinEsploraTransactionInput +import jp.co.soramitsu.common.data.network.bitcoin.BitcoinEsploraTransactionOutput +import jp.co.soramitsu.common.data.network.bitcoin.BitcoinEsploraTxStatus +import jp.co.soramitsu.common.data.network.bitcoin.BitcoinEsploraUtxo +import jp.co.soramitsu.common.data.network.bitcoin.BitcoinIndexerClient +import jp.co.soramitsu.common.data.network.bitcoin.BitcoinIndexerRoutes +import jp.co.soramitsu.common.data.network.bitcoin.BitcoinTransactionHistorySync +import jp.co.soramitsu.common.data.network.iroha.IrohaToriiClient +import jp.co.soramitsu.common.data.network.solana.SolanaIndexerClient +import jp.co.soramitsu.common.data.network.solana.SolanaTransactionHistorySync +import jp.co.soramitsu.core.models.Asset +import jp.co.soramitsu.core.models.ChainAssetType +import jp.co.soramitsu.core.models.Ecosystem +import jp.co.soramitsu.runtime.multiNetwork.ChainRegistry +import jp.co.soramitsu.runtime.multiNetwork.chain.model.Chain +import jp.co.soramitsu.runtime.multiNetwork.chain.remote.TonRemoteSource +import jp.co.soramitsu.wallet.impl.data.network.subquery.OperationsHistoryApi +import jp.co.soramitsu.wallet.impl.domain.interfaces.TransactionFilter +import jp.co.soramitsu.wallet.impl.domain.model.Operation +import jp.co.soramitsu.xnetworking.lib.datasources.txhistory.api.adapters.HistoryInfoRemoteLoader +import kotlinx.coroutines.runBlocking +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Test +import org.mockito.Mockito.mock + +class BitcoinHistorySourceTest { + + @Test + fun `maps bitcoin history entries to transfer operations and preserves page cursor`() = runBlocking { + val transactions = listOf( + outgoingTransaction(txid = TXID_1, amountSats = 60_000, feeSats = 141, confirmed = true), + incomingTransaction(txid = TXID_2, amountSats = 25_000, confirmed = false) + ) + val client = FakeBitcoinIndexerClient(transactions) + val source = BitcoinHistorySource(BitcoinTransactionHistorySync(client), INDEXER_URL) + + val page = source.getOperations( + pageSize = 1, + cursor = null, + filters = setOf(TransactionFilter.TRANSFER), + accountId = byteArrayOf(), + chain = bitcoinChain(), + chainAsset = bitcoinAsset(), + accountAddress = MAINNET_ADDRESS + ) + + assertEquals(INDEXER_URL, client.lastBaseUrl) + assertEquals(listOf(null), client.lastSeenTxids) + assertEquals(TXID_1, page.nextCursor) + assertEquals(1, page.items.size) + + val operation = page.items.single() + assertEquals(TXID_1, operation.id) + assertEquals(MAINNET_ADDRESS, operation.address) + assertEquals(1_710_000_000_000L, operation.time) + val transfer = operation.type as Operation.Type.Transfer + assertEquals(TXID_1, transfer.hash) + assertEquals(MAINNET_ADDRESS, transfer.myAddress) + assertEquals(BigInteger.valueOf(60_000), transfer.amount) + assertEquals(MAINNET_ADDRESS, transfer.sender) + assertEquals(COUNTERPARTY, transfer.receiver) + assertEquals(Operation.Status.COMPLETED, transfer.status) + assertEquals(BigInteger.valueOf(141), transfer.fee) + } + + @Test + fun `uses the supplied cursor for follow up bitcoin pages`() = runBlocking { + val client = FakeBitcoinIndexerClient(listOf(incomingTransaction(txid = TXID_2, amountSats = 25_000, confirmed = false))) + val source = BitcoinHistorySource(BitcoinTransactionHistorySync(client), INDEXER_URL) + + val page = source.getOperations( + pageSize = 25, + cursor = TXID_1, + filters = setOf(TransactionFilter.TRANSFER), + accountId = byteArrayOf(), + chain = bitcoinChain(), + chainAsset = bitcoinAsset(), + accountAddress = MAINNET_ADDRESS + ) + + assertEquals(listOf(TXID_1), client.lastSeenTxids) + assertEquals(TXID_2, page.nextCursor) + assertEquals(1, page.items.size) + val transfer = page.items.single().type as Operation.Type.Transfer + assertEquals(COUNTERPARTY, transfer.sender) + assertEquals(MAINNET_ADDRESS, transfer.receiver) + assertEquals(Operation.Status.PENDING, transfer.status) + assertEquals(BigInteger.ZERO, transfer.fee) + } + + @Test + fun `returns empty page without network calls for unsupported filters assets and limits`() = runBlocking { + val client = FakeBitcoinIndexerClient(listOf(outgoingTransaction())) + val source = BitcoinHistorySource(BitcoinTransactionHistorySync(client), INDEXER_URL) + + val noTransfer = source.getOperations( + pageSize = 25, + cursor = null, + filters = emptySet(), + accountId = byteArrayOf(), + chain = bitcoinChain(), + chainAsset = bitcoinAsset(), + accountAddress = MAINNET_ADDRESS + ) + val wrongAsset = source.getOperations( + pageSize = 25, + cursor = null, + filters = setOf(TransactionFilter.TRANSFER), + accountId = byteArrayOf(), + chain = bitcoinChain(), + chainAsset = bitcoinAsset(symbol = "DOT"), + accountAddress = MAINNET_ADDRESS + ) + val emptyLimit = source.getOperations( + pageSize = 0, + cursor = null, + filters = setOf(TransactionFilter.TRANSFER), + accountId = byteArrayOf(), + chain = bitcoinChain(), + chainAsset = bitcoinAsset(), + accountAddress = MAINNET_ADDRESS + ) + + assertTrue(noTransfer.items.isEmpty()) + assertTrue(wrongAsset.items.isEmpty()) + assertTrue(emptyLimit.items.isEmpty()) + assertEquals(0, client.calls) + } + + @Test + fun `fails closed for wrong bitcoin network address before fetching`() = runBlocking { + val client = FakeBitcoinIndexerClient(listOf(outgoingTransaction())) + val source = BitcoinHistorySource(BitcoinTransactionHistorySync(client), INDEXER_URL) + + val page = source.getOperations( + pageSize = 25, + cursor = null, + filters = setOf(TransactionFilter.TRANSFER), + accountId = byteArrayOf(), + chain = bitcoinChain(isTestNet = false), + chainAsset = bitcoinAsset(), + accountAddress = TESTNET_ADDRESS + ) + + assertTrue(page.items.isEmpty()) + assertEquals(0, client.calls) + } + + @Test + fun `provider routes bitcoin history type to bitcoin source`() { + val provider = HistorySourceProvider( + walletOperationsApi = mock(OperationsHistoryApi::class.java), + chainRegistry = mock(ChainRegistry::class.java), + historyInfoRemoteLoader = mock(HistoryInfoRemoteLoader::class.java), + tonRemoteSource = mock(TonRemoteSource::class.java), + bitcoinTransactionHistorySync = BitcoinTransactionHistorySync(FakeBitcoinIndexerClient()), + solanaTransactionHistorySync = SolanaTransactionHistorySync(mock(SolanaIndexerClient::class.java)), + irohaToriiClient = mock(IrohaToriiClient::class.java) + ) + + assertTrue(provider(INDEXER_URL, Chain.ExternalApi.Section.Type.BITCOIN) is BitcoinHistorySource) + } + + private class FakeBitcoinIndexerClient( + private val transactions: List = emptyList() + ) : BitcoinIndexerClient { + var calls = 0 + private set + var lastBaseUrl: String? = null + private set + val lastSeenTxids = mutableListOf() + + override suspend fun address( + address: String, + network: BitcoinIndexerRoutes.Network, + baseUrl: String? + ): BitcoinEsploraAddress { + return BitcoinEsploraAddress( + address = address, + chainStats = BitcoinEsploraStats(0, 0, 0, 0, 0), + mempoolStats = BitcoinEsploraStats(0, 0, 0, 0, 0) + ) + } + + override suspend fun utxos( + address: String, + network: BitcoinIndexerRoutes.Network, + baseUrl: String? + ): List = emptyList() + + override suspend fun transactions( + address: String, + network: BitcoinIndexerRoutes.Network, + baseUrl: String?, + lastSeenTxid: String?, + mempool: Boolean + ): List { + calls += 1 + lastBaseUrl = baseUrl + lastSeenTxids.add(lastSeenTxid) + + return transactions + } + + override suspend fun feeEstimates( + network: BitcoinIndexerRoutes.Network, + baseUrl: String? + ): Map = emptyMap() + + override suspend fun broadcastTransaction( + txHex: String, + network: BitcoinIndexerRoutes.Network, + baseUrl: String? + ): String = TXID_1 + } + + private companion object { + const val INDEXER_URL = "https://bitcoin.example/api" + const val MAINNET_ADDRESS = "bc1qcr8te4kr609gcawutmrza0j4xv80jy8z306fyu" + const val TESTNET_ADDRESS = "tb1q6rz28mcfaxtmd6v789l9rrlrusdprr9pqcpvkl" + const val COUNTERPARTY = "bc1q6rz28mcfaxtmd6v789l9rrlrusdprr9pkv76kj" + val TXID_1 = "11".repeat(32) + val TXID_2 = "22".repeat(32) + val BLOCK_HASH = "aa".repeat(32) + + fun bitcoinChain(isTestNet: Boolean = false): Chain { + return Chain( + id = if (isTestNet) "bitcoin-testnet" else "bitcoin-mainnet", + paraId = null, + rank = null, + name = "Bitcoin", + minSupportedVersion = null, + assets = listOf(bitcoinAsset(isTestNet = isTestNet)), + nodes = emptyList(), + explorers = emptyList(), + externalApi = Chain.ExternalApi( + staking = null, + history = Chain.ExternalApi.Section(Chain.ExternalApi.Section.Type.BITCOIN, INDEXER_URL), + crowdloans = null + ), + icon = "", + addressPrefix = 0, + isEthereumBased = false, + isTestNet = isTestNet, + hasCrowdloans = false, + parentId = null, + supportStakingPool = false, + isEthereumChain = false, + chainlinkProvider = false, + supportNft = false, + isUsesAppId = false, + identityChain = null, + ecosystem = Ecosystem.Substrate, + remoteAssetsSource = null + ) + } + + fun bitcoinAsset(symbol: String = "BTC", isTestNet: Boolean = false): Asset { + return Asset( + id = "BTC", + name = "Bitcoin", + symbol = symbol, + iconUrl = "", + chainId = if (isTestNet) "bitcoin-testnet" else "bitcoin-mainnet", + chainName = "Bitcoin", + chainIcon = null, + isTestNet = isTestNet, + priceId = null, + precision = 8, + staking = Asset.StakingType.UNSUPPORTED, + purchaseProviders = null, + supportStakingPool = false, + isUtility = true, + type = ChainAssetType.Normal, + currencyId = null, + existentialDeposit = null, + color = null, + isNative = true + ) + } + + fun outgoingTransaction( + txid: String = TXID_1, + amountSats: Long = 60_000, + feeSats: Long = 141, + confirmed: Boolean = true + ): BitcoinEsploraTransaction { + return BitcoinEsploraTransaction( + txid = txid, + fee = feeSats, + status = BitcoinEsploraTxStatus( + confirmed = confirmed, + blockHash = if (confirmed) BLOCK_HASH else null, + blockHeight = if (confirmed) 100 else null, + blockTime = if (confirmed) 1_710_000_000 else null + ), + vin = listOf(input(MAINNET_ADDRESS, amountSats + feeSats + 10_000)), + vout = listOf(output(COUNTERPARTY, amountSats), output(MAINNET_ADDRESS, 10_000)) + ) + } + + fun incomingTransaction( + txid: String, + amountSats: Long, + confirmed: Boolean + ): BitcoinEsploraTransaction { + return BitcoinEsploraTransaction( + txid = txid, + status = BitcoinEsploraTxStatus(confirmed = confirmed), + vin = listOf(input(COUNTERPARTY, amountSats)), + vout = listOf(output(MAINNET_ADDRESS, amountSats)) + ) + } + + fun input(address: String, valueSats: Long): BitcoinEsploraTransactionInput { + return BitcoinEsploraTransactionInput(output(address, valueSats)) + } + + fun output(address: String, valueSats: Long): BitcoinEsploraTransactionOutput { + return BitcoinEsploraTransactionOutput(address, JsonPrimitive(valueSats)) + } + } +} diff --git a/feature-wallet-impl/src/test/java/jp/co/soramitsu/wallet/impl/data/historySource/IrohaHistorySourceTest.kt b/feature-wallet-impl/src/test/java/jp/co/soramitsu/wallet/impl/data/historySource/IrohaHistorySourceTest.kt new file mode 100644 index 0000000000..5503f880a5 --- /dev/null +++ b/feature-wallet-impl/src/test/java/jp/co/soramitsu/wallet/impl/data/historySource/IrohaHistorySourceTest.kt @@ -0,0 +1,368 @@ +package jp.co.soramitsu.wallet.impl.data.historySource + +import java.math.BigInteger +import jp.co.soramitsu.common.data.network.bitcoin.BitcoinIndexerClient +import jp.co.soramitsu.common.data.network.bitcoin.BitcoinTransactionHistorySync +import jp.co.soramitsu.common.data.network.iroha.IrohaAccountAssetListResponse +import jp.co.soramitsu.common.data.network.iroha.IrohaAccountListItem +import jp.co.soramitsu.common.data.network.iroha.IrohaAccountListResponse +import jp.co.soramitsu.common.data.network.iroha.IrohaAssetDefinitionListResponse +import jp.co.soramitsu.common.data.network.iroha.IrohaMcpJsonRpcError +import jp.co.soramitsu.common.data.network.iroha.IrohaMcpJsonRpcRequest +import jp.co.soramitsu.common.data.network.iroha.IrohaMcpJsonRpcResponse +import jp.co.soramitsu.common.data.network.iroha.IrohaPipelineTransactionStatusResponse +import jp.co.soramitsu.common.data.network.iroha.IrohaToriiClient +import jp.co.soramitsu.common.data.network.iroha.IrohaToriiRoutes +import jp.co.soramitsu.common.data.network.iroha.IrohaTransactionSubmissionReceipt +import jp.co.soramitsu.common.data.network.solana.SolanaIndexerClient +import jp.co.soramitsu.common.data.network.solana.SolanaTransactionHistorySync +import jp.co.soramitsu.common.model.UniversalWalletRegistry +import jp.co.soramitsu.common.utils.IrohaKeyDerivation +import jp.co.soramitsu.core.models.Asset +import jp.co.soramitsu.core.models.ChainAssetType +import jp.co.soramitsu.core.models.Ecosystem +import jp.co.soramitsu.runtime.multiNetwork.ChainRegistry +import jp.co.soramitsu.runtime.multiNetwork.chain.model.Chain +import jp.co.soramitsu.runtime.multiNetwork.chain.remote.TonRemoteSource +import jp.co.soramitsu.wallet.impl.data.network.subquery.OperationsHistoryApi +import jp.co.soramitsu.wallet.impl.domain.interfaces.TransactionFilter +import jp.co.soramitsu.wallet.impl.domain.model.Operation +import jp.co.soramitsu.xnetworking.lib.datasources.txhistory.api.adapters.HistoryInfoRemoteLoader +import kotlinx.coroutines.runBlocking +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Test +import org.mockito.Mockito.mock + +class IrohaHistorySourceTest { + + @Test + fun `maps taira torii instruction entries to transfer operations`() = runBlocking { + val client = FakeIrohaToriiClient( + response = mcpResponse( + instruction = instruction( + value = mapOf( + "source" to "${ASSET_ID}#$ADDRESS", + "destination" to COUNTERPARTY, + "object" to "123" + ) + ) + ) + ) + val source = IrohaHistorySource(client, INDEXER_URL) + + val page = source.getOperations( + pageSize = 25, + cursor = null, + filters = setOf(TransactionFilter.TRANSFER), + accountId = byteArrayOf(), + chain = irohaChain(), + chainAsset = irohaAsset(), + accountAddress = ADDRESS + ) + + assertEquals(1, client.mcpCalls) + assertEquals(UniversalWalletRegistry.taira, client.lastNetwork) + assertEquals(INDEXER_URL, client.lastBaseUrl) + assertEquals("tools/call", client.lastRequest?.method) + assertEquals("iroha.instructions.list", client.lastRequest?.params?.get("name")) + + val arguments = client.lastRequest?.params?.get("arguments") as Map<*, *> + assertEquals(ADDRESS, arguments["account"]) + assertEquals(ASSET_ID, arguments["asset_id"]) + assertEquals("Transfer", arguments["kind"]) + assertEquals(0, arguments["page"]) + assertEquals(25, arguments["per_page"]) + assertEquals("committed", arguments["transaction_status"]) + + assertNull(page.nextCursor) + assertEquals(1, page.items.size) + + val operation = page.items.single() + assertEquals(TX_HASH, operation.id) + assertEquals(ADDRESS, operation.address) + assertEquals(1_704_067_200_000L, operation.time) + val transfer = operation.type as Operation.Type.Transfer + assertEquals(TX_HASH, transfer.hash) + assertEquals(ADDRESS, transfer.myAddress) + assertEquals(BigInteger.valueOf(123), transfer.amount) + assertEquals(ADDRESS, transfer.sender) + assertEquals(COUNTERPARTY, transfer.receiver) + assertEquals(Operation.Status.COMPLETED, transfer.status) + assertEquals(BigInteger.ZERO, transfer.fee) + } + + @Test + fun `uses supplied cursor and caps taira mcp page size`() = runBlocking { + val client = FakeIrohaToriiClient(response = mcpResponse(instruction())) + val source = IrohaHistorySource(client, INDEXER_URL) + + source.getOperations( + pageSize = IrohaToriiRoutes.MAX_LIMIT + 10, + cursor = "3", + filters = setOf(TransactionFilter.TRANSFER), + accountId = byteArrayOf(), + chain = irohaChain(), + chainAsset = irohaAsset(), + accountAddress = ADDRESS + ) + + val arguments = client.lastRequest?.params?.get("arguments") as Map<*, *> + assertEquals(3, arguments["page"]) + assertEquals(IrohaToriiRoutes.MAX_LIMIT, arguments["per_page"]) + } + + @Test + fun `returns empty page without torii calls for unsupported filters addresses and limits`() = runBlocking { + val client = FakeIrohaToriiClient(response = mcpResponse(instruction())) + val source = IrohaHistorySource(client, INDEXER_URL) + + val noTransfer = source.getOperations( + pageSize = 25, + cursor = null, + filters = emptySet(), + accountId = byteArrayOf(), + chain = irohaChain(), + chainAsset = irohaAsset(), + accountAddress = ADDRESS + ) + val emptyLimit = source.getOperations( + pageSize = 0, + cursor = null, + filters = setOf(TransactionFilter.TRANSFER), + accountId = byteArrayOf(), + chain = irohaChain(), + chainAsset = irohaAsset(), + accountAddress = ADDRESS + ) + val malformedAddress = source.getOperations( + pageSize = 25, + cursor = null, + filters = setOf(TransactionFilter.TRANSFER), + accountId = byteArrayOf(), + chain = irohaChain(), + chainAsset = irohaAsset(), + accountAddress = "../bad" + ) + + assertTrue(noTransfer.items.isEmpty()) + assertTrue(emptyLimit.items.isEmpty()) + assertTrue(malformedAddress.items.isEmpty()) + assertEquals(0, client.mcpCalls) + } + + @Test + fun `fails closed when torii mcp returns an error`() = runBlocking { + val client = FakeIrohaToriiClient( + response = IrohaMcpJsonRpcResponse( + id = "history-0", + error = IrohaMcpJsonRpcError(code = -32000, message = "index unavailable") + ) + ) + val source = IrohaHistorySource(client, INDEXER_URL) + + val page = source.getOperations( + pageSize = 25, + cursor = null, + filters = setOf(TransactionFilter.TRANSFER), + accountId = byteArrayOf(), + chain = irohaChain(), + chainAsset = irohaAsset(), + accountAddress = ADDRESS + ) + + assertEquals(1, client.mcpCalls) + assertTrue(page.items.isEmpty()) + } + + @Test + fun `provider routes iroha history type to iroha source`() { + val provider = HistorySourceProvider( + walletOperationsApi = mock(OperationsHistoryApi::class.java), + chainRegistry = mock(ChainRegistry::class.java), + historyInfoRemoteLoader = mock(HistoryInfoRemoteLoader::class.java), + tonRemoteSource = mock(TonRemoteSource::class.java), + bitcoinTransactionHistorySync = BitcoinTransactionHistorySync(mock(BitcoinIndexerClient::class.java)), + solanaTransactionHistorySync = SolanaTransactionHistorySync(mock(SolanaIndexerClient::class.java)), + irohaToriiClient = FakeIrohaToriiClient() + ) + + assertTrue(provider(INDEXER_URL, Chain.ExternalApi.Section.Type.IROHA) is IrohaHistorySource) + } + + private class FakeIrohaToriiClient( + private val response: IrohaMcpJsonRpcResponse = mcpResponse(instruction()) + ) : IrohaToriiClient { + var mcpCalls = 0 + private set + var lastRequest: IrohaMcpJsonRpcRequest? = null + private set + var lastNetwork: UniversalWalletRegistry.IrohaNetwork? = null + private set + var lastBaseUrl: String? = null + private set + + override suspend fun health(baseUrl: String?): String = "ok" + + override suspend fun accounts( + baseUrl: String?, + limit: Int?, + offset: Long?, + countMode: IrohaToriiRoutes.CountMode? + ): IrohaAccountListResponse = error("Unexpected Iroha accounts call") + + override suspend fun account( + accountId: String, + baseUrl: String?, + network: UniversalWalletRegistry.IrohaNetwork + ): IrohaAccountListItem = error("Unexpected Iroha account call") + + override suspend fun accountAssets( + accountId: String, + baseUrl: String?, + limit: Int?, + offset: Long?, + countMode: IrohaToriiRoutes.CountMode?, + asset: String?, + scope: String?, + network: UniversalWalletRegistry.IrohaNetwork + ): IrohaAccountAssetListResponse = error("Unexpected Iroha account-assets call") + + override suspend fun assetDefinitions(baseUrl: String?): IrohaAssetDefinitionListResponse { + error("Unexpected Iroha asset-definitions call") + } + + override suspend fun submitTransaction( + noritoBytes: ByteArray, + baseUrl: String? + ): IrohaTransactionSubmissionReceipt = error("Unexpected Iroha submit call") + + override suspend fun transactionStatus( + hash: String, + baseUrl: String?, + scope: IrohaToriiRoutes.TransactionStatusScope + ): IrohaPipelineTransactionStatusResponse = error("Unexpected Iroha transaction-status call") + + override suspend fun mcpCapabilities( + network: UniversalWalletRegistry.IrohaNetwork, + baseUrl: String? + ): Map = error("Unexpected Iroha mcp capabilities call") + + override suspend fun mcpJsonRpc( + request: IrohaMcpJsonRpcRequest, + network: UniversalWalletRegistry.IrohaNetwork, + baseUrl: String? + ): IrohaMcpJsonRpcResponse { + mcpCalls += 1 + lastRequest = request + lastNetwork = network + lastBaseUrl = baseUrl + + return response + } + } + + private companion object { + const val INDEXER_URL = "https://taira.sora.org" + const val ASSET_ID = "xor#sora" + const val TX_HASH = "aa-aa-aa" + const val MNEMONIC = "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about" + const val COUNTERPARTY = "i105counterparty" + val ADDRESS = IrohaKeyDerivation.deriveAddress( + mnemonic = MNEMONIC, + chainDiscriminant = UniversalWalletRegistry.taira.chainDiscriminant + ).i105 + + fun mcpResponse(instruction: Map): IrohaMcpJsonRpcResponse { + return IrohaMcpJsonRpcResponse( + id = "history-0", + result = mapOf( + "body" to mapOf( + "items" to listOf(instruction) + ) + ) + ) + } + + fun instruction( + value: Map = mapOf( + "source" to "${ASSET_ID}#$ADDRESS", + "destination" to COUNTERPARTY, + "object" to "123" + ) + ): Map { + return mapOf( + "transaction_hash" to TX_HASH, + "created_at" to "2024-01-01T00:00:00Z", + "transaction_status" to "Committed", + "box" to mapOf( + "json" to mapOf( + "payload" to mapOf( + "variant" to "Asset", + "value" to value + ) + ) + ) + ) + } + + fun irohaChain(): Chain { + val network = UniversalWalletRegistry.taira + + return Chain( + id = network.id, + paraId = null, + rank = null, + name = "Taira Testnet", + minSupportedVersion = null, + assets = listOf(irohaAsset()), + nodes = emptyList(), + explorers = emptyList(), + externalApi = Chain.ExternalApi( + staking = null, + history = Chain.ExternalApi.Section(Chain.ExternalApi.Section.Type.IROHA, INDEXER_URL), + crowdloans = null + ), + icon = "", + addressPrefix = 0, + isEthereumBased = false, + isTestNet = true, + hasCrowdloans = false, + parentId = null, + supportStakingPool = false, + isEthereumChain = false, + chainlinkProvider = false, + supportNft = false, + isUsesAppId = false, + identityChain = null, + ecosystem = Ecosystem.Substrate, + remoteAssetsSource = null + ) + } + + fun irohaAsset(): Asset { + return Asset( + id = ASSET_ID, + name = "XOR", + symbol = "XOR", + iconUrl = "", + chainId = UniversalWalletRegistry.taira.id, + chainName = "Taira Testnet", + chainIcon = null, + isTestNet = true, + priceId = null, + precision = 18, + staking = Asset.StakingType.UNSUPPORTED, + purchaseProviders = null, + supportStakingPool = false, + isUtility = true, + type = ChainAssetType.Normal, + currencyId = null, + existentialDeposit = null, + color = null, + isNative = true + ) + } + } +} diff --git a/feature-wallet-impl/src/test/java/jp/co/soramitsu/wallet/impl/data/historySource/SolanaHistorySourceTest.kt b/feature-wallet-impl/src/test/java/jp/co/soramitsu/wallet/impl/data/historySource/SolanaHistorySourceTest.kt new file mode 100644 index 0000000000..06fbd16d94 --- /dev/null +++ b/feature-wallet-impl/src/test/java/jp/co/soramitsu/wallet/impl/data/historySource/SolanaHistorySourceTest.kt @@ -0,0 +1,394 @@ +package jp.co.soramitsu.wallet.impl.data.historySource + +import java.math.BigInteger +import jp.co.soramitsu.common.data.network.bitcoin.BitcoinIndexerClient +import jp.co.soramitsu.common.data.network.bitcoin.BitcoinTransactionHistorySync +import jp.co.soramitsu.common.data.network.iroha.IrohaToriiClient +import jp.co.soramitsu.common.data.network.solana.SolanaIndexerClient +import jp.co.soramitsu.common.data.network.solana.SolanaIndexerServiceInfo +import jp.co.soramitsu.common.data.network.solana.SolanaNativeBalance +import jp.co.soramitsu.common.data.network.solana.SolanaTokenBalanceChange +import jp.co.soramitsu.common.data.network.solana.SolanaTokenMetadata +import jp.co.soramitsu.common.data.network.solana.SolanaTokenMetadataBatchResponse +import jp.co.soramitsu.common.data.network.solana.SolanaTransactionHistorySync +import jp.co.soramitsu.common.data.network.solana.SolanaWalletAssetsResponse +import jp.co.soramitsu.common.data.network.solana.SolanaWalletBalancesResponse +import jp.co.soramitsu.common.data.network.solana.SolanaWalletStateResponse +import jp.co.soramitsu.common.data.network.solana.SolanaWalletTransactionRecord +import jp.co.soramitsu.common.data.network.solana.SolanaWalletTransactionsResponse +import jp.co.soramitsu.common.model.UniversalWalletRegistry +import jp.co.soramitsu.core.models.Asset +import jp.co.soramitsu.core.models.ChainAssetType +import jp.co.soramitsu.core.models.Ecosystem +import jp.co.soramitsu.runtime.multiNetwork.ChainRegistry +import jp.co.soramitsu.runtime.multiNetwork.chain.model.Chain +import jp.co.soramitsu.runtime.multiNetwork.chain.remote.TonRemoteSource +import jp.co.soramitsu.wallet.impl.data.network.subquery.OperationsHistoryApi +import jp.co.soramitsu.wallet.impl.domain.interfaces.TransactionFilter +import jp.co.soramitsu.wallet.impl.domain.model.Operation +import jp.co.soramitsu.xnetworking.lib.datasources.txhistory.api.adapters.HistoryInfoRemoteLoader +import kotlinx.coroutines.runBlocking +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Test +import org.mockito.Mockito.mock + +class SolanaHistorySourceTest { + + @Test + fun `maps solana history entries to native transfer operations and preserves cursor`() = runBlocking { + val client = FakeSolanaIndexerClient( + response = transactionsResponse( + transactions = listOf(transaction(signature = SIGNATURE_1), transaction(signature = SIGNATURE_2, nativeDelta = "0")), + nextBefore = SIGNATURE_2 + ) + ) + val source = SolanaHistorySource(SolanaTransactionHistorySync(client), INDEXER_URL) + + val page = source.getOperations( + pageSize = 25, + cursor = SIGNATURE_0, + filters = setOf(TransactionFilter.TRANSFER), + accountId = byteArrayOf(), + chain = solanaChain(), + chainAsset = solanaAsset(), + accountAddress = WALLET + ) + + assertEquals(listOf(INDEXER_URL), client.verifiedBaseUrls) + assertEquals(WALLET, client.lastWallet) + assertEquals(INDEXER_URL, client.lastBaseUrl) + assertEquals(SIGNATURE_0, client.lastBefore) + assertEquals(25, client.lastLimit) + assertEquals(SIGNATURE_2, page.nextCursor) + assertEquals(1, page.items.size) + + val operation = page.items.single() + assertEquals(SIGNATURE_1, operation.id) + assertEquals(WALLET, operation.address) + assertEquals(1_710_000_000_000L, operation.time) + val transfer = operation.type as Operation.Type.Transfer + assertEquals(SIGNATURE_1, transfer.hash) + assertEquals(WALLET, transfer.myAddress) + assertEquals(BigInteger("1005000"), transfer.amount) + assertEquals(WALLET, transfer.sender) + assertEquals("", transfer.receiver) + assertEquals(Operation.Status.COMPLETED, transfer.status) + assertEquals(BigInteger("5000"), transfer.fee) + } + + @Test + fun `maps solana token history entries to token transfer operations`() = runBlocking { + val client = FakeSolanaIndexerClient( + response = transactionsResponse( + transactions = listOf( + transaction( + signature = SIGNATURE_1, + nativeDelta = null, + tokenChanges = listOf(tokenChange(mint = TOKEN_MINT, amountDelta = "-1500")), + feeLamports = "5000" + ), + transaction( + signature = SIGNATURE_2, + nativeDelta = null, + tokenChanges = listOf(tokenChange(mint = OTHER_TOKEN_MINT, amountDelta = "999")) + ) + ) + ) + ) + val source = SolanaHistorySource(SolanaTransactionHistorySync(client), INDEXER_URL) + + val page = source.getOperations( + pageSize = 25, + cursor = null, + filters = setOf(TransactionFilter.TRANSFER), + accountId = byteArrayOf(), + chain = solanaChain(), + chainAsset = solanaAsset(id = TOKEN_MINT, symbol = "USDC", precision = 6, isNative = false), + accountAddress = WALLET + ) + + assertEquals(listOf(INDEXER_URL), client.verifiedBaseUrls) + assertEquals(1, client.transactionCalls) + assertEquals(1, page.items.size) + + val transfer = page.items.single().type as Operation.Type.Transfer + assertEquals(SIGNATURE_1, transfer.hash) + assertEquals(BigInteger("1500"), transfer.amount) + assertEquals(WALLET, transfer.sender) + assertEquals("", transfer.receiver) + assertEquals(Operation.Status.COMPLETED, transfer.status) + assertEquals(null, transfer.fee) + } + + @Test + fun `returns empty page without network calls for unsupported filters assets and limits`() = runBlocking { + val client = FakeSolanaIndexerClient(response = transactionsResponse(listOf(transaction()))) + val source = SolanaHistorySource(SolanaTransactionHistorySync(client), INDEXER_URL) + + val noTransfer = source.getOperations( + pageSize = 25, + cursor = null, + filters = emptySet(), + accountId = byteArrayOf(), + chain = solanaChain(), + chainAsset = solanaAsset(), + accountAddress = WALLET + ) + val wrongAsset = source.getOperations( + pageSize = 25, + cursor = null, + filters = setOf(TransactionFilter.TRANSFER), + accountId = byteArrayOf(), + chain = solanaChain(), + chainAsset = solanaAsset(symbol = "USDC", isNative = false), + accountAddress = WALLET + ) + val emptyLimit = source.getOperations( + pageSize = 0, + cursor = null, + filters = setOf(TransactionFilter.TRANSFER), + accountId = byteArrayOf(), + chain = solanaChain(), + chainAsset = solanaAsset(), + accountAddress = WALLET + ) + + assertTrue(noTransfer.items.isEmpty()) + assertTrue(wrongAsset.items.isEmpty()) + assertTrue(emptyLimit.items.isEmpty()) + assertEquals(0, client.transactionCalls) + } + + @Test + fun `fails closed for malformed solana address before fetching`() = runBlocking { + val client = FakeSolanaIndexerClient(response = transactionsResponse(listOf(transaction()))) + val source = SolanaHistorySource(SolanaTransactionHistorySync(client), INDEXER_URL) + + val page = source.getOperations( + pageSize = 25, + cursor = null, + filters = setOf(TransactionFilter.TRANSFER), + accountId = byteArrayOf(), + chain = solanaChain(), + chainAsset = solanaAsset(), + accountAddress = "../bad" + ) + + assertTrue(page.items.isEmpty()) + assertEquals(0, client.transactionCalls) + } + + @Test + fun `provider routes solana history type to solana source`() { + val provider = HistorySourceProvider( + walletOperationsApi = mock(OperationsHistoryApi::class.java), + chainRegistry = mock(ChainRegistry::class.java), + historyInfoRemoteLoader = mock(HistoryInfoRemoteLoader::class.java), + tonRemoteSource = mock(TonRemoteSource::class.java), + bitcoinTransactionHistorySync = BitcoinTransactionHistorySync(mock(BitcoinIndexerClient::class.java)), + solanaTransactionHistorySync = SolanaTransactionHistorySync(FakeSolanaIndexerClient()), + irohaToriiClient = mock(IrohaToriiClient::class.java) + ) + + assertTrue(provider(INDEXER_URL, Chain.ExternalApi.Section.Type.SOLANA) is SolanaHistorySource) + } + + private class FakeSolanaIndexerClient( + private val response: SolanaWalletTransactionsResponse = transactionsResponse(emptyList()) + ) : SolanaIndexerClient { + val verifiedBaseUrls = mutableListOf() + var transactionCalls = 0 + private set + var lastWallet: String? = null + private set + var lastBaseUrl: String? = null + private set + var lastBefore: String? = null + private set + var lastLimit: Int? = null + private set + + override suspend fun serviceInfo(baseUrl: String?): SolanaIndexerServiceInfo { + error("Unexpected Solana service-info call") + } + + override suspend fun verifyServiceInfo(baseUrl: String?): SolanaIndexerServiceInfo { + verifiedBaseUrls += baseUrl.orEmpty() + return SolanaIndexerServiceInfo( + schemaVersion = 1, + serviceId = "si.soramitsu.io", + serviceName = "Solswap Indexer", + ecosystem = "solana", + chainId = "solana:mainnet", + network = "mainnet", + publicBaseUrl = "https://si.soramitsu.io", + readOnly = true + ) + } + + override suspend fun balances(wallet: String, baseUrl: String?): SolanaWalletBalancesResponse { + return SolanaWalletBalancesResponse( + wallet = wallet, + native = SolanaNativeBalance(lamports = "0", uiAmountString = "0"), + total = 1, + syncedAt = 1L + ) + } + + override suspend fun assets(wallet: String, baseUrl: String?): SolanaWalletAssetsResponse { + error("Unexpected Solana assets call") + } + + override suspend fun state(wallet: String, baseUrl: String?): SolanaWalletStateResponse { + error("Unexpected Solana state call") + } + + override suspend fun transactions( + wallet: String, + baseUrl: String?, + before: String?, + limit: Int + ): SolanaWalletTransactionsResponse { + transactionCalls += 1 + lastWallet = wallet + lastBaseUrl = baseUrl + lastBefore = before + lastLimit = limit + return response + } + + override suspend fun tokenMetadata(mint: String, baseUrl: String?): SolanaTokenMetadata { + error("Unexpected Solana metadata call") + } + + override suspend fun tokenMetadataBatch( + mints: List, + baseUrl: String? + ): SolanaTokenMetadataBatchResponse { + error("Unexpected Solana metadata batch call") + } + } + + private companion object { + const val INDEXER_URL = "https://si.soramitsu.io" + const val WALLET = "HAgk14JpMQLgt6rVgv7cBQFJWFto5Dqxi472uT3DKpqk" + val SIGNATURE_0 = "1".repeat(88) + val SIGNATURE_1 = "2".repeat(88) + val SIGNATURE_2 = "3".repeat(88) + const val TOKEN_MINT = "EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v" + const val OTHER_TOKEN_MINT = "Es9vMFrzaCERmJfrF4H2FYD4KCoNkYur5nC8LqgxmVZ1" + + fun transactionsResponse( + transactions: List, + nextBefore: String? = null + ): SolanaWalletTransactionsResponse { + return SolanaWalletTransactionsResponse( + wallet = WALLET, + before = null, + nextBefore = nextBefore, + limit = 25, + total = transactions.size, + syncedAt = 1_710_000_000_000L, + transactions = transactions + ) + } + + fun transaction( + signature: String = SIGNATURE_1, + nativeDelta: String? = "-1005000", + feeLamports: String? = "5000", + tokenChanges: List = emptyList(), + solswapRoute: String? = null + ): SolanaWalletTransactionRecord { + return SolanaWalletTransactionRecord( + signature = signature, + slot = 100, + timestamp = 1_710_000_000L, + status = "success", + feeLamports = feeLamports, + nativeBalanceChangeLamports = nativeDelta, + tokenBalanceChanges = tokenChanges, + solswapRoute = solswapRoute + ) + } + + fun tokenChange( + mint: String = TOKEN_MINT, + amountDelta: String = "-1500", + decimals: Int = 6 + ): SolanaTokenBalanceChange { + return SolanaTokenBalanceChange( + mint = mint, + preAmount = "2000", + postAmount = "500", + amountDelta = amountDelta, + decimals = decimals, + uiAmountDeltaString = "-0.0015" + ) + } + + fun solanaChain(): Chain { + val network = UniversalWalletRegistry.solanaMainnet + + return Chain( + id = network.id, + paraId = null, + rank = null, + name = network.name, + minSupportedVersion = null, + assets = listOf(solanaAsset()), + nodes = emptyList(), + explorers = emptyList(), + externalApi = Chain.ExternalApi( + staking = null, + history = Chain.ExternalApi.Section(Chain.ExternalApi.Section.Type.SOLANA, INDEXER_URL), + crowdloans = null + ), + icon = "", + addressPrefix = 0, + isEthereumBased = false, + isTestNet = false, + hasCrowdloans = false, + parentId = null, + supportStakingPool = false, + isEthereumChain = false, + chainlinkProvider = false, + supportNft = false, + isUsesAppId = false, + identityChain = null, + ecosystem = Ecosystem.Substrate, + remoteAssetsSource = null + ) + } + + fun solanaAsset( + id: String = "SOL", + symbol: String = "SOL", + precision: Int = 9, + isNative: Boolean = true + ): Asset { + return Asset( + id = id, + name = "Solana", + symbol = symbol, + iconUrl = "", + chainId = UniversalWalletRegistry.solanaMainnet.id, + chainName = "Solana", + chainIcon = null, + isTestNet = false, + priceId = null, + precision = precision, + staking = Asset.StakingType.UNSUPPORTED, + purchaseProviders = null, + supportStakingPool = false, + isUtility = isNative, + type = ChainAssetType.Normal, + currencyId = null, + existentialDeposit = null, + color = null, + isNative = isNative + ) + } + } +} diff --git a/feature-wallet-impl/src/test/java/jp/co/soramitsu/wallet/impl/data/network/blockchain/balance/BitcoinBalanceLoaderTest.kt b/feature-wallet-impl/src/test/java/jp/co/soramitsu/wallet/impl/data/network/blockchain/balance/BitcoinBalanceLoaderTest.kt new file mode 100644 index 0000000000..1461b174b7 --- /dev/null +++ b/feature-wallet-impl/src/test/java/jp/co/soramitsu/wallet/impl/data/network/blockchain/balance/BitcoinBalanceLoaderTest.kt @@ -0,0 +1,309 @@ +package jp.co.soramitsu.wallet.impl.data.network.blockchain.balance + +import jp.co.soramitsu.account.api.domain.model.MetaAccount +import jp.co.soramitsu.common.data.network.bitcoin.BitcoinEsploraAddress +import jp.co.soramitsu.common.data.network.bitcoin.BitcoinEsploraStats +import jp.co.soramitsu.common.data.network.bitcoin.BitcoinEsploraTransaction +import jp.co.soramitsu.common.data.network.bitcoin.BitcoinEsploraUtxo +import jp.co.soramitsu.common.data.network.bitcoin.BitcoinIndexerClient +import jp.co.soramitsu.common.data.network.bitcoin.BitcoinIndexerRoutes +import jp.co.soramitsu.common.data.network.iroha.IrohaToriiClient +import jp.co.soramitsu.common.data.network.solana.SolanaBalanceSync +import jp.co.soramitsu.common.data.network.solana.SolanaIndexerClient +import jp.co.soramitsu.common.model.UniversalWalletRegistry +import jp.co.soramitsu.common.utils.BitcoinKeyDerivation +import jp.co.soramitsu.core.models.Asset +import jp.co.soramitsu.core.models.ChainAssetType +import jp.co.soramitsu.core.models.CryptoType +import jp.co.soramitsu.core.models.Ecosystem +import jp.co.soramitsu.coredb.dao.OperationDao +import jp.co.soramitsu.coredb.model.OperationLocal +import jp.co.soramitsu.runtime.multiNetwork.ChainRegistry +import jp.co.soramitsu.runtime.multiNetwork.chain.ChainsRepository +import jp.co.soramitsu.runtime.multiNetwork.chain.TonSyncDataRepository +import jp.co.soramitsu.runtime.multiNetwork.chain.model.Chain +import jp.co.soramitsu.runtime.multiNetwork.chain.remote.TonRemoteSource +import jp.co.soramitsu.runtime.storage.source.RemoteStorageSource +import jp.co.soramitsu.wallet.api.data.BalanceLoader +import jp.co.soramitsu.wallet.impl.data.network.blockchain.EthereumRemoteSource +import jp.co.soramitsu.wallet.impl.data.network.blockchain.SubstrateRemoteSource +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.flowOf +import kotlinx.coroutines.runBlocking +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Test +import org.mockito.Mockito.mock +import java.math.BigInteger + +class BitcoinBalanceLoaderTest { + + @Test + fun `loads native bitcoin balance from validated mainnet address`() = runBlocking { + val chain = bitcoinChain(isTestNet = false) + val account = BitcoinKeyDerivation.deriveAccount(MNEMONIC, network = BitcoinKeyDerivation.Network.Mainnet) + val metaAccount = metaAccount(chain, publicKey = account.publicKey, accountId = ACCOUNT_ID) + val client = FakeBitcoinIndexerClient( + addressResponse = BitcoinEsploraAddress( + address = MAINNET_ADDRESS, + chainStats = BitcoinEsploraStats(1, 1_500, 1, 300, 2), + mempoolStats = BitcoinEsploraStats(1, 200, 1, 50, 2) + ) + ) + + val updates = BitcoinBalanceLoader(chain, client).loadBalance(setOf(metaAccount)) + + assertEquals(listOf(MAINNET_ADDRESS), client.receivedAddresses) + assertEquals(listOf(BitcoinIndexerRoutes.Network.Mainnet), client.receivedNetworks) + assertEquals(listOf(UniversalWalletRegistry.BITCOIN_MAINNET_INDEXER_BASE_URL), client.receivedBaseUrls) + assertEquals(1, updates.size) + assertEquals(chain.id, updates.single().chainId) + assertEquals("BTC", updates.single().id) + assertTrue(ACCOUNT_ID.contentEquals(updates.single().accountId)) + assertEquals(BigInteger.valueOf(1_350), updates.single().freeInPlanks) + } + + @Test + fun `rejects malformed bitcoin public key before balance fetch`() = runBlocking { + val chain = bitcoinChain(isTestNet = false) + val metaAccount = metaAccount(chain, publicKey = ByteArray(32), accountId = ACCOUNT_ID) + val client = FakeBitcoinIndexerClient() + + val updates = BitcoinBalanceLoader(chain, client).loadBalance(setOf(metaAccount)) + + assertTrue(updates.isEmpty()) + assertTrue(client.receivedAddresses.isEmpty()) + } + + @Test + fun `rejects negative bitcoin indexer balances`() = runBlocking { + val chain = bitcoinChain(isTestNet = false) + val account = BitcoinKeyDerivation.deriveAccount(MNEMONIC, network = BitcoinKeyDerivation.Network.Mainnet) + val metaAccount = metaAccount(chain, publicKey = account.publicKey, accountId = ACCOUNT_ID) + val client = FakeBitcoinIndexerClient( + addressResponse = BitcoinEsploraAddress( + address = MAINNET_ADDRESS, + chainStats = BitcoinEsploraStats(1, 100, 1, 200, 2), + mempoolStats = BitcoinEsploraStats(0, 0, 0, 0, 0) + ) + ) + + val updates = BitcoinBalanceLoader(chain, client).loadBalance(setOf(metaAccount)) + + assertTrue(updates.isEmpty()) + assertEquals(listOf(MAINNET_ADDRESS), client.receivedAddresses) + } + + @Test + fun `provider routes universal wallet bitcoin chains to bitcoin balance loader`() { + val provider = BalanceLoaderProvider( + chainRegistry = mock(ChainRegistry::class.java), + remoteStorageSource = mock(RemoteStorageSource::class.java), + ethereumRemoteSource = mock(EthereumRemoteSource::class.java), + substrateSource = mock(SubstrateRemoteSource::class.java), + operationDao = NoopOperationDao(), + tonRemoteSource = mock(TonRemoteSource::class.java), + chainsRepository = mock(ChainsRepository::class.java), + tonSyncDataRepository = mock(TonSyncDataRepository::class.java), + bitcoinIndexerClient = FakeBitcoinIndexerClient(), + solanaBalanceSync = SolanaBalanceSync(mock(SolanaIndexerClient::class.java)), + irohaToriiClient = mock(IrohaToriiClient::class.java) + ) + + assertTrue(provider.invoke(bitcoinChain(isTestNet = false)) is BitcoinBalanceLoader) + } + + private class FakeBitcoinIndexerClient( + private val addressResponse: BitcoinEsploraAddress = BitcoinEsploraAddress( + address = MAINNET_ADDRESS, + chainStats = BitcoinEsploraStats(0, 0, 0, 0, 0), + mempoolStats = BitcoinEsploraStats(0, 0, 0, 0, 0) + ) + ) : BitcoinIndexerClient { + val receivedAddresses = mutableListOf() + val receivedNetworks = mutableListOf() + val receivedBaseUrls = mutableListOf() + + override suspend fun address( + address: String, + network: BitcoinIndexerRoutes.Network, + baseUrl: String? + ): BitcoinEsploraAddress { + receivedAddresses += address + receivedNetworks += network + receivedBaseUrls += baseUrl + + return addressResponse + } + + override suspend fun utxos( + address: String, + network: BitcoinIndexerRoutes.Network, + baseUrl: String? + ): List = error("Unexpected UTXO call") + + override suspend fun transactions( + address: String, + network: BitcoinIndexerRoutes.Network, + baseUrl: String?, + lastSeenTxid: String?, + mempool: Boolean + ): List = error("Unexpected transactions call") + + override suspend fun feeEstimates( + network: BitcoinIndexerRoutes.Network, + baseUrl: String? + ): Map = error("Unexpected fee estimates call") + + override suspend fun broadcastTransaction( + txHex: String, + network: BitcoinIndexerRoutes.Network, + baseUrl: String? + ): String = error("Unexpected broadcast call") + } + + private class NoopOperationDao : OperationDao() { + override suspend fun insert(operation: OperationLocal) = Unit + + override suspend fun insertAll(operations: List) = Unit + + override fun observe( + address: String, + chainId: String, + chainAssetId: String, + statusUp: OperationLocal.Status + ): Flow> = flowOf(emptyList()) + + override suspend fun getOperation(hash: String): OperationLocal? = null + + override suspend fun getOperations(): List = emptyList() + + override fun observeOperations(): Flow> = flowOf(emptyList()) + + override fun observeOperations(chainId: String): Flow> = flowOf(emptyList()) + + override fun observeOperationAddresses(chainId: String, address: String, limit: Int): Flow> = flowOf(emptyList()) + + override fun getOperationAddresses(chainId: String, address: String, limit: Int): List = emptyList() + + override suspend fun clearBySource( + address: String, + chainId: String, + chainAssetId: String, + source: OperationLocal.Source + ): Int = 0 + + override suspend fun clearOld( + address: String, + chainId: String, + chainAssetId: String, + minTime: Long + ): Int = 0 + + override suspend fun clearByHashes( + address: String, + chainId: String, + chainAssetId: String, + hashes: Set + ): Int = 0 + } + + private companion object { + const val MNEMONIC = "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about" + const val MAINNET_ADDRESS = "bc1qcr8te4kr609gcawutmrza0j4xv80jy8z306fyu" + val ACCOUNT_ID = ByteArray(32) { 9 } + + fun metaAccount( + chain: Chain, + publicKey: ByteArray, + accountId: ByteArray + ): MetaAccount { + return MetaAccount( + id = 1, + chainAccounts = mapOf( + chain.id to MetaAccount.ChainAccount( + metaId = 1, + chain = chain, + publicKey = publicKey, + accountId = accountId, + cryptoType = CryptoType.ECDSA, + accountName = "Bitcoin" + ) + ), + favoriteChains = emptyMap(), + substratePublicKey = null, + substrateCryptoType = CryptoType.SR25519, + substrateAccountId = null, + ethereumAddress = null, + ethereumPublicKey = null, + tonPublicKey = null, + isSelected = true, + isBackedUp = true, + googleBackupAddress = null, + name = "Wallet", + initialized = true + ) + } + + fun bitcoinChain(isTestNet: Boolean): Chain { + val network = if (isTestNet) UniversalWalletRegistry.bitcoinTestnet else UniversalWalletRegistry.bitcoinMainnet + + return Chain( + id = network.id, + paraId = null, + rank = null, + name = network.name, + minSupportedVersion = null, + assets = listOf(bitcoinAsset(network.id, isTestNet)), + nodes = emptyList(), + explorers = emptyList(), + externalApi = Chain.ExternalApi( + staking = null, + history = Chain.ExternalApi.Section( + Chain.ExternalApi.Section.Type.BITCOIN, + network.indexerBaseUrl + ), + crowdloans = null + ), + icon = "", + addressPrefix = 0, + isEthereumBased = false, + isTestNet = isTestNet, + hasCrowdloans = false, + parentId = null, + supportStakingPool = false, + isEthereumChain = false, + chainlinkProvider = false, + supportNft = false, + isUsesAppId = false, + identityChain = null, + ecosystem = Ecosystem.Substrate, + remoteAssetsSource = null + ) + } + + fun bitcoinAsset(chainId: String, isTestNet: Boolean): Asset { + return Asset( + id = "BTC", + name = "Bitcoin", + symbol = "BTC", + iconUrl = "", + chainId = chainId, + chainName = "Bitcoin", + chainIcon = null, + isTestNet = isTestNet, + priceId = null, + precision = 8, + staking = Asset.StakingType.UNSUPPORTED, + purchaseProviders = null, + supportStakingPool = false, + isUtility = true, + type = ChainAssetType.Normal, + currencyId = null, + existentialDeposit = null, + color = null, + isNative = true + ) + } + } +} diff --git a/feature-wallet-impl/src/test/java/jp/co/soramitsu/wallet/impl/data/network/blockchain/balance/IrohaBalanceLoaderTest.kt b/feature-wallet-impl/src/test/java/jp/co/soramitsu/wallet/impl/data/network/blockchain/balance/IrohaBalanceLoaderTest.kt new file mode 100644 index 0000000000..a4160a8bac --- /dev/null +++ b/feature-wallet-impl/src/test/java/jp/co/soramitsu/wallet/impl/data/network/blockchain/balance/IrohaBalanceLoaderTest.kt @@ -0,0 +1,319 @@ +package jp.co.soramitsu.wallet.impl.data.network.blockchain.balance + +import java.math.BigInteger +import jp.co.soramitsu.account.api.domain.model.MetaAccount +import jp.co.soramitsu.common.data.network.bitcoin.BitcoinIndexerClient +import jp.co.soramitsu.common.data.network.iroha.IrohaAccountAssetListItem +import jp.co.soramitsu.common.data.network.iroha.IrohaAccountAssetListResponse +import jp.co.soramitsu.common.data.network.iroha.IrohaAccountListItem +import jp.co.soramitsu.common.data.network.iroha.IrohaAccountListResponse +import jp.co.soramitsu.common.data.network.iroha.IrohaAssetDefinitionListResponse +import jp.co.soramitsu.common.data.network.iroha.IrohaMcpJsonRpcRequest +import jp.co.soramitsu.common.data.network.iroha.IrohaMcpJsonRpcResponse +import jp.co.soramitsu.common.data.network.iroha.IrohaPipelineTransactionStatusResponse +import jp.co.soramitsu.common.data.network.iroha.IrohaToriiClient +import jp.co.soramitsu.common.data.network.iroha.IrohaToriiRoutes +import jp.co.soramitsu.common.data.network.iroha.IrohaTransactionSubmissionReceipt +import jp.co.soramitsu.common.data.network.solana.SolanaBalanceSync +import jp.co.soramitsu.common.data.network.solana.SolanaIndexerClient +import jp.co.soramitsu.common.model.UniversalWalletRegistry +import jp.co.soramitsu.common.utils.IrohaKeyDerivation +import jp.co.soramitsu.core.models.Asset +import jp.co.soramitsu.core.models.ChainAssetType +import jp.co.soramitsu.core.models.CryptoType +import jp.co.soramitsu.core.models.Ecosystem +import jp.co.soramitsu.coredb.dao.OperationDao +import jp.co.soramitsu.coredb.model.OperationLocal +import jp.co.soramitsu.runtime.multiNetwork.ChainRegistry +import jp.co.soramitsu.runtime.multiNetwork.chain.ChainsRepository +import jp.co.soramitsu.runtime.multiNetwork.chain.TonSyncDataRepository +import jp.co.soramitsu.runtime.multiNetwork.chain.model.Chain +import jp.co.soramitsu.runtime.multiNetwork.chain.remote.TonRemoteSource +import jp.co.soramitsu.runtime.storage.source.RemoteStorageSource +import jp.co.soramitsu.wallet.impl.data.network.blockchain.EthereumRemoteSource +import jp.co.soramitsu.wallet.impl.data.network.blockchain.SubstrateRemoteSource +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.flowOf +import kotlinx.coroutines.runBlocking +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Test +import org.mockito.Mockito.mock + +class IrohaBalanceLoaderTest { + + @Test + fun `loads taira account asset balances through torii`() = runBlocking { + val chain = irohaChain() + val account = IrohaKeyDerivation.deriveAccount(MNEMONIC) + val address = IrohaKeyDerivation.deriveAddress( + mnemonic = MNEMONIC, + chainDiscriminant = UniversalWalletRegistry.taira.chainDiscriminant + ).i105 + val client = FakeIrohaToriiClient( + items = listOf( + IrohaAccountAssetListItem( + accountId = address, + asset = "xor#sora", + quantity = "1.5" + ), + IrohaAccountAssetListItem( + accountId = address, + asset = "xor#sora", + quantity = "0.25", + scope = "rewards" + ) + ) + ) + val metaAccount = metaAccount(chain, account.publicKey) + + val updates = IrohaBalanceLoader(chain, client).loadBalance(setOf(metaAccount)) + + assertEquals(address, client.lastAccountId) + assertEquals(UniversalWalletRegistry.taira.toriiBaseUrl, client.lastBaseUrl) + assertEquals(IrohaToriiRoutes.MAX_LIMIT, client.lastLimit) + assertEquals(IrohaToriiRoutes.CountMode.Bounded, client.lastCountMode) + assertEquals(1, updates.size) + assertEquals("xor#sora", updates.single().id) + assertEquals(BigInteger("1750000000000000000"), updates.single().freeInPlanks) + } + + @Test + fun `provider routes universal wallet iroha chains to torii iroha loader`() = runBlocking { + val loader = provider().invoke(irohaChain()) + + assertTrue(loader is IrohaBalanceLoader) + } + + private fun provider( + irohaToriiClient: IrohaToriiClient = FakeIrohaToriiClient() + ): BalanceLoaderProvider { + return BalanceLoaderProvider( + chainRegistry = mock(ChainRegistry::class.java), + remoteStorageSource = mock(RemoteStorageSource::class.java), + ethereumRemoteSource = mock(EthereumRemoteSource::class.java), + substrateSource = mock(SubstrateRemoteSource::class.java), + operationDao = NoopOperationDao(), + tonRemoteSource = mock(TonRemoteSource::class.java), + chainsRepository = mock(ChainsRepository::class.java), + tonSyncDataRepository = mock(TonSyncDataRepository::class.java), + bitcoinIndexerClient = mock(BitcoinIndexerClient::class.java), + solanaBalanceSync = SolanaBalanceSync(mock(SolanaIndexerClient::class.java)), + irohaToriiClient = irohaToriiClient + ) + } + + private class FakeIrohaToriiClient( + private val items: List = emptyList() + ) : IrohaToriiClient { + var lastAccountId: String? = null + private set + var lastBaseUrl: String? = null + private set + var lastLimit: Int? = null + private set + var lastCountMode: IrohaToriiRoutes.CountMode? = null + private set + + override suspend fun health(baseUrl: String?): String = "ok" + + override suspend fun accounts( + baseUrl: String?, + limit: Int?, + offset: Long?, + countMode: IrohaToriiRoutes.CountMode? + ): IrohaAccountListResponse = throw NotImplementedError() + + override suspend fun account( + accountId: String, + baseUrl: String?, + network: UniversalWalletRegistry.IrohaNetwork + ): IrohaAccountListItem = throw NotImplementedError() + + override suspend fun accountAssets( + accountId: String, + baseUrl: String?, + limit: Int?, + offset: Long?, + countMode: IrohaToriiRoutes.CountMode?, + asset: String?, + scope: String?, + network: UniversalWalletRegistry.IrohaNetwork + ): IrohaAccountAssetListResponse { + lastAccountId = accountId + lastBaseUrl = baseUrl + lastLimit = limit + lastCountMode = countMode + + return IrohaAccountAssetListResponse( + items = items, + hasMore = false, + countMode = countMode?.apiValue ?: "bounded", + total = items.size.toLong() + ) + } + + override suspend fun assetDefinitions(baseUrl: String?): IrohaAssetDefinitionListResponse = throw NotImplementedError() + + override suspend fun submitTransaction( + noritoBytes: ByteArray, + baseUrl: String? + ): IrohaTransactionSubmissionReceipt = throw NotImplementedError() + + override suspend fun transactionStatus( + hash: String, + baseUrl: String?, + scope: IrohaToriiRoutes.TransactionStatusScope + ): IrohaPipelineTransactionStatusResponse = throw NotImplementedError() + + override suspend fun mcpCapabilities( + network: UniversalWalletRegistry.IrohaNetwork, + baseUrl: String? + ): Map = throw NotImplementedError() + + override suspend fun mcpJsonRpc( + request: IrohaMcpJsonRpcRequest, + network: UniversalWalletRegistry.IrohaNetwork, + baseUrl: String? + ): IrohaMcpJsonRpcResponse = throw NotImplementedError() + } + + private class NoopOperationDao : OperationDao() { + override suspend fun insert(operation: OperationLocal) = Unit + + override suspend fun insertAll(operations: List) = Unit + + override fun observe( + address: String, + chainId: String, + chainAssetId: String, + statusUp: OperationLocal.Status + ): Flow> = flowOf(emptyList()) + + override suspend fun getOperation(hash: String): OperationLocal? = null + + override suspend fun getOperations(): List = emptyList() + + override fun observeOperations(): Flow> = flowOf(emptyList()) + + override fun observeOperations(chainId: String): Flow> = flowOf(emptyList()) + + override fun observeOperationAddresses(chainId: String, address: String, limit: Int): Flow> = flowOf(emptyList()) + + override fun getOperationAddresses(chainId: String, address: String, limit: Int): List = emptyList() + + override suspend fun clearBySource( + address: String, + chainId: String, + chainAssetId: String, + source: OperationLocal.Source + ): Int = 0 + + override suspend fun clearOld( + address: String, + chainId: String, + chainAssetId: String, + minTime: Long + ): Int = 0 + + override suspend fun clearByHashes( + address: String, + chainId: String, + chainAssetId: String, + hashes: Set + ): Int = 0 + } + + private companion object { + const val MNEMONIC = "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about" + + fun metaAccount(chain: Chain, publicKey: ByteArray): MetaAccount { + return MetaAccount( + id = 1, + chainAccounts = mapOf( + chain.id to MetaAccount.ChainAccount( + metaId = 1, + chain = chain, + publicKey = publicKey, + accountId = publicKey, + cryptoType = CryptoType.ED25519, + accountName = "Iroha" + ) + ), + favoriteChains = emptyMap(), + substratePublicKey = ByteArray(32), + substrateCryptoType = CryptoType.SR25519, + substrateAccountId = ByteArray(32), + ethereumAddress = null, + ethereumPublicKey = null, + tonPublicKey = null, + isSelected = true, + isBackedUp = true, + googleBackupAddress = null, + name = "Wallet", + initialized = true + ) + } + + fun irohaChain(): Chain { + val network = UniversalWalletRegistry.taira + + return Chain( + id = network.id, + paraId = null, + rank = null, + name = "Taira Testnet", + minSupportedVersion = null, + assets = listOf(irohaAsset(network.id)), + nodes = emptyList(), + explorers = emptyList(), + externalApi = Chain.ExternalApi( + staking = null, + history = Chain.ExternalApi.Section( + Chain.ExternalApi.Section.Type.IROHA, + network.toriiBaseUrl ?: "https://taira.sora.org" + ), + crowdloans = null + ), + icon = "", + addressPrefix = 0, + isEthereumBased = false, + isTestNet = true, + hasCrowdloans = false, + parentId = null, + supportStakingPool = false, + isEthereumChain = false, + chainlinkProvider = false, + supportNft = false, + isUsesAppId = false, + identityChain = null, + ecosystem = Ecosystem.Substrate, + remoteAssetsSource = null + ) + } + + fun irohaAsset(chainId: String): Asset { + return Asset( + id = "xor#sora", + name = "XOR", + symbol = "XOR", + iconUrl = "", + chainId = chainId, + chainName = "Taira Testnet", + chainIcon = null, + isTestNet = true, + priceId = null, + precision = 18, + staking = Asset.StakingType.UNSUPPORTED, + purchaseProviders = null, + supportStakingPool = false, + isUtility = true, + type = ChainAssetType.Normal, + currencyId = null, + existentialDeposit = null, + color = null, + isNative = true + ) + } + } +} diff --git a/feature-wallet-impl/src/test/java/jp/co/soramitsu/wallet/impl/data/network/blockchain/balance/SolanaBalanceLoaderTest.kt b/feature-wallet-impl/src/test/java/jp/co/soramitsu/wallet/impl/data/network/blockchain/balance/SolanaBalanceLoaderTest.kt new file mode 100644 index 0000000000..6b21fb9fcc --- /dev/null +++ b/feature-wallet-impl/src/test/java/jp/co/soramitsu/wallet/impl/data/network/blockchain/balance/SolanaBalanceLoaderTest.kt @@ -0,0 +1,444 @@ +package jp.co.soramitsu.wallet.impl.data.network.blockchain.balance + +import java.math.BigInteger +import jp.co.soramitsu.account.api.domain.model.MetaAccount +import jp.co.soramitsu.account.api.domain.model.address +import jp.co.soramitsu.account.api.domain.model.chainAddress +import jp.co.soramitsu.common.data.network.bitcoin.BitcoinIndexerClient +import jp.co.soramitsu.common.data.network.solana.SolanaBalanceSync +import jp.co.soramitsu.common.data.network.iroha.IrohaToriiClient +import jp.co.soramitsu.common.data.network.solana.SolanaIndexerClient +import jp.co.soramitsu.common.data.network.solana.SolanaIndexerServiceInfo +import jp.co.soramitsu.common.data.network.solana.SolanaNativeBalance +import jp.co.soramitsu.common.data.network.solana.SolanaTokenMetadata +import jp.co.soramitsu.common.data.network.solana.SolanaTokenMetadataBatchResponse +import jp.co.soramitsu.common.data.network.solana.SolanaTokenBalance +import jp.co.soramitsu.common.data.network.solana.SolanaWalletAssetsResponse +import jp.co.soramitsu.common.data.network.solana.SolanaWalletBalancesResponse +import jp.co.soramitsu.common.data.network.solana.SolanaWalletStateResponse +import jp.co.soramitsu.common.data.network.solana.SolanaWalletTransactionsResponse +import jp.co.soramitsu.common.model.UniversalWalletRegistry +import jp.co.soramitsu.common.utils.SolanaKeyDerivation +import jp.co.soramitsu.core.models.Asset +import jp.co.soramitsu.core.models.ChainAssetType +import jp.co.soramitsu.core.models.CryptoType +import jp.co.soramitsu.core.models.Ecosystem +import jp.co.soramitsu.coredb.dao.OperationDao +import jp.co.soramitsu.coredb.model.OperationLocal +import jp.co.soramitsu.runtime.multiNetwork.ChainRegistry +import jp.co.soramitsu.runtime.multiNetwork.chain.ChainsRepository +import jp.co.soramitsu.runtime.multiNetwork.chain.TonSyncDataRepository +import jp.co.soramitsu.runtime.multiNetwork.chain.model.Chain +import jp.co.soramitsu.runtime.multiNetwork.chain.remote.TonRemoteSource +import jp.co.soramitsu.runtime.storage.source.RemoteStorageSource +import jp.co.soramitsu.wallet.impl.data.network.blockchain.EthereumRemoteSource +import jp.co.soramitsu.wallet.impl.data.network.blockchain.SubstrateRemoteSource +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.flowOf +import kotlinx.coroutines.runBlocking +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Test +import org.mockito.Mockito.mock + +class SolanaBalanceLoaderTest { + + @Test + fun `meta account derives solana address from chain account public key`() { + val chain = solanaChain(isTestNet = false) + val account = SolanaKeyDerivation.deriveAccount(MNEMONIC) + val metaAccount = metaAccount(chain, publicKey = account.publicKey, accountId = account.publicKey) + + assertEquals(account.address, metaAccount.address(chain)) + assertEquals(account.address, metaAccount.chainAddress(chain)) + } + + @Test + fun `solana address resolution does not fall back to substrate account id`() { + val chain = solanaChain(isTestNet = false) + val metaAccount = metaAccount( + chainAccounts = emptyMap(), + substrateAccountId = ByteArray(32) + ) + + assertEquals(null, metaAccount.address(chain)) + assertEquals(null, metaAccount.chainAddress(chain)) + } + + @Test + fun `loads native sol balance from validated mainnet address`() = runBlocking { + val chain = solanaChain(isTestNet = false) + val account = SolanaKeyDerivation.deriveAccount(MNEMONIC) + val metaAccount = metaAccount(chain, publicKey = account.publicKey, accountId = account.publicKey) + val client = FakeSolanaIndexerClient(response = balancesResponse(wallet = account.address, lamports = "2500000000")) + + val updates = SolanaBalanceLoader(chain, SolanaBalanceSync(client)).loadBalance(setOf(metaAccount)) + + assertEquals(listOf(UniversalWalletRegistry.SOLANA_INDEXER_BASE_URL), client.verifiedBaseUrls) + assertEquals(listOf(account.address), client.receivedWallets) + assertEquals(listOf(UniversalWalletRegistry.SOLANA_INDEXER_BASE_URL), client.receivedBaseUrls) + assertEquals(1, updates.size) + assertEquals(chain.id, updates.single().chainId) + assertEquals("SOL", updates.single().id) + assertTrue(account.publicKey.contentEquals(updates.single().accountId)) + assertEquals(BigInteger("2500000000"), updates.single().freeInPlanks) + } + + @Test + fun `loads token balances from matching solana token mints`() = runBlocking { + val chain = solanaChain( + isTestNet = false, + assets = listOf( + solanaAsset(UniversalWalletRegistry.solanaMainnet.id, isTestNet = false), + solanaTokenAsset(UniversalWalletRegistry.solanaMainnet.id, isTestNet = false) + ) + ) + val account = SolanaKeyDerivation.deriveAccount(MNEMONIC) + val metaAccount = metaAccount(chain, publicKey = account.publicKey, accountId = account.publicKey) + val client = FakeSolanaIndexerClient( + response = balancesResponse( + wallet = account.address, + lamports = "2500000000", + tokens = listOf(tokenBalance(account.address, USDC_MINT, USDC_TOKEN_ACCOUNT, "42000000", 6, "spl-token")) + ) + ) + + val updates = SolanaBalanceLoader(chain, SolanaBalanceSync(client)) + .loadBalance(setOf(metaAccount)) + .sortedBy { it.id } + + assertEquals(listOf(UniversalWalletRegistry.SOLANA_INDEXER_BASE_URL), client.verifiedBaseUrls) + assertEquals(listOf("SOL", USDC_MINT), updates.map { it.id }) + assertEquals(BigInteger("2500000000"), updates[0].freeInPlanks) + assertEquals(BigInteger("42000000"), updates[1].freeInPlanks) + assertTrue(updates.all { account.publicKey.contentEquals(it.accountId) }) + } + + @Test + fun `rejects malformed solana public key before balance fetch`() = runBlocking { + val chain = solanaChain(isTestNet = false) + val metaAccount = metaAccount(chain, publicKey = ByteArray(31), accountId = ByteArray(31)) + val client = FakeSolanaIndexerClient() + + val updates = SolanaBalanceLoader(chain, SolanaBalanceSync(client)).loadBalance(setOf(metaAccount)) + + assertTrue(updates.isEmpty()) + assertTrue(client.receivedWallets.isEmpty()) + assertTrue(client.verifiedBaseUrls.isEmpty()) + } + + @Test + fun `loads solana devnet balance through registry network`() = runBlocking { + val chain = solanaChain(isTestNet = true) + val account = SolanaKeyDerivation.deriveAccount(MNEMONIC) + val metaAccount = metaAccount(chain, publicKey = account.publicKey, accountId = account.publicKey) + val client = FakeSolanaIndexerClient(response = balancesResponse(wallet = account.address, lamports = "1")) + + val updates = SolanaBalanceLoader(chain, SolanaBalanceSync(client)).loadBalance(setOf(metaAccount)) + + assertEquals(listOf(UniversalWalletRegistry.solanaDevnet.indexerBaseUrl), client.verifiedBaseUrls) + assertEquals(listOf(account.address), client.receivedWallets) + assertEquals(1, updates.size) + assertEquals("SOL", updates.single().id) + assertEquals(BigInteger.ONE, updates.single().freeInPlanks) + } + + @Test + fun `provider routes universal wallet solana chains to solana balance loader`() { + val provider = BalanceLoaderProvider( + chainRegistry = mock(ChainRegistry::class.java), + remoteStorageSource = mock(RemoteStorageSource::class.java), + ethereumRemoteSource = mock(EthereumRemoteSource::class.java), + substrateSource = mock(SubstrateRemoteSource::class.java), + operationDao = NoopOperationDao(), + tonRemoteSource = mock(TonRemoteSource::class.java), + chainsRepository = mock(ChainsRepository::class.java), + tonSyncDataRepository = mock(TonSyncDataRepository::class.java), + bitcoinIndexerClient = mock(BitcoinIndexerClient::class.java), + solanaBalanceSync = SolanaBalanceSync(FakeSolanaIndexerClient()), + irohaToriiClient = mock(IrohaToriiClient::class.java) + ) + + assertTrue(provider.invoke(solanaChain(isTestNet = false)) is SolanaBalanceLoader) + } + + private class FakeSolanaIndexerClient( + private val response: SolanaWalletBalancesResponse = balancesResponse() + ) : SolanaIndexerClient { + val verifiedBaseUrls = mutableListOf() + val receivedWallets = mutableListOf() + val receivedBaseUrls = mutableListOf() + + override suspend fun serviceInfo(baseUrl: String?): SolanaIndexerServiceInfo { + error("Unexpected Solana service-info call") + } + + override suspend fun verifyServiceInfo(baseUrl: String?): SolanaIndexerServiceInfo { + verifiedBaseUrls += baseUrl.orEmpty() + return SolanaIndexerServiceInfo( + schemaVersion = 1, + serviceId = "si.soramitsu.io", + serviceName = "Solswap Indexer", + ecosystem = "solana", + chainId = "solana:mainnet", + network = "mainnet", + publicBaseUrl = "https://si.soramitsu.io", + readOnly = true + ) + } + + override suspend fun balances(wallet: String, baseUrl: String?): SolanaWalletBalancesResponse { + receivedWallets += wallet + receivedBaseUrls += baseUrl + return response + } + + override suspend fun assets(wallet: String, baseUrl: String?): SolanaWalletAssetsResponse { + error("Unexpected Solana assets call") + } + + override suspend fun state(wallet: String, baseUrl: String?): SolanaWalletStateResponse { + error("Unexpected Solana state call") + } + + override suspend fun transactions( + wallet: String, + baseUrl: String?, + before: String?, + limit: Int + ): SolanaWalletTransactionsResponse { + error("Unexpected Solana transactions call") + } + + override suspend fun tokenMetadata(mint: String, baseUrl: String?): SolanaTokenMetadata { + error("Unexpected Solana metadata call") + } + + override suspend fun tokenMetadataBatch( + mints: List, + baseUrl: String? + ): SolanaTokenMetadataBatchResponse { + error("Unexpected Solana metadata batch call") + } + } + + private class NoopOperationDao : OperationDao() { + override suspend fun insert(operation: OperationLocal) = Unit + + override suspend fun insertAll(operations: List) = Unit + + override fun observe( + address: String, + chainId: String, + chainAssetId: String, + statusUp: OperationLocal.Status + ): Flow> = flowOf(emptyList()) + + override suspend fun getOperation(hash: String): OperationLocal? = null + + override suspend fun getOperations(): List = emptyList() + + override fun observeOperations(): Flow> = flowOf(emptyList()) + + override fun observeOperations(chainId: String): Flow> = flowOf(emptyList()) + + override fun observeOperationAddresses(chainId: String, address: String, limit: Int): Flow> = flowOf(emptyList()) + + override fun getOperationAddresses(chainId: String, address: String, limit: Int): List = emptyList() + + override suspend fun clearBySource( + address: String, + chainId: String, + chainAssetId: String, + source: OperationLocal.Source + ): Int = 0 + + override suspend fun clearOld( + address: String, + chainId: String, + chainAssetId: String, + minTime: Long + ): Int = 0 + + override suspend fun clearByHashes( + address: String, + chainId: String, + chainAssetId: String, + hashes: Set + ): Int = 0 + } + + private companion object { + const val MNEMONIC = "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about" + + fun balancesResponse( + wallet: String = "HAgk14JpMQLgt6rVgv7cBQFJWFto5Dqxi472uT3DKpqk", + lamports: String = "0", + tokens: List = emptyList() + ): SolanaWalletBalancesResponse { + return SolanaWalletBalancesResponse( + wallet = wallet, + native = SolanaNativeBalance(lamports = lamports, uiAmountString = "0"), + tokens = tokens, + total = tokens.size + 1, + syncedAt = 1_710_000_000_000L + ) + } + + fun tokenBalance( + owner: String, + mint: String, + accountAddress: String, + amount: String, + decimals: Int, + program: String + ): SolanaTokenBalance { + return SolanaTokenBalance( + accountAddress = accountAddress, + mint = mint, + owner = owner, + program = program, + programId = if (program == "token-2022") { + "TokenzQdBNbLqP5VEhdkAS6EPFLC1PHnBqCXEpPxuEb" + } else { + "TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA" + }, + amount = amount, + decimals = decimals, + uiAmountString = amount + ) + } + + fun metaAccount( + chain: Chain, + publicKey: ByteArray, + accountId: ByteArray + ): MetaAccount { + return metaAccount( + chainAccounts = mapOf( + chain.id to MetaAccount.ChainAccount( + metaId = 1, + chain = chain, + publicKey = publicKey, + accountId = accountId, + cryptoType = CryptoType.ED25519, + accountName = "Solana" + ) + ) + ) + } + + fun metaAccount( + chainAccounts: Map, + substrateAccountId: ByteArray? = null + ): MetaAccount { + return MetaAccount( + id = 1, + chainAccounts = chainAccounts, + favoriteChains = emptyMap(), + substratePublicKey = substrateAccountId, + substrateCryptoType = CryptoType.SR25519, + substrateAccountId = substrateAccountId, + ethereumAddress = null, + ethereumPublicKey = null, + tonPublicKey = null, + isSelected = true, + isBackedUp = true, + googleBackupAddress = null, + name = "Wallet", + initialized = true + ) + } + + fun solanaChain( + isTestNet: Boolean, + assets: List? = null + ): Chain { + val network = if (isTestNet) UniversalWalletRegistry.solanaDevnet else UniversalWalletRegistry.solanaMainnet + + return Chain( + id = network.id, + paraId = null, + rank = null, + name = network.name, + minSupportedVersion = null, + assets = assets ?: listOf(solanaAsset(network.id, isTestNet)), + nodes = emptyList(), + explorers = emptyList(), + externalApi = Chain.ExternalApi( + staking = null, + history = Chain.ExternalApi.Section( + Chain.ExternalApi.Section.Type.SOLANA, + network.indexerBaseUrl + ), + crowdloans = null + ), + icon = "", + addressPrefix = 0, + isEthereumBased = false, + isTestNet = isTestNet, + hasCrowdloans = false, + parentId = null, + supportStakingPool = false, + isEthereumChain = false, + chainlinkProvider = false, + supportNft = false, + isUsesAppId = false, + identityChain = null, + ecosystem = Ecosystem.Substrate, + remoteAssetsSource = null + ) + } + + fun solanaAsset(chainId: String, isTestNet: Boolean): Asset { + return Asset( + id = "SOL", + name = "Solana", + symbol = "SOL", + iconUrl = "", + chainId = chainId, + chainName = "Solana", + chainIcon = null, + isTestNet = isTestNet, + priceId = null, + precision = 9, + staking = Asset.StakingType.UNSUPPORTED, + purchaseProviders = null, + supportStakingPool = false, + isUtility = true, + type = ChainAssetType.Normal, + currencyId = null, + existentialDeposit = null, + color = null, + isNative = true + ) + } + + fun solanaTokenAsset(chainId: String, isTestNet: Boolean): Asset { + return Asset( + id = USDC_MINT, + name = "USD Coin", + symbol = "USDC", + iconUrl = "", + chainId = chainId, + chainName = "Solana", + chainIcon = null, + isTestNet = isTestNet, + priceId = null, + precision = 6, + staking = Asset.StakingType.UNSUPPORTED, + purchaseProviders = null, + supportStakingPool = false, + isUtility = false, + type = ChainAssetType.Normal, + currencyId = USDC_MINT, + existentialDeposit = null, + color = null, + isNative = false + ) + } + + const val USDC_MINT = "So11111111111111111111111111111111111111112" + const val USDC_TOKEN_ACCOUNT = "9xQeWvG816bUx9EPfQ4vF5xXw4wa9VFeTuzA7h4sFnH" + } +} diff --git a/feature-wallet-impl/src/test/java/jp/co/soramitsu/wallet/impl/data/network/integration/Common.kt b/feature-wallet-impl/src/test/java/jp/co/soramitsu/wallet/impl/data/network/integration/Common.kt index 4d12c98a94..4bd95b6d89 100644 --- a/feature-wallet-impl/src/test/java/jp/co/soramitsu/wallet/impl/data/network/integration/Common.kt +++ b/feature-wallet-impl/src/test/java/jp/co/soramitsu/wallet/impl/data/network/integration/Common.kt @@ -1,6 +1,6 @@ package jp.co.soramitsu.wallet.impl.data.network.integration -import jp.co.soramitsu.shared_utils.wsrpc.logging.Logger +import jp.co.soramitsu.fearless_utils.wsrpc.logging.Logger class StdoutLogger : Logger { override fun log(message: String?) { diff --git a/feature-wallet-impl/src/test/java/jp/co/soramitsu/wallet/impl/data/repository/UniversalWalletBitcoinAddressRoutingTest.kt b/feature-wallet-impl/src/test/java/jp/co/soramitsu/wallet/impl/data/repository/UniversalWalletBitcoinAddressRoutingTest.kt new file mode 100644 index 0000000000..2a396bdfa8 --- /dev/null +++ b/feature-wallet-impl/src/test/java/jp/co/soramitsu/wallet/impl/data/repository/UniversalWalletBitcoinAddressRoutingTest.kt @@ -0,0 +1,382 @@ +package jp.co.soramitsu.wallet.impl.data.repository + +import com.google.gson.JsonPrimitive +import jp.co.soramitsu.account.api.domain.model.MetaAccount +import jp.co.soramitsu.account.api.domain.model.address +import jp.co.soramitsu.account.api.domain.model.chainAddress +import jp.co.soramitsu.common.data.network.bitcoin.BitcoinEsploraAddress +import jp.co.soramitsu.common.data.network.bitcoin.BitcoinEsploraStats +import jp.co.soramitsu.common.data.network.bitcoin.BitcoinEsploraTransaction +import jp.co.soramitsu.common.data.network.bitcoin.BitcoinEsploraTransactionInput +import jp.co.soramitsu.common.data.network.bitcoin.BitcoinEsploraTransactionOutput +import jp.co.soramitsu.common.data.network.bitcoin.BitcoinEsploraTxStatus +import jp.co.soramitsu.common.data.network.bitcoin.BitcoinEsploraUtxo +import jp.co.soramitsu.common.data.network.bitcoin.BitcoinIndexerClient +import jp.co.soramitsu.common.data.network.bitcoin.BitcoinIndexerRoutes +import jp.co.soramitsu.common.data.network.bitcoin.BitcoinTransactionHistorySync +import jp.co.soramitsu.common.data.network.iroha.IrohaToriiClient +import jp.co.soramitsu.common.data.network.solana.SolanaIndexerClient +import jp.co.soramitsu.common.data.network.solana.SolanaTransactionHistorySync +import jp.co.soramitsu.common.model.UniversalWalletRegistry +import jp.co.soramitsu.common.utils.BitcoinKeyDerivation +import jp.co.soramitsu.core.models.Asset +import jp.co.soramitsu.core.models.ChainAssetType +import jp.co.soramitsu.core.models.CryptoType +import jp.co.soramitsu.core.models.Ecosystem +import jp.co.soramitsu.coredb.dao.OperationDao +import jp.co.soramitsu.coredb.model.OperationLocal +import jp.co.soramitsu.runtime.multiNetwork.ChainRegistry +import jp.co.soramitsu.runtime.multiNetwork.chain.model.Chain +import jp.co.soramitsu.runtime.multiNetwork.chain.remote.TonRemoteSource +import jp.co.soramitsu.wallet.impl.data.historySource.HistorySourceProvider +import jp.co.soramitsu.wallet.impl.data.network.subquery.OperationsHistoryApi +import jp.co.soramitsu.wallet.impl.data.storage.TransferCursorStorage +import jp.co.soramitsu.wallet.impl.domain.CurrentAccountAddressUseCase +import jp.co.soramitsu.wallet.impl.domain.interfaces.TransactionFilter +import jp.co.soramitsu.xnetworking.lib.datasources.txhistory.api.adapters.HistoryInfoRemoteLoader +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.flowOf +import kotlinx.coroutines.runBlocking +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Test +import org.mockito.Mockito.mock +import java.math.BigInteger + +class UniversalWalletBitcoinAddressRoutingTest { + + @Test + fun `meta account derives bitcoin mainnet address from chain account public key`() { + val chain = bitcoinChain(isTestNet = false) + val bitcoin = BitcoinKeyDerivation.deriveAccount(MNEMONIC, network = BitcoinKeyDerivation.Network.Mainnet) + val metaAccount = metaAccount(chain, publicKey = bitcoin.publicKey, accountId = ByteArray(32) { 7 }) + + assertEquals(MAINNET_ADDRESS, metaAccount.address(chain)) + assertEquals(MAINNET_ADDRESS, metaAccount.chainAddress(chain)) + } + + @Test + fun `meta account derives bitcoin testnet address from chain account public key`() { + val chain = bitcoinChain(isTestNet = true) + val bitcoin = BitcoinKeyDerivation.deriveAccount(MNEMONIC, network = BitcoinKeyDerivation.Network.Testnet) + val metaAccount = metaAccount(chain, publicKey = bitcoin.publicKey, accountId = ByteArray(32) { 7 }) + + assertEquals(TESTNET_ADDRESS, metaAccount.address(chain)) + assertEquals(TESTNET_ADDRESS, metaAccount.chainAddress(chain)) + } + + @Test + fun `bitcoin address resolution does not fall back to substrate account id`() { + val chain = bitcoinChain(isTestNet = false) + val metaAccount = metaAccount(chainAccounts = emptyMap(), substrateAccountId = ByteArray(32)) + + assertNull(metaAccount.address(chain)) + assertNull(metaAccount.chainAddress(chain)) + } + + @Test + fun `bitcoin address resolution fails closed for malformed public key`() { + val chain = bitcoinChain(isTestNet = false) + val metaAccount = metaAccount(chain, publicKey = ByteArray(32), accountId = ByteArray(32)) + + assertNull(metaAccount.address(chain)) + assertNull(metaAccount.chainAddress(chain)) + } + + @Test + fun `history repository uses supplied bitcoin address instead of deriving substrate address`() = runBlocking { + val client = FakeBitcoinIndexerClient(transactions = listOf(incomingTransaction(MAINNET_ADDRESS))) + val repository = historyRepository(client) + + val page = repository.getOperations( + pageSize = 25, + cursor = null, + filters = setOf(TransactionFilter.TRANSFER), + accountId = ByteArray(32) { 7 }, + chain = bitcoinChain(isTestNet = false), + chainAsset = bitcoinAsset(isTestNet = false), + accountAddress = MAINNET_ADDRESS + ) + + assertEquals(listOf(MAINNET_ADDRESS), client.receivedAddresses) + assertEquals(1, page.items.size) + assertEquals(MAINNET_ADDRESS, page.items.single().address) + } + + @Test + fun `history repository rejects wrong-network bitcoin address before network calls`() = runBlocking { + val client = FakeBitcoinIndexerClient(transactions = listOf(incomingTransaction(MAINNET_ADDRESS))) + val repository = historyRepository(client) + + val page = repository.getOperations( + pageSize = 25, + cursor = null, + filters = setOf(TransactionFilter.TRANSFER), + accountId = ByteArray(32) { 7 }, + chain = bitcoinChain(isTestNet = false), + chainAsset = bitcoinAsset(isTestNet = false), + accountAddress = TESTNET_ADDRESS + ) + + assertTrue(page.items.isEmpty()) + assertTrue(client.receivedAddresses.isEmpty()) + } + + @Test + fun `history repository can derive bitcoin address from public key fallback`() = runBlocking { + val bitcoin = BitcoinKeyDerivation.deriveAccount(MNEMONIC, network = BitcoinKeyDerivation.Network.Mainnet) + val client = FakeBitcoinIndexerClient(transactions = listOf(incomingTransaction(MAINNET_ADDRESS))) + val repository = historyRepository(client) + + val page = repository.getOperations( + pageSize = 25, + cursor = null, + filters = setOf(TransactionFilter.TRANSFER), + accountId = bitcoin.publicKey, + chain = bitcoinChain(isTestNet = false), + chainAsset = bitcoinAsset(isTestNet = false) + ) + + assertEquals(listOf(MAINNET_ADDRESS), client.receivedAddresses) + assertEquals(1, page.items.size) + } + + private fun historyRepository(client: FakeBitcoinIndexerClient): HistoryRepository { + val provider = HistorySourceProvider( + walletOperationsApi = mock(OperationsHistoryApi::class.java), + chainRegistry = mock(ChainRegistry::class.java), + historyInfoRemoteLoader = mock(HistoryInfoRemoteLoader::class.java), + tonRemoteSource = mock(TonRemoteSource::class.java), + bitcoinTransactionHistorySync = BitcoinTransactionHistorySync(client), + solanaTransactionHistorySync = SolanaTransactionHistorySync(mock(SolanaIndexerClient::class.java)), + irohaToriiClient = mock(IrohaToriiClient::class.java) + ) + + return HistoryRepository( + historySourceProvider = provider, + operationDao = NoopOperationDao(), + cursorStorage = mock(TransferCursorStorage::class.java), + currentAccountAddress = mock(CurrentAccountAddressUseCase::class.java) + ) + } + + private class FakeBitcoinIndexerClient( + private val transactions: List + ) : BitcoinIndexerClient { + val receivedAddresses = mutableListOf() + + override suspend fun address( + address: String, + network: BitcoinIndexerRoutes.Network, + baseUrl: String? + ): BitcoinEsploraAddress { + return BitcoinEsploraAddress( + address = address, + chainStats = BitcoinEsploraStats(0, 0, 0, 0, 0), + mempoolStats = BitcoinEsploraStats(0, 0, 0, 0, 0) + ) + } + + override suspend fun utxos( + address: String, + network: BitcoinIndexerRoutes.Network, + baseUrl: String? + ): List = emptyList() + + override suspend fun transactions( + address: String, + network: BitcoinIndexerRoutes.Network, + baseUrl: String?, + lastSeenTxid: String?, + mempool: Boolean + ): List { + receivedAddresses += address + + return transactions + } + + override suspend fun feeEstimates( + network: BitcoinIndexerRoutes.Network, + baseUrl: String? + ): Map = emptyMap() + + override suspend fun broadcastTransaction( + txHex: String, + network: BitcoinIndexerRoutes.Network, + baseUrl: String? + ): String = TXID + } + + private class NoopOperationDao : OperationDao() { + override suspend fun insert(operation: OperationLocal) = Unit + + override suspend fun insertAll(operations: List) = Unit + + override fun observe( + address: String, + chainId: String, + chainAssetId: String, + statusUp: OperationLocal.Status + ): Flow> = flowOf(emptyList()) + + override suspend fun getOperation(hash: String): OperationLocal? = null + + override suspend fun getOperations(): List = emptyList() + + override fun observeOperations(): Flow> = flowOf(emptyList()) + + override fun observeOperations(chainId: String): Flow> = flowOf(emptyList()) + + override fun observeOperationAddresses(chainId: String, address: String, limit: Int): Flow> = flowOf(emptyList()) + + override fun getOperationAddresses(chainId: String, address: String, limit: Int): List = emptyList() + + override suspend fun clearBySource( + address: String, + chainId: String, + chainAssetId: String, + source: OperationLocal.Source + ): Int = 0 + + override suspend fun clearOld( + address: String, + chainId: String, + chainAssetId: String, + minTime: Long + ): Int = 0 + + override suspend fun clearByHashes( + address: String, + chainId: String, + chainAssetId: String, + hashes: Set + ): Int = 0 + } + + private companion object { + const val MNEMONIC = "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about" + const val MAINNET_ADDRESS = "bc1qcr8te4kr609gcawutmrza0j4xv80jy8z306fyu" + const val TESTNET_ADDRESS = "tb1q6rz28mcfaxtmd6v789l9rrlrusdprr9pqcpvkl" + val TXID = "11".repeat(32) + + fun metaAccount( + chain: Chain, + publicKey: ByteArray, + accountId: ByteArray + ): MetaAccount { + return metaAccount( + chainAccounts = mapOf( + chain.id to MetaAccount.ChainAccount( + metaId = 1, + chain = chain, + publicKey = publicKey, + accountId = accountId, + cryptoType = CryptoType.ECDSA, + accountName = "Bitcoin" + ) + ) + ) + } + + fun metaAccount( + chainAccounts: Map, + substrateAccountId: ByteArray? = null + ): MetaAccount { + return MetaAccount( + id = 1, + chainAccounts = chainAccounts, + favoriteChains = emptyMap(), + substratePublicKey = substrateAccountId, + substrateCryptoType = CryptoType.SR25519, + substrateAccountId = substrateAccountId, + ethereumAddress = null, + ethereumPublicKey = null, + tonPublicKey = null, + isSelected = true, + isBackedUp = true, + googleBackupAddress = null, + name = "Wallet", + initialized = true + ) + } + + fun bitcoinChain(isTestNet: Boolean): Chain { + val network = if (isTestNet) UniversalWalletRegistry.bitcoinTestnet else UniversalWalletRegistry.bitcoinMainnet + + return Chain( + id = network.id, + paraId = null, + rank = null, + name = network.name, + minSupportedVersion = null, + assets = listOf(bitcoinAsset(isTestNet)), + nodes = emptyList(), + explorers = emptyList(), + externalApi = Chain.ExternalApi( + staking = null, + history = Chain.ExternalApi.Section( + Chain.ExternalApi.Section.Type.BITCOIN, + network.indexerBaseUrl + ), + crowdloans = null + ), + icon = "", + addressPrefix = 0, + isEthereumBased = false, + isTestNet = isTestNet, + hasCrowdloans = false, + parentId = null, + supportStakingPool = false, + isEthereumChain = false, + chainlinkProvider = false, + supportNft = false, + isUsesAppId = false, + identityChain = null, + ecosystem = Ecosystem.Substrate, + remoteAssetsSource = null + ) + } + + fun bitcoinAsset(isTestNet: Boolean): Asset { + return Asset( + id = "BTC", + name = "Bitcoin", + symbol = "BTC", + iconUrl = "", + chainId = if (isTestNet) UniversalWalletRegistry.bitcoinTestnet.id else UniversalWalletRegistry.bitcoinMainnet.id, + chainName = "Bitcoin", + chainIcon = null, + isTestNet = isTestNet, + priceId = null, + precision = 8, + staking = Asset.StakingType.UNSUPPORTED, + purchaseProviders = null, + supportStakingPool = false, + isUtility = true, + type = ChainAssetType.Normal, + currencyId = null, + existentialDeposit = null, + color = null, + isNative = true + ) + } + + fun incomingTransaction(address: String): BitcoinEsploraTransaction { + return BitcoinEsploraTransaction( + txid = TXID, + status = BitcoinEsploraTxStatus(confirmed = true, blockTime = 1_710_000_000), + vin = listOf(input("bc1q6rz28mcfaxtmd6v789l9rrlrusdprr9pkv76kj", 50_000)), + vout = listOf(output(address, 50_000)) + ) + } + + fun input(address: String, valueSats: Long): BitcoinEsploraTransactionInput { + return BitcoinEsploraTransactionInput(output(address, valueSats)) + } + + fun output(address: String, valueSats: Long): BitcoinEsploraTransactionOutput { + return BitcoinEsploraTransactionOutput(address, JsonPrimitive(valueSats)) + } + } +} diff --git a/feature-wallet-impl/src/test/java/jp/co/soramitsu/wallet/impl/data/repository/UniversalWalletIrohaRoutingTest.kt b/feature-wallet-impl/src/test/java/jp/co/soramitsu/wallet/impl/data/repository/UniversalWalletIrohaRoutingTest.kt new file mode 100644 index 0000000000..7d64fced50 --- /dev/null +++ b/feature-wallet-impl/src/test/java/jp/co/soramitsu/wallet/impl/data/repository/UniversalWalletIrohaRoutingTest.kt @@ -0,0 +1,180 @@ +package jp.co.soramitsu.wallet.impl.data.repository + +import jp.co.soramitsu.account.api.domain.model.MetaAccount +import jp.co.soramitsu.account.api.domain.model.address +import jp.co.soramitsu.account.api.domain.model.chainAddress +import jp.co.soramitsu.common.model.UniversalWalletRegistry +import jp.co.soramitsu.common.utils.IrohaKeyDerivation +import jp.co.soramitsu.core.models.Asset +import jp.co.soramitsu.core.models.ChainAssetType +import jp.co.soramitsu.core.models.CryptoType +import jp.co.soramitsu.core.models.Ecosystem +import jp.co.soramitsu.runtime.ext.normalizedIrohaAddress +import jp.co.soramitsu.runtime.multiNetwork.chain.model.Chain +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Test + +class UniversalWalletIrohaRoutingTest { + + @Test + fun `meta account derives taira address from chain account public key`() { + val chain = irohaChain(UniversalWalletRegistry.taira) + val account = IrohaKeyDerivation.deriveAccount(MNEMONIC) + val expected = IrohaKeyDerivation.deriveAddress( + mnemonic = MNEMONIC, + chainDiscriminant = UniversalWalletRegistry.taira.chainDiscriminant + ).i105 + val metaAccount = metaAccount(chain, publicKey = account.publicKey, accountId = account.publicKey) + + assertEquals(expected, metaAccount.address(chain)) + assertEquals(expected, metaAccount.chainAddress(chain)) + } + + @Test + fun `meta account derives nexus address from the same public key with nexus discriminant`() { + val chain = irohaChain(UniversalWalletRegistry.nexus) + val account = IrohaKeyDerivation.deriveAccount(MNEMONIC) + val expected = IrohaKeyDerivation.deriveAddress( + mnemonic = MNEMONIC, + chainDiscriminant = UniversalWalletRegistry.nexus.chainDiscriminant + ).i105 + val metaAccount = metaAccount(chain, publicKey = account.publicKey, accountId = account.publicKey) + + assertEquals(expected, metaAccount.address(chain)) + assertEquals(expected, metaAccount.chainAddress(chain)) + } + + @Test + fun `iroha address resolution does not fall back to substrate account id`() { + val chain = irohaChain(UniversalWalletRegistry.taira) + val metaAccount = metaAccount( + chainAccounts = emptyMap(), + substrateAccountId = ByteArray(32) + ) + + assertNull(metaAccount.address(chain)) + assertNull(metaAccount.chainAddress(chain)) + } + + @Test + fun `iroha address normalization rejects wrong network discriminant`() { + val taira = irohaChain(UniversalWalletRegistry.taira) + val nexusAddress = IrohaKeyDerivation.deriveAddress( + mnemonic = MNEMONIC, + chainDiscriminant = UniversalWalletRegistry.nexus.chainDiscriminant + ).i105 + + assertNull(taira.normalizedIrohaAddress(nexusAddress)) + } + + @Test + fun `iroha external api type is history capable`() { + assertTrue(Chain.ExternalApi.Section.Type.IROHA.isHistory()) + } + + private companion object { + const val MNEMONIC = "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about" + + fun metaAccount( + chain: Chain, + publicKey: ByteArray, + accountId: ByteArray + ): MetaAccount { + return metaAccount( + chainAccounts = mapOf( + chain.id to MetaAccount.ChainAccount( + metaId = 1, + chain = chain, + publicKey = publicKey, + accountId = accountId, + cryptoType = CryptoType.ED25519, + accountName = "Iroha" + ) + ) + ) + } + + fun metaAccount( + chainAccounts: Map, + substrateAccountId: ByteArray? = null + ): MetaAccount { + return MetaAccount( + id = 1, + chainAccounts = chainAccounts, + favoriteChains = emptyMap(), + substratePublicKey = substrateAccountId, + substrateCryptoType = CryptoType.SR25519, + substrateAccountId = substrateAccountId, + ethereumAddress = null, + ethereumPublicKey = null, + tonPublicKey = null, + isSelected = true, + isBackedUp = true, + googleBackupAddress = null, + name = "Wallet", + initialized = true + ) + } + + fun irohaChain(network: UniversalWalletRegistry.IrohaNetwork): Chain { + return Chain( + id = network.id, + paraId = null, + rank = null, + name = network.id, + minSupportedVersion = null, + assets = listOf(irohaAsset(network.id)), + nodes = emptyList(), + explorers = emptyList(), + externalApi = Chain.ExternalApi( + staking = null, + history = Chain.ExternalApi.Section( + Chain.ExternalApi.Section.Type.IROHA, + network.toriiBaseUrl ?: "https://nexus.example.org" + ), + crowdloans = null + ), + icon = "", + addressPrefix = 0, + isEthereumBased = false, + isTestNet = network == UniversalWalletRegistry.taira, + hasCrowdloans = false, + parentId = null, + supportStakingPool = false, + isEthereumChain = false, + chainlinkProvider = false, + supportNft = false, + isUsesAppId = false, + identityChain = null, + ecosystem = Ecosystem.Substrate, + remoteAssetsSource = null + ) + } + + fun irohaAsset(chainId: String): Asset { + return Asset( + id = "xor#sora", + name = "XOR", + symbol = "XOR", + iconUrl = "", + chainId = chainId, + chainName = "Iroha", + chainIcon = null, + isTestNet = false, + priceId = null, + precision = 18, + staking = Asset.StakingType.UNSUPPORTED, + purchaseProviders = null, + supportStakingPool = false, + isUtility = true, + type = ChainAssetType.Normal, + currencyId = null, + existentialDeposit = null, + color = null, + isNative = true + ) + } + } +} diff --git a/feature-wallet-impl/src/test/java/jp/co/soramitsu/wallet/impl/data/repository/tranfser/BitcoinTransferServiceProviderTest.kt b/feature-wallet-impl/src/test/java/jp/co/soramitsu/wallet/impl/data/repository/tranfser/BitcoinTransferServiceProviderTest.kt new file mode 100644 index 0000000000..3470d3d297 --- /dev/null +++ b/feature-wallet-impl/src/test/java/jp/co/soramitsu/wallet/impl/data/repository/tranfser/BitcoinTransferServiceProviderTest.kt @@ -0,0 +1,1467 @@ +package jp.co.soramitsu.wallet.impl.data.repository.tranfser + +import jp.co.soramitsu.account.api.domain.interfaces.AccountRepository +import jp.co.soramitsu.account.api.domain.model.MetaAccount +import jp.co.soramitsu.common.data.network.bitcoin.BitcoinEsploraAddress +import jp.co.soramitsu.common.data.network.bitcoin.BitcoinEsploraStats +import jp.co.soramitsu.common.data.network.bitcoin.BitcoinEsploraTransaction +import jp.co.soramitsu.common.data.network.bitcoin.BitcoinEsploraTxStatus +import jp.co.soramitsu.common.data.network.bitcoin.BitcoinEsploraUtxo +import jp.co.soramitsu.common.data.network.bitcoin.BitcoinIndexerClient +import jp.co.soramitsu.common.data.network.bitcoin.BitcoinIndexerRoutes +import jp.co.soramitsu.common.data.network.iroha.IrohaAccountAssetListResponse +import jp.co.soramitsu.common.data.network.iroha.IrohaAccountListItem +import jp.co.soramitsu.common.data.network.iroha.IrohaAccountListResponse +import jp.co.soramitsu.common.data.network.iroha.IrohaAssetDefinitionListResponse +import jp.co.soramitsu.common.data.network.iroha.IrohaMcpJsonRpcRequest +import jp.co.soramitsu.common.data.network.iroha.IrohaMcpJsonRpcResponse +import jp.co.soramitsu.common.data.network.iroha.IrohaPipelineTransactionStatusResponse +import jp.co.soramitsu.common.data.network.iroha.IrohaToriiClient +import jp.co.soramitsu.common.data.network.iroha.IrohaToriiRoutes +import jp.co.soramitsu.common.data.network.iroha.IrohaTransactionSubmissionPayload +import jp.co.soramitsu.common.data.network.iroha.IrohaTransactionSubmissionReceipt +import jp.co.soramitsu.common.data.network.solana.SolanaBalanceSync +import jp.co.soramitsu.common.data.network.solana.SolanaBroadcastOptions +import jp.co.soramitsu.common.data.network.solana.SolanaFeeForMessageResponse +import jp.co.soramitsu.common.data.network.solana.SolanaIndexerClient +import jp.co.soramitsu.common.data.network.solana.SolanaIndexerServiceInfo +import jp.co.soramitsu.common.data.network.solana.SolanaLatestBlockhash +import jp.co.soramitsu.common.data.network.solana.SolanaLatestBlockhashResponse +import jp.co.soramitsu.common.data.network.solana.SolanaNativeBalance +import jp.co.soramitsu.common.data.network.solana.SolanaRpcClient +import jp.co.soramitsu.common.data.network.solana.SolanaRpcCommitment +import jp.co.soramitsu.common.data.network.solana.SolanaRpcContext +import jp.co.soramitsu.common.data.network.solana.SolanaSimulationOptions +import jp.co.soramitsu.common.data.network.solana.SolanaSimulationResponse +import jp.co.soramitsu.common.data.network.solana.SolanaSimulationValue +import jp.co.soramitsu.common.data.network.solana.SolanaTokenMetadata +import jp.co.soramitsu.common.data.network.solana.SolanaTokenMetadataBatchResponse +import jp.co.soramitsu.common.data.network.solana.SolanaTokenBalance +import jp.co.soramitsu.common.data.network.solana.SolanaTokenProgram +import jp.co.soramitsu.common.data.network.solana.SolanaTransferTransactionBuilder +import jp.co.soramitsu.common.data.network.solana.SolanaWalletAssetsResponse +import jp.co.soramitsu.common.data.network.solana.SolanaWalletBalancesResponse +import jp.co.soramitsu.common.data.network.solana.SolanaWalletStateResponse +import jp.co.soramitsu.common.data.network.solana.SolanaWalletTransactionsResponse +import jp.co.soramitsu.common.data.network.ton.AccountStatus +import jp.co.soramitsu.common.data.network.ton.SendBlockchainMessageRequest +import jp.co.soramitsu.common.data.network.ton.TonAccountData +import jp.co.soramitsu.common.data.secrets.v3.SubstrateSecrets +import jp.co.soramitsu.common.model.UniversalWalletRegistry +import jp.co.soramitsu.common.utils.BitcoinKeyDerivation +import jp.co.soramitsu.common.utils.IrohaAddressCodec +import jp.co.soramitsu.common.utils.IrohaKeyDerivation +import jp.co.soramitsu.common.utils.SolanaKeyDerivation +import jp.co.soramitsu.common.utils.TonKeyDerivation +import jp.co.soramitsu.core.extrinsic.keypair_provider.KeypairProvider +import jp.co.soramitsu.core.extrinsic.keypair_provider.SingleKeypairProvider +import jp.co.soramitsu.core.models.Asset +import jp.co.soramitsu.core.models.ChainAssetType +import jp.co.soramitsu.core.models.CryptoType +import jp.co.soramitsu.core.models.Ecosystem +import jp.co.soramitsu.coredb.dao.AssetDao +import jp.co.soramitsu.fearless_utils.encrypt.keypair.BaseKeypair +import jp.co.soramitsu.fearless_utils.encrypt.mnemonic.MnemonicCreator +import jp.co.soramitsu.runtime.multiNetwork.chain.model.Chain +import jp.co.soramitsu.runtime.multiNetwork.chain.remote.TonRemoteSource +import jp.co.soramitsu.testshared.any +import jp.co.soramitsu.testshared.eq +import jp.co.soramitsu.testshared.whenever +import jp.co.soramitsu.wallet.impl.data.network.blockchain.EthereumRemoteSource +import jp.co.soramitsu.wallet.impl.data.network.blockchain.SubstrateRemoteSource +import jp.co.soramitsu.wallet.impl.domain.model.Transfer +import kotlinx.coroutines.runBlocking +import org.junit.Assert.assertEquals +import org.junit.Assert.assertThrows +import org.junit.Assert.assertTrue +import org.junit.Test +import org.mockito.Mockito.mock +import org.mockito.Mockito.verifyNoInteractions +import org.mockito.Mockito.`when` +import java.math.BigDecimal +import java.util.Base64 + +class BitcoinTransferServiceProviderTest { + + @Test + fun `provider routes bitcoin chains to signed bitcoin transfer service`() { + val chain = bitcoinChain() + val client = FakeBitcoinIndexerClient(broadcastResponse = EXPECTED_TXID.uppercase()) + val service = provider( + accountRepository = accountRepository( + metaAccount = bitcoinMetaAccount(chain), + mnemonic = MNEMONIC + ), + bitcoinIndexerClient = client + ).provide(chain) + + assertTrue(service is BitcoinTransferService) + val fee = runBlocking { + service.getTransferFee(Transfer(MAINNET_ADDRESS, MAINNET_RECIPIENT, BigDecimal("0.0005"), chain.assets.single())) + } + val txid = runBlocking { + service.transfer(Transfer(MAINNET_ADDRESS, MAINNET_RECIPIENT, BigDecimal("0.0005"), chain.assets.single())) + } + + assertEquals(BigDecimal("0.00000282"), fee) + assertEquals(EXPECTED_TXID, txid) + assertEquals(EXPECTED_TX_HEX, client.lastBroadcastTxHex) + assertEquals(BitcoinIndexerRoutes.Network.Mainnet, client.lastBroadcastNetwork) + assertEquals(UniversalWalletRegistry.BITCOIN_MAINNET_INDEXER_BASE_URL, client.lastBroadcastBaseUrl) + } + + @Test + fun `bitcoin transfer rejects sender mismatch and missing mnemonic root`() { + val chain = bitcoinChain() + val service = provider( + accountRepository = accountRepository( + metaAccount = bitcoinMetaAccount(chain), + mnemonic = MNEMONIC + ), + bitcoinIndexerClient = FakeBitcoinIndexerClient() + ).provide(chain) + + assertThrows(IllegalStateException::class.java) { + runBlocking { + service.getTransferFee(Transfer(MAINNET_RECIPIENT, MAINNET_RECIPIENT, BigDecimal("0.0005"), chain.assets.single())) + } + } + + val noSecretService = provider( + accountRepository = accountRepository( + metaAccount = bitcoinMetaAccount(chain), + mnemonic = null + ), + bitcoinIndexerClient = FakeBitcoinIndexerClient() + ).provide(chain) + + assertThrows(IllegalStateException::class.java) { + runBlocking { + noSecretService.transfer(Transfer(MAINNET_ADDRESS, MAINNET_RECIPIENT, BigDecimal("0.0005"), chain.assets.single())) + } + } + } + + @Test + fun `bitcoin transfer rejects mnemonic mismatch before indexer calls`() { + val chain = bitcoinChain() + val client = FakeBitcoinIndexerClient() + val service = provider( + accountRepository = accountRepository( + metaAccount = bitcoinMetaAccount(chain), + mnemonic = OTHER_MNEMONIC + ), + bitcoinIndexerClient = client + ).provide(chain) + + val error = assertThrows(IllegalStateException::class.java) { + runBlocking { + service.transfer(Transfer(MAINNET_ADDRESS, MAINNET_RECIPIENT, BigDecimal("0.0005"), chain.assets.single())) + } + } + + assertTrue(error.message!!.contains("does not match selected wallet")) + assertEquals(0, client.feeEstimateCalls) + assertEquals(0, client.utxoCalls) + assertEquals(null, client.lastBroadcastTxHex) + } + + @Test + fun `provider routes solana chains to signed native sol transfer service`() { + val chain = solanaChain() + val rpcClient = FakeSolanaRpcClient() + val indexerClient = FakeSolanaIndexerClient() + val service = provider( + accountRepository = accountRepository( + metaAccount = solanaMetaAccount(chain), + mnemonic = MNEMONIC + ), + solanaRpcClient = rpcClient, + solanaBalanceSync = SolanaBalanceSync(indexerClient) + ).provide(chain) + + assertTrue(service is SolanaTransferService) + val fee = runBlocking { + service.getTransferFee(Transfer(SOLANA_ADDRESS, SOLANA_RECIPIENT, BigDecimal("0.001"), chain.assets.single())) + } + val signature = runBlocking { + service.transfer(Transfer(SOLANA_ADDRESS, SOLANA_RECIPIENT, BigDecimal("0.001"), chain.assets.single())) + } + + assertEquals(0, BigDecimal("0.000005").compareTo(fee)) + assertEquals(rpcClient.lastExpectedSignature, signature) + assertEquals(List(6) { UniversalWalletRegistry.SOLANA_MAINNET_RPC_URL }, rpcClient.urls) + assertEquals(SOLANA_ADDRESS, indexerClient.lastBalancesWallet) + assertEquals(UniversalWalletRegistry.SOLANA_INDEXER_BASE_URL, indexerClient.lastBalancesBaseUrl) + } + + @Test + fun `solana transfer sends standard spl token from indexed source account`() { + val tokenAsset = solanaTokenAsset(UniversalWalletRegistry.solanaMainnet.id) + val chain = solanaChain( + assets = listOf( + solanaAsset(UniversalWalletRegistry.solanaMainnet.id), + tokenAsset + ) + ) + val rpcClient = FakeSolanaRpcClient() + val indexerClient = FakeSolanaIndexerClient( + tokens = listOf( + solanaTokenBalance() + ) + ) + val service = provider( + accountRepository = accountRepository( + metaAccount = solanaMetaAccount(chain), + mnemonic = MNEMONIC + ), + solanaRpcClient = rpcClient, + solanaBalanceSync = SolanaBalanceSync(indexerClient) + ).provide(chain) + val transfer = Transfer(SOLANA_ADDRESS, SOLANA_RECIPIENT, BigDecimal("1.25"), tokenAsset) + + val fee = runBlocking { + service.getTransferFee(transfer) + } + val signature = runBlocking { + service.transfer(transfer) + } + + val destinationTokenAccount = SolanaTransferTransactionBuilder.deriveAssociatedTokenAccountAddress( + walletAddress = SOLANA_RECIPIENT, + mintAddress = SPL_TOKEN_MINT, + tokenProgram = SolanaTokenProgram.SplToken + ) + assertEquals(0, BigDecimal("0.000005").compareTo(fee)) + assertEquals(rpcClient.lastExpectedSignature, signature) + assertEquals(List(2) { destinationTokenAccount }, rpcClient.accountExistenceChecks) + assertEquals(SOLANA_ADDRESS, indexerClient.lastBalancesWallet) + assertEquals(UniversalWalletRegistry.SOLANA_INDEXER_BASE_URL, indexerClient.lastBalancesBaseUrl) + } + + @Test + fun `solana token transfer rejects token 2022 until fee policy is supported`() { + val tokenAsset = solanaTokenAsset(UniversalWalletRegistry.solanaMainnet.id) + val chain = solanaChain( + assets = listOf( + solanaAsset(UniversalWalletRegistry.solanaMainnet.id), + tokenAsset + ) + ) + val service = provider( + accountRepository = accountRepository( + metaAccount = solanaMetaAccount(chain), + mnemonic = MNEMONIC + ), + solanaRpcClient = FakeSolanaRpcClient(), + solanaBalanceSync = SolanaBalanceSync( + FakeSolanaIndexerClient( + tokens = listOf( + solanaTokenBalance( + program = "token-2022", + programId = TOKEN_2022_PROGRAM_ID + ) + ) + ) + ) + ).provide(chain) + + assertThrows(IllegalStateException::class.java) { + runBlocking { + service.getTransferFee(Transfer(SOLANA_ADDRESS, SOLANA_RECIPIENT, BigDecimal("1.25"), tokenAsset)) + } + } + } + + @Test + fun `solana transfer uses devnet rpc for devnet registry chains`() { + val chain = solanaChain(UniversalWalletRegistry.solanaDevnet) + val rpcClient = FakeSolanaRpcClient() + val service = provider( + accountRepository = accountRepository( + metaAccount = solanaMetaAccount(chain), + mnemonic = MNEMONIC + ), + solanaRpcClient = rpcClient + ).provide(chain) + + runBlocking { + service.getTransferFee(Transfer(SOLANA_ADDRESS, SOLANA_RECIPIENT, BigDecimal("0.001"), chain.assets.single())) + } + + assertEquals(List(2) { UniversalWalletRegistry.SOLANA_DEVNET_RPC_URL }, rpcClient.urls) + } + + @Test + fun `solana transfer rejects sender mismatch and missing mnemonic root`() { + val chain = solanaChain() + val service = provider( + accountRepository = accountRepository( + metaAccount = solanaMetaAccount(chain), + mnemonic = MNEMONIC + ), + solanaRpcClient = FakeSolanaRpcClient() + ).provide(chain) + + assertThrows(IllegalStateException::class.java) { + runBlocking { + service.getTransferFee(Transfer(SOLANA_RECIPIENT, SOLANA_RECIPIENT, BigDecimal("0.001"), chain.assets.single())) + } + } + + val noSecretService = provider( + accountRepository = accountRepository( + metaAccount = solanaMetaAccount(chain), + mnemonic = null + ), + solanaRpcClient = FakeSolanaRpcClient() + ).provide(chain) + + assertThrows(IllegalStateException::class.java) { + runBlocking { + noSecretService.transfer(Transfer(SOLANA_ADDRESS, SOLANA_RECIPIENT, BigDecimal("0.001"), chain.assets.single())) + } + } + } + + @Test + fun `solana transfer rejects mnemonic mismatch before rpc or indexer calls`() { + val tokenAsset = solanaTokenAsset(UniversalWalletRegistry.solanaMainnet.id) + val chain = solanaChain( + assets = listOf( + solanaAsset(UniversalWalletRegistry.solanaMainnet.id), + tokenAsset + ) + ) + val rpcClient = FakeSolanaRpcClient() + val indexerClient = FakeSolanaIndexerClient( + tokens = listOf(solanaTokenBalance()) + ) + val service = provider( + accountRepository = accountRepository( + metaAccount = solanaMetaAccount(chain), + mnemonic = OTHER_MNEMONIC + ), + solanaRpcClient = rpcClient, + solanaBalanceSync = SolanaBalanceSync(indexerClient) + ).provide(chain) + + val nativeError = assertThrows(IllegalStateException::class.java) { + runBlocking { + service.transfer(Transfer(SOLANA_ADDRESS, SOLANA_RECIPIENT, BigDecimal("0.001"), chain.assets.first { it.isNative == true })) + } + } + val tokenError = assertThrows(IllegalStateException::class.java) { + runBlocking { + service.transfer(Transfer(SOLANA_ADDRESS, SOLANA_RECIPIENT, BigDecimal("1.25"), tokenAsset)) + } + } + + assertTrue(nativeError.message!!.contains("does not match selected wallet")) + assertTrue(tokenError.message!!.contains("does not match selected wallet")) + assertTrue(rpcClient.urls.isEmpty()) + assertEquals(null, indexerClient.lastBalancesWallet) + assertEquals(null, indexerClient.lastBalancesBaseUrl) + } + + @Test + fun `provider routes iroha chains to fail closed iroha transfer service`() { + val chain = irohaChain() + val sourceAddress = IrohaKeyDerivation.deriveAddress( + mnemonic = MNEMONIC, + chainDiscriminant = UniversalWalletRegistry.taira.chainDiscriminant + ).i105 + val recipientAddress = IrohaAddressCodec.encode( + publicKeyHex = "11".repeat(32), + chainDiscriminant = UniversalWalletRegistry.taira.chainDiscriminant + ) + val service = provider( + accountRepository = accountRepository( + metaAccount = irohaMetaAccount(chain), + mnemonic = MNEMONIC + ) + ).provide(chain) + + assertTrue(service is IrohaTransferService) + val fee = runBlocking { + service.getTransferFee(Transfer(sourceAddress, recipientAddress, BigDecimal.ONE, chain.assets.single())) + } + assertEquals(BigDecimal.ZERO, fee) + assertThrows(IllegalStateException::class.java) { + runBlocking { + service.transfer(Transfer(sourceAddress, recipientAddress, BigDecimal.ONE, chain.assets.single())) + } + } + } + + @Test + fun `iroha transfer builds signed transfer request and submits norito to torii`() { + val chain = irohaChain() + val signer = FakeIrohaTransferSigner() + val toriiClient = FakeIrohaToriiClient() + val sourceAddress = IrohaKeyDerivation.deriveAddress( + mnemonic = MNEMONIC, + chainDiscriminant = UniversalWalletRegistry.taira.chainDiscriminant + ).i105 + val recipientAddress = IrohaAddressCodec.encode( + publicKeyHex = "11".repeat(32), + chainDiscriminant = UniversalWalletRegistry.taira.chainDiscriminant + ) + val service = provider( + accountRepository = accountRepository( + metaAccount = irohaMetaAccount(chain), + mnemonic = MNEMONIC + ), + irohaToriiClient = toriiClient, + irohaTransferSigner = signer + ).provide(chain) + val transfer = Transfer(sourceAddress, recipientAddress, BigDecimal("1.25"), chain.assets.single()) + + val fee = runBlocking { + service.getTransferFee(transfer) + } + val hash = runBlocking { + service.transfer(transfer) + } + + assertEquals(BigDecimal.ZERO, fee) + assertEquals(SIGNED_TRANSACTION_HASH, hash) + assertEquals(SIGNED_TRANSACTION_BYTES.toList(), toriiClient.lastSubmittedNorito?.toList()) + assertEquals(UniversalWalletRegistry.taira.toriiBaseUrl, toriiClient.lastSubmitBaseUrl) + assertEquals("1.25", signer.lastRequest?.amount) + assertEquals("xor#sora", signer.lastRequest?.assetDefinitionId) + assertEquals(sourceAddress, signer.lastRequest?.authority) + assertEquals(UniversalWalletRegistry.taira.chainId, signer.lastRequest?.chainId) + assertEquals("m/44'/617'/0'/0'", signer.lastRequest?.derivationPath) + assertEquals(recipientAddress, signer.lastRequest?.destinationAccountId) + assertEquals(MNEMONIC, signer.lastRequest?.mnemonicOrSeed) + assertEquals("taira", signer.lastRequest?.network) + assertEquals(IrohaAddressCodec.parse(sourceAddress, UniversalWalletRegistry.taira.chainDiscriminant).publicKeyHex, signer.lastRequest?.signingPublicKeyHex) + assertEquals(sourceAddress, signer.lastRequest?.sourceAccountId) + assertEquals("xor#sora#$sourceAddress", signer.lastRequest?.sourceAssetId) + } + + @Test + fun `iroha transfer builds nexus signed transfer request and submits norito to minamoto torii`() { + val chain = irohaChain(UniversalWalletRegistry.nexus) + val signer = FakeIrohaTransferSigner() + val toriiClient = FakeIrohaToriiClient() + val sourceAddress = IrohaKeyDerivation.deriveAddress( + mnemonic = MNEMONIC, + chainDiscriminant = UniversalWalletRegistry.nexus.chainDiscriminant + ).i105 + val recipientAddress = IrohaAddressCodec.encode( + publicKeyHex = "22".repeat(32), + chainDiscriminant = UniversalWalletRegistry.nexus.chainDiscriminant + ) + val service = provider( + accountRepository = accountRepository( + metaAccount = irohaMetaAccount(chain), + mnemonic = MNEMONIC + ), + irohaToriiClient = toriiClient, + irohaTransferSigner = signer + ).provide(chain) + val transfer = Transfer(sourceAddress, recipientAddress, BigDecimal("1.25"), chain.assets.single()) + + val fee = runBlocking { + service.getTransferFee(transfer) + } + val hash = runBlocking { + service.transfer(transfer) + } + + assertEquals(BigDecimal.ZERO, fee) + assertEquals(SIGNED_TRANSACTION_HASH, hash) + assertEquals(SIGNED_TRANSACTION_BYTES.toList(), toriiClient.lastSubmittedNorito?.toList()) + assertEquals(UniversalWalletRegistry.nexus.toriiBaseUrl, toriiClient.lastSubmitBaseUrl) + assertEquals("1.25", signer.lastRequest?.amount) + assertEquals("xor#sora", signer.lastRequest?.assetDefinitionId) + assertEquals(sourceAddress, signer.lastRequest?.authority) + assertEquals(UniversalWalletRegistry.nexus.chainId, signer.lastRequest?.chainId) + assertEquals("m/44'/617'/0'/0'", signer.lastRequest?.derivationPath) + assertEquals(recipientAddress, signer.lastRequest?.destinationAccountId) + assertEquals(MNEMONIC, signer.lastRequest?.mnemonicOrSeed) + assertEquals("nexus", signer.lastRequest?.network) + assertEquals(IrohaAddressCodec.parse(sourceAddress, UniversalWalletRegistry.nexus.chainDiscriminant).publicKeyHex, signer.lastRequest?.signingPublicKeyHex) + assertEquals(sourceAddress, signer.lastRequest?.sourceAccountId) + assertEquals("xor#sora#$sourceAddress", signer.lastRequest?.sourceAssetId) + } + + @Test + fun `iroha transfer rejects mnemonic mismatch before signer or torii calls`() { + val chain = irohaChain() + val signer = FakeIrohaTransferSigner() + val toriiClient = FakeIrohaToriiClient() + val sourceAddress = IrohaKeyDerivation.deriveAddress( + mnemonic = MNEMONIC, + chainDiscriminant = UniversalWalletRegistry.taira.chainDiscriminant + ).i105 + val recipientAddress = IrohaAddressCodec.encode( + publicKeyHex = "11".repeat(32), + chainDiscriminant = UniversalWalletRegistry.taira.chainDiscriminant + ) + val service = provider( + accountRepository = accountRepository( + metaAccount = irohaMetaAccount(chain), + mnemonic = OTHER_MNEMONIC + ), + irohaToriiClient = toriiClient, + irohaTransferSigner = signer + ).provide(chain) + val transfer = Transfer(sourceAddress, recipientAddress, BigDecimal.ONE, chain.assets.single()) + + val error = assertThrows(IllegalStateException::class.java) { + runBlocking { + service.transfer(transfer) + } + } + + assertTrue(error.message!!.contains("does not match selected wallet")) + assertEquals(null, signer.lastRequest) + assertEquals(null, toriiClient.lastSubmittedNorito) + } + + @Test + fun `provider routes ton chains to ton transfer service`() { + val service = provider().provide(tonChain()) + + assertTrue(service is TonTransferService) + } + + @Test + fun `ton transfer signs and sends blockchain message`() { + val chain = tonChain() + val ton = TonKeyDerivation.deriveAccount(MNEMONIC) + val tonRemoteSource = mock(TonRemoteSource::class.java) + val service = provider( + accountRepository = accountRepository( + metaAccount = tonMetaAccount(chain), + mnemonic = MNEMONIC + ), + keyPairProvider = SingleKeypairProvider( + keypair = BaseKeypair(ton.privateKey, ton.publicKey), + cryptoType = CryptoType.ED25519 + ), + tonRemoteSource = tonRemoteSource + ).provide(chain) + val transfer = Transfer(ton.addressNonBounceable, ton.addressNonBounceable, BigDecimal("0.05"), chain.assets.single()) + var sentRequest: SendBlockchainMessageRequest? = null + + runBlocking { + whenever(tonRemoteSource.getSeqno(chain, ton.accountId)).thenReturn(7) + whenever(tonRemoteSource.getRawTime(chain, ton.accountId)).thenReturn(1_700_000_000) + whenever(tonRemoteSource.loadAccountData(chain, ton.addressNonBounceable)).thenReturn(tonActiveAccount(ton.accountId)) + whenever(tonRemoteSource.sendBlockchainMessage(eq(chain), any())).thenAnswer { invocation -> + sentRequest = invocation.getArgument(1) + "{\"ok\":true}" + } + } + + val hash = runBlocking { + service.transfer(transfer) + } + + assertTrue(hash.matches(Regex("[0-9a-fA-F]{64}"))) + assertTrue(sentRequest?.boc?.isNotBlank() == true) + assertEquals(null, sentRequest?.batch) + } + + @Test + fun `ton transfer rejects missing ton key before remote calls`() { + val chain = tonChain() + val tonRemoteSource = mock(TonRemoteSource::class.java) + val service = provider( + accountRepository = accountRepository( + metaAccount = bitcoinMetaAccount(bitcoinChain()), + mnemonic = MNEMONIC + ), + tonRemoteSource = tonRemoteSource + ).provide(chain) + val transfer = Transfer(TON_ADDRESS, TON_RECIPIENT, BigDecimal("0.05"), chain.assets.single()) + + assertThrows(IllegalStateException::class.java) { + runBlocking { + service.getTransferFee(transfer) + } + } + assertThrows(IllegalStateException::class.java) { + runBlocking { + service.transfer(transfer) + } + } + verifyNoInteractions(tonRemoteSource) + } + + @Test + fun `ton transfer rejects sender mismatch before remote calls`() { + val chain = tonChain() + val ton = TonKeyDerivation.deriveAccount(MNEMONIC) + val tonRemoteSource = mock(TonRemoteSource::class.java) + val service = provider( + accountRepository = accountRepository( + metaAccount = tonMetaAccount(chain), + mnemonic = MNEMONIC + ), + keyPairProvider = SingleKeypairProvider( + keypair = BaseKeypair(ton.privateKey, ton.publicKey), + cryptoType = CryptoType.ED25519 + ), + tonRemoteSource = tonRemoteSource + ).provide(chain) + val transfer = Transfer(MAINNET_ADDRESS, ton.addressNonBounceable, BigDecimal("0.05"), chain.assets.single()) + + assertThrows(IllegalArgumentException::class.java) { + runBlocking { + service.getTransferFee(transfer) + } + } + assertThrows(IllegalArgumentException::class.java) { + runBlocking { + service.transfer(transfer) + } + } + verifyNoInteractions(tonRemoteSource) + } + + private fun provider( + accountRepository: AccountRepository = mock(AccountRepository::class.java), + keyPairProvider: KeypairProvider = mock(KeypairProvider::class.java), + bitcoinIndexerClient: BitcoinIndexerClient = FakeBitcoinIndexerClient(), + solanaRpcClient: SolanaRpcClient = FakeSolanaRpcClient(), + solanaBalanceSync: SolanaBalanceSync = SolanaBalanceSync(FakeSolanaIndexerClient()), + irohaToriiClient: IrohaToriiClient = FakeIrohaToriiClient(), + irohaTransferSigner: IrohaTransferSigner = UnavailableIrohaTransferSigner, + tonRemoteSource: TonRemoteSource = mock(TonRemoteSource::class.java) + ): TransferServiceProvider { + return TransferServiceProvider( + substrateSource = mock(SubstrateRemoteSource::class.java), + ethereumRemoteSource = mock(EthereumRemoteSource::class.java), + keyPairRepository = keyPairProvider, + accountRepository = accountRepository, + tonRemoteSource = tonRemoteSource, + assetDao = mock(AssetDao::class.java), + bitcoinIndexerClient = bitcoinIndexerClient, + solanaRpcClient = solanaRpcClient, + solanaBalanceSync = solanaBalanceSync, + irohaToriiClient = irohaToriiClient, + irohaTransferSigner = irohaTransferSigner + ) + } + + private fun accountRepository( + metaAccount: MetaAccount, + mnemonic: String? + ): AccountRepository { + val accountRepository = mock(AccountRepository::class.java) + + runBlocking { + `when`(accountRepository.getSelectedMetaAccount()).thenReturn(metaAccount) + `when`(accountRepository.getSubstrateSecrets(metaAccount.id)).thenReturn( + mnemonic?.let { + SubstrateSecrets( + substrateKeyPair = BaseKeypair(ByteArray(32), ByteArray(32)), + entropy = MnemonicCreator.fromWords(it).entropy + ) + } + ) + `when`(accountRepository.getEthereumSecrets(metaAccount.id)).thenReturn(null) + `when`(accountRepository.getTonSecrets(metaAccount.id)).thenReturn(null) + } + + return accountRepository + } + + private class FakeBitcoinIndexerClient( + private val broadcastResponse: String = EXPECTED_TXID + ) : BitcoinIndexerClient { + var lastBroadcastTxHex: String? = null + private set + var lastBroadcastNetwork: BitcoinIndexerRoutes.Network? = null + private set + var lastBroadcastBaseUrl: String? = null + private set + var feeEstimateCalls = 0 + private set + var utxoCalls = 0 + private set + + override suspend fun address( + address: String, + network: BitcoinIndexerRoutes.Network, + baseUrl: String? + ): BitcoinEsploraAddress { + return BitcoinEsploraAddress( + address = address, + chainStats = BitcoinEsploraStats(0, 0, 0, 0, 0), + mempoolStats = BitcoinEsploraStats(0, 0, 0, 0, 0) + ) + } + + override suspend fun utxos( + address: String, + network: BitcoinIndexerRoutes.Network, + baseUrl: String? + ): List { + utxoCalls += 1 + return listOf( + BitcoinEsploraUtxo( + txid = TXID, + vout = 1, + value = 100_000, + status = BitcoinEsploraTxStatus(confirmed = true) + ) + ) + } + + override suspend fun transactions( + address: String, + network: BitcoinIndexerRoutes.Network, + baseUrl: String?, + lastSeenTxid: String?, + mempool: Boolean + ): List = emptyList() + + override suspend fun feeEstimates( + network: BitcoinIndexerRoutes.Network, + baseUrl: String? + ): Map { + feeEstimateCalls += 1 + return mapOf("2" to 2.0) + } + + override suspend fun broadcastTransaction( + txHex: String, + network: BitcoinIndexerRoutes.Network, + baseUrl: String? + ): String { + lastBroadcastTxHex = txHex + lastBroadcastNetwork = network + lastBroadcastBaseUrl = baseUrl + + return broadcastResponse + } + } + + private class FakeSolanaRpcClient( + private val fee: Long? = 5_000L, + private val simulationError: String? = null, + private val existingAccounts: Set = emptySet() + ) : SolanaRpcClient { + val urls = mutableListOf() + val accountExistenceChecks = mutableListOf() + var lastExpectedSignature: String? = null + private set + + override suspend fun latestBlockhash( + commitment: SolanaRpcCommitment, + rpcUrl: String? + ): SolanaLatestBlockhashResponse { + urls += rpcUrl + return SolanaLatestBlockhashResponse( + context = SolanaRpcContext(slot = 1), + value = SolanaLatestBlockhash(blockhash = SOLANA_BLOCKHASH, lastValidBlockHeight = 99) + ) + } + + override suspend fun feeForMessage( + messageBase64: String, + commitment: SolanaRpcCommitment, + rpcUrl: String? + ): SolanaFeeForMessageResponse { + urls += rpcUrl + return SolanaFeeForMessageResponse(SolanaRpcContext(slot = 2), fee) + } + + override suspend fun minimumBalanceForRentExemption( + dataLength: Int, + commitment: SolanaRpcCommitment, + rpcUrl: String? + ): Long { + urls += rpcUrl + return 2_039_280L + } + + override suspend fun accountExists( + address: String, + commitment: SolanaRpcCommitment, + rpcUrl: String? + ): Boolean { + urls += rpcUrl + accountExistenceChecks += address + return address in existingAccounts + } + + override suspend fun simulateTransaction( + transactionBase64: String, + options: SolanaSimulationOptions, + rpcUrl: String? + ): SolanaSimulationResponse { + urls += rpcUrl + return SolanaSimulationResponse( + context = SolanaRpcContext(slot = 3), + value = SolanaSimulationValue( + errorJson = simulationError, + logs = listOf("Program log: ok"), + replacementBlockhash = null, + unitsConsumed = 42 + ) + ) + } + + override suspend fun sendRawTransaction( + transactionBase64: String, + options: SolanaBroadcastOptions, + rpcUrl: String? + ): String { + urls += rpcUrl + lastExpectedSignature = firstSignature(transactionBase64) + return lastExpectedSignature!! + } + } + + private class FakeSolanaIndexerClient( + private val tokens: List = emptyList() + ) : SolanaIndexerClient { + var lastBalancesWallet: String? = null + private set + var lastBalancesBaseUrl: String? = null + private set + + override suspend fun serviceInfo(baseUrl: String?): SolanaIndexerServiceInfo { + return SolanaIndexerServiceInfo( + schemaVersion = 1, + serviceId = "si.soramitsu.io", + serviceName = "Soramitsu Solana Indexer", + ecosystem = "solana", + chainId = "solana:mainnet", + network = "mainnet-beta", + publicBaseUrl = UniversalWalletRegistry.SOLANA_INDEXER_BASE_URL, + readOnly = true + ) + } + + override suspend fun verifyServiceInfo(baseUrl: String?): SolanaIndexerServiceInfo { + return serviceInfo(baseUrl) + } + + override suspend fun balances(wallet: String, baseUrl: String?): SolanaWalletBalancesResponse { + lastBalancesWallet = wallet + lastBalancesBaseUrl = baseUrl + return SolanaWalletBalancesResponse( + wallet = wallet, + native = SolanaNativeBalance( + lamports = "10000000000", + uiAmountString = "10" + ), + tokens = tokens, + total = 1 + tokens.size, + syncedAt = 1L + ) + } + + override suspend fun assets(wallet: String, baseUrl: String?): SolanaWalletAssetsResponse { + return SolanaWalletAssetsResponse(wallet = wallet, total = 0, syncedAt = 1L) + } + + override suspend fun state(wallet: String, baseUrl: String?): SolanaWalletStateResponse { + return SolanaWalletStateResponse( + wallet = wallet, + exists = true, + lamports = "10000000000", + executable = false, + dataLength = 0, + syncedAt = 1L + ) + } + + override suspend fun transactions( + wallet: String, + baseUrl: String?, + before: String?, + limit: Int + ): SolanaWalletTransactionsResponse { + return SolanaWalletTransactionsResponse( + wallet = wallet, + limit = limit, + total = 0, + syncedAt = 1L + ) + } + + override suspend fun tokenMetadata(mint: String, baseUrl: String?): SolanaTokenMetadata { + return SolanaTokenMetadata( + mint = mint, + exists = false, + program = "spl-token", + syncedAt = 1L + ) + } + + override suspend fun tokenMetadataBatch( + mints: List, + baseUrl: String? + ): SolanaTokenMetadataBatchResponse { + return SolanaTokenMetadataBatchResponse(total = 0, syncedAt = 1L) + } + } + + private class FakeIrohaTransferSigner : IrohaTransferSigner { + var lastRequest: IrohaTransferSigningRequest? = null + private set + + override suspend fun buildAndSignTransfer(request: IrohaTransferSigningRequest): IrohaSignedTransfer { + lastRequest = request + + return IrohaSignedTransfer( + signedTransaction = SIGNED_TRANSACTION_BYTES, + transactionHashHex = SIGNED_TRANSACTION_HASH + ) + } + } + + private class FakeIrohaToriiClient : IrohaToriiClient { + var lastSubmittedNorito: ByteArray? = null + private set + var lastSubmitBaseUrl: String? = null + private set + + override suspend fun health(baseUrl: String?): String = "ok" + + override suspend fun accounts( + baseUrl: String?, + limit: Int?, + offset: Long?, + countMode: IrohaToriiRoutes.CountMode? + ): IrohaAccountListResponse = error("Unexpected Iroha accounts call") + + override suspend fun account( + accountId: String, + baseUrl: String?, + network: UniversalWalletRegistry.IrohaNetwork + ): IrohaAccountListItem = error("Unexpected Iroha account call") + + override suspend fun accountAssets( + accountId: String, + baseUrl: String?, + limit: Int?, + offset: Long?, + countMode: IrohaToriiRoutes.CountMode?, + asset: String?, + scope: String?, + network: UniversalWalletRegistry.IrohaNetwork + ): IrohaAccountAssetListResponse = error("Unexpected Iroha account-assets call") + + override suspend fun assetDefinitions(baseUrl: String?): IrohaAssetDefinitionListResponse { + error("Unexpected Iroha asset-definitions call") + } + + override suspend fun submitTransaction( + noritoBytes: ByteArray, + baseUrl: String? + ): IrohaTransactionSubmissionReceipt { + lastSubmittedNorito = noritoBytes + lastSubmitBaseUrl = baseUrl + + return IrohaTransactionSubmissionReceipt( + payload = IrohaTransactionSubmissionPayload( + txHash = RECEIPT_TX_HASH, + entrypointHash = RECEIPT_ENTRYPOINT_HASH, + signedTransactionHash = RECEIPT_SIGNED_TRANSACTION_HASH, + submittedAtMs = 1L, + submittedAtHeight = 2L + ) + ) + } + + override suspend fun transactionStatus( + hash: String, + baseUrl: String?, + scope: IrohaToriiRoutes.TransactionStatusScope + ): IrohaPipelineTransactionStatusResponse = error("Unexpected Iroha transaction-status call") + + override suspend fun mcpCapabilities( + network: UniversalWalletRegistry.IrohaNetwork, + baseUrl: String? + ): Map = error("Unexpected Iroha mcp capabilities call") + + override suspend fun mcpJsonRpc( + request: IrohaMcpJsonRpcRequest, + network: UniversalWalletRegistry.IrohaNetwork, + baseUrl: String? + ): IrohaMcpJsonRpcResponse = error("Unexpected Iroha mcp call") + } + + private companion object { + const val MNEMONIC = "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about" + const val OTHER_MNEMONIC = "legal winner thank year wave sausage worth useful legal winner thank yellow" + const val MAINNET_ADDRESS = "bc1qcr8te4kr609gcawutmrza0j4xv80jy8z306fyu" + const val MAINNET_RECIPIENT = "bc1qslk39wvggqa0vl8nd6jckaz54dw3vk45c5w60m" + const val SOLANA_ADDRESS = "HAgk14JpMQLgt6rVgv7cBQFJWFto5Dqxi472uT3DKpqk" + const val SOLANA_RECIPIENT = "So11111111111111111111111111111111111111112" + const val TON_ADDRESS = "UQAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAJKZ" + const val TON_RECIPIENT = "UQAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAJKZ" + const val SOLANA_BLOCKHASH = "7GjNiPun3AzEazTZoFEjZgcBMeuaXdpjHq2raZTmTrfs" + const val SPL_TOKEN_MINT = "EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v" + const val SPL_TOKEN_SOURCE_ACCOUNT = "9xQeWvG816bUx9EPfFny7R5AySPGaWWSLS9eSg9wAYjr" + const val SPL_TOKEN_PROGRAM_ID = "TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA" + const val TOKEN_2022_PROGRAM_ID = "TokenzQdBNbLqP5VEhdkAS6EPFLC1PHnBMeiDFFbmqS" + const val IROHA_ADDRESS = "test-i105-address" + val TXID = "11".repeat(32) + val SIGNED_TRANSACTION_BYTES = byteArrayOf(1, 2, 3, 4) + const val SIGNED_TRANSACTION_HASH = "signed-transaction-hash" + const val RECEIPT_TX_HASH = "receipt-tx-hash" + const val RECEIPT_ENTRYPOINT_HASH = "receipt-entrypoint-hash" + const val RECEIPT_SIGNED_TRANSACTION_HASH = "receipt-signed-transaction-hash" + const val EXPECTED_TXID = "94c9b9d5070f24e06725b1000d9b1a0d46473d07b59088aca35e3d3da345023d" + const val EXPECTED_TX_HEX = "0200000000010111111111111111111111111111111111111111111111111111111111111111110100000000ffffffff0250c300000000000016001487ed12b988403af67cf36ea58b7454ab5d165ab436c2000000000000160014c0cebcd6c3d3ca8c75dc5ec62ebe55330ef910e202473044022009ec0c24a20c4346c6516065723e2e83e6a7e4dd278fb66e27f36d9108d7ef4c022077f115bfbd68a2bc7a4c100766d9cceaf5300bcfcdc775be0248e7fb5a58216801210330d54fd0dd420a6e5f8d3624f5f3482cae350f79d5f0753bf5beef9c2d91af3c00000000" + private val BASE58_ALPHABET = "123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz".toCharArray() + + fun bitcoinMetaAccount(chain: Chain): MetaAccount { + val bitcoin = BitcoinKeyDerivation.deriveAccount(MNEMONIC) + + return MetaAccount( + id = 1L, + chainAccounts = mapOf( + chain.id to MetaAccount.ChainAccount( + metaId = 1L, + chain = chain, + publicKey = bitcoin.publicKey, + accountId = bitcoin.publicKey, + cryptoType = CryptoType.ECDSA, + accountName = "Bitcoin" + ) + ), + favoriteChains = emptyMap(), + substratePublicKey = ByteArray(32), + substrateCryptoType = CryptoType.SR25519, + substrateAccountId = ByteArray(32), + ethereumAddress = null, + ethereumPublicKey = null, + tonPublicKey = null, + isSelected = true, + isBackedUp = true, + googleBackupAddress = null, + name = "Wallet", + initialized = true + ) + } + + fun solanaMetaAccount(chain: Chain): MetaAccount { + val solana = SolanaKeyDerivation.deriveAccount(MNEMONIC) + + return MetaAccount( + id = 1L, + chainAccounts = mapOf( + chain.id to MetaAccount.ChainAccount( + metaId = 1L, + chain = chain, + publicKey = solana.publicKey, + accountId = solana.publicKey, + cryptoType = CryptoType.ED25519, + accountName = "Solana" + ) + ), + favoriteChains = emptyMap(), + substratePublicKey = ByteArray(32), + substrateCryptoType = CryptoType.SR25519, + substrateAccountId = ByteArray(32), + ethereumAddress = null, + ethereumPublicKey = null, + tonPublicKey = null, + isSelected = true, + isBackedUp = true, + googleBackupAddress = null, + name = "Wallet", + initialized = true + ) + } + + fun irohaMetaAccount(chain: Chain): MetaAccount { + val iroha = IrohaKeyDerivation.deriveAccount(MNEMONIC) + + return MetaAccount( + id = 1L, + chainAccounts = mapOf( + chain.id to MetaAccount.ChainAccount( + metaId = 1L, + chain = chain, + publicKey = iroha.publicKey, + accountId = iroha.publicKey, + cryptoType = CryptoType.ED25519, + accountName = "Iroha" + ) + ), + favoriteChains = emptyMap(), + substratePublicKey = ByteArray(32), + substrateCryptoType = CryptoType.SR25519, + substrateAccountId = ByteArray(32), + ethereumAddress = null, + ethereumPublicKey = null, + tonPublicKey = null, + isSelected = true, + isBackedUp = true, + googleBackupAddress = null, + name = "Wallet", + initialized = true + ) + } + + fun tonMetaAccount(chain: Chain): MetaAccount { + val ton = TonKeyDerivation.deriveAccount(MNEMONIC) + + return MetaAccount( + id = 1L, + chainAccounts = mapOf( + chain.id to MetaAccount.ChainAccount( + metaId = 1L, + chain = chain, + publicKey = ton.publicKey, + accountId = ton.publicKey, + cryptoType = CryptoType.ED25519, + accountName = "TON" + ) + ), + favoriteChains = emptyMap(), + substratePublicKey = ByteArray(32), + substrateCryptoType = CryptoType.SR25519, + substrateAccountId = ByteArray(32), + ethereumAddress = null, + ethereumPublicKey = null, + tonPublicKey = ton.publicKey, + isSelected = true, + isBackedUp = true, + googleBackupAddress = null, + name = "Wallet", + initialized = true + ) + } + + fun tonActiveAccount(rawAddress: String): TonAccountData { + return TonAccountData( + address = rawAddress, + balance = 1_000_000_000L, + lastActivity = 1_700_000_000L, + status = AccountStatus.active, + getMethods = listOf("seqno"), + isWallet = true + ) + } + + fun firstSignature(transactionBase64: String): String { + val decoded = Base64.getDecoder().decode(transactionBase64) + return base58Encode(decoded.copyOfRange(1, 65)) + } + + fun base58Encode(bytes: ByteArray): String { + var zeros = 0 + while (zeros < bytes.size && bytes[zeros].toInt() == 0) { + zeros += 1 + } + + val encoded = ArrayList() + val digits = bytes.copyOf() + var start = zeros + while (start < digits.size) { + var remainder = 0 + for (i in start until digits.size) { + val value = (remainder shl 8) + (digits[i].toInt() and 0xff) + digits[i] = (value / 58).toByte() + remainder = value % 58 + } + encoded.add(BASE58_ALPHABET[remainder]) + while (start < digits.size && digits[start].toInt() == 0) { + start += 1 + } + } + + repeat(zeros) { encoded.add(BASE58_ALPHABET[0]) } + return encoded.asReversed().joinToString("") + } + + fun bitcoinChain(): Chain { + val network = UniversalWalletRegistry.bitcoinMainnet + + return Chain( + id = network.id, + paraId = null, + rank = null, + name = network.name, + minSupportedVersion = null, + assets = listOf(bitcoinAsset(network.id)), + nodes = emptyList(), + explorers = emptyList(), + externalApi = Chain.ExternalApi( + staking = null, + history = Chain.ExternalApi.Section( + Chain.ExternalApi.Section.Type.BITCOIN, + network.indexerBaseUrl + ), + crowdloans = null + ), + icon = "", + addressPrefix = 0, + isEthereumBased = false, + isTestNet = false, + hasCrowdloans = false, + parentId = null, + supportStakingPool = false, + isEthereumChain = false, + chainlinkProvider = false, + supportNft = false, + isUsesAppId = false, + identityChain = null, + ecosystem = Ecosystem.Substrate, + remoteAssetsSource = null + ) + } + + fun bitcoinAsset(chainId: String): Asset { + return Asset( + id = "BTC", + name = "Bitcoin", + symbol = "BTC", + iconUrl = "", + chainId = chainId, + chainName = "Bitcoin", + chainIcon = null, + isTestNet = false, + priceId = null, + precision = 8, + staking = Asset.StakingType.UNSUPPORTED, + purchaseProviders = null, + supportStakingPool = false, + isUtility = true, + type = ChainAssetType.Normal, + currencyId = null, + existentialDeposit = null, + color = null, + isNative = true + ) + } + + fun solanaChain( + network: UniversalWalletRegistry.SolanaNetwork = UniversalWalletRegistry.solanaMainnet, + assets: List = listOf(solanaAsset(network.id)) + ): Chain { + return Chain( + id = network.id, + paraId = null, + rank = null, + name = network.name, + minSupportedVersion = null, + assets = assets, + nodes = emptyList(), + explorers = emptyList(), + externalApi = Chain.ExternalApi( + staking = null, + history = Chain.ExternalApi.Section( + Chain.ExternalApi.Section.Type.SOLANA, + network.indexerBaseUrl + ), + crowdloans = null + ), + icon = "", + addressPrefix = 0, + isEthereumBased = false, + isTestNet = network == UniversalWalletRegistry.solanaDevnet, + hasCrowdloans = false, + parentId = null, + supportStakingPool = false, + isEthereumChain = false, + chainlinkProvider = false, + supportNft = false, + isUsesAppId = false, + identityChain = null, + ecosystem = Ecosystem.Substrate, + remoteAssetsSource = null + ) + } + + fun solanaAsset(chainId: String): Asset { + return Asset( + id = "SOL", + name = "Solana", + symbol = "SOL", + iconUrl = "", + chainId = chainId, + chainName = "Solana", + chainIcon = null, + isTestNet = false, + priceId = null, + precision = 9, + staking = Asset.StakingType.UNSUPPORTED, + purchaseProviders = null, + supportStakingPool = false, + isUtility = true, + type = ChainAssetType.Normal, + currencyId = null, + existentialDeposit = null, + color = null, + isNative = true + ) + } + + fun solanaTokenAsset(chainId: String): Asset { + return Asset( + id = "USDC", + name = "USD Coin", + symbol = "USDC", + iconUrl = "", + chainId = chainId, + chainName = "Solana", + chainIcon = null, + isTestNet = false, + priceId = null, + precision = 6, + staking = Asset.StakingType.UNSUPPORTED, + purchaseProviders = null, + supportStakingPool = false, + isUtility = false, + type = ChainAssetType.Normal, + currencyId = SPL_TOKEN_MINT, + existentialDeposit = null, + color = null, + isNative = false + ) + } + + fun solanaTokenBalance( + program: String = "spl-token", + programId: String = SPL_TOKEN_PROGRAM_ID + ): SolanaTokenBalance { + return SolanaTokenBalance( + accountAddress = SPL_TOKEN_SOURCE_ACCOUNT, + mint = SPL_TOKEN_MINT, + owner = SOLANA_ADDRESS, + program = program, + programId = programId, + amount = "5000000", + decimals = 6, + uiAmountString = "5" + ) + } + + fun tonChain(): Chain { + return Chain( + id = "ton-mainnet", + paraId = null, + rank = null, + name = "TON", + minSupportedVersion = null, + assets = listOf(tonAsset("ton-mainnet")), + nodes = emptyList(), + explorers = emptyList(), + externalApi = Chain.ExternalApi( + staking = null, + history = Chain.ExternalApi.Section( + Chain.ExternalApi.Section.Type.TON, + UniversalWalletRegistry.TON_INDEXER_BASE_URL + ), + crowdloans = null + ), + icon = "", + addressPrefix = 0, + isEthereumBased = false, + isTestNet = false, + hasCrowdloans = false, + parentId = null, + supportStakingPool = false, + isEthereumChain = false, + chainlinkProvider = false, + supportNft = false, + isUsesAppId = false, + identityChain = null, + ecosystem = Ecosystem.Ton, + remoteAssetsSource = null + ) + } + + fun tonAsset(chainId: String): Asset { + return Asset( + id = "TON", + name = "Toncoin", + symbol = "TON", + iconUrl = "", + chainId = chainId, + chainName = "TON", + chainIcon = null, + isTestNet = false, + priceId = null, + precision = 9, + staking = Asset.StakingType.UNSUPPORTED, + purchaseProviders = null, + supportStakingPool = false, + isUtility = true, + type = ChainAssetType.Normal, + currencyId = null, + existentialDeposit = null, + color = null, + isNative = true + ) + } + + fun irohaChain( + network: UniversalWalletRegistry.IrohaNetwork = UniversalWalletRegistry.taira + ): Chain { + val displayName = if (network == UniversalWalletRegistry.nexus) "SORA Nexus" else "Taira Testnet" + + return Chain( + id = network.id, + paraId = null, + rank = null, + name = displayName, + minSupportedVersion = null, + assets = listOf(irohaAsset(network.id)), + nodes = emptyList(), + explorers = emptyList(), + externalApi = Chain.ExternalApi( + staking = null, + history = Chain.ExternalApi.Section( + Chain.ExternalApi.Section.Type.IROHA, + network.toriiBaseUrl ?: "https://taira.sora.org" + ), + crowdloans = null + ), + icon = "", + addressPrefix = 0, + isEthereumBased = false, + isTestNet = network == UniversalWalletRegistry.taira, + hasCrowdloans = false, + parentId = null, + supportStakingPool = false, + isEthereumChain = false, + chainlinkProvider = false, + supportNft = false, + isUsesAppId = false, + identityChain = null, + ecosystem = Ecosystem.Substrate, + remoteAssetsSource = null + ) + } + + fun irohaAsset(chainId: String): Asset { + return Asset( + id = "xor#sora", + name = "XOR", + symbol = "XOR", + iconUrl = "", + chainId = chainId, + chainName = if (chainId == UniversalWalletRegistry.nexus.id) "SORA Nexus" else "Taira Testnet", + chainIcon = null, + isTestNet = chainId != UniversalWalletRegistry.nexus.id, + priceId = null, + precision = 18, + staking = Asset.StakingType.UNSUPPORTED, + purchaseProviders = null, + supportStakingPool = false, + isUtility = true, + type = ChainAssetType.Normal, + currencyId = null, + existentialDeposit = null, + color = null, + isNative = true + ) + } + } +} diff --git a/feature-wallet-impl/src/test/java/jp/co/soramitsu/wallet/impl/domain/qr/CbdcQrParserTest.kt b/feature-wallet-impl/src/test/java/jp/co/soramitsu/wallet/impl/domain/qr/CbdcQrParserTest.kt new file mode 100644 index 0000000000..2267e59381 --- /dev/null +++ b/feature-wallet-impl/src/test/java/jp/co/soramitsu/wallet/impl/domain/qr/CbdcQrParserTest.kt @@ -0,0 +1,95 @@ +package jp.co.soramitsu.wallet.impl.domain.qr + +import java.math.BigDecimal +import java.net.URLEncoder +import java.nio.charset.StandardCharsets +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Test + +class CbdcQrParserTest { + + @Test + fun `parses encoded cbdc qr payload`() { + val content = "https://pay.example/scan?qr=${encode(validPayload())}&source=camera" + + val result = CbdcQrParser.parse(content) + + requireNotNull(result) + assertEquals(BigDecimal("12.345"), result.transactionAmount) + assertEquals("840", result.transactionCurrencyCode) + assertEquals("Order #1", result.description) + assertEquals("Merchant", result.name) + assertEquals("BILL-7", result.billNumber) + assertEquals("sora-account-id", result.recipientId) + } + + @Test + fun `returns zero amount when amount is absent`() { + val content = "https://pay.example/scan?qr=${encode(validPayload(amount = null))}" + + val result = CbdcQrParser.parse(content) + + requireNotNull(result) + assertEquals(BigDecimal.ZERO, result.transactionAmount) + } + + @Test + fun `returns null when qr query parameter is absent`() { + assertNull(CbdcQrParser.parse("https://pay.example/scan?payload=${encode(validPayload())}")) + } + + @Test + fun `returns null for malformed tlv length`() { + val malformed = "0002015303840549912.345" + + assertNull(CbdcQrParser.parse("https://pay.example/scan?qr=${encode(malformed)}")) + } + + @Test + fun `returns null for invalid amount`() { + val content = "https://pay.example/scan?qr=${encode(validPayload(amount = "12.3.4"))}" + + assertNull(CbdcQrParser.parse(content)) + } + + @Test + fun `returns null when merchant account id is missing`() { + val payload = tlv("00", "01") + + tlv("53", "840") + + tlv("59", "Merchant") + + tlv("26", tlv("01", "not-aid")) + + assertNull(CbdcQrParser.parse("https://pay.example/scan?qr=${encode(payload)}")) + } + + @Test + fun `returns null for oversized qr payload`() { + val oversized = "0".repeat(4097) + + assertNull(CbdcQrParser.parse("https://pay.example/scan?qr=$oversized")) + } + + private fun validPayload(amount: String? = "12.345"): String { + return listOfNotNull( + tlv("00", "01"), + tlv("26", tlv("00", "sora-account-id")), + tlv("53", "840"), + amount?.let { tlv("54", it) }, + tlv("59", "Merchant"), + tlv( + "62", + tlv("01", "BILL-7") + + tlv("08", "Order #1") + ) + ).joinToString(separator = "") + } + + private fun tlv(tag: String, value: String): String { + return tag + value.length.toString().padStart(2, '0') + value + } + + private fun encode(value: String): String { + return URLEncoder.encode(value, StandardCharsets.UTF_8.name()) + } +} diff --git a/feature-walletconnect-impl/src/main/java/jp/co/soramitsu/walletconnect/impl/presentation/WCExt.kt b/feature-walletconnect-impl/src/main/java/jp/co/soramitsu/walletconnect/impl/presentation/WCExt.kt index 2ab5571764..8d9e4a5787 100644 --- a/feature-walletconnect-impl/src/main/java/jp/co/soramitsu/walletconnect/impl/presentation/WCExt.kt +++ b/feature-walletconnect-impl/src/main/java/jp/co/soramitsu/walletconnect/impl/presentation/WCExt.kt @@ -4,8 +4,8 @@ import androidx.core.net.toUri import com.reown.android.Core import com.reown.walletkit.client.Wallet import io.ipfs.multibase.CharEncoding +import jp.co.soramitsu.fearless_utils.extensions.fromHex import jp.co.soramitsu.runtime.multiNetwork.chain.model.Chain -import jp.co.soramitsu.shared_utils.extensions.fromHex import org.json.JSONArray import org.json.JSONObject import java.nio.charset.Charset diff --git a/feature-walletconnect-impl/src/main/java/jp/co/soramitsu/walletconnect/impl/presentation/WalletConnectInteractorImpl.kt b/feature-walletconnect-impl/src/main/java/jp/co/soramitsu/walletconnect/impl/presentation/WalletConnectInteractorImpl.kt index 101516a99e..a3d4543d10 100644 --- a/feature-walletconnect-impl/src/main/java/jp/co/soramitsu/walletconnect/impl/presentation/WalletConnectInteractorImpl.kt +++ b/feature-walletconnect-impl/src/main/java/jp/co/soramitsu/walletconnect/impl/presentation/WalletConnectInteractorImpl.kt @@ -19,17 +19,17 @@ import jp.co.soramitsu.core.crypto.mapCryptoTypeToEncryption import jp.co.soramitsu.core.extrinsic.ExtrinsicService import jp.co.soramitsu.core.extrinsic.keypair_provider.KeypairProvider import jp.co.soramitsu.core.models.ChainId +import jp.co.soramitsu.fearless_utils.encrypt.MultiChainEncryption +import jp.co.soramitsu.fearless_utils.encrypt.SignatureWrapper +import jp.co.soramitsu.fearless_utils.encrypt.Signer +import jp.co.soramitsu.fearless_utils.extensions.fromHex +import jp.co.soramitsu.fearless_utils.extensions.toHexString +import jp.co.soramitsu.fearless_utils.hash.Hasher.blake2b256 +import jp.co.soramitsu.fearless_utils.runtime.definitions.types.useScaleWriter +import jp.co.soramitsu.fearless_utils.scale.utils.directWrite import jp.co.soramitsu.runtime.ext.accountIdOf import jp.co.soramitsu.runtime.multiNetwork.chain.ChainsRepository import jp.co.soramitsu.runtime.multiNetwork.chain.model.Chain -import jp.co.soramitsu.shared_utils.encrypt.MultiChainEncryption -import jp.co.soramitsu.shared_utils.encrypt.SignatureWrapper -import jp.co.soramitsu.shared_utils.encrypt.Signer -import jp.co.soramitsu.shared_utils.extensions.fromHex -import jp.co.soramitsu.shared_utils.extensions.toHexString -import jp.co.soramitsu.shared_utils.hash.Hasher.blake2b256 -import jp.co.soramitsu.shared_utils.runtime.definitions.types.useScaleWriter -import jp.co.soramitsu.shared_utils.scale.utils.directWrite import jp.co.soramitsu.wallet.impl.data.network.blockchain.EthereumRemoteSource import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext diff --git a/feature-walletconnect-impl/src/test/kotlin/jp/co/soramitsu/sign/SignTest.kt b/feature-walletconnect-impl/src/test/kotlin/jp/co/soramitsu/sign/SignTest.kt index 6baf9dd47a..b68d73aee8 100644 --- a/feature-walletconnect-impl/src/test/kotlin/jp/co/soramitsu/sign/SignTest.kt +++ b/feature-walletconnect-impl/src/test/kotlin/jp/co/soramitsu/sign/SignTest.kt @@ -1,7 +1,7 @@ package jp.co.soramitsu.sign -import jp.co.soramitsu.shared_utils.encrypt.SignatureWrapper -import jp.co.soramitsu.shared_utils.extensions.toHexString +import jp.co.soramitsu.fearless_utils.encrypt.SignatureWrapper +import jp.co.soramitsu.fearless_utils.extensions.toHexString import org.junit.Assert.assertEquals import org.junit.Test import org.web3j.crypto.Credentials diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 894c9bc614..5ce934ff98 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -16,6 +16,7 @@ composeThemeAdapter = "1.2.1" constraintlayoutComposeVersion = "1.1.0" constraintVersion = "2.2.0" coreKtx = "1.13.1" +credentialsVersion = "1.6.0" coroutines = "1.8.1" customview = "1.2.0-alpha02" customviewPoolingcontainer = "1.0.0" @@ -24,6 +25,7 @@ detekt = "1.23.6" firebaseAppdistributionGradle = "4.2.0" fragmentKtx = "1.8.5" gmsPlayServices = "18.5.0" +googlePlayServicesAuth = "21.6.0" googleServices = "4.4.1" gson = "2.10.1" hiltNavComposeVersion = "1.2.0" @@ -73,6 +75,7 @@ tonVersion = "0.3.1" security-crypto = { module = "androidx.security:security-crypto", version.ref = "securityCryptoVersion" } soramitsu-android-foundation = { module = "jp.co.soramitsu:android-foundation", version.ref = "soramitsuAndroidFoundation" } appcompat = { module = "androidx.appcompat:appcompat", version.ref = "appcompat" } +activity = { module = "androidx.activity:activity", version.ref = "activityCompose" } bouncycastle = { module = "org.bouncycastle:bcprov-jdk18on", version.ref = "bouncyCastleVersion" } biometric = { module = "androidx.biometric:biometric", version.ref = "biometricVersion" } cardview = { module = "androidx.cardview:cardview", version.ref = "cardViewVersion" } @@ -83,6 +86,8 @@ constraintlayout = { module = "androidx.constraintlayout:constraintlayout", vers converter-gson = { module = "com.squareup.retrofit2:converter-gson", version.ref = "retrofit" } converter-scalars = { module = "com.squareup.retrofit2:converter-scalars", version.ref = "retrofit" } core-ktx = { module = "androidx.core:core-ktx", version.ref = "coreKtx" } +credentials = { module = "androidx.credentials:credentials", version.ref = "credentialsVersion" } +credentials-play-services-auth = { module = "androidx.credentials:credentials-play-services-auth", version.ref = "credentialsVersion" } ext-junit = { module = "androidx.test.ext:junit", version.ref = "junitVersion" } arch-core-testing = { module = "androidx.arch.core:core-testing", version.ref = "archCoreTest" } firebase-appdistribution-gradle = { module = "com.google.firebase:firebase-appdistribution-gradle", version.ref = "firebaseAppdistributionGradle" } @@ -100,6 +105,7 @@ lifecycle-process = { module = "androidx.lifecycle:lifecycle-process", version.r lifecycle-runtime-ktx = { module = "androidx.lifecycle:lifecycle-runtime-ktx", version.ref = "architectureComponentVersion" } lifecycle-viewmodel-ktx = { module = "androidx.lifecycle:lifecycle-viewmodel-ktx", version.ref = "architectureComponentVersion" } logging-interceptor = { module = "com.squareup.okhttp3:logging-interceptor", version.ref = "okhttpVersion" } +okhttp = { module = "com.squareup.okhttp3:okhttp", version.ref = "okhttpVersion" } okhttp-sse = { module = "com.squareup.okhttp3:okhttp-sse", version.ref = "okhttpVersion" } middle-ellipsis-text = { module = "io.github.mataku:middle-ellipsis-text", version.ref = "middleEllipsisTextVersion" } mockito-core = { module = "org.mockito:mockito-core", version.ref = "mockitoVersion" } @@ -174,6 +180,7 @@ customview-poolingcontainer = { module = "androidx.customview:customview-pooling fragmentKtx = { module = "androidx.fragment:fragment-ktx", version.ref = "fragmentKtx" } material = { module = "com.google.android.material:material", version.ref = "material" } play-services-base = { module = "com.google.android.gms:play-services-base", version.ref = "gmsPlayServices" } +play-services-auth = { module = "com.google.android.gms:play-services-auth", version.ref = "googlePlayServicesAuth" } coroutines-core = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-core", version.ref = "coroutines" } coroutines-android = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-android", version.ref = "coroutines" } diff --git a/key/fake.json b/key/fake.json index 3c5c546677..e26fc52447 100644 --- a/key/fake.json +++ b/key/fake.json @@ -2,7 +2,7 @@ "type": "null", "project_id": "null", "private_key_id": "null", - "private_key": "-----BEGIN PRIVATE KEY-----\nnull\n-----END PRIVATE KEY-----\n", + "private_key": "null", "client_email": "null@null.iam.gserviceaccount.com", "client_id": "null", "auth_uri": "https://accounts.google.com/o/oauth2/auth", diff --git a/public-android-foundation/build.gradle b/public-android-foundation/build.gradle new file mode 100644 index 0000000000..8299b7a5c4 --- /dev/null +++ b/public-android-foundation/build.gradle @@ -0,0 +1,29 @@ +apply plugin: 'com.android.library' +apply plugin: 'kotlin-android' + +android { + namespace = 'jp.co.soramitsu.androidfoundation' + compileSdkVersion rootProject.compileSdkVersion + + defaultConfig { + minSdkVersion rootProject.minSdkVersion + targetSdkVersion rootProject.targetSdkVersion + } + + compileOptions { + sourceCompatibility = JavaVersion.VERSION_21 + targetCompatibility = JavaVersion.VERSION_21 + } + + kotlinOptions { + jvmTarget = JavaVersion.VERSION_21.toString() + } +} + +dependencies { + api libs.kotlin.stdlib.jdk7 + api libs.coroutines.core + api libs.coroutines.android + api libs.kotlinx.coroutines.test + api libs.junit +} diff --git a/public-android-foundation/src/main/java/jp/co/soramitsu/androidfoundation/coroutine/CoroutineManager.kt b/public-android-foundation/src/main/java/jp/co/soramitsu/androidfoundation/coroutine/CoroutineManager.kt new file mode 100644 index 0000000000..c8778a248f --- /dev/null +++ b/public-android-foundation/src/main/java/jp/co/soramitsu/androidfoundation/coroutine/CoroutineManager.kt @@ -0,0 +1,11 @@ +package jp.co.soramitsu.androidfoundation.coroutine + +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.MainCoroutineDispatcher + +open class CoroutineManager( + open val io: CoroutineDispatcher = Dispatchers.IO, + open val default: CoroutineDispatcher = Dispatchers.Default, + open val main: MainCoroutineDispatcher = Dispatchers.Main +) diff --git a/public-android-foundation/src/main/java/jp/co/soramitsu/androidfoundation/format/Format.kt b/public-android-foundation/src/main/java/jp/co/soramitsu/androidfoundation/format/Format.kt new file mode 100644 index 0000000000..ab967a4f57 --- /dev/null +++ b/public-android-foundation/src/main/java/jp/co/soramitsu/androidfoundation/format/Format.kt @@ -0,0 +1,55 @@ +package jp.co.soramitsu.androidfoundation.format + +import java.math.BigDecimal +import java.math.BigInteger +import java.math.RoundingMode + +data class StringPair(val first: String, val second: String) + +val Big100: BigDecimal = BigDecimal(100) + +fun BigDecimal?.isZero(): Boolean = this == null || compareTo(BigDecimal.ZERO) == 0 + +fun BigInteger?.isZero(): Boolean = this == null || compareTo(BigInteger.ZERO) == 0 + +fun BigDecimal?.orZero(): BigDecimal = this ?: BigDecimal.ZERO + +fun BigInteger?.orZero(): BigInteger = this ?: BigInteger.ZERO + +fun compareNullDesc(current: BigDecimal?, next: BigDecimal?): Int { + return when { + current == null && next == null -> 0 + current == null -> 1 + next == null -> -1 + else -> next.compareTo(current) + } +} + +fun String.addHexPrefix(): String = if (startsWith("0x")) this else "0x$this" + +fun mapBalance(balance: BigInteger, precision: Int): BigDecimal { + return BigDecimal(balance).movePointLeft(precision) +} + +fun mapBalance(balance: BigDecimal, precision: Int): BigInteger { + return balance.movePointRight(precision).setScale(0, RoundingMode.DOWN).toBigInteger() +} + +inline fun Any?.safeCast(): T? = this as? T + +fun BigDecimal.divideBy(divisor: BigDecimal, precision: Int? = null): BigDecimal { + require(divisor.compareTo(BigDecimal.ZERO) != 0) { "Cannot divide by zero" } + return precision?.let { + divide(divisor, it, RoundingMode.HALF_UP).stripTrailingZeros() + } ?: divide(divisor, 18, RoundingMode.HALF_UP).stripTrailingZeros() +} + +fun BigDecimal.safeDivide(divisor: BigDecimal, precision: Int? = null): BigDecimal { + return if (divisor.compareTo(BigDecimal.ZERO) == 0) { + BigDecimal.ZERO + } else { + divideBy(divisor, precision) + } +} + +fun BigDecimal.equalTo(other: BigDecimal): Boolean = compareTo(other) == 0 diff --git a/public-android-foundation/src/main/java/jp/co/soramitsu/androidfoundation/testing/MainCoroutineRule.kt b/public-android-foundation/src/main/java/jp/co/soramitsu/androidfoundation/testing/MainCoroutineRule.kt new file mode 100644 index 0000000000..3f1a4e128e --- /dev/null +++ b/public-android-foundation/src/main/java/jp/co/soramitsu/androidfoundation/testing/MainCoroutineRule.kt @@ -0,0 +1,24 @@ +package jp.co.soramitsu.androidfoundation.testing + +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.StandardTestDispatcher +import kotlinx.coroutines.test.TestDispatcher +import kotlinx.coroutines.test.resetMain +import kotlinx.coroutines.test.setMain +import org.junit.rules.TestWatcher +import org.junit.runner.Description + +@OptIn(ExperimentalCoroutinesApi::class) +class MainCoroutineRule( + val dispatcher: TestDispatcher = StandardTestDispatcher() +) : TestWatcher() { + + override fun starting(description: Description) { + Dispatchers.setMain(dispatcher) + } + + override fun finished(description: Description) { + Dispatchers.resetMain() + } +} diff --git a/public-shared-features-backup/build.gradle b/public-shared-features-backup/build.gradle new file mode 100644 index 0000000000..07e45dcbbc --- /dev/null +++ b/public-shared-features-backup/build.gradle @@ -0,0 +1,36 @@ +apply plugin: 'com.android.library' +apply plugin: 'kotlin-android' + +android { + namespace = 'jp.co.soramitsu.shared_features.backup' + compileSdkVersion rootProject.compileSdkVersion + + defaultConfig { + minSdkVersion rootProject.minSdkVersion + targetSdkVersion rootProject.targetSdkVersion + } + + compileOptions { + sourceCompatibility = JavaVersion.VERSION_21 + targetCompatibility = JavaVersion.VERSION_21 + } + + kotlinOptions { + jvmTarget = JavaVersion.VERSION_21.toString() + } +} + +dependencies { + api libs.kotlin.stdlib.jdk7 + api libs.activity + api libs.credentials + implementation libs.credentials.play.services.auth + implementation libs.coroutines.core + implementation libs.gson + implementation libs.okhttp + implementation libs.play.services.auth + api project(':core-api') + + testImplementation libs.junit + testImplementation libs.kotlinx.coroutines.test +} diff --git a/public-shared-features-backup/src/main/java/jp/co/soramitsu/backup/BackupService.kt b/public-shared-features-backup/src/main/java/jp/co/soramitsu/backup/BackupService.kt new file mode 100644 index 0000000000..90fc64f792 --- /dev/null +++ b/public-shared-features-backup/src/main/java/jp/co/soramitsu/backup/BackupService.kt @@ -0,0 +1,54 @@ +package jp.co.soramitsu.backup + +import android.content.Context +import androidx.activity.result.ActivityResultLauncher +import jp.co.soramitsu.backup.domain.models.BackupAccountMeta +import jp.co.soramitsu.backup.domain.models.DecryptedBackupAccount + +@Suppress("FunctionOnlyReturningConstant", "UnusedParameter") +class BackupService private constructor( + private val context: Context?, + private val token: String? +) { + fun logout() { + // The public compatibility layer does not maintain a remote backup session. + } + + fun authorize(launcher: ActivityResultLauncher<*>): Boolean { + return false + } + + suspend fun getBackupAccounts(): List { + return emptyList() + } + + suspend fun getWebBackupAccounts(): List { + return emptyList() + } + + suspend fun saveBackupAccount(account: DecryptedBackupAccount, password: String) { + unsupportedRemoteBackup() + } + + suspend fun importBackupAccount(address: String, password: String): DecryptedBackupAccount { + unsupportedRemoteBackup() + } + + suspend fun importWebBackupAccount(address: String, name: String): DecryptedBackupAccount { + unsupportedRemoteBackup() + } + + suspend fun deleteBackupAccount(address: String) { + unsupportedRemoteBackup() + } + + private fun unsupportedRemoteBackup(): Nothing { + throw UnsupportedOperationException( + "Remote backup is unavailable in the public shared-features compatibility layer" + ) + } + + companion object { + fun create(context: Context, token: String): BackupService = BackupService(context, token) + } +} diff --git a/public-shared-features-backup/src/main/java/jp/co/soramitsu/backup/domain/exceptions/AuthConsentException.kt b/public-shared-features-backup/src/main/java/jp/co/soramitsu/backup/domain/exceptions/AuthConsentException.kt new file mode 100644 index 0000000000..a2f40dfcaa --- /dev/null +++ b/public-shared-features-backup/src/main/java/jp/co/soramitsu/backup/domain/exceptions/AuthConsentException.kt @@ -0,0 +1,7 @@ +package jp.co.soramitsu.backup.domain.exceptions + +import android.content.Intent + +class AuthConsentException( + val intent: Intent +) : Exception() diff --git a/public-shared-features-backup/src/main/java/jp/co/soramitsu/backup/domain/models/BackupAccountModels.kt b/public-shared-features-backup/src/main/java/jp/co/soramitsu/backup/domain/models/BackupAccountModels.kt new file mode 100644 index 0000000000..993e1f26d7 --- /dev/null +++ b/public-shared-features-backup/src/main/java/jp/co/soramitsu/backup/domain/models/BackupAccountModels.kt @@ -0,0 +1,36 @@ +package jp.co.soramitsu.backup.domain.models + +import jp.co.soramitsu.core.models.CryptoType + +data class BackupAccountMeta( + val name: String, + val address: String +) + +enum class BackupAccountType { + PASSPHRASE, + SEED, + JSON +} + +data class DecryptedBackupAccount( + val name: String, + val address: String, + val mnemonicPhrase: String?, + val substrateDerivationPath: String?, + val ethDerivationPath: String?, + val cryptoType: CryptoType, + val backupAccountType: List, + val seed: Seed?, + val json: Json? +) + +data class Seed( + val substrateSeed: String?, + val ethSeed: String? +) + +data class Json( + val substrateJson: String?, + val ethJson: String? +) diff --git a/public-shared-features-backup/src/main/java/jp/co/soramitsu/backup/passkey/GoogleDrivePasskeyBackupCloudStorage.kt b/public-shared-features-backup/src/main/java/jp/co/soramitsu/backup/passkey/GoogleDrivePasskeyBackupCloudStorage.kt new file mode 100644 index 0000000000..14ccdd645c --- /dev/null +++ b/public-shared-features-backup/src/main/java/jp/co/soramitsu/backup/passkey/GoogleDrivePasskeyBackupCloudStorage.kt @@ -0,0 +1,417 @@ +package jp.co.soramitsu.backup.passkey + +import com.google.gson.JsonArray +import com.google.gson.JsonObject +import com.google.gson.JsonParser +import okhttp3.MediaType.Companion.toMediaType +import okhttp3.OkHttpClient +import okhttp3.Request +import okhttp3.RequestBody.Companion.toRequestBody +import java.net.URLEncoder +import java.nio.charset.StandardCharsets + +object GoogleDrivePasskeyBackup { + const val APP_DATA_SCOPE = "https://www.googleapis.com/auth/drive.appdata" + const val OAUTH_APP_DATA_SCOPE = "oauth2:$APP_DATA_SCOPE" + const val APP_DATA_FOLDER = "appDataFolder" + const val MIME_TYPE = "application/octet-stream" + + internal const val HTTP_SUCCESS_MIN = 200 + internal const val HTTP_SUCCESS_MAX = 299 + internal const val HTTP_NOT_FOUND = 404 + internal const val DRIVE_BASE_URL = "https://www.googleapis.com/drive/v3" + internal const val UPLOAD_BASE_URL = "https://www.googleapis.com/upload/drive/v3" + internal const val FILE_NAME_PREFIX = "fearless-passkey-backup-" + + private const val MAX_ACCOUNT_NAME_LENGTH = 320 + + fun requireAccountName(accountName: String): String { + val normalized = accountName.trim() + require(normalized.isNotEmpty()) { + "Google account is required for passkey backup Drive access" + } + require(normalized.length <= MAX_ACCOUNT_NAME_LENGTH) { + "Google account for passkey backup is too long" + } + require(normalized.none { it.isISOControl() || it.isWhitespace() }) { + "Google account for passkey backup must not contain whitespace or control characters" + } + require(normalized.contains("@")) { + "Google account for passkey backup must be an email address" + } + return normalized + } + + fun requireMatchingAccountName( + expected: String, + actual: String, + ceremony: String + ): String { + val normalizedExpected = requireAccountName(expected) + val normalizedActual = requireAccountName(actual) + require(normalizedActual.equals(normalizedExpected, ignoreCase = true)) { + "Passkey $ceremony returned a mismatched Google account" + } + return normalizedExpected + } +} + +fun interface GoogleDriveAccessTokenProvider { + suspend fun accessToken(): String +} + +data class GoogleDriveHttpRequest( + val method: String, + val url: String, + val headers: Map = emptyMap(), + val body: ByteArray? = null +) + +data class GoogleDriveHttpResponse( + val code: Int, + val body: ByteArray = ByteArray(0) +) + +interface GoogleDriveHttpTransport { + suspend fun execute(request: GoogleDriveHttpRequest): GoogleDriveHttpResponse +} + +class OkHttpGoogleDriveHttpTransport( + private val okHttpClient: OkHttpClient +) : GoogleDriveHttpTransport { + override suspend fun execute(request: GoogleDriveHttpRequest): GoogleDriveHttpResponse { + val requestBody = request.body?.toRequestBody(request.contentType()?.toMediaType()) + val builder = Request.Builder().url(request.url) + + request.headers.forEach { (name, value) -> + builder.header(name, value) + } + + when (request.method.uppercase()) { + "GET" -> builder.get() + "DELETE" -> builder.delete() + "POST" -> builder.post(requestBody ?: ByteArray(0).toRequestBody(null)) + "PATCH" -> builder.patch(requestBody ?: ByteArray(0).toRequestBody(null)) + else -> error("Unsupported Google Drive HTTP method: ${request.method}") + } + + return okHttpClient.newCall(builder.build()).execute().use { response -> + GoogleDriveHttpResponse( + code = response.code, + body = response.body?.bytes() ?: ByteArray(0) + ) + } + } + + private fun GoogleDriveHttpRequest.contentType(): String? { + return headers.entries.firstOrNull { it.key.equals("Content-Type", ignoreCase = true) }?.value + } +} + +class GoogleDrivePasskeyBackupCloudStorage( + private val driveClient: GoogleDrivePasskeyBackupDriveClient +) : PasskeyBackupCloudStorage { + override suspend fun savePasskeyBackup(payload: PasskeyBackupEncryptedPayload) { + driveClient.saveBackup(payload) + } + + override suspend fun loadPasskeyBackup(storageKey: String): PasskeyBackupEncryptedPayload? { + val normalizedStorageKey = PasskeyBackupContract.requireStorageKey(storageKey) + return driveClient.loadBackup(normalizedStorageKey) + } + + override suspend fun deletePasskeyBackup(storageKey: String) { + val normalizedStorageKey = PasskeyBackupContract.requireStorageKey(storageKey) + driveClient.deleteBackup(normalizedStorageKey) + } +} + +class GoogleDrivePasskeyBackupDriveClient( + private val accessTokenProvider: GoogleDriveAccessTokenProvider, + private val transport: GoogleDriveHttpTransport +) { + suspend fun saveBackup(payload: PasskeyBackupEncryptedPayload) { + val normalizedPayload = PasskeyBackupEncryptedPayload( + storageKey = PasskeyBackupContract.requireStorageKey(payload.storageKey), + walletId = PasskeyBackupContract.requireWalletId(payload.walletId), + accountName = GoogleDrivePasskeyBackup.requireAccountName(payload.accountName), + createdAtMillis = PasskeyBackupContract.requireCreatedAtMillis(payload.createdAtMillis), + encryptedPayload = payload.encryptedPayload, + schemaVersion = payload.schemaVersion + ) + val existingFile = findBackupFile(normalizedPayload.storageKey) + val boundary = "fearless-passkey-backup-${normalizedPayload.storageKey}" + val requestBody = multipartBody( + boundary = boundary, + metadata = metadataJson(normalizedPayload).toString(), + encryptedPayload = normalizedPayload.encryptedPayload + ) + + val request = if (existingFile == null) { + authenticatedRequest( + method = "POST", + url = uploadUrl( + path = "/files", + query = mapOf( + "uploadType" to "multipart", + "fields" to "id,name,appProperties" + ) + ), + headers = mapOf("Content-Type" to "multipart/related; boundary=$boundary"), + body = requestBody + ) + } else { + authenticatedRequest( + method = "PATCH", + url = uploadUrl( + path = "/files/${encodePathSegment(existingFile.id)}", + query = mapOf( + "uploadType" to "multipart", + "fields" to "id,name,appProperties" + ) + ), + headers = mapOf("Content-Type" to "multipart/related; boundary=$boundary"), + body = requestBody + ) + } + + requireSuccess(transport.execute(request), "upload") + } + + suspend fun loadBackup(storageKey: String): PasskeyBackupEncryptedPayload? { + val normalizedStorageKey = PasskeyBackupContract.requireStorageKey(storageKey) + val file = findBackupFile(normalizedStorageKey) ?: return null + + require(file.schemaVersion == PasskeyBackupContract.SCHEMA_VERSION) { + "Unsupported passkey backup schemaVersion: ${file.schemaVersion}" + } + + val response = transport.execute( + authenticatedRequest( + method = "GET", + url = driveUrl( + path = "/files/${encodePathSegment(file.id)}", + query = mapOf("alt" to "media") + ) + ) + ) + + if (response.code == GoogleDrivePasskeyBackup.HTTP_NOT_FOUND) { + return null + } + + requireSuccess(response, "download") + + return PasskeyBackupEncryptedPayload( + storageKey = normalizedStorageKey, + walletId = file.walletId, + accountName = file.accountName, + createdAtMillis = file.createdAtMillis, + encryptedPayload = response.body, + schemaVersion = file.schemaVersion + ) + } + + suspend fun deleteBackup(storageKey: String) { + val normalizedStorageKey = PasskeyBackupContract.requireStorageKey(storageKey) + val file = findBackupFile(normalizedStorageKey) ?: return + + val response = transport.execute( + authenticatedRequest( + method = "DELETE", + url = driveUrl(path = "/files/${encodePathSegment(file.id)}") + ) + ) + + if (response.code != GoogleDrivePasskeyBackup.HTTP_NOT_FOUND) { + requireSuccess(response, "delete") + } + } + + private suspend fun findBackupFile(storageKey: String): GoogleDrivePasskeyBackupFile? { + val normalizedStorageKey = PasskeyBackupContract.requireStorageKey(storageKey) + val response = transport.execute( + authenticatedRequest( + method = "GET", + url = driveUrl( + path = "/files", + query = mapOf( + "spaces" to GoogleDrivePasskeyBackup.APP_DATA_FOLDER, + "pageSize" to "10", + "fields" to "files(id,name,appProperties)", + "q" to "name = '${fileName(normalizedStorageKey)}' and trashed = false" + ) + ) + ) + ) + + requireSuccess(response, "list") + + val files = parseFileList(response.bodyText()) + files.forEach { file -> + require(file.storageKey == normalizedStorageKey) { + "Google Drive passkey backup metadata storageKey mismatch" + } + } + require(files.size <= 1) { + "Multiple passkey backup files found for storage key: $normalizedStorageKey" + } + + return files.firstOrNull() + } + + private suspend fun authenticatedRequest( + method: String, + url: String, + headers: Map = emptyMap(), + body: ByteArray? = null + ): GoogleDriveHttpRequest { + val accessToken = accessTokenProvider.accessToken().trim() + require(accessToken.isNotEmpty()) { "Google Drive access token is required" } + + return GoogleDriveHttpRequest( + method = method, + url = url, + headers = headers + mapOf("Authorization" to "Bearer $accessToken"), + body = body + ) + } + + private fun metadataJson(payload: PasskeyBackupEncryptedPayload): JsonObject { + val parents = JsonArray().apply { + add(GoogleDrivePasskeyBackup.APP_DATA_FOLDER) + } + val appProperties = JsonObject().apply { + addProperty("storageKey", payload.storageKey) + addProperty("walletId", payload.walletId) + addProperty("accountName", payload.accountName) + addProperty("createdAtMillis", payload.createdAtMillis.toString()) + addProperty("schemaVersion", payload.schemaVersion.toString()) + } + + return JsonObject().apply { + addProperty("name", fileName(payload.storageKey)) + addProperty("mimeType", GoogleDrivePasskeyBackup.MIME_TYPE) + add("parents", parents) + add("appProperties", appProperties) + } + } + + private fun multipartBody( + boundary: String, + metadata: String, + encryptedPayload: ByteArray + ): ByteArray { + val prefix = buildString { + append("--").append(boundary).append("\r\n") + append("Content-Type: application/json; charset=UTF-8\r\n\r\n") + append(metadata).append("\r\n") + append("--").append(boundary).append("\r\n") + append("Content-Type: ").append(GoogleDrivePasskeyBackup.MIME_TYPE).append("\r\n\r\n") + }.toByteArray(StandardCharsets.UTF_8) + val suffix = "\r\n--$boundary--\r\n".toByteArray(StandardCharsets.UTF_8) + + return prefix + encryptedPayload + suffix + } + + private fun parseFileList(body: String): List { + val root = runCatching { JsonParser.parseString(body).asJsonObject }.getOrElse { + error("Malformed Google Drive passkey backup file list") + } + val files = checkNotNull(root.getAsJsonArray("files")) { + "Malformed Google Drive passkey backup file list" + } + + return files.map { element -> + val file = element.asJsonObject + val id = file.get("id")?.asString?.trim().orEmpty() + require(id.isNotEmpty()) { "Google Drive passkey backup file id is required" } + + val appProperties = checkNotNull(file.getAsJsonObject("appProperties")) { + "Google Drive passkey backup metadata is required" + } + val storageKey = PasskeyBackupContract.requireStorageKey( + requiredAppProperty(appProperties, "storageKey") + ) + val walletId = PasskeyBackupContract.requireWalletId( + requiredAppProperty(appProperties, "walletId") + ) + val accountName = GoogleDrivePasskeyBackup.requireAccountName( + requiredAppProperty(appProperties, "accountName") + ) + val createdAtMillis = PasskeyBackupContract.requireCreatedAtMillis( + requiredAppProperty(appProperties, "createdAtMillis").toLongOrNull() + ?: error("Google Drive passkey backup createdAtMillis must be numeric") + ) + val schemaVersion = requiredAppProperty(appProperties, "schemaVersion").toIntOrNull() + ?: error("Google Drive passkey backup schemaVersion must be numeric") + + GoogleDrivePasskeyBackupFile( + id = id, + storageKey = storageKey, + walletId = walletId, + accountName = accountName, + createdAtMillis = createdAtMillis, + schemaVersion = schemaVersion + ) + } + } + + private fun requiredAppProperty(appProperties: JsonObject, name: String): String { + val value = appProperties.get(name) + require(value != null && value.isJsonPrimitive && value.asJsonPrimitive.isString) { + "Google Drive passkey backup appProperties.$name is required" + } + return value.asString + } + + private fun fileName(storageKey: String): String { + return "${GoogleDrivePasskeyBackup.FILE_NAME_PREFIX}$storageKey.bin" + } + + private fun driveUrl(path: String, query: Map = emptyMap()): String { + return buildUrl(GoogleDrivePasskeyBackup.DRIVE_BASE_URL, path, query) + } + + private fun uploadUrl(path: String, query: Map): String { + return buildUrl(GoogleDrivePasskeyBackup.UPLOAD_BASE_URL, path, query) + } + + private fun buildUrl( + baseUrl: String, + path: String, + query: Map + ): String { + val queryString = query.entries.joinToString("&") { (name, value) -> + "${urlEncode(name)}=${urlEncode(value)}" + } + return baseUrl.trimEnd('/') + path + if (queryString.isEmpty()) "" else "?$queryString" + } + + private fun encodePathSegment(value: String): String { + return urlEncode(value) + } + + private fun urlEncode(value: String): String { + return URLEncoder.encode(value, StandardCharsets.UTF_8.name()).replace("+", "%20") + } + + private fun GoogleDriveHttpResponse.bodyText(): String { + return body.toString(StandardCharsets.UTF_8) + } + + private fun requireSuccess(response: GoogleDriveHttpResponse, operation: String) { + require(response.code in GoogleDrivePasskeyBackup.HTTP_SUCCESS_MIN..GoogleDrivePasskeyBackup.HTTP_SUCCESS_MAX) { + "Google Drive passkey backup $operation failed with HTTP ${response.code}" + } + } +} + +private data class GoogleDrivePasskeyBackupFile( + val id: String, + val storageKey: String, + val walletId: String, + val accountName: String, + val createdAtMillis: Long, + val schemaVersion: Int +) diff --git a/public-shared-features-backup/src/main/java/jp/co/soramitsu/backup/passkey/GoogleDrivePasskeyBackupTokenProvider.kt b/public-shared-features-backup/src/main/java/jp/co/soramitsu/backup/passkey/GoogleDrivePasskeyBackupTokenProvider.kt new file mode 100644 index 0000000000..7ef999970f --- /dev/null +++ b/public-shared-features-backup/src/main/java/jp/co/soramitsu/backup/passkey/GoogleDrivePasskeyBackupTokenProvider.kt @@ -0,0 +1,57 @@ +package jp.co.soramitsu.backup.passkey + +import android.accounts.Account +import android.content.Context +import com.google.android.gms.auth.GoogleAuthUtil +import com.google.android.gms.auth.UserRecoverableAuthException +import jp.co.soramitsu.backup.domain.exceptions.AuthConsentException +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext + +fun interface GoogleDriveAccountNameProvider { + suspend fun accountName(): String +} + +fun interface GoogleDriveOAuthTokenFetcher { + suspend fun fetchAccessToken(accountName: String, oauthScope: String): String +} + +class GoogleDrivePasskeyBackupTokenProvider( + private val accountNameProvider: GoogleDriveAccountNameProvider, + private val tokenFetcher: GoogleDriveOAuthTokenFetcher +) : GoogleDriveAccessTokenProvider { + override suspend fun accessToken(): String { + val accountName = GoogleDrivePasskeyBackup.requireAccountName(accountNameProvider.accountName()) + + val accessToken = tokenFetcher.fetchAccessToken( + accountName = accountName, + oauthScope = GoogleDrivePasskeyBackup.OAUTH_APP_DATA_SCOPE + ).trim() + require(accessToken.isNotEmpty()) { + "Google Drive access token is required for passkey backup" + } + + return accessToken + } +} + +class GoogleAuthUtilDriveOAuthTokenFetcher( + private val context: Context, + private val dispatcher: CoroutineDispatcher = Dispatchers.IO +) : GoogleDriveOAuthTokenFetcher { + override suspend fun fetchAccessToken(accountName: String, oauthScope: String): String { + return withContext(dispatcher) { + try { + GoogleAuthUtil.getToken( + context, + Account(accountName, GoogleAuthUtil.GOOGLE_ACCOUNT_TYPE), + oauthScope + ) + } catch (e: UserRecoverableAuthException) { + val consentIntent = e.intent ?: throw e + throw AuthConsentException(consentIntent) + } + } + } +} diff --git a/public-shared-features-backup/src/main/java/jp/co/soramitsu/backup/passkey/PasskeyBackupChallengeService.kt b/public-shared-features-backup/src/main/java/jp/co/soramitsu/backup/passkey/PasskeyBackupChallengeService.kt new file mode 100644 index 0000000000..04c1208698 --- /dev/null +++ b/public-shared-features-backup/src/main/java/jp/co/soramitsu/backup/passkey/PasskeyBackupChallengeService.kt @@ -0,0 +1,311 @@ +package jp.co.soramitsu.backup.passkey + +import androidx.credentials.CreatePublicKeyCredentialRequest +import androidx.credentials.GetPublicKeyCredentialOption +import com.google.gson.JsonElement +import com.google.gson.JsonObject +import com.google.gson.JsonParser +import java.net.URI + +data class PasskeyBackupRegistrationChallenge( + val registrationId: String, + val challenge: ByteArray, + val userId: ByteArray, + val userName: String, + val displayName: String, + val storageKey: String, + val schemaVersion: Int = PasskeyBackupContract.SCHEMA_VERSION +) { + init { + requireCeremonyId(registrationId, "registrationId") + PasskeyBackupContract.requireChallenge(challenge) + PasskeyBackupContract.requireUserId(userId) + GoogleDrivePasskeyBackup.requireAccountName(userName) + require(displayName.isNotBlank()) { "Passkey registration displayName is required" } + PasskeyBackupContract.requireStorageKey(storageKey) + require(schemaVersion == PasskeyBackupContract.SCHEMA_VERSION) { + "Unsupported passkey backup schemaVersion: $schemaVersion" + } + } +} + +data class PasskeyBackupAssertionChallenge( + val assertionId: String, + val challenge: ByteArray, + val storageKey: String, + val schemaVersion: Int = PasskeyBackupContract.SCHEMA_VERSION +) { + init { + requireCeremonyId(assertionId, "assertionId") + PasskeyBackupContract.requireChallenge(challenge) + PasskeyBackupContract.requireStorageKey(storageKey) + require(schemaVersion == PasskeyBackupContract.SCHEMA_VERSION) { + "Unsupported passkey backup schemaVersion: $schemaVersion" + } + } +} + +data class PasskeyBackupChallengeResult( + val storageKey: String, + val schemaVersion: Int = PasskeyBackupContract.SCHEMA_VERSION +) { + init { + PasskeyBackupContract.requireStorageKey(storageKey) + require(schemaVersion == PasskeyBackupContract.SCHEMA_VERSION) { + "Unsupported passkey backup schemaVersion: $schemaVersion" + } + } +} + +data class PendingPasskeyBackupRegistration( + val registrationId: String, + val storageKey: String, + val walletId: String, + val accountName: String, + val requestJson: String +) { + fun createRequest(): CreatePublicKeyCredentialRequest { + return CreatePublicKeyCredentialRequest(requestJson = requestJson) + } +} + +data class PendingPasskeyBackupAssertion( + val assertionId: String, + val storageKey: String, + val requestJson: String +) { + fun createOption(): GetPublicKeyCredentialOption { + return GetPublicKeyCredentialOption(requestJson = requestJson) + } +} + +interface PasskeyBackupChallengeService { + suspend fun registrationChallenge( + walletId: String, + accountName: String, + displayName: String + ): PasskeyBackupRegistrationChallenge + + suspend fun completeRegistration( + registrationId: String, + credentialResponseJson: String + ): PasskeyBackupChallengeResult + + suspend fun assertionChallenge(storageKey: String): PasskeyBackupAssertionChallenge + + suspend fun completeAssertion(assertionId: String, credentialResponseJson: String): PasskeyBackupChallengeResult +} + +class HttpPasskeyBackupChallengeService( + baseUrl: String, + private val transport: GoogleDriveHttpTransport +) : PasskeyBackupChallengeService { + private val normalizedBaseUrl = normalizeBaseUrl(baseUrl) + + override suspend fun registrationChallenge( + walletId: String, + accountName: String, + displayName: String + ): PasskeyBackupRegistrationChallenge { + val normalizedWalletId = PasskeyBackupContract.requireWalletId(walletId) + val selectedAccountName = GoogleDrivePasskeyBackup.requireAccountName(accountName) + require(displayName.isNotBlank()) { "Passkey registration displayName is required" } + + val response = post( + path = REGISTRATION_CHALLENGE_PATH, + body = JsonObject().apply { + addProperty("walletId", normalizedWalletId) + addProperty("accountName", selectedAccountName) + addProperty("displayName", displayName.trim()) + addProperty("rpId", PasskeyBackupContract.PASSKEY_RP_ID) + addProperty("schemaVersion", PasskeyBackupContract.SCHEMA_VERSION) + } + ) + + requireRpId(response) + val schemaVersion = requiredInt(response, "schemaVersion") + + return PasskeyBackupRegistrationChallenge( + registrationId = requiredString(response, "registrationId"), + challenge = PasskeyBackupContract.requireChallenge( + PasskeyBackupContract.decodeBase64Url(requiredString(response, "challenge"), "registration challenge") + ), + userId = PasskeyBackupContract.requireUserId( + PasskeyBackupContract.decodeBase64Url(requiredString(response, "userId"), "registration userId") + ), + userName = GoogleDrivePasskeyBackup.requireAccountName(requiredString(response, "userName")), + displayName = requiredString(response, "displayName").trim(), + storageKey = PasskeyBackupContract.requireStorageKey(requiredString(response, "storageKey")), + schemaVersion = schemaVersion + ) + } + + override suspend fun completeRegistration( + registrationId: String, + credentialResponseJson: String + ): PasskeyBackupChallengeResult { + val normalizedRegistrationId = requireCeremonyId(registrationId, "registrationId") + + val response = post( + path = REGISTRATION_COMPLETE_PATH, + body = JsonObject().apply { + addProperty("registrationId", normalizedRegistrationId) + addProperty("rpId", PasskeyBackupContract.PASSKEY_RP_ID) + add("credential", credentialJsonObject(credentialResponseJson)) + } + ) + + return challengeResult(response) + } + + override suspend fun assertionChallenge(storageKey: String): PasskeyBackupAssertionChallenge { + val normalizedStorageKey = PasskeyBackupContract.requireStorageKey(storageKey) + + val response = post( + path = ASSERTION_CHALLENGE_PATH, + body = JsonObject().apply { + addProperty("storageKey", normalizedStorageKey) + addProperty("rpId", PasskeyBackupContract.PASSKEY_RP_ID) + addProperty("schemaVersion", PasskeyBackupContract.SCHEMA_VERSION) + } + ) + + requireRpId(response) + val responseStorageKey = PasskeyBackupContract.requireStorageKey(requiredString(response, "storageKey")) + require(responseStorageKey == normalizedStorageKey) { + "Passkey assertion challenge returned a mismatched storageKey" + } + + return PasskeyBackupAssertionChallenge( + assertionId = requiredString(response, "assertionId"), + challenge = PasskeyBackupContract.requireChallenge( + PasskeyBackupContract.decodeBase64Url(requiredString(response, "challenge"), "assertion challenge") + ), + storageKey = responseStorageKey, + schemaVersion = requiredInt(response, "schemaVersion") + ) + } + + override suspend fun completeAssertion( + assertionId: String, + credentialResponseJson: String + ): PasskeyBackupChallengeResult { + val normalizedAssertionId = requireCeremonyId(assertionId, "assertionId") + + val response = post( + path = ASSERTION_COMPLETE_PATH, + body = JsonObject().apply { + addProperty("assertionId", normalizedAssertionId) + addProperty("rpId", PasskeyBackupContract.PASSKEY_RP_ID) + add("credential", credentialJsonObject(credentialResponseJson)) + } + ) + + return challengeResult(response) + } + + private suspend fun post(path: String, body: JsonObject): JsonObject { + val response = transport.execute( + GoogleDriveHttpRequest( + method = "POST", + url = "$normalizedBaseUrl$path", + headers = mapOf("Content-Type" to "application/json; charset=utf-8"), + body = body.toString().toByteArray(Charsets.UTF_8) + ) + ) + + require(response.code in HTTP_SUCCESS_MIN..HTTP_SUCCESS_MAX) { + "Passkey backup challenge service request failed with HTTP ${response.code}" + } + + val responseText = response.body.toString(Charsets.UTF_8) + require(responseText.isNotBlank()) { + "Passkey backup challenge service returned an empty response" + } + + return try { + JsonParser.parseString(responseText).asJsonObject + } catch (e: RuntimeException) { + throw IllegalStateException("Malformed passkey backup challenge service response", e) + } + } + + private fun challengeResult(response: JsonObject): PasskeyBackupChallengeResult { + requireRpId(response) + return PasskeyBackupChallengeResult( + storageKey = PasskeyBackupContract.requireStorageKey(requiredString(response, "storageKey")), + schemaVersion = requiredInt(response, "schemaVersion") + ) + } + + private fun requireRpId(response: JsonObject) { + PasskeyBackupContract.requireValidRpId(requiredString(response, "rpId")) + } + + private fun credentialJsonObject(credentialResponseJson: String): JsonObject { + val normalized = credentialResponseJson.trim() + require(normalized.isNotEmpty()) { "Passkey credential response JSON is required" } + + val element = try { + JsonParser.parseString(normalized) + } catch (e: RuntimeException) { + throw IllegalArgumentException("Passkey credential response must be valid JSON", e) + } + + require(element.isJsonObject) { "Passkey credential response must be a JSON object" } + return element.asJsonObject + } + + private fun requiredString(response: JsonObject, name: String): String { + val value = response.get(name) + require(value != null && value.isJsonPrimitive && value.asJsonPrimitive.isString) { + "Passkey challenge response field $name is required" + } + return value.asString + } + + private fun requiredInt(response: JsonObject, name: String): Int { + val value: JsonElement? = response.get(name) + require(value != null && value.isJsonPrimitive && value.asJsonPrimitive.isNumber) { + "Passkey challenge response field $name is required" + } + return value.asInt + } + + private fun normalizeBaseUrl(baseUrl: String): String { + val trimmed = baseUrl.trim().trimEnd('/') + require(trimmed.isNotEmpty()) { "Passkey backup challenge service baseUrl is required" } + + val uri = URI(trimmed) + require(uri.scheme == "https") { + "Passkey backup challenge service baseUrl must use HTTPS" + } + require(!uri.host.isNullOrBlank()) { + "Passkey backup challenge service baseUrl must include a host" + } + require(uri.query == null && uri.fragment == null) { + "Passkey backup challenge service baseUrl must not include query or fragment" + } + + return trimmed + } + + private companion object { + const val HTTP_SUCCESS_MIN = 200 + const val HTTP_SUCCESS_MAX = 299 + const val REGISTRATION_CHALLENGE_PATH = "/api/passkey-backup/v1/registration/challenge" + const val REGISTRATION_COMPLETE_PATH = "/api/passkey-backup/v1/registration/complete" + const val ASSERTION_CHALLENGE_PATH = "/api/passkey-backup/v1/assertion/challenge" + const val ASSERTION_COMPLETE_PATH = "/api/passkey-backup/v1/assertion/complete" + } +} + +private val ceremonyIdPattern = Regex("^[A-Za-z0-9._:-]{8,128}$") + +private fun requireCeremonyId(id: String, fieldName: String): String { + val normalized = id.trim() + require(ceremonyIdPattern.matches(normalized)) { + "Passkey $fieldName must be 8-128 URL-safe characters" + } + return normalized +} diff --git a/public-shared-features-backup/src/main/java/jp/co/soramitsu/backup/passkey/PasskeyBackupContract.kt b/public-shared-features-backup/src/main/java/jp/co/soramitsu/backup/passkey/PasskeyBackupContract.kt new file mode 100644 index 0000000000..8454334b4f --- /dev/null +++ b/public-shared-features-backup/src/main/java/jp/co/soramitsu/backup/passkey/PasskeyBackupContract.kt @@ -0,0 +1,420 @@ +package jp.co.soramitsu.backup.passkey + +import androidx.credentials.CreatePublicKeyCredentialRequest +import androidx.credentials.CredentialManager +import androidx.credentials.GetPublicKeyCredentialOption +import androidx.credentials.PublicKeyCredential +import java.util.Base64 + +object PasskeyBackupContract { + const val PASSKEY_RP_ID = "fearlesswallet.io" + const val passkeyRelyingPartyId = PASSKEY_RP_ID + const val SCHEMA_VERSION = 1 + + private const val MAX_ENCRYPTED_PAYLOAD_BYTES = 256 * 1024 + private const val MIN_CHALLENGE_BYTES = 16 + private const val MAX_CHALLENGE_BYTES = 1024 + private const val MIN_USER_ID_BYTES = 16 + private const val MAX_USER_ID_BYTES = 64 + private const val MAX_CREATED_AT_MILLIS = 4_102_444_800_000L + private const val MIN_JSON_CONTROL_CHAR_CODE = 0x20 + private const val JSON_UNICODE_ESCAPE_RADIX = 16 + private const val JSON_UNICODE_ESCAPE_WIDTH = 4 + private const val JSON_UNICODE_ESCAPE_PAD_CHAR = '0' + private val storageKeyPattern = Regex("^[A-Za-z0-9._:-]{8,128}$") + + fun registrationOptionsJson( + challenge: ByteArray, + userId: ByteArray, + userName: String, + displayName: String, + rpId: String = PASSKEY_RP_ID + ): String { + requireValidRpId(rpId) + requireChallenge(challenge) + requireUserId(userId) + val normalizedUserName = GoogleDrivePasskeyBackup.requireAccountName(userName) + require(displayName.isNotBlank()) { "Passkey display name is required" } + + return buildString { + append("{") + append("\"challenge\":").append(jsonString(base64Url(challenge))).append(",") + append("\"rp\":{\"id\":").append(jsonString(rpId)).append(",\"name\":\"Fearless Wallet\"},") + append("\"user\":{") + append("\"id\":").append(jsonString(base64Url(userId))).append(",") + append("\"name\":").append(jsonString(normalizedUserName)).append(",") + append("\"displayName\":").append(jsonString(displayName.trim())) + append("},") + append("\"pubKeyCredParams\":[") + append("{\"type\":\"public-key\",\"alg\":-7},") + append("{\"type\":\"public-key\",\"alg\":-257}") + append("],") + append("\"authenticatorSelection\":{") + append("\"residentKey\":\"required\",") + append("\"requireResidentKey\":true,") + append("\"userVerification\":\"required\"") + append("},") + append("\"attestation\":\"none\",") + append("\"timeout\":60000") + append("}") + } + } + + fun assertionOptionsJson(challenge: ByteArray, rpId: String = PASSKEY_RP_ID): String { + requireValidRpId(rpId) + requireChallenge(challenge) + + return buildString { + append("{") + append("\"challenge\":").append(jsonString(base64Url(challenge))).append(",") + append("\"rpId\":").append(jsonString(rpId)).append(",") + append("\"userVerification\":\"required\",") + append("\"timeout\":60000") + append("}") + } + } + + fun requireStorageKey(storageKey: String): String { + val normalized = storageKey.trim() + require(storageKeyPattern.matches(normalized)) { + "Passkey backup storage key must be 8-128 URL-safe characters" + } + return normalized + } + + fun requireWalletId(walletId: String): String { + val normalized = walletId.trim() + require(storageKeyPattern.matches(normalized)) { + "Passkey backup walletId must be 8-128 URL-safe characters" + } + return normalized + } + + fun requireEncryptedPayload(payload: ByteArray): ByteArray { + require(payload.isNotEmpty()) { "Passkey backup payload must be encrypted before storage" } + require(payload.size <= MAX_ENCRYPTED_PAYLOAD_BYTES) { "Passkey backup payload is too large" } + return payload + } + + fun requireCreatedAtMillis(createdAtMillis: Long): Long { + require(createdAtMillis in 1..MAX_CREATED_AT_MILLIS) { + "Passkey backup createdAtMillis must be a positive Unix epoch millisecond timestamp" + } + return createdAtMillis + } + + fun requireValidRpId(rpId: String): String { + require(rpId == PASSKEY_RP_ID) { "Unsupported passkey relyingPartyId: $rpId" } + return rpId + } + + fun requireChallenge(challenge: ByteArray): ByteArray { + require(challenge.size in MIN_CHALLENGE_BYTES..MAX_CHALLENGE_BYTES) { + "Passkey challenge must be $MIN_CHALLENGE_BYTES-$MAX_CHALLENGE_BYTES bytes" + } + return challenge + } + + fun requireUserId(userId: ByteArray): ByteArray { + require(userId.size in MIN_USER_ID_BYTES..MAX_USER_ID_BYTES) { + "Passkey user id must be $MIN_USER_ID_BYTES-$MAX_USER_ID_BYTES bytes" + } + return userId + } + + fun decodeBase64Url(value: String, label: String): ByteArray { + val normalized = value.trim() + require(normalized.isNotEmpty()) { "Passkey $label is required" } + + return try { + Base64.getUrlDecoder().decode(normalized) + } catch (e: IllegalArgumentException) { + throw IllegalArgumentException("Passkey $label must be base64url encoded", e) + } + } + + private fun base64Url(value: ByteArray): String { + return Base64.getUrlEncoder().withoutPadding().encodeToString(value) + } + + private fun jsonString(value: String): String { + return buildString { + append('"') + value.forEach { char -> + when (char) { + '\\' -> append("\\\\") + '"' -> append("\\\"") + '\b' -> append("\\b") + '\u000c' -> append("\\f") + '\n' -> append("\\n") + '\r' -> append("\\r") + '\t' -> append("\\t") + else -> { + if (char.code < MIN_JSON_CONTROL_CHAR_CODE) { + append("\\u").append( + char.code.toString(JSON_UNICODE_ESCAPE_RADIX).padStart( + JSON_UNICODE_ESCAPE_WIDTH, + JSON_UNICODE_ESCAPE_PAD_CHAR + ) + ) + } else { + append(char) + } + } + } + } + append('"') + } + } +} + +object PasskeyBackupReleaseConfig { + const val CHALLENGE_SERVICE_BASE_URL = "https://backup.fearlesswallet.io" + const val challengeServiceBaseUrl = CHALLENGE_SERVICE_BASE_URL + const val PASSKEY_BACKUP_ENABLED = false + const val passkeyBackupEnabled = PASSKEY_BACKUP_ENABLED + + fun requireEnabled(isEnabled: Boolean = PASSKEY_BACKUP_ENABLED) { + check(isEnabled) { + "Passkey backup is disabled for this release" + } + } +} + +data class PasskeyBackupEncryptedPayload( + val storageKey: String, + val walletId: String, + val accountName: String, + val createdAtMillis: Long, + val encryptedPayload: ByteArray, + val schemaVersion: Int = PasskeyBackupContract.SCHEMA_VERSION +) { + init { + PasskeyBackupContract.requireStorageKey(storageKey) + PasskeyBackupContract.requireWalletId(walletId) + GoogleDrivePasskeyBackup.requireAccountName(accountName) + PasskeyBackupContract.requireCreatedAtMillis(createdAtMillis) + PasskeyBackupContract.requireEncryptedPayload(encryptedPayload) + require(schemaVersion == PasskeyBackupContract.SCHEMA_VERSION) { + "Unsupported passkey backup schemaVersion: $schemaVersion" + } + } +} + +interface PasskeyBackupCloudStorage { + suspend fun savePasskeyBackup(payload: PasskeyBackupEncryptedPayload) + suspend fun loadPasskeyBackup(storageKey: String): PasskeyBackupEncryptedPayload? + suspend fun deletePasskeyBackup(storageKey: String) +} + +class UnavailablePasskeyBackupCloudStorage : PasskeyBackupCloudStorage { + override suspend fun savePasskeyBackup(payload: PasskeyBackupEncryptedPayload) { + unavailable() + } + + override suspend fun loadPasskeyBackup(storageKey: String): PasskeyBackupEncryptedPayload? { + unavailable() + } + + override suspend fun deletePasskeyBackup(storageKey: String) { + unavailable() + } + + private fun unavailable(): Nothing { + throw UnsupportedOperationException( + "PasskeyBackup cloud storage is unavailable until the public challenge service and encrypted storage backend are configured" + ) + } +} + +class PasskeyBackupCoordinator( + @Suppress("unused") private val credentialManager: CredentialManager, + private val cloudBackup: PasskeyBackupCloudStorage = UnavailablePasskeyBackupCloudStorage(), + private val relyingPartyId: String = PasskeyBackupContract.PASSKEY_RP_ID, + private val isReleaseEnabled: Boolean = PasskeyBackupReleaseConfig.PASSKEY_BACKUP_ENABLED +) { + init { + PasskeyBackupContract.requireValidRpId(relyingPartyId) + } + + fun createRegistrationRequest( + challenge: ByteArray, + userId: ByteArray, + userName: String, + displayName: String + ): CreatePublicKeyCredentialRequest { + PasskeyBackupReleaseConfig.requireEnabled(isReleaseEnabled) + return CreatePublicKeyCredentialRequest( + requestJson = PasskeyBackupContract.registrationOptionsJson( + challenge = challenge, + userId = userId, + userName = userName, + displayName = displayName, + rpId = relyingPartyId + ) + ) + } + + fun createRestoreOption(challenge: ByteArray): GetPublicKeyCredentialOption { + PasskeyBackupReleaseConfig.requireEnabled(isReleaseEnabled) + return GetPublicKeyCredentialOption( + requestJson = PasskeyBackupContract.assertionOptionsJson( + challenge = challenge, + rpId = relyingPartyId + ) + ) + } + + fun requirePublicKeyCredential(credential: PublicKeyCredential): PublicKeyCredential { + PasskeyBackupReleaseConfig.requireEnabled(isReleaseEnabled) + return credential + } + + suspend fun saveEncryptedCloudBackup(payload: PasskeyBackupEncryptedPayload) { + PasskeyBackupReleaseConfig.requireEnabled(isReleaseEnabled) + cloudBackup.savePasskeyBackup(payload) + } + + suspend fun loadEncryptedCloudBackup(storageKey: String): PasskeyBackupEncryptedPayload? { + PasskeyBackupReleaseConfig.requireEnabled(isReleaseEnabled) + return cloudBackup.loadPasskeyBackup(PasskeyBackupContract.requireStorageKey(storageKey)) + } + + suspend fun deleteEncryptedCloudBackup(storageKey: String) { + PasskeyBackupReleaseConfig.requireEnabled(isReleaseEnabled) + cloudBackup.deletePasskeyBackup(PasskeyBackupContract.requireStorageKey(storageKey)) + } +} + +class PasskeyBackupWorkflow( + private val challengeService: PasskeyBackupChallengeService, + private val cloudBackup: PasskeyBackupCloudStorage, + private val relyingPartyId: String = PasskeyBackupContract.PASSKEY_RP_ID, + private val isReleaseEnabled: Boolean = PasskeyBackupReleaseConfig.PASSKEY_BACKUP_ENABLED, + private val createdAtMillisProvider: () -> Long = { System.currentTimeMillis() } +) { + init { + PasskeyBackupContract.requireValidRpId(relyingPartyId) + } + + suspend fun beginRegistration( + walletId: String, + accountName: String, + displayName: String + ): PendingPasskeyBackupRegistration { + PasskeyBackupReleaseConfig.requireEnabled(isReleaseEnabled) + val normalizedWalletId = PasskeyBackupContract.requireWalletId(walletId) + val selectedAccountName = GoogleDrivePasskeyBackup.requireAccountName(accountName) + val selectedDisplayName = displayName.trim() + require(selectedDisplayName.isNotEmpty()) { "Passkey registration displayName is required" } + val challenge = challengeService.registrationChallenge( + walletId = normalizedWalletId, + accountName = selectedAccountName, + displayName = selectedDisplayName + ) + val challengeAccountName = GoogleDrivePasskeyBackup.requireMatchingAccountName( + expected = selectedAccountName, + actual = challenge.userName, + ceremony = "registration challenge" + ) + + return PendingPasskeyBackupRegistration( + registrationId = challenge.registrationId, + storageKey = challenge.storageKey, + walletId = normalizedWalletId, + accountName = challengeAccountName, + requestJson = PasskeyBackupContract.registrationOptionsJson( + challenge = challenge.challenge, + userId = challenge.userId, + userName = challengeAccountName, + displayName = challenge.displayName, + rpId = relyingPartyId + ) + ) + } + + suspend fun finishRegistration( + pending: PendingPasskeyBackupRegistration, + credentialResponseJson: String, + encryptedPayload: ByteArray + ): PasskeyBackupEncryptedPayload { + PasskeyBackupReleaseConfig.requireEnabled(isReleaseEnabled) + val result = challengeService.completeRegistration( + registrationId = pending.registrationId, + credentialResponseJson = credentialResponseJson + ) + val storageKey = requireMatchingStorageKey( + expected = pending.storageKey, + actual = result.storageKey, + ceremony = "registration" + ) + val payload = PasskeyBackupEncryptedPayload( + storageKey = storageKey, + walletId = pending.walletId, + accountName = pending.accountName, + createdAtMillis = PasskeyBackupContract.requireCreatedAtMillis(createdAtMillisProvider()), + encryptedPayload = PasskeyBackupContract.requireEncryptedPayload(encryptedPayload) + ) + + cloudBackup.savePasskeyBackup(payload) + return payload + } + + suspend fun beginRestore(storageKey: String): PendingPasskeyBackupAssertion { + PasskeyBackupReleaseConfig.requireEnabled(isReleaseEnabled) + val normalizedStorageKey = PasskeyBackupContract.requireStorageKey(storageKey) + val challenge = challengeService.assertionChallenge(normalizedStorageKey) + val challengeStorageKey = requireMatchingStorageKey( + expected = normalizedStorageKey, + actual = challenge.storageKey, + ceremony = "assertion challenge" + ) + + return PendingPasskeyBackupAssertion( + assertionId = challenge.assertionId, + storageKey = challengeStorageKey, + requestJson = PasskeyBackupContract.assertionOptionsJson( + challenge = challenge.challenge, + rpId = relyingPartyId + ) + ) + } + + suspend fun finishRestore( + pending: PendingPasskeyBackupAssertion, + credentialResponseJson: String + ): PasskeyBackupEncryptedPayload { + PasskeyBackupReleaseConfig.requireEnabled(isReleaseEnabled) + val result = challengeService.completeAssertion( + assertionId = pending.assertionId, + credentialResponseJson = credentialResponseJson + ) + val storageKey = requireMatchingStorageKey( + expected = pending.storageKey, + actual = result.storageKey, + ceremony = "assertion" + ) + + return requireNotNull(cloudBackup.loadPasskeyBackup(storageKey)) { + "Passkey backup cloud storage did not contain storageKey: $storageKey" + } + } + + suspend fun deleteBackup(storageKey: String) { + PasskeyBackupReleaseConfig.requireEnabled(isReleaseEnabled) + cloudBackup.deletePasskeyBackup(PasskeyBackupContract.requireStorageKey(storageKey)) + } + + private fun requireMatchingStorageKey( + expected: String, + actual: String, + ceremony: String + ): String { + val normalizedExpected = PasskeyBackupContract.requireStorageKey(expected) + val normalizedActual = PasskeyBackupContract.requireStorageKey(actual) + require(normalizedActual == normalizedExpected) { + "Passkey $ceremony returned a mismatched storageKey" + } + return normalizedActual + } +} diff --git a/public-shared-features-backup/src/main/java/jp/co/soramitsu/shared_features/backup/PublicSharedFeaturesBackup.kt b/public-shared-features-backup/src/main/java/jp/co/soramitsu/shared_features/backup/PublicSharedFeaturesBackup.kt new file mode 100644 index 0000000000..b28a066dfe --- /dev/null +++ b/public-shared-features-backup/src/main/java/jp/co/soramitsu/shared_features/backup/PublicSharedFeaturesBackup.kt @@ -0,0 +1,3 @@ +package jp.co.soramitsu.shared_features.backup + +object PublicSharedFeaturesBackup diff --git a/public-shared-features-backup/src/test/java/jp/co/soramitsu/backup/passkey/GoogleDrivePasskeyBackupCloudStorageTest.kt b/public-shared-features-backup/src/test/java/jp/co/soramitsu/backup/passkey/GoogleDrivePasskeyBackupCloudStorageTest.kt new file mode 100644 index 0000000000..bbe5a100ab --- /dev/null +++ b/public-shared-features-backup/src/test/java/jp/co/soramitsu/backup/passkey/GoogleDrivePasskeyBackupCloudStorageTest.kt @@ -0,0 +1,328 @@ +package jp.co.soramitsu.backup.passkey + +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 + +class GoogleDrivePasskeyBackupCloudStorageTest { + @Test + fun `save creates appDataFolder multipart upload when backup does not exist`() = runBlocking { + val transport = RecordingDriveTransport( + jsonResponse("""{"files":[]}"""), + jsonResponse("""{"id":"drive-file"}""") + ) + val storage = storage(transport) + + storage.savePasskeyBackup(payload(encryptedPayload = "ABC".toByteArray())) + + assertEquals("GET", transport.requests[0].method) + assertTrue(transport.requests[0].url.startsWith("https://www.googleapis.com/drive/v3/files?")) + assertTrue(transport.requests[0].url.contains("spaces=appDataFolder")) + assertTrue(transport.requests[0].url.contains("fields=files%28id%2Cname%2CappProperties%29")) + assertTrue(transport.requests[0].url.contains("q=name%20%3D%20%27fearless-passkey-backup-wallet-1234.bin%27%20and%20trashed%20%3D%20false")) + assertEquals("Bearer token-123", transport.requests[0].headers["Authorization"]) + + val upload = transport.requests[1] + assertEquals("POST", upload.method) + assertTrue(upload.url.startsWith("https://www.googleapis.com/upload/drive/v3/files?")) + assertTrue(upload.url.contains("uploadType=multipart")) + assertTrue(upload.headers.getValue("Content-Type").startsWith("multipart/related; boundary=fearless-passkey-backup-wallet-1234")) + + val uploadBody = upload.bodyText() + assertTrue(uploadBody.contains(""""parents":["appDataFolder"]""")) + assertTrue(uploadBody.contains(""""name":"fearless-passkey-backup-wallet-1234.bin"""")) + assertTrue(uploadBody.contains(""""storageKey":"wallet-1234"""")) + assertTrue(uploadBody.contains(""""walletId":"wallet-001"""")) + assertTrue(uploadBody.contains(""""accountName":"alice@example.com"""")) + assertTrue(uploadBody.contains(""""createdAtMillis":"1767225600000"""")) + assertTrue(uploadBody.contains(""""schemaVersion":"1"""")) + assertTrue(uploadBody.contains("ABC")) + } + + @Test + fun `save updates existing backup file instead of creating duplicate`() = runBlocking { + val transport = RecordingDriveTransport( + jsonResponse(fileListJson()), + jsonResponse("""{"id":"file-1"}""") + ) + val storage = storage(transport) + + storage.savePasskeyBackup(payload()) + + assertEquals("PATCH", transport.requests[1].method) + assertTrue(transport.requests[1].url.startsWith("https://www.googleapis.com/upload/drive/v3/files/file-1?")) + } + + @Test + fun `load returns encrypted payload from appDataFolder`() = runBlocking { + val transport = RecordingDriveTransport( + jsonResponse(fileListJson()), + GoogleDriveHttpResponse(code = 200, body = byteArrayOf(7, 8, 9)) + ) + val storage = storage(transport) + + val loaded = storage.loadPasskeyBackup(" wallet-1234 ") + + assertEquals("wallet-1234", loaded?.storageKey) + assertEquals("wallet-001", loaded?.walletId) + assertEquals("alice@example.com", loaded?.accountName) + assertEquals(1_767_225_600_000L, loaded?.createdAtMillis) + assertArrayEquals(byteArrayOf(7, 8, 9), loaded?.encryptedPayload) + assertEquals("GET", transport.requests[1].method) + assertEquals("https://www.googleapis.com/drive/v3/files/file-1?alt=media", transport.requests[1].url) + } + + @Test + fun `load returns null when appDataFolder backup is absent`() = runBlocking { + val transport = RecordingDriveTransport(jsonResponse("""{"files":[]}""")) + val storage = storage(transport) + + assertNull(storage.loadPasskeyBackup("wallet-1234")) + assertEquals(1, transport.requests.size) + } + + @Test(expected = IllegalArgumentException::class) + fun `load rejects unsupported schema before downloading file`() { + val transport = RecordingDriveTransport( + jsonResponse(fileListJson(schemaVersion = "2")) + ) + val storage = storage(transport) + + runBlocking { + storage.loadPasskeyBackup("wallet-1234") + } + } + + @Test(expected = IllegalArgumentException::class) + fun `load rejects duplicate Drive backup files`() { + val transport = RecordingDriveTransport( + jsonResponse( + """ + { + "files": [ + { + "id":"file-1", + "appProperties":{ + "storageKey":"wallet-1234", + "walletId":"wallet-001", + "accountName":"alice@example.com", + "createdAtMillis":"1767225600000", + "schemaVersion":"1" + } + }, + { + "id":"file-2", + "appProperties":{ + "storageKey":"wallet-1234", + "walletId":"wallet-001", + "accountName":"alice@example.com", + "createdAtMillis":"1767225600000", + "schemaVersion":"1" + } + } + ] + } + """.trimIndent() + ) + ) + val storage = storage(transport) + + runBlocking { + storage.loadPasskeyBackup("wallet-1234") + } + } + + @Test(expected = IllegalStateException::class) + fun `load rejects malformed Drive file list`() { + val transport = RecordingDriveTransport(jsonResponse("""{"unexpected":[]}""")) + val storage = storage(transport) + + runBlocking { + storage.loadPasskeyBackup("wallet-1234") + } + } + + @Test(expected = IllegalStateException::class) + fun `load rejects missing Drive backup metadata before downloading file`() { + val transport = RecordingDriveTransport(jsonResponse("""{"files":[{"id":"file-1"}]}""")) + val storage = storage(transport) + + runBlocking { + storage.loadPasskeyBackup("wallet-1234") + } + } + + @Test(expected = IllegalArgumentException::class) + fun `load rejects mismatched Drive backup storage key metadata before downloading file`() { + val transport = RecordingDriveTransport( + jsonResponse(fileListJson(storageKey = "wallet-5678")) + ) + val storage = storage(transport) + + runBlocking { + storage.loadPasskeyBackup("wallet-1234") + } + } + + @Test(expected = IllegalArgumentException::class) + fun `load rejects invalid Drive backup account metadata before downloading file`() { + val transport = RecordingDriveTransport( + jsonResponse(fileListJson(accountName = "alice example.com")) + ) + val storage = storage(transport) + + runBlocking { + storage.loadPasskeyBackup("wallet-1234") + } + } + + @Test(expected = IllegalArgumentException::class) + fun `load rejects invalid Drive backup creation timestamp before downloading file`() { + val transport = RecordingDriveTransport( + jsonResponse(fileListJson(createdAtMillis = "0")) + ) + val storage = storage(transport) + + runBlocking { + storage.loadPasskeyBackup("wallet-1234") + } + } + + @Test(expected = IllegalArgumentException::class) + fun `load rejects empty downloaded encrypted payload`() { + val transport = RecordingDriveTransport( + jsonResponse(fileListJson()), + GoogleDriveHttpResponse(code = 200, body = ByteArray(0)) + ) + val storage = storage(transport) + + runBlocking { + storage.loadPasskeyBackup("wallet-1234") + } + } + + @Test + fun `delete is idempotent when backup is absent`() = runBlocking { + val transport = RecordingDriveTransport(jsonResponse("""{"files":[]}""")) + val storage = storage(transport) + + storage.deletePasskeyBackup("wallet-1234") + + assertEquals(1, transport.requests.size) + assertEquals("GET", transport.requests.single().method) + } + + @Test + fun `delete removes existing backup file`() = runBlocking { + val transport = RecordingDriveTransport( + jsonResponse(fileListJson()), + GoogleDriveHttpResponse(code = 204) + ) + val storage = storage(transport) + + storage.deletePasskeyBackup("wallet-1234") + + assertEquals("DELETE", transport.requests[1].method) + assertEquals("https://www.googleapis.com/drive/v3/files/file-1", transport.requests[1].url) + } + + @Test(expected = IllegalArgumentException::class) + fun `invalid storage key is rejected before token or network access`() { + val transport = RecordingDriveTransport() + val storage = GoogleDrivePasskeyBackupCloudStorage( + GoogleDrivePasskeyBackupDriveClient( + accessTokenProvider = GoogleDriveAccessTokenProvider { + throw AssertionError("token provider should not be called for invalid keys") + }, + transport = transport + ) + ) + + runBlocking { + storage.loadPasskeyBackup("../wallet") + } + } + + @Test(expected = IllegalArgumentException::class) + fun `unauthorized Drive response fails closed`() { + val transport = RecordingDriveTransport(GoogleDriveHttpResponse(code = 401, body = ByteArray(0))) + val storage = storage(transport) + + runBlocking { + storage.loadPasskeyBackup("wallet-1234") + } + } + + private fun storage(transport: RecordingDriveTransport): GoogleDrivePasskeyBackupCloudStorage { + return GoogleDrivePasskeyBackupCloudStorage( + GoogleDrivePasskeyBackupDriveClient( + accessTokenProvider = GoogleDriveAccessTokenProvider { "token-123" }, + transport = transport + ) + ) + } + + private fun payload( + storageKey: String = "wallet-1234", + walletId: String = "wallet-001", + accountName: String = "alice@example.com", + createdAtMillis: Long = 1_767_225_600_000L, + encryptedPayload: ByteArray = byteArrayOf(1, 2, 3) + ): PasskeyBackupEncryptedPayload { + return PasskeyBackupEncryptedPayload( + storageKey = storageKey, + walletId = walletId, + accountName = accountName, + createdAtMillis = createdAtMillis, + encryptedPayload = encryptedPayload + ) + } + + private fun fileListJson( + storageKey: String = "wallet-1234", + walletId: String = "wallet-001", + accountName: String = "alice@example.com", + createdAtMillis: String = "1767225600000", + schemaVersion: String = "1" + ): String { + return """ + { + "files": [ + { + "id": "file-1", + "appProperties": { + "storageKey": "$storageKey", + "walletId": "$walletId", + "accountName": "$accountName", + "createdAtMillis": "$createdAtMillis", + "schemaVersion": "$schemaVersion" + } + } + ] + } + """.trimIndent() + } + + private fun jsonResponse(body: String): GoogleDriveHttpResponse { + return GoogleDriveHttpResponse(code = 200, body = body.toByteArray()) + } + + private fun GoogleDriveHttpRequest.bodyText(): String { + return body?.toString(Charsets.UTF_8).orEmpty() + } + + private class RecordingDriveTransport( + vararg responses: GoogleDriveHttpResponse + ) : GoogleDriveHttpTransport { + val requests = mutableListOf() + private val responses = ArrayDeque(responses.toList()) + + override suspend fun execute(request: GoogleDriveHttpRequest): GoogleDriveHttpResponse { + requests += request + return responses.removeFirstOrNull() ?: error("Unexpected request: $request") + } + } +} diff --git a/public-shared-features-backup/src/test/java/jp/co/soramitsu/backup/passkey/GoogleDrivePasskeyBackupTokenProviderTest.kt b/public-shared-features-backup/src/test/java/jp/co/soramitsu/backup/passkey/GoogleDrivePasskeyBackupTokenProviderTest.kt new file mode 100644 index 0000000000..895a189ad2 --- /dev/null +++ b/public-shared-features-backup/src/test/java/jp/co/soramitsu/backup/passkey/GoogleDrivePasskeyBackupTokenProviderTest.kt @@ -0,0 +1,112 @@ +package jp.co.soramitsu.backup.passkey + +import kotlinx.coroutines.runBlocking +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Test + +class GoogleDrivePasskeyBackupTokenProviderTest { + @Test + fun `token provider trims account and token and requests Drive appdata oauth scope`() = runBlocking { + val fetcher = RecordingTokenFetcher(" token-123 ") + val provider = GoogleDrivePasskeyBackupTokenProvider( + accountNameProvider = GoogleDriveAccountNameProvider { " user@example.com " }, + tokenFetcher = fetcher + ) + + val accessToken = provider.accessToken() + + assertEquals("token-123", accessToken) + assertEquals("user@example.com", fetcher.accountName) + assertEquals(GoogleDrivePasskeyBackup.OAUTH_APP_DATA_SCOPE, fetcher.oauthScope) + } + + @Test + fun `oauth scope is pinned to Google Drive appdata`() { + assertEquals( + "oauth2:${GoogleDrivePasskeyBackup.APP_DATA_SCOPE}", + GoogleDrivePasskeyBackup.OAUTH_APP_DATA_SCOPE + ) + } + + @Test(expected = IllegalArgumentException::class) + fun `blank Google account is rejected before token fetch`() { + val fetcher = RecordingTokenFetcher("token-123") + val provider = GoogleDrivePasskeyBackupTokenProvider( + accountNameProvider = GoogleDriveAccountNameProvider { " " }, + tokenFetcher = fetcher + ) + + try { + runBlocking { + provider.accessToken() + } + } finally { + assertFalse(fetcher.wasCalled) + } + } + + @Test(expected = IllegalArgumentException::class) + fun `malformed Google account is rejected before token fetch`() { + val fetcher = RecordingTokenFetcher("token-123") + val provider = GoogleDrivePasskeyBackupTokenProvider( + accountNameProvider = GoogleDriveAccountNameProvider { "user example.com" }, + tokenFetcher = fetcher + ) + + try { + runBlocking { + provider.accessToken() + } + } finally { + assertFalse(fetcher.wasCalled) + } + } + + @Test(expected = IllegalArgumentException::class) + fun `Google account without email shape is rejected before token fetch`() { + val fetcher = RecordingTokenFetcher("token-123") + val provider = GoogleDrivePasskeyBackupTokenProvider( + accountNameProvider = GoogleDriveAccountNameProvider { "user" }, + tokenFetcher = fetcher + ) + + try { + runBlocking { + provider.accessToken() + } + } finally { + assertFalse(fetcher.wasCalled) + } + } + + @Test(expected = IllegalArgumentException::class) + fun `blank Google access token is rejected`() { + val provider = GoogleDrivePasskeyBackupTokenProvider( + accountNameProvider = GoogleDriveAccountNameProvider { "user@example.com" }, + tokenFetcher = RecordingTokenFetcher(" ") + ) + + runBlocking { + provider.accessToken() + } + } + + private class RecordingTokenFetcher( + private val token: String + ) : GoogleDriveOAuthTokenFetcher { + var wasCalled = false + private set + var accountName: String? = null + private set + var oauthScope: String? = null + private set + + override suspend fun fetchAccessToken(accountName: String, oauthScope: String): String { + wasCalled = true + this.accountName = accountName + this.oauthScope = oauthScope + return token + } + } +} diff --git a/public-shared-features-backup/src/test/java/jp/co/soramitsu/backup/passkey/PasskeyBackupChallengeServiceTest.kt b/public-shared-features-backup/src/test/java/jp/co/soramitsu/backup/passkey/PasskeyBackupChallengeServiceTest.kt new file mode 100644 index 0000000000..252961f1f1 --- /dev/null +++ b/public-shared-features-backup/src/test/java/jp/co/soramitsu/backup/passkey/PasskeyBackupChallengeServiceTest.kt @@ -0,0 +1,446 @@ +package jp.co.soramitsu.backup.passkey + +import com.google.gson.JsonObject +import com.google.gson.JsonParser +import kotlinx.coroutines.runBlocking +import org.junit.Assert.assertArrayEquals +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Test +import java.util.Base64 + +class PasskeyBackupChallengeServiceTest { + @Test + fun `registration challenge posts wallet metadata and validates response`() = runBlocking { + val challenge = ByteArray(32) { it.toByte() } + val userId = ByteArray(16) { (it + 32).toByte() } + val transport = RecordingChallengeTransport( + jsonResponse( + """ + { + "registrationId": "registration-1234", + "challenge": "${base64Url(challenge)}", + "userId": "${base64Url(userId)}", + "userName": "alice@example.com", + "displayName": "Alice", + "storageKey": "wallet-1234", + "rpId": "${PasskeyBackupContract.PASSKEY_RP_ID}", + "schemaVersion": ${PasskeyBackupContract.SCHEMA_VERSION} + } + """.trimIndent() + ) + ) + val service = service("${PasskeyBackupReleaseConfig.CHALLENGE_SERVICE_BASE_URL}/", transport) + + val result = service.registrationChallenge( + walletId = " wallet-001 ", + accountName = " alice@example.com ", + displayName = " Alice " + ) + + assertEquals("registration-1234", result.registrationId) + assertArrayEquals(challenge, result.challenge) + assertArrayEquals(userId, result.userId) + assertEquals("alice@example.com", result.userName) + assertEquals("Alice", result.displayName) + assertEquals("wallet-1234", result.storageKey) + + val request = transport.requests.single() + assertEquals("POST", request.method) + assertEquals( + "${PasskeyBackupReleaseConfig.CHALLENGE_SERVICE_BASE_URL}/api/passkey-backup/v1/registration/challenge", + request.url + ) + assertEquals("application/json; charset=utf-8", request.headers["Content-Type"]) + + val body = request.jsonBody() + assertEquals("wallet-001", body.get("walletId").asString) + assertEquals("alice@example.com", body.get("accountName").asString) + assertEquals("Alice", body.get("displayName").asString) + assertEquals(PasskeyBackupContract.PASSKEY_RP_ID, body.get("rpId").asString) + assertEquals(PasskeyBackupContract.SCHEMA_VERSION, body.get("schemaVersion").asInt) + } + + @Test + fun `complete registration posts credential object and returns storage key`() = runBlocking { + val transport = RecordingChallengeTransport( + jsonResponse( + """ + { + "storageKey": "wallet-1234", + "rpId": "${PasskeyBackupContract.PASSKEY_RP_ID}", + "schemaVersion": ${PasskeyBackupContract.SCHEMA_VERSION} + } + """.trimIndent() + ) + ) + val service = service(transport = transport) + + val result = service.completeRegistration( + registrationId = " registration-1234 ", + credentialResponseJson = """{"id":"cred-1","response":{"clientDataJSON":"abc"}}""" + ) + + assertEquals("wallet-1234", result.storageKey) + val request = transport.requests.single() + assertEquals( + "${PasskeyBackupReleaseConfig.CHALLENGE_SERVICE_BASE_URL}/api/passkey-backup/v1/registration/complete", + request.url + ) + val body = request.jsonBody() + assertEquals("registration-1234", body.get("registrationId").asString) + assertEquals(PasskeyBackupContract.PASSKEY_RP_ID, body.get("rpId").asString) + assertEquals("cred-1", body.getAsJsonObject("credential").get("id").asString) + } + + @Test + fun `assertion challenge posts storage key and rejects mismatched response keys`() = runBlocking { + val challenge = ByteArray(32) { (it + 1).toByte() } + val transport = RecordingChallengeTransport( + jsonResponse( + """ + { + "assertionId": "assertion-1234", + "challenge": "${base64Url(challenge)}", + "storageKey": "wallet-1234", + "rpId": "${PasskeyBackupContract.PASSKEY_RP_ID}", + "schemaVersion": ${PasskeyBackupContract.SCHEMA_VERSION} + } + """.trimIndent() + ) + ) + val service = service(transport = transport) + + val result = service.assertionChallenge(" wallet-1234 ") + + assertEquals("assertion-1234", result.assertionId) + assertArrayEquals(challenge, result.challenge) + assertEquals("wallet-1234", result.storageKey) + + val request = transport.requests.single() + assertEquals( + "${PasskeyBackupReleaseConfig.CHALLENGE_SERVICE_BASE_URL}/api/passkey-backup/v1/assertion/challenge", + request.url + ) + val body = request.jsonBody() + assertEquals("wallet-1234", body.get("storageKey").asString) + assertEquals(PasskeyBackupContract.PASSKEY_RP_ID, body.get("rpId").asString) + assertEquals(PasskeyBackupContract.SCHEMA_VERSION, body.get("schemaVersion").asInt) + } + + @Test + fun `complete assertion posts credential object`() = runBlocking { + val transport = RecordingChallengeTransport( + jsonResponse( + """ + { + "storageKey": "wallet-1234", + "rpId": "${PasskeyBackupContract.PASSKEY_RP_ID}", + "schemaVersion": ${PasskeyBackupContract.SCHEMA_VERSION} + } + """.trimIndent() + ) + ) + val service = service(transport = transport) + + val result = service.completeAssertion( + assertionId = " assertion-1234 ", + credentialResponseJson = """{"id":"cred-1","response":{"authenticatorData":"abc"}}""" + ) + + assertEquals("wallet-1234", result.storageKey) + val request = transport.requests.single() + assertEquals( + "${PasskeyBackupReleaseConfig.CHALLENGE_SERVICE_BASE_URL}/api/passkey-backup/v1/assertion/complete", + request.url + ) + val body = request.jsonBody() + assertEquals("assertion-1234", body.get("assertionId").asString) + assertEquals("cred-1", body.getAsJsonObject("credential").get("id").asString) + } + + @Test(expected = IllegalArgumentException::class) + fun `challenge service requires HTTPS base URL`() { + service(baseUrl = "http://backup.fearlesswallet.io") + } + + @Test(expected = IllegalArgumentException::class) + fun `challenge service rejects query in base URL`() { + service(baseUrl = "https://backup.fearlesswallet.io?env=dev") + } + + @Test(expected = IllegalArgumentException::class) + fun `challenge service rejects blank wallet id before network`() { + val transport = RecordingChallengeTransport() + val service = service(transport = transport) + + runBlocking { + service.registrationChallenge(walletId = " ", accountName = "alice@example.com", displayName = "Alice") + } + } + + @Test(expected = IllegalArgumentException::class) + fun `challenge service rejects invalid account name before network`() { + val transport = RecordingChallengeTransport() + val service = service(transport = transport) + + try { + runBlocking { + service.registrationChallenge( + walletId = "wallet-001", + accountName = "alice example.com", + displayName = "Alice" + ) + } + } finally { + assertTrue(transport.requests.isEmpty()) + } + } + + @Test(expected = IllegalArgumentException::class) + fun `challenge service rejects invalid ceremony id before network`() { + val transport = RecordingChallengeTransport() + val service = service(transport = transport) + + runBlocking { + service.completeRegistration( + registrationId = "../registration", + credentialResponseJson = """{"id":"cred-1"}""" + ) + } + } + + @Test(expected = IllegalArgumentException::class) + fun `challenge service rejects credential arrays before network`() { + val transport = RecordingChallengeTransport() + val service = service(transport = transport) + + runBlocking { + service.completeAssertion( + assertionId = "assertion-1234", + credentialResponseJson = """["not-an-object"]""" + ) + } + } + + @Test(expected = IllegalArgumentException::class) + fun `assertion challenge rejects invalid storage key before network`() { + val transport = RecordingChallengeTransport() + val service = service(transport = transport) + + runBlocking { + service.assertionChallenge("../wallet") + } + } + + @Test(expected = IllegalArgumentException::class) + fun `assertion challenge rejects mismatched response storage key`() { + val challenge = ByteArray(32) { it.toByte() } + val transport = RecordingChallengeTransport( + jsonResponse( + """ + { + "assertionId": "assertion-1234", + "challenge": "${base64Url(challenge)}", + "storageKey": "wallet-5678", + "rpId": "${PasskeyBackupContract.PASSKEY_RP_ID}", + "schemaVersion": ${PasskeyBackupContract.SCHEMA_VERSION} + } + """.trimIndent() + ) + ) + val service = service(transport = transport) + + runBlocking { + service.assertionChallenge("wallet-1234") + } + } + + @Test(expected = IllegalArgumentException::class) + fun `registration challenge rejects wrong relying party`() { + val challenge = ByteArray(32) { it.toByte() } + val userId = ByteArray(16) { it.toByte() } + val transport = RecordingChallengeTransport( + jsonResponse( + """ + { + "registrationId": "registration-1234", + "challenge": "${base64Url(challenge)}", + "userId": "${base64Url(userId)}", + "userName": "alice@example.com", + "displayName": "Alice", + "storageKey": "wallet-1234", + "rpId": "example.com", + "schemaVersion": ${PasskeyBackupContract.SCHEMA_VERSION} + } + """.trimIndent() + ) + ) + val service = service(transport = transport) + + runBlocking { + service.registrationChallenge("wallet-001", "alice@example.com", "Alice") + } + } + + @Test(expected = IllegalArgumentException::class) + fun `registration challenge rejects unsupported schema version`() { + val challenge = ByteArray(32) { it.toByte() } + val userId = ByteArray(16) { it.toByte() } + val transport = RecordingChallengeTransport( + jsonResponse( + """ + { + "registrationId": "registration-1234", + "challenge": "${base64Url(challenge)}", + "userId": "${base64Url(userId)}", + "userName": "alice@example.com", + "displayName": "Alice", + "storageKey": "wallet-1234", + "rpId": "${PasskeyBackupContract.PASSKEY_RP_ID}", + "schemaVersion": ${PasskeyBackupContract.SCHEMA_VERSION + 1} + } + """.trimIndent() + ) + ) + val service = service(transport = transport) + + runBlocking { + service.registrationChallenge("wallet-001", "alice@example.com", "Alice") + } + } + + @Test(expected = IllegalArgumentException::class) + fun `registration challenge rejects invalid base64url challenge`() { + val userId = ByteArray(16) { it.toByte() } + val transport = RecordingChallengeTransport( + jsonResponse( + """ + { + "registrationId": "registration-1234", + "challenge": "not base64url", + "userId": "${base64Url(userId)}", + "userName": "alice@example.com", + "displayName": "Alice", + "storageKey": "wallet-1234", + "rpId": "${PasskeyBackupContract.PASSKEY_RP_ID}", + "schemaVersion": ${PasskeyBackupContract.SCHEMA_VERSION} + } + """.trimIndent() + ) + ) + val service = service(transport = transport) + + runBlocking { + service.registrationChallenge("wallet-001", "alice@example.com", "Alice") + } + } + + @Test(expected = IllegalArgumentException::class) + fun `registration challenge rejects short decoded user id`() { + val challenge = ByteArray(32) { it.toByte() } + val userId = ByteArray(15) { it.toByte() } + val transport = RecordingChallengeTransport( + jsonResponse( + """ + { + "registrationId": "registration-1234", + "challenge": "${base64Url(challenge)}", + "userId": "${base64Url(userId)}", + "userName": "alice@example.com", + "displayName": "Alice", + "storageKey": "wallet-1234", + "rpId": "${PasskeyBackupContract.PASSKEY_RP_ID}", + "schemaVersion": ${PasskeyBackupContract.SCHEMA_VERSION} + } + """.trimIndent() + ) + ) + val service = service(transport = transport) + + runBlocking { + service.registrationChallenge("wallet-001", "alice@example.com", "Alice") + } + } + + @Test(expected = IllegalStateException::class) + fun `challenge service rejects malformed JSON responses`() { + val transport = RecordingChallengeTransport( + GoogleDriveHttpResponse(code = 200, body = """{"not":""".toByteArray()) + ) + val service = service(transport = transport) + + runBlocking { + service.assertionChallenge("wallet-1234") + } + } + + @Test(expected = IllegalArgumentException::class) + fun `challenge service rejects empty responses`() { + val transport = RecordingChallengeTransport(GoogleDriveHttpResponse(code = 200, body = ByteArray(0))) + val service = service(transport = transport) + + runBlocking { + service.assertionChallenge("wallet-1234") + } + } + + @Test(expected = IllegalArgumentException::class) + fun `challenge service fails closed on non success HTTP responses`() { + val transport = RecordingChallengeTransport(GoogleDriveHttpResponse(code = 503, body = ByteArray(0))) + val service = service(transport = transport) + + runBlocking { + service.assertionChallenge("wallet-1234") + } + } + + @Test + fun `invalid local inputs do not call challenge transport`() { + val transport = RecordingChallengeTransport() + val service = service(transport = transport) + + runCatching { + runBlocking { + service.completeAssertion(assertionId = "../assertion", credentialResponseJson = """{"id":"cred"}""") + } + } + + assertTrue(transport.requests.isEmpty()) + } + + private fun service( + baseUrl: String = PasskeyBackupReleaseConfig.CHALLENGE_SERVICE_BASE_URL, + transport: RecordingChallengeTransport = RecordingChallengeTransport() + ): HttpPasskeyBackupChallengeService { + return HttpPasskeyBackupChallengeService(baseUrl = baseUrl, transport = transport) + } + + private fun jsonResponse(body: String): GoogleDriveHttpResponse { + return GoogleDriveHttpResponse(code = 200, body = body.toByteArray()) + } + + private fun base64Url(value: ByteArray): String { + return Base64.getUrlEncoder().withoutPadding().encodeToString(value) + } + + private fun GoogleDriveHttpRequest.jsonBody(): JsonObject { + return JsonParser.parseString(bodyText()).asJsonObject + } + + private fun GoogleDriveHttpRequest.bodyText(): String { + return body?.toString(Charsets.UTF_8).orEmpty() + } + + private class RecordingChallengeTransport( + vararg responses: GoogleDriveHttpResponse + ) : GoogleDriveHttpTransport { + val requests = mutableListOf() + private val responses = ArrayDeque(responses.toList()) + + override suspend fun execute(request: GoogleDriveHttpRequest): GoogleDriveHttpResponse { + requests += request + return responses.removeFirstOrNull() ?: error("Unexpected request: $request") + } + } +} diff --git a/public-shared-features-backup/src/test/java/jp/co/soramitsu/backup/passkey/PasskeyBackupContractTest.kt b/public-shared-features-backup/src/test/java/jp/co/soramitsu/backup/passkey/PasskeyBackupContractTest.kt new file mode 100644 index 0000000000..bbcfa67e46 --- /dev/null +++ b/public-shared-features-backup/src/test/java/jp/co/soramitsu/backup/passkey/PasskeyBackupContractTest.kt @@ -0,0 +1,135 @@ +package jp.co.soramitsu.backup.passkey + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test + +class PasskeyBackupContractTest { + @Test + fun `release config pins production challenge service`() { + assertTrue(PasskeyBackupReleaseConfig.CHALLENGE_SERVICE_BASE_URL == "https://backup.fearlesswallet.io") + assertFalse(PasskeyBackupReleaseConfig.CHALLENGE_SERVICE_BASE_URL.contains("localhost")) + assertFalse(PasskeyBackupReleaseConfig.CHALLENGE_SERVICE_BASE_URL.contains("example")) + assertFalse(PasskeyBackupReleaseConfig.PASSKEY_BACKUP_ENABLED) + } + + @Test + fun `registration json pins rp id and requires resident passkey`() { + val json = PasskeyBackupContract.registrationOptionsJson( + challenge = ByteArray(32) { it.toByte() }, + userId = ByteArray(16) { (it + 1).toByte() }, + userName = "user@example.com", + displayName = "Fearless User" + ) + + assertTrue(json.contains("\"rp\":{\"id\":\"fearlesswallet.io\"")) + assertTrue(json.contains("\"residentKey\":\"required\"")) + assertTrue(json.contains("\"userVerification\":\"required\"")) + assertTrue(json.contains("\"attestation\":\"none\"")) + assertFalse(json.contains("+")) + assertFalse(json.contains("/")) + } + + @Test + fun `assertion json pins rp id and user verification`() { + val json = PasskeyBackupContract.assertionOptionsJson(ByteArray(32) { 7 }) + + assertTrue(json.contains("\"rpId\":\"fearlesswallet.io\"")) + assertTrue(json.contains("\"userVerification\":\"required\"")) + assertFalse(json.contains("+")) + assertFalse(json.contains("/")) + } + + @Test(expected = IllegalArgumentException::class) + fun `registration rejects short challenge`() { + PasskeyBackupContract.registrationOptionsJson( + challenge = ByteArray(15), + userId = ByteArray(16), + userName = "user@example.com", + displayName = "Fearless User" + ) + } + + @Test(expected = IllegalArgumentException::class) + fun `registration rejects unsupported relying party`() { + PasskeyBackupContract.registrationOptionsJson( + challenge = ByteArray(32), + userId = ByteArray(16), + userName = "user@example.com", + displayName = "Fearless User", + rpId = "example.com" + ) + } + + @Test(expected = IllegalArgumentException::class) + fun `storage payload rejects plaintext empty backup`() { + PasskeyBackupEncryptedPayload( + storageKey = "wallet-1234", + walletId = "wallet-001", + accountName = "alice@example.com", + createdAtMillis = 1_767_225_600_000L, + encryptedPayload = ByteArray(0) + ) + } + + @Test(expected = IllegalArgumentException::class) + fun `storage key rejects traversal characters`() { + PasskeyBackupEncryptedPayload( + storageKey = "../wallet", + walletId = "wallet-001", + accountName = "alice@example.com", + createdAtMillis = 1_767_225_600_000L, + encryptedPayload = byteArrayOf(1, 2, 3) + ) + } + + @Test + fun `encrypted payload accepts identity metadata`() { + val payload = PasskeyBackupEncryptedPayload( + storageKey = "wallet-1234", + walletId = "wallet-001", + accountName = "alice@example.com", + createdAtMillis = 1_767_225_600_000L, + encryptedPayload = byteArrayOf(1, 2, 3) + ) + + assertEquals("wallet-1234", payload.storageKey) + assertEquals("wallet-001", payload.walletId) + assertEquals("alice@example.com", payload.accountName) + assertEquals(1_767_225_600_000L, payload.createdAtMillis) + } + + @Test(expected = IllegalArgumentException::class) + fun `encrypted payload rejects blank wallet id`() { + PasskeyBackupEncryptedPayload( + storageKey = "wallet-1234", + walletId = " ", + accountName = "alice@example.com", + createdAtMillis = 1_767_225_600_000L, + encryptedPayload = byteArrayOf(1, 2, 3) + ) + } + + @Test(expected = IllegalArgumentException::class) + fun `encrypted payload rejects malformed account name`() { + PasskeyBackupEncryptedPayload( + storageKey = "wallet-1234", + walletId = "wallet-001", + accountName = "alice example.com", + createdAtMillis = 1_767_225_600_000L, + encryptedPayload = byteArrayOf(1, 2, 3) + ) + } + + @Test(expected = IllegalArgumentException::class) + fun `encrypted payload rejects zero creation timestamp`() { + PasskeyBackupEncryptedPayload( + storageKey = "wallet-1234", + walletId = "wallet-001", + accountName = "alice@example.com", + createdAtMillis = 0, + encryptedPayload = byteArrayOf(1, 2, 3) + ) + } +} diff --git a/public-shared-features-backup/src/test/java/jp/co/soramitsu/backup/passkey/PasskeyBackupWorkflowTest.kt b/public-shared-features-backup/src/test/java/jp/co/soramitsu/backup/passkey/PasskeyBackupWorkflowTest.kt new file mode 100644 index 0000000000..5e1e7e3c11 --- /dev/null +++ b/public-shared-features-backup/src/test/java/jp/co/soramitsu/backup/passkey/PasskeyBackupWorkflowTest.kt @@ -0,0 +1,439 @@ +package jp.co.soramitsu.backup.passkey + +import kotlinx.coroutines.runBlocking +import org.junit.Assert.assertArrayEquals +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Assert.fail +import org.junit.Test + +class PasskeyBackupWorkflowTest { + @Test + fun `begin registration creates credential request from remote challenge`() = runBlocking { + val challenge = ByteArray(32) { it.toByte() } + val userId = ByteArray(16) { (it + 17).toByte() } + val service = FakeChallengeService( + registrationChallenge = PasskeyBackupRegistrationChallenge( + registrationId = "registration-1234", + challenge = challenge, + userId = userId, + userName = "alice@example.com", + displayName = "Alice", + storageKey = "wallet-1234" + ) + ) + val workflow = workflow(service = service) + + val pending = workflow.beginRegistration( + walletId = " wallet-001 ", + accountName = " alice@example.com ", + displayName = " Alice " + ) + + assertEquals("registration-1234", pending.registrationId) + assertEquals("wallet-1234", pending.storageKey) + assertEquals("wallet-001", service.registrationWalletId) + assertEquals("alice@example.com", service.registrationAccountName) + assertEquals("Alice", service.registrationDisplayName) + assertTrue(pending.requestJson.contains("\"rp\":{\"id\":\"fearlesswallet.io\"")) + assertTrue(pending.requestJson.contains("\"name\":\"alice@example.com\"")) + assertTrue(pending.requestJson.contains("\"userVerification\":\"required\"")) + } + + @Test + fun `begin registration rejects blank Google account before challenge service`() = runBlocking { + val service = FakeChallengeService() + val workflow = workflow(service = service) + + try { + workflow.beginRegistration( + walletId = "wallet-001", + accountName = " ", + displayName = "Alice" + ) + fail("expected blank Google account to reject") + } catch (e: IllegalArgumentException) { + assertTrue(e.message?.contains("Google account is required") == true) + } + + assertEquals(null, service.registrationWalletId) + assertEquals(null, service.registrationAccountName) + } + + @Test + fun `begin registration rejects challenge account mismatch before request creation`() = runBlocking { + val service = FakeChallengeService( + registrationChallenge = PasskeyBackupRegistrationChallenge( + registrationId = "registration-1234", + challenge = ByteArray(32) { it.toByte() }, + userId = ByteArray(16) { (it + 17).toByte() }, + userName = "mallory@example.com", + displayName = "Mallory", + storageKey = "wallet-1234" + ) + ) + val workflow = workflow(service = service) + + try { + workflow.beginRegistration( + walletId = "wallet-001", + accountName = "alice@example.com", + displayName = "Alice" + ) + fail("expected mismatched Google account to reject") + } catch (e: IllegalArgumentException) { + assertTrue(e.message?.contains("mismatched Google account") == true) + } + + assertEquals("wallet-001", service.registrationWalletId) + assertEquals("alice@example.com", service.registrationAccountName) + } + + @Test + fun `workflow fails closed while passkey backup is not release enabled`() = runBlocking { + val service = FakeChallengeService() + val storage = FakeCloudStorage( + "wallet-1234" to PasskeyBackupEncryptedPayload( + storageKey = "wallet-1234", + walletId = "wallet-001", + accountName = "alice@example.com", + createdAtMillis = 1_767_225_600_000L, + encryptedPayload = byteArrayOf(1) + ) + ) + val workflow = PasskeyBackupWorkflow( + challengeService = service, + cloudBackup = storage + ) + + expectReleaseDisabled { + workflow.beginRegistration( + walletId = "wallet-001", + accountName = "alice@example.com", + displayName = "Alice" + ) + } + expectReleaseDisabled { + workflow.finishRegistration( + pending = pendingRegistration(storageKey = "wallet-1234"), + credentialResponseJson = """{"id":"cred-1"}""", + encryptedPayload = byteArrayOf(9) + ) + } + expectReleaseDisabled { + workflow.beginRestore("wallet-1234") + } + expectReleaseDisabled { + workflow.finishRestore( + pending = pendingAssertion(storageKey = "wallet-1234"), + credentialResponseJson = """{"id":"cred-1"}""" + ) + } + expectReleaseDisabled { + workflow.deleteBackup("wallet-1234") + } + + assertEquals(null, service.registrationWalletId) + assertEquals(null, service.completedRegistrationId) + assertEquals(null, service.assertionStorageKey) + assertEquals(null, service.completedAssertionId) + assertEquals(null, storage.deletedStorageKey) + assertTrue(storage.contains("wallet-1234")) + } + + @Test + fun `finish registration completes ceremony and saves encrypted cloud payload`() = runBlocking { + val encryptedPayload = byteArrayOf(9, 8, 7) + val service = FakeChallengeService( + registrationResult = PasskeyBackupChallengeResult(storageKey = "wallet-1234") + ) + val storage = FakeCloudStorage() + val workflow = workflow(service = service, storage = storage) + val pending = pendingRegistration(storageKey = "wallet-1234") + + val saved = workflow.finishRegistration( + pending = pending, + credentialResponseJson = """{"id":"cred-1"}""", + encryptedPayload = encryptedPayload + ) + + assertEquals("registration-1234", service.completedRegistrationId) + assertEquals("""{"id":"cred-1"}""", service.completedRegistrationCredential) + assertEquals("wallet-1234", saved.storageKey) + assertEquals("wallet-001", saved.walletId) + assertEquals("alice@example.com", saved.accountName) + assertEquals(1_767_225_600_000L, saved.createdAtMillis) + assertArrayEquals(encryptedPayload, saved.encryptedPayload) + assertEquals("wallet-001", storage.savedPayload("wallet-1234")?.walletId) + assertEquals("alice@example.com", storage.savedPayload("wallet-1234")?.accountName) + assertEquals(1_767_225_600_000L, storage.savedPayload("wallet-1234")?.createdAtMillis) + assertArrayEquals(encryptedPayload, storage.savedPayload("wallet-1234")?.encryptedPayload) + } + + @Test(expected = IllegalArgumentException::class) + fun `finish registration rejects mismatched storage key before saving`() = runBlocking { + val service = FakeChallengeService( + registrationResult = PasskeyBackupChallengeResult(storageKey = "wallet-5678") + ) + val storage = FakeCloudStorage() + val workflow = workflow(service = service, storage = storage) + + workflow.finishRegistration( + pending = pendingRegistration(storageKey = "wallet-1234"), + credentialResponseJson = """{"id":"cred-1"}""", + encryptedPayload = byteArrayOf(1) + ) + + assertFalse(storage.contains("wallet-5678")) + } + + @Test + fun `begin restore creates assertion option from remote challenge`() = runBlocking { + val challenge = ByteArray(32) { (it + 1).toByte() } + val service = FakeChallengeService( + assertionChallenge = PasskeyBackupAssertionChallenge( + assertionId = "assertion-1234", + challenge = challenge, + storageKey = "wallet-1234" + ) + ) + val workflow = workflow(service = service) + + val pending = workflow.beginRestore(" wallet-1234 ") + + assertEquals("wallet-1234", service.assertionStorageKey) + assertEquals("assertion-1234", pending.assertionId) + assertEquals("wallet-1234", pending.storageKey) + assertTrue(pending.requestJson.contains("\"rpId\":\"fearlesswallet.io\"")) + assertTrue(pending.requestJson.contains("\"userVerification\":\"required\"")) + } + + @Test + fun `finish restore completes assertion and loads encrypted cloud payload`() = runBlocking { + val encryptedPayload = byteArrayOf(1, 2, 3, 4) + val service = FakeChallengeService( + assertionResult = PasskeyBackupChallengeResult(storageKey = "wallet-1234") + ) + val storage = FakeCloudStorage( + "wallet-1234" to PasskeyBackupEncryptedPayload( + storageKey = "wallet-1234", + walletId = "wallet-001", + accountName = "alice@example.com", + createdAtMillis = 1_767_225_600_000L, + encryptedPayload = encryptedPayload + ) + ) + val workflow = workflow(service = service, storage = storage) + + val restored = workflow.finishRestore( + pending = pendingAssertion(storageKey = "wallet-1234"), + credentialResponseJson = """{"id":"cred-1"}""" + ) + + assertEquals("assertion-1234", service.completedAssertionId) + assertEquals("""{"id":"cred-1"}""", service.completedAssertionCredential) + assertEquals("wallet-1234", restored.storageKey) + assertEquals("wallet-001", restored.walletId) + assertEquals("alice@example.com", restored.accountName) + assertEquals(1_767_225_600_000L, restored.createdAtMillis) + assertArrayEquals(encryptedPayload, restored.encryptedPayload) + } + + @Test(expected = IllegalArgumentException::class) + fun `finish restore rejects mismatched storage key before cloud load`() = runBlocking { + val service = FakeChallengeService( + assertionResult = PasskeyBackupChallengeResult(storageKey = "wallet-5678") + ) + val storage = FakeCloudStorage( + "wallet-5678" to PasskeyBackupEncryptedPayload( + storageKey = "wallet-5678", + walletId = "wallet-002", + accountName = "alice@example.com", + createdAtMillis = 1_767_225_600_000L, + encryptedPayload = byteArrayOf(1) + ) + ) + val workflow = workflow(service = service, storage = storage) + + workflow.finishRestore( + pending = pendingAssertion(storageKey = "wallet-1234"), + credentialResponseJson = """{"id":"cred-1"}""" + ) + Unit + } + + @Test(expected = IllegalArgumentException::class) + fun `finish restore fails closed when cloud payload is missing`() = runBlocking { + val service = FakeChallengeService( + assertionResult = PasskeyBackupChallengeResult(storageKey = "wallet-1234") + ) + val workflow = workflow(service = service, storage = FakeCloudStorage()) + + workflow.finishRestore( + pending = pendingAssertion(storageKey = "wallet-1234"), + credentialResponseJson = """{"id":"cred-1"}""" + ) + Unit + } + + @Test + fun `delete backup normalizes storage key`() = runBlocking { + val storage = FakeCloudStorage( + "wallet-1234" to PasskeyBackupEncryptedPayload( + storageKey = "wallet-1234", + walletId = "wallet-001", + accountName = "alice@example.com", + createdAtMillis = 1_767_225_600_000L, + encryptedPayload = byteArrayOf(1) + ) + ) + val workflow = workflow(storage = storage) + + workflow.deleteBackup(" wallet-1234 ") + + assertEquals("wallet-1234", storage.deletedStorageKey) + assertFalse(storage.contains("wallet-1234")) + } + + @Test(expected = IllegalArgumentException::class) + fun `workflow rejects unsupported relying party`() { + PasskeyBackupWorkflow( + challengeService = FakeChallengeService(), + cloudBackup = FakeCloudStorage(), + relyingPartyId = "example.com" + ) + } + + private fun workflow( + service: FakeChallengeService = FakeChallengeService(), + storage: FakeCloudStorage = FakeCloudStorage() + ): PasskeyBackupWorkflow { + return PasskeyBackupWorkflow( + challengeService = service, + cloudBackup = storage, + isReleaseEnabled = true, + createdAtMillisProvider = { 1_767_225_600_000L } + ) + } + + private suspend fun expectReleaseDisabled(block: suspend () -> Unit) { + try { + block() + fail("expected passkey backup release gate to reject the operation") + } catch (e: IllegalStateException) { + assertTrue(e.message?.contains("Passkey backup is disabled") == true) + } + } + + private fun pendingRegistration(storageKey: String): PendingPasskeyBackupRegistration { + return PendingPasskeyBackupRegistration( + registrationId = "registration-1234", + storageKey = storageKey, + walletId = "wallet-001", + accountName = "alice@example.com", + requestJson = "{}" + ) + } + + private fun pendingAssertion(storageKey: String): PendingPasskeyBackupAssertion { + return PendingPasskeyBackupAssertion( + assertionId = "assertion-1234", + storageKey = storageKey, + requestJson = "{}" + ) + } + + private class FakeChallengeService( + private val registrationChallenge: PasskeyBackupRegistrationChallenge = + PasskeyBackupRegistrationChallenge( + registrationId = "registration-1234", + challenge = ByteArray(32), + userId = ByteArray(16), + userName = "alice@example.com", + displayName = "Alice", + storageKey = "wallet-1234" + ), + private val registrationResult: PasskeyBackupChallengeResult = + PasskeyBackupChallengeResult(storageKey = "wallet-1234"), + private val assertionChallenge: PasskeyBackupAssertionChallenge = + PasskeyBackupAssertionChallenge( + assertionId = "assertion-1234", + challenge = ByteArray(32), + storageKey = "wallet-1234" + ), + private val assertionResult: PasskeyBackupChallengeResult = + PasskeyBackupChallengeResult(storageKey = "wallet-1234") + ) : PasskeyBackupChallengeService { + var registrationWalletId: String? = null + var registrationAccountName: String? = null + var registrationDisplayName: String? = null + var completedRegistrationId: String? = null + var completedRegistrationCredential: String? = null + var assertionStorageKey: String? = null + var completedAssertionId: String? = null + var completedAssertionCredential: String? = null + + override suspend fun registrationChallenge( + walletId: String, + accountName: String, + displayName: String + ): PasskeyBackupRegistrationChallenge { + registrationWalletId = walletId + registrationAccountName = accountName + registrationDisplayName = displayName + return registrationChallenge + } + + override suspend fun completeRegistration( + registrationId: String, + credentialResponseJson: String + ): PasskeyBackupChallengeResult { + completedRegistrationId = registrationId + completedRegistrationCredential = credentialResponseJson + return registrationResult + } + + override suspend fun assertionChallenge(storageKey: String): PasskeyBackupAssertionChallenge { + assertionStorageKey = storageKey + return assertionChallenge + } + + override suspend fun completeAssertion( + assertionId: String, + credentialResponseJson: String + ): PasskeyBackupChallengeResult { + completedAssertionId = assertionId + completedAssertionCredential = credentialResponseJson + return assertionResult + } + } + + private class FakeCloudStorage( + vararg records: Pair + ) : PasskeyBackupCloudStorage { + private val records = records.toMap().toMutableMap() + var deletedStorageKey: String? = null + + override suspend fun savePasskeyBackup(payload: PasskeyBackupEncryptedPayload) { + records[payload.storageKey] = payload + } + + override suspend fun loadPasskeyBackup(storageKey: String): PasskeyBackupEncryptedPayload? { + return records[storageKey] + } + + override suspend fun deletePasskeyBackup(storageKey: String) { + deletedStorageKey = storageKey + records.remove(storageKey) + } + + fun savedPayload(storageKey: String): PasskeyBackupEncryptedPayload? { + return records[storageKey] + } + + fun contains(storageKey: String): Boolean { + return records.containsKey(storageKey) + } + } +} diff --git a/public-shared-features-core/build.gradle b/public-shared-features-core/build.gradle new file mode 100644 index 0000000000..0ef2e0c7e4 --- /dev/null +++ b/public-shared-features-core/build.gradle @@ -0,0 +1,26 @@ +apply plugin: 'com.android.library' +apply plugin: 'kotlin-android' + +android { + namespace = 'jp.co.soramitsu.shared_features.core' + compileSdkVersion rootProject.compileSdkVersion + + defaultConfig { + minSdkVersion rootProject.minSdkVersion + targetSdkVersion rootProject.targetSdkVersion + } + + compileOptions { + sourceCompatibility = JavaVersion.VERSION_21 + targetCompatibility = JavaVersion.VERSION_21 + } + + kotlinOptions { + jvmTarget = JavaVersion.VERSION_21.toString() + } +} + +dependencies { + api libs.kotlin.stdlib.jdk7 + api "jp.co.soramitsu.fearless-utils:fearless-utils:1.0.121" +} diff --git a/public-shared-features-core/src/main/java/jp/co/soramitsu/shared_features/core/PublicSharedFeaturesCore.kt b/public-shared-features-core/src/main/java/jp/co/soramitsu/shared_features/core/PublicSharedFeaturesCore.kt new file mode 100644 index 0000000000..b58f2b7da8 --- /dev/null +++ b/public-shared-features-core/src/main/java/jp/co/soramitsu/shared_features/core/PublicSharedFeaturesCore.kt @@ -0,0 +1,3 @@ +package jp.co.soramitsu.shared_features.core + +object PublicSharedFeaturesCore diff --git a/public-shared-features-xcm/build.gradle b/public-shared-features-xcm/build.gradle new file mode 100644 index 0000000000..cc05d8d779 --- /dev/null +++ b/public-shared-features-xcm/build.gradle @@ -0,0 +1,30 @@ +apply plugin: 'com.android.library' +apply plugin: 'kotlin-android' + +android { + namespace = 'jp.co.soramitsu.xcm' + compileSdkVersion rootProject.compileSdkVersion + + defaultConfig { + minSdkVersion rootProject.minSdkVersion + targetSdkVersion rootProject.targetSdkVersion + } + + compileOptions { + sourceCompatibility = JavaVersion.VERSION_21 + targetCompatibility = JavaVersion.VERSION_21 + } + + kotlinOptions { + jvmTarget = JavaVersion.VERSION_21.toString() + } +} + +dependencies { + api libs.kotlin.stdlib.jdk7 + api libs.coroutines.core + api project(':core-api') + api project(':runtime') + + testImplementation libs.junit +} diff --git a/public-shared-features-xcm/src/main/java/jp/co/soramitsu/xcm/SubstrateXcmTransferEngine.kt b/public-shared-features-xcm/src/main/java/jp/co/soramitsu/xcm/SubstrateXcmTransferEngine.kt new file mode 100644 index 0000000000..4dfb13c810 --- /dev/null +++ b/public-shared-features-xcm/src/main/java/jp/co/soramitsu/xcm/SubstrateXcmTransferEngine.kt @@ -0,0 +1,358 @@ +package jp.co.soramitsu.xcm + +import jp.co.soramitsu.core.extrinsic.ExtrinsicBuilderFactory +import jp.co.soramitsu.core.extrinsic.ExtrinsicService +import jp.co.soramitsu.core.extrinsic.keypair_provider.KeypairProvider +import jp.co.soramitsu.core.models.Asset +import jp.co.soramitsu.core.models.ChainId +import jp.co.soramitsu.core.models.ChainIdWithMetadata +import jp.co.soramitsu.core.rpc.RpcCalls +import jp.co.soramitsu.core.utils.removedXcPrefix +import jp.co.soramitsu.fearless_utils.runtime.extrinsic.ExtrinsicBuilder +import jp.co.soramitsu.runtime.multiNetwork.chain.model.Chain +import jp.co.soramitsu.xcm.domain.XcmArgumentShape +import jp.co.soramitsu.xcm.domain.XcmDestinationFeeMode +import jp.co.soramitsu.xcm.domain.XcmExecutionSpec +import jp.co.soramitsu.xcm.domain.XcmJunctionSpec +import jp.co.soramitsu.xcm.domain.XcmJunctionType +import jp.co.soramitsu.xcm.domain.XcmMultiLocationSpec +import jp.co.soramitsu.xcm.domain.XcmTransferType +import jp.co.soramitsu.xcm.domain.XcmWeightLimitType +import java.math.BigDecimal +import java.math.BigInteger +import java.util.Locale + +data class XcmExtrinsicCall( + val moduleName: String, + val callName: String, + val arguments: Map +) + +interface XcmExtrinsicSubmitter { + suspend fun submit( + chain: Chain, + accountId: ByteArray, + keypairProvider: KeypairProvider, + call: XcmExtrinsicCall + ): String + + suspend fun estimateFee( + chain: Chain, + accountId: ByteArray, + keypairProvider: KeypairProvider, + call: XcmExtrinsicCall + ): BigInteger +} + +class ExtrinsicServiceXcmSubmitter( + private val rpcCalls: RpcCalls, + private val extrinsicBuilderFactory: ExtrinsicBuilderFactory +) : XcmExtrinsicSubmitter { + + override suspend fun submit( + chain: Chain, + accountId: ByteArray, + keypairProvider: KeypairProvider, + call: XcmExtrinsicCall + ): String { + return extrinsicService(keypairProvider) + .submitExtrinsic( + chain = chain, + accountId = accountId, + formExtrinsic = { applyXcmCall(call) } + ) + .getOrThrow() + } + + override suspend fun estimateFee( + chain: Chain, + accountId: ByteArray, + keypairProvider: KeypairProvider, + call: XcmExtrinsicCall + ): BigInteger { + return extrinsicService(keypairProvider) + .estimateFee( + chain = chain, + accountId = accountId, + formExtrinsic = { applyXcmCall(call) } + ) + } + + private fun extrinsicService(keypairProvider: KeypairProvider) = ExtrinsicService( + rpcCalls = rpcCalls, + keypairProvider = keypairProvider, + extrinsicBuilderFactory = extrinsicBuilderFactory + ) + + private fun ExtrinsicBuilder.applyXcmCall(call: XcmExtrinsicCall) { + call( + moduleName = call.moduleName, + callName = call.callName, + arguments = call.arguments + ) + } +} + +class SubstrateXcmTransferEngine( + private val submitter: XcmExtrinsicSubmitter +) : XcmTransferEngine { + + private val keypairProviders = mutableMapOf() + private val preloadedMetadata = mutableMapOf() + + override val isAvailable: Boolean = true + + override fun updateKeypairProvider(chainId: ChainId, keypairProvider: Any) { + require(chainId.isNotBlank()) { "XCM keypair provider chain id must not be blank" } + require(keypairProvider is KeypairProvider) { + "XCM keypair provider must implement KeypairProvider" + } + keypairProviders[chainId] = keypairProvider + } + + override fun addPreloadedMetadata(vararg chainMetadatas: ChainIdWithMetadata) { + chainMetadatas.forEach { metadata -> + require(metadata.chainId.isNotBlank()) { "XCM preloaded metadata chain id must not be blank" } + preloadedMetadata[metadata.chainId] = metadata.metadata + } + } + + override suspend fun transfer(request: XcmTransferRequest): String { + val provider = requireKeypairProvider(request.originChain.id) + val call = buildTransferCall(request.executionSpec, request.recipientAddress, request.amount) + + return submitter.submit( + chain = request.originChain, + accountId = request.senderAccountId, + keypairProvider = provider, + call = call + ) + } + + override suspend fun getDestinationFee( + originChainId: ChainId, + destinationChainId: ChainId, + asset: Asset, + executionSpec: XcmExecutionSpec + ): BigDecimal { + require(originChainId.isNotBlank()) { "XCM destination fee origin chain id must not be blank" } + require(destinationChainId.isNotBlank()) { "XCM destination fee destination chain id must not be blank" } + require(originChainId != destinationChainId) { "XCM destination fee route chains must be different" } + require(asset.precision >= 0) { "XCM destination fee asset precision must not be negative" } + require(asset.symbol.normalizedAssetSymbol() == executionSpec.destinationFee.assetSymbol.normalizedAssetSymbol()) { + "XCM destination fee asset ${executionSpec.destinationFee.assetSymbol} does not match ${asset.symbol}" + } + + return when (executionSpec.destinationFee.mode) { + XcmDestinationFeeMode.INCLUDED -> BigDecimal.ZERO + XcmDestinationFeeMode.FIXED -> BigDecimal(requireNotNull(executionSpec.destinationFee.amount), asset.precision) + XcmDestinationFeeMode.ESTIMATED -> throw UnsupportedOperationException( + "XCM destination fee estimation is unavailable for ${asset.symbol} from $originChainId to $destinationChainId" + ) + } + } + + override suspend fun getOriginFee( + originChain: Chain, + originChainId: ChainId, + destinationChainId: ChainId, + asset: Asset, + originFeeAsset: Asset, + address: String, + amount: BigInteger, + executionSpec: XcmExecutionSpec + ): BigDecimal { + require(originChain.id == originChainId) { + "XCM origin fee chain object must match originChainId" + } + require(originFeeAsset.precision >= 0) { "XCM origin fee asset precision must not be negative" } + require(originFeeAsset.chainId == originChainId) { + "XCM origin fee asset chain must match originChainId" + } + val provider = requireKeypairProvider(originChainId) + val call = buildTransferCall(executionSpec, address, amount) + + val estimatedOriginFeePlancks = submitter.estimateFee( + chain = originChain, + accountId = ByteArray(FEE_ESTIMATE_ACCOUNT_ID_SIZE_BYTES), + keypairProvider = provider, + call = call + ) + + return BigDecimal(estimatedOriginFeePlancks, originFeeAsset.precision) + } + + fun buildTransferCall( + executionSpec: XcmExecutionSpec, + recipientAddress: String, + amount: BigInteger + ): XcmExtrinsicCall { + require(recipientAddress.isNotBlank()) { "XCM recipient address must not be blank" } + require(amount > BigInteger.ZERO) { "XCM transfer amount must be greater than zero" } + + return when (executionSpec.argumentShape) { + XcmArgumentShape.POLKADOT_XCM_TRANSFER_ASSETS -> buildPolkadotXcmTransferAssetsCall( + executionSpec, + recipientAddress, + amount + ) + XcmArgumentShape.X_TOKENS_TRANSFER_MULTIASSET -> buildXTokensTransferMultiassetCall( + executionSpec, + recipientAddress, + amount + ) + } + } + + private fun buildPolkadotXcmTransferAssetsCall( + executionSpec: XcmExecutionSpec, + recipientAddress: String, + amount: BigInteger + ): XcmExtrinsicCall { + require(executionSpec.palletName == "PolkadotXcm") { + "XCM PolkadotXcm transfer-assets call requires palletName PolkadotXcm" + } + require(executionSpec.callName == executionSpec.transferType.polkadotXcmCallName()) { + "XCM PolkadotXcm transfer-assets callName must match transferType" + } + + val arguments = linkedMapOf( + "dest" to versionedMultiLocation(executionSpec, executionSpec.destinationLocation, recipientAddress), + "beneficiary" to versionedMultiLocation(executionSpec, executionSpec.beneficiaryLocation, recipientAddress), + "assets" to versioned( + executionSpec, + listOf( + mapOf( + "id" to versionedMultiLocation(executionSpec, executionSpec.assetLocation, recipientAddress), + "fun" to mapOf("Fungible" to amount) + ) + ) + ), + "fee_asset_item" to executionSpec.feeAssetItem + ) + + if (executionSpec.transferType.requiresWeightLimit()) { + arguments["weight_limit"] = weightLimit(executionSpec) + } + + return XcmExtrinsicCall( + moduleName = executionSpec.palletName, + callName = executionSpec.callName, + arguments = arguments + ) + } + + private fun buildXTokensTransferMultiassetCall( + executionSpec: XcmExecutionSpec, + recipientAddress: String, + amount: BigInteger + ): XcmExtrinsicCall { + require(executionSpec.palletName == "XTokens") { + "XCM XTokens transferMultiasset call requires palletName XTokens" + } + require(executionSpec.callName == "transferMultiasset") { + "XCM XTokens transferMultiasset call requires callName transferMultiasset" + } + require(executionSpec.transferType == XcmTransferType.X_TOKENS_TRANSFER_MULTIASSET) { + "XCM XTokens transferMultiasset call requires transferType xTokensTransferMultiasset" + } + + return XcmExtrinsicCall( + moduleName = executionSpec.palletName, + callName = executionSpec.callName, + arguments = linkedMapOf( + "asset" to versioned( + executionSpec, + mapOf( + "id" to versionedMultiLocation(executionSpec, executionSpec.assetLocation, recipientAddress), + "fun" to mapOf("Fungible" to amount) + ) + ), + "dest" to versionedMultiLocation(executionSpec, executionSpec.beneficiaryLocation, recipientAddress), + "dest_weight_limit" to weightLimit(executionSpec) + ) + ) + } + + private fun requireKeypairProvider(chainId: ChainId): KeypairProvider { + return keypairProviders[chainId] + ?: error("XCM keypair provider missing for $chainId") + } + + private fun XcmTransferType.requiresWeightLimit(): Boolean { + return this == XcmTransferType.LIMITED_RESERVE_TRANSFER_ASSETS || + this == XcmTransferType.LIMITED_TELEPORT_ASSETS + } + + private fun XcmTransferType.polkadotXcmCallName(): String { + return when (this) { + XcmTransferType.RESERVE_TRANSFER_ASSETS -> "reserveTransferAssets" + XcmTransferType.LIMITED_RESERVE_TRANSFER_ASSETS -> "limitedReserveTransferAssets" + XcmTransferType.TELEPORT_ASSETS -> "teleportAssets" + XcmTransferType.LIMITED_TELEPORT_ASSETS -> "limitedTeleportAssets" + XcmTransferType.X_TOKENS_TRANSFER_MULTIASSET -> "" + } + } + + private fun versionedMultiLocation( + executionSpec: XcmExecutionSpec, + location: XcmMultiLocationSpec, + recipientAddress: String + ): Map = versioned(executionSpec, multiLocation(location, recipientAddress)) + + private fun versioned(executionSpec: XcmExecutionSpec, value: Any?): Map { + return mapOf(xcmVersionVariant(executionSpec.xcmVersion) to value) + } + + private fun xcmVersionVariant(value: String): String { + val version = value.trim() + require(Regex("^v[0-9]+$", RegexOption.IGNORE_CASE).matches(version)) { + "XCM version must use v format" + } + return version.uppercase(Locale.US) + } + + private fun String.normalizedAssetSymbol(): String = trim() + .removedXcPrefix() + .uppercase(Locale.US) + + private fun multiLocation(location: XcmMultiLocationSpec, recipientAddress: String): Map { + val interior = if (location.junctions.isEmpty()) { + "Here" + } else { + mapOf("X${location.junctions.size}" to location.junctions.map { it.toRuntimeJunction(recipientAddress) }) + } + + return mapOf( + "parents" to location.parents, + "interior" to interior + ) + } + + private fun XcmJunctionSpec.toRuntimeJunction(recipientAddress: String): Map { + return when (type) { + XcmJunctionType.PARACHAIN -> mapOf("Parachain" to value!!.toBigInteger()) + XcmJunctionType.ACCOUNT_ID32 -> mapOf("AccountId32" to value!!.replace("", recipientAddress)) + XcmJunctionType.ACCOUNT_KEY20 -> mapOf("AccountKey20" to value!!.replace("", recipientAddress)) + XcmJunctionType.PALLET_INSTANCE -> mapOf("PalletInstance" to value!!.toBigInteger()) + XcmJunctionType.GENERAL_INDEX -> mapOf("GeneralIndex" to value!!.toBigInteger()) + XcmJunctionType.GENERAL_KEY -> mapOf("GeneralKey" to value) + } + } + + private fun weightLimit(executionSpec: XcmExecutionSpec): Any { + return when (executionSpec.weightLimit.type) { + XcmWeightLimitType.UNLIMITED -> "Unlimited" + XcmWeightLimitType.LIMITED -> mapOf( + "Limited" to mapOf( + "refTime" to requireNotNull(executionSpec.weightLimit.refTime), + "proofSize" to requireNotNull(executionSpec.weightLimit.proofSize) + ) + ) + } + } + + private companion object { + const val FEE_ESTIMATE_ACCOUNT_ID_SIZE_BYTES = 32 + } +} diff --git a/public-shared-features-xcm/src/main/java/jp/co/soramitsu/xcm/XcmService.kt b/public-shared-features-xcm/src/main/java/jp/co/soramitsu/xcm/XcmService.kt new file mode 100644 index 0000000000..807aa9e16f --- /dev/null +++ b/public-shared-features-xcm/src/main/java/jp/co/soramitsu/xcm/XcmService.kt @@ -0,0 +1,170 @@ +package jp.co.soramitsu.xcm + +import jp.co.soramitsu.core.models.Asset +import jp.co.soramitsu.core.models.ChainId +import jp.co.soramitsu.core.models.ChainIdWithMetadata +import jp.co.soramitsu.core.utils.removedXcPrefix +import jp.co.soramitsu.runtime.multiNetwork.ChainRegistry +import jp.co.soramitsu.runtime.multiNetwork.chain.model.Chain +import jp.co.soramitsu.xcm.domain.XcmEntitiesFetcher +import jp.co.soramitsu.xcm.domain.XcmExecutableRoute +import jp.co.soramitsu.xcm.domain.XcmJunctionType +import java.math.BigDecimal +import java.math.BigInteger + +@Suppress("FunctionOnlyReturningConstant", "UnusedParameter") +class XcmService internal constructor( + private val xcmEntitiesFetcher: XcmEntitiesFetcher, + private val transferEngine: XcmTransferEngine = UnavailableXcmTransferEngine +) { + constructor( + chainRegistry: ChainRegistry, + transferEngine: XcmTransferEngine = UnavailableXcmTransferEngine + ) : this(XcmEntitiesFetcher(chainRegistry), transferEngine) + + fun updateKeypairProvider(chainId: ChainId, keypairProvider: Any) { + transferEngine.updateKeypairProvider(chainId, keypairProvider) + } + + fun addPreloadedMetadata(vararg chainMetadatas: ChainIdWithMetadata) { + transferEngine.addPreloadedMetadata(*chainMetadatas) + } + + suspend fun transfer( + originChain: Chain, + destinationChain: Chain, + asset: Asset, + senderAccountId: ByteArray, + address: String, + amount: BigInteger + ): String { + val executableRoute = validateRouteAndAmount( + originChain = originChain, + destinationChain = destinationChain, + asset = asset, + amount = amount + ) + require(senderAccountId.isNotEmpty()) { "XCM sender account id must not be empty" } + require(address.isNotBlank()) { "XCM recipient address must not be blank" } + validateRecipientAddress(address, executableRoute) + + return transferEngine.transfer( + XcmTransferRequest( + originChain = originChain, + destinationChain = destinationChain, + asset = asset, + senderAccountId = senderAccountId, + recipientAddress = address, + amount = amount, + executionSpec = executableRoute.executionSpec + ) + ) + } + + suspend fun getXcmDestinationFee( + originChainId: ChainId, + destinationChainId: ChainId, + asset: Asset + ): BigDecimal { + require(originChainId.isNotBlank()) { "XCM origin chain id must not be blank" } + require(destinationChainId.isNotBlank()) { "XCM destination chain id must not be blank" } + require(originChainId != destinationChainId) { "XCM origin and destination chains must be different" } + require(asset.symbol.isNotBlank()) { "XCM asset symbol must not be blank" } + val executableRoute = requireSupportedRoute(originChainId, destinationChainId, asset.symbol, amount = null) + + return transferEngine.getDestinationFee( + originChainId = originChainId, + destinationChainId = destinationChainId, + asset = asset, + executionSpec = executableRoute.executionSpec + ) + } + + suspend fun getXcmOriginFee( + originChain: Chain, + destinationChainId: ChainId, + asset: Asset, + originFeeAsset: Asset = asset, + address: String, + amount: BigInteger + ): BigDecimal { + require(address.isNotBlank()) { "XCM fee recipient address must not be blank" } + require(amount > BigInteger.ZERO) { "XCM fee amount must be greater than zero" } + val executableRoute = requireSupportedRoute(originChain.id, destinationChainId, asset.symbol, amount) + validateRecipientAddress(address, executableRoute) + + return transferEngine.getOriginFee( + originChain = originChain, + originChainId = originChain.id, + destinationChainId, + asset, + originFeeAsset, + address, + amount, + executableRoute.executionSpec + ) + } + + suspend fun getAmountMinLimit( + originChainId: ChainId, + destinationChainId: ChainId, + asset: Asset + ): BigInteger? = xcmEntitiesFetcher.getMinAmount(originChainId, destinationChainId, asset.symbol) + + suspend fun isXcmSupportAsset(originChainId: String, assetSymbol: String): Boolean { + if (!transferEngine.isAvailable || originChainId.isBlank() || assetSymbol.isBlank()) return false + + return xcmEntitiesFetcher.hasExecutableRouteAsset(originChainId, assetSymbol.removedXcPrefix()) + } + + private suspend fun validateRouteAndAmount( + originChain: Chain, + destinationChain: Chain, + asset: Asset, + amount: BigInteger + ): XcmExecutableRoute { + require(originChain.id.isNotBlank()) { "XCM origin chain id must not be blank" } + require(destinationChain.id.isNotBlank()) { "XCM destination chain id must not be blank" } + require(originChain.id != destinationChain.id) { "XCM origin and destination chains must be different" } + require(amount > BigInteger.ZERO) { "XCM transfer amount must be greater than zero" } + + return requireSupportedRoute(originChain.id, destinationChain.id, asset.symbol, amount) + } + + private suspend fun requireSupportedRoute( + originChainId: ChainId, + destinationChainId: ChainId, + assetSymbol: String, + amount: BigInteger? + ): XcmExecutableRoute { + require(originChainId.isNotBlank()) { "XCM origin chain id must not be blank" } + require(destinationChainId.isNotBlank()) { "XCM destination chain id must not be blank" } + require(assetSymbol.isNotBlank()) { "XCM asset symbol must not be blank" } + + val executableRoute = xcmEntitiesFetcher.getExecutableRoute(originChainId, destinationChainId, assetSymbol) + ?: throw IllegalArgumentException( + "XCM route does not support $assetSymbol from $originChainId to $destinationChainId" + ) + if (amount != null) { + val minAmount = executableRoute.asset.minAmount + require(minAmount == null || amount >= minAmount) { + "XCM amount $amount is below minAmount $minAmount for $assetSymbol from $originChainId to $destinationChainId" + } + } + + return executableRoute + } + + private fun validateRecipientAddress(address: String, executableRoute: XcmExecutableRoute) { + val beneficiaryJunctions = executableRoute.executionSpec.beneficiaryLocation.junctions + if (beneficiaryJunctions.any { it.type == XcmJunctionType.ACCOUNT_KEY20 }) { + require(EVM_ADDRESS_PATTERN.matches(address.trim())) { + "XCM AccountKey20 recipient address must be a 0x-prefixed 20-byte hex address" + } + } + } + + private companion object { + val EVM_ADDRESS_PATTERN = Regex("^0x[0-9a-fA-F]{40}$") + } +} diff --git a/public-shared-features-xcm/src/main/java/jp/co/soramitsu/xcm/XcmTransferEngine.kt b/public-shared-features-xcm/src/main/java/jp/co/soramitsu/xcm/XcmTransferEngine.kt new file mode 100644 index 0000000000..1ccaed292c --- /dev/null +++ b/public-shared-features-xcm/src/main/java/jp/co/soramitsu/xcm/XcmTransferEngine.kt @@ -0,0 +1,80 @@ +package jp.co.soramitsu.xcm + +import jp.co.soramitsu.core.models.Asset +import jp.co.soramitsu.core.models.ChainId +import jp.co.soramitsu.core.models.ChainIdWithMetadata +import jp.co.soramitsu.runtime.multiNetwork.chain.model.Chain +import jp.co.soramitsu.xcm.domain.XcmExecutionSpec +import java.math.BigDecimal +import java.math.BigInteger + +class XcmTransferRequest( + val originChain: Chain, + val destinationChain: Chain, + val asset: Asset, + val senderAccountId: ByteArray, + val recipientAddress: String, + val amount: BigInteger, + val executionSpec: XcmExecutionSpec +) + +interface XcmTransferEngine { + val isAvailable: Boolean + + fun updateKeypairProvider(chainId: ChainId, keypairProvider: Any) + + fun addPreloadedMetadata(vararg chainMetadatas: ChainIdWithMetadata) + + suspend fun transfer(request: XcmTransferRequest): String + + suspend fun getDestinationFee( + originChainId: ChainId, + destinationChainId: ChainId, + asset: Asset, + executionSpec: XcmExecutionSpec + ): BigDecimal + + @Suppress("LongParameterList") + suspend fun getOriginFee( + originChain: Chain, + originChainId: ChainId, + destinationChainId: ChainId, + asset: Asset, + originFeeAsset: Asset, + address: String, + amount: BigInteger, + executionSpec: XcmExecutionSpec + ): BigDecimal +} + +object UnavailableXcmTransferEngine : XcmTransferEngine { + override val isAvailable: Boolean = false + + override fun updateKeypairProvider(chainId: ChainId, keypairProvider: Any) = Unit + + override fun addPreloadedMetadata(vararg chainMetadatas: ChainIdWithMetadata) = Unit + + override suspend fun transfer(request: XcmTransferRequest): String = throw xcmUnavailable() + + override suspend fun getDestinationFee( + originChainId: ChainId, + destinationChainId: ChainId, + asset: Asset, + executionSpec: XcmExecutionSpec + ): BigDecimal = throw xcmUnavailable() + + override suspend fun getOriginFee( + originChain: Chain, + originChainId: ChainId, + destinationChainId: ChainId, + asset: Asset, + originFeeAsset: Asset, + address: String, + amount: BigInteger, + executionSpec: XcmExecutionSpec + ): BigDecimal = throw xcmUnavailable() + + private fun xcmUnavailable() = UnsupportedOperationException( + "XCM transfers are unavailable until an open-source transfer engine is configured" + ) +} diff --git a/public-shared-features-xcm/src/main/java/jp/co/soramitsu/xcm/domain/XcmEntitiesFetcher.kt b/public-shared-features-xcm/src/main/java/jp/co/soramitsu/xcm/domain/XcmEntitiesFetcher.kt new file mode 100644 index 0000000000..5ca41a0a9a --- /dev/null +++ b/public-shared-features-xcm/src/main/java/jp/co/soramitsu/xcm/domain/XcmEntitiesFetcher.kt @@ -0,0 +1,193 @@ +package jp.co.soramitsu.xcm.domain + +import jp.co.soramitsu.core.models.ChainId +import jp.co.soramitsu.core.utils.removedXcPrefix +import jp.co.soramitsu.runtime.multiNetwork.ChainRegistry +import jp.co.soramitsu.runtime.multiNetwork.chain.model.Chain +import java.math.BigInteger + +data class XcmAsset( + val id: String?, + val symbol: String, + val minAmount: BigInteger? +) + +data class XcmExecutableRoute( + val asset: XcmAsset, + val executionSpec: XcmExecutionSpec +) + +class XcmEntitiesFetcher internal constructor( + private val chainsProvider: suspend () -> List +) { + constructor(chainRegistry: ChainRegistry) : this({ chainRegistry.getChains() }) + + suspend fun getAvailableOriginChains(assetSymbol: String?, destinationChainId: ChainId?): List { + val normalizedAssetSymbol = assetSymbol?.normalizedXcmSymbol() + + return chainsProvider() + .asSequence() + .filter { chain -> chain.xcm != null } + .filter { chain -> + destinationChainId == null || + chain.xcm?.availableDestinations.orEmpty().any { it.chainId == destinationChainId } + } + .filter { chain -> + normalizedAssetSymbol == null || + chain.assetsForDestination(destinationChainId).any { it.normalizedSymbol == normalizedAssetSymbol } + } + .map { it.id } + .distinct() + .toList() + } + + suspend fun getAvailableAssets(originChainId: ChainId?, destinationChainId: ChainId?): List { + return chainsProvider() + .asSequence() + .filter { originChainId == null || it.id == originChainId } + .flatMap { it.assetsForDestination(destinationChainId).asSequence() } + .distinctBy { it.normalizedSymbol } + .map { it.asset } + .toList() + } + + suspend fun getAvailableDestinationChains(originChainId: ChainId?, assetSymbol: String?): List { + val normalizedAssetSymbol = assetSymbol?.normalizedXcmSymbol() + + return chainsProvider() + .asSequence() + .filter { originChainId == null || it.id == originChainId } + .flatMap { chain -> + chain.xcm?.availableDestinations.orEmpty().asSequence() + .filter { destination -> + normalizedAssetSymbol == null || + destination.assets.orEmpty().mapNotNull { it.toDomainAsset() }.any { + it.normalizedSymbol == normalizedAssetSymbol + } + } + .mapNotNull { it.chainId?.takeIf(String::isNotBlank) } + } + .distinct() + .toList() + } + + suspend fun getMinAmount( + originChainId: ChainId, + destinationChainId: ChainId, + assetSymbol: String + ): BigInteger? = getRouteAsset(originChainId, destinationChainId, assetSymbol)?.minAmount + + suspend fun getRouteAsset( + originChainId: ChainId, + destinationChainId: ChainId, + assetSymbol: String + ): XcmAsset? { + val normalizedAssetSymbol = assetSymbol.normalizedXcmSymbol() + + return chainsProvider() + .firstOrNull { it.id == originChainId } + ?.assetsForDestination(destinationChainId) + ?.firstOrNull { it.normalizedSymbol == normalizedAssetSymbol } + ?.asset + } + + suspend fun getExecutableRoute( + originChainId: ChainId, + destinationChainId: ChainId, + assetSymbol: String + ): XcmExecutableRoute? { + val normalizedAssetSymbol = assetSymbol.normalizedXcmSymbol() + val originChain = chainsProvider().firstOrNull { it.id == originChainId } + val xcm = originChain?.xcm + val destination = xcm?.availableDestinations.orEmpty() + .firstOrNull { it.chainId == destinationChainId } + val routeAsset = destination?.assets.orEmpty() + .mapNotNull { it.toDomainAsset() } + .firstOrNull { it.normalizedSymbol == normalizedAssetSymbol } + ?.asset + + return destination?.let { executableDestination -> + routeAsset?.let { asset -> + XcmExecutableRoute( + asset = asset, + executionSpec = XcmExecutionSpecValidator.requireValid( + originChainId = originChainId, + destinationChainId = destinationChainId, + assetSymbol = asset.symbol, + xcmVersion = xcm?.xcmVersion, + destination = executableDestination + ) + ) + } + } + } + + suspend fun hasExecutableRouteAsset(originChainId: ChainId, assetSymbol: String): Boolean { + val normalizedAssetSymbol = assetSymbol.normalizedXcmSymbol() + val originChain = chainsProvider().firstOrNull { it.id == originChainId } ?: return false + val xcm = originChain.xcm ?: return false + + return xcm.availableDestinations.orEmpty().any { destination -> + val destinationChainId = destination.chainId?.takeIf(String::isNotBlank) ?: return@any false + + destination.assets.orEmpty() + .mapNotNull { it.toDomainAsset() } + .any { routeAsset -> + routeAsset.normalizedSymbol == normalizedAssetSymbol && + runCatching { + XcmExecutionSpecValidator.requireValid( + originChainId = originChainId, + destinationChainId = destinationChainId, + assetSymbol = routeAsset.asset.symbol, + xcmVersion = xcm.xcmVersion, + destination = destination + ) + }.isSuccess + } + } + } + + private fun Chain.assetsForDestination(destinationChainId: ChainId?): List { + val xcm = xcm ?: return emptyList() + + val routeAssets = xcm.availableDestinations.orEmpty() + .asSequence() + .filter { destinationChainId == null || it.chainId == destinationChainId } + .flatMap { it.assets.orEmpty().asSequence() } + .mapNotNull { it.toDomainAsset() } + .toList() + + return when { + destinationChainId != null -> routeAssets + routeAssets.isNotEmpty() -> routeAssets + else -> xcm.availableAssets.orEmpty().mapNotNull { it.toDomainAsset() } + } + } + + private fun Chain.Xcm.Asset.toDomainAsset(): NormalizedXcmAsset? { + val normalizedSymbol = symbol?.normalizedXcmSymbol()?.takeIf(String::isNotBlank) ?: return null + val parsedMinAmount = minAmount + ?.takeIf(String::isNotBlank) + ?.toBigIntegerOrNull() + ?.takeIf { it >= BigInteger.ZERO } + + return NormalizedXcmAsset( + normalizedSymbol = normalizedSymbol, + asset = XcmAsset( + id = id, + symbol = normalizedSymbol, + minAmount = parsedMinAmount + ) + ) + } + + private data class NormalizedXcmAsset( + val normalizedSymbol: String, + val asset: XcmAsset + ) + + private fun String.normalizedXcmSymbol(): String = trim() + .lowercase() + .removedXcPrefix() + .uppercase() +} diff --git a/public-shared-features-xcm/src/main/java/jp/co/soramitsu/xcm/domain/XcmExecutionSpec.kt b/public-shared-features-xcm/src/main/java/jp/co/soramitsu/xcm/domain/XcmExecutionSpec.kt new file mode 100644 index 0000000000..34e49058ea --- /dev/null +++ b/public-shared-features-xcm/src/main/java/jp/co/soramitsu/xcm/domain/XcmExecutionSpec.kt @@ -0,0 +1,405 @@ +package jp.co.soramitsu.xcm.domain + +import jp.co.soramitsu.core.models.ChainId +import jp.co.soramitsu.runtime.multiNetwork.chain.model.Chain +import java.math.BigInteger +import java.util.Locale + +data class XcmExecutionSpec( + val palletName: String, + val callName: String, + val transferType: XcmTransferType, + val argumentShape: XcmArgumentShape, + val xcmVersion: String, + val destinationLocation: XcmMultiLocationSpec, + val assetLocation: XcmMultiLocationSpec, + val beneficiaryLocation: XcmMultiLocationSpec, + val feeAssetLocation: XcmMultiLocationSpec, + val feeAssetItem: Int, + val weightLimit: XcmWeightLimitSpec, + val destinationFee: XcmDestinationFeeSpec, + val bridge: XcmBridgeExecutionSpec? +) + +enum class XcmTransferType { + RESERVE_TRANSFER_ASSETS, + LIMITED_RESERVE_TRANSFER_ASSETS, + TELEPORT_ASSETS, + LIMITED_TELEPORT_ASSETS, + X_TOKENS_TRANSFER_MULTIASSET +} + +enum class XcmArgumentShape { + POLKADOT_XCM_TRANSFER_ASSETS, + X_TOKENS_TRANSFER_MULTIASSET +} + +data class XcmMultiLocationSpec( + val parents: Int, + val interior: String, + val junctions: List +) + +data class XcmJunctionSpec( + val type: XcmJunctionType, + val value: String? +) + +enum class XcmJunctionType { + PARACHAIN, + ACCOUNT_ID32, + ACCOUNT_KEY20, + PALLET_INSTANCE, + GENERAL_INDEX, + GENERAL_KEY +} + +data class XcmWeightLimitSpec( + val type: XcmWeightLimitType, + val refTime: BigInteger?, + val proofSize: BigInteger? +) + +enum class XcmWeightLimitType { + UNLIMITED, + LIMITED +} + +data class XcmDestinationFeeSpec( + val mode: XcmDestinationFeeMode, + val assetSymbol: String, + val amount: BigInteger? +) + +enum class XcmDestinationFeeMode { + INCLUDED, + ESTIMATED, + FIXED +} + +data class XcmBridgeExecutionSpec( + val parachainId: String, + val feeAssetLocation: XcmMultiLocationSpec, + val feeAssetItem: Int +) + +internal object XcmExecutionSpecValidator { + + fun requireValid( + originChainId: ChainId, + destinationChainId: ChainId, + assetSymbol: String, + xcmVersion: String?, + destination: Chain.Xcm.Destination + ): XcmExecutionSpec { + val execution = destination.execution ?: error( + "XCM execution spec missing for $assetSymbol from $originChainId to $destinationChainId" + ) + + val bridgeSpec = execution.bridge?.let { + XcmBridgeExecutionSpec( + parachainId = it.parachainId.requiredField("bridge.parachainId"), + feeAssetLocation = it.feeAssetLocation.requiredMultiLocation("bridge.feeAssetLocation"), + feeAssetItem = it.feeAssetItem.requiredNonNegative("bridge.feeAssetItem") + ) + } + + val routeBridgeParachainId = destination.bridgeParachainId?.trim().orEmpty() + require(routeBridgeParachainId.isEmpty() || bridgeSpec?.parachainId == routeBridgeParachainId) { + "XCM bridge execution spec must match bridgeParachainId for $assetSymbol from $originChainId to $destinationChainId" + } + + val palletName = execution.palletName.requiredField("palletName") + val callName = execution.callName.requiredField("callName") + val transferType = execution.transferType.requiredTransferType() + val argumentShape = execution.argumentShape.optionalArgumentShape() + requireValidCallShape(palletName, callName, transferType, argumentShape) + + return XcmExecutionSpec( + palletName = palletName, + callName = callName, + transferType = transferType, + argumentShape = argumentShape, + xcmVersion = xcmVersion.requiredField("xcmVersion"), + destinationLocation = execution.destinationLocation.requiredMultiLocation("destinationLocation"), + assetLocation = execution.assetLocation.requiredMultiLocation("assetLocation"), + beneficiaryLocation = execution.beneficiaryLocation.requiredMultiLocation("beneficiaryLocation"), + feeAssetLocation = execution.feeAssetLocation.requiredMultiLocation("feeAssetLocation"), + feeAssetItem = execution.feeAssetItem.requiredNonNegative("feeAssetItem"), + weightLimit = execution.weightLimit.requiredWeightLimit(), + destinationFee = execution.destinationFee.requiredDestinationFee(), + bridge = bridgeSpec + ) + } + + private fun String?.requiredField(fieldName: String): String { + val value = this?.trim().orEmpty() + require(value.isNotEmpty()) { "XCM execution spec field $fieldName must not be blank" } + return value + } + + private fun Int?.requiredNonNegative(fieldName: String): Int { + val value = requireNotNull(this) { "XCM execution spec field $fieldName is required" } + require(value >= 0) { "XCM execution spec field $fieldName must not be negative" } + return value + } + + private fun String?.requiredTransferType(): XcmTransferType { + return when (requiredField("transferType").normalizedEnumValue()) { + "RESERVE_TRANSFER_ASSETS" -> XcmTransferType.RESERVE_TRANSFER_ASSETS + "LIMITED_RESERVE_TRANSFER_ASSETS" -> XcmTransferType.LIMITED_RESERVE_TRANSFER_ASSETS + "TELEPORT_ASSETS" -> XcmTransferType.TELEPORT_ASSETS + "LIMITED_TELEPORT_ASSETS" -> XcmTransferType.LIMITED_TELEPORT_ASSETS + "X_TOKENS_TRANSFER_MULTIASSET" -> XcmTransferType.X_TOKENS_TRANSFER_MULTIASSET + else -> throw IllegalArgumentException("XCM execution spec transferType is unsupported") + } + } + + private fun String?.optionalArgumentShape(): XcmArgumentShape { + return when (this?.trim().orEmpty().takeIf(String::isNotEmpty)?.normalizedEnumValue()) { + null -> XcmArgumentShape.POLKADOT_XCM_TRANSFER_ASSETS + "POLKADOT_XCM_TRANSFER_ASSETS" -> XcmArgumentShape.POLKADOT_XCM_TRANSFER_ASSETS + "X_TOKENS_TRANSFER_MULTIASSET" -> XcmArgumentShape.X_TOKENS_TRANSFER_MULTIASSET + else -> throw IllegalArgumentException("XCM execution spec argumentShape is unsupported") + } + } + + private fun requireValidCallShape( + palletName: String, + callName: String, + transferType: XcmTransferType, + argumentShape: XcmArgumentShape + ) { + when (argumentShape) { + XcmArgumentShape.POLKADOT_XCM_TRANSFER_ASSETS -> { + require(palletName == "PolkadotXcm") { + "XCM PolkadotXcm transfer-assets argument shape requires palletName PolkadotXcm" + } + require(callName == transferType.polkadotXcmCallName()) { + "XCM PolkadotXcm transfer-assets argument shape callName must match transferType" + } + } + XcmArgumentShape.X_TOKENS_TRANSFER_MULTIASSET -> { + require(palletName == "XTokens") { + "XCM XTokens transferMultiasset argument shape requires palletName XTokens" + } + require(callName == "transferMultiasset") { + "XCM XTokens transferMultiasset argument shape requires callName transferMultiasset" + } + require(transferType == XcmTransferType.X_TOKENS_TRANSFER_MULTIASSET) { + "XCM XTokens transferMultiasset argument shape requires transferType xTokensTransferMultiasset" + } + } + } + } + + private fun XcmTransferType.polkadotXcmCallName(): String { + return when (this) { + XcmTransferType.RESERVE_TRANSFER_ASSETS -> "reserveTransferAssets" + XcmTransferType.LIMITED_RESERVE_TRANSFER_ASSETS -> "limitedReserveTransferAssets" + XcmTransferType.TELEPORT_ASSETS -> "teleportAssets" + XcmTransferType.LIMITED_TELEPORT_ASSETS -> "limitedTeleportAssets" + XcmTransferType.X_TOKENS_TRANSFER_MULTIASSET -> "" + } + } + + private fun Chain.Xcm.MultiLocation?.requiredMultiLocation(fieldName: String): XcmMultiLocationSpec { + val location = requireNotNull(this) { "XCM execution spec field $fieldName is required" } + val parents = location.parents.requiredNonNegative("$fieldName.parents") + val interior = location.interior.requiredField("$fieldName.interior") + return XcmMultiLocationSpec( + parents = parents, + interior = interior, + junctions = XcmMultiLocationParser.requireValidInterior(interior, "$fieldName.interior") + ) + } + + private fun Chain.Xcm.WeightLimit?.requiredWeightLimit(): XcmWeightLimitSpec { + val weightLimit = requireNotNull(this) { "XCM execution spec field weightLimit is required" } + val type = when (weightLimit.type.requiredField("weightLimit.type").normalizedEnumValue()) { + "UNLIMITED" -> XcmWeightLimitType.UNLIMITED + "LIMITED" -> XcmWeightLimitType.LIMITED + else -> throw IllegalArgumentException("XCM execution spec weightLimit.type is unsupported") + } + + val refTime = weightLimit.refTime?.parseUnsignedBigInteger("weightLimit.refTime") + val proofSize = weightLimit.proofSize?.parseUnsignedBigInteger("weightLimit.proofSize") + require(type == XcmWeightLimitType.UNLIMITED || refTime != null && proofSize != null) { + "XCM limited weightLimit requires refTime and proofSize" + } + require(type == XcmWeightLimitType.LIMITED || refTime == null && proofSize == null) { + "XCM unlimited weightLimit must not include refTime or proofSize" + } + + return XcmWeightLimitSpec(type = type, refTime = refTime, proofSize = proofSize) + } + + private fun Chain.Xcm.DestinationFee?.requiredDestinationFee(): XcmDestinationFeeSpec { + val destinationFee = requireNotNull(this) { "XCM execution spec field destinationFee is required" } + val mode = when (destinationFee.mode.requiredField("destinationFee.mode").normalizedEnumValue()) { + "INCLUDED" -> XcmDestinationFeeMode.INCLUDED + "ESTIMATED" -> XcmDestinationFeeMode.ESTIMATED + "FIXED" -> XcmDestinationFeeMode.FIXED + else -> throw IllegalArgumentException("XCM execution spec destinationFee.mode is unsupported") + } + val amount = destinationFee.amount?.parseUnsignedBigInteger("destinationFee.amount") + require(mode == XcmDestinationFeeMode.FIXED || amount == null) { + "XCM destination fee amount is only valid for fixed destination fees" + } + require(mode != XcmDestinationFeeMode.FIXED || amount != null) { + "XCM fixed destination fee requires amount" + } + + return XcmDestinationFeeSpec( + mode = mode, + assetSymbol = destinationFee.assetSymbol.requiredField("destinationFee.assetSymbol"), + amount = amount + ) + } + + private fun String.parseUnsignedBigInteger(fieldName: String): BigInteger { + val value = trim().toBigIntegerOrNull() + require(value != null && value >= BigInteger.ZERO) { + "XCM execution spec field $fieldName must be a non-negative integer" + } + return value + } + + private fun String.normalizedEnumValue(): String { + return trim() + .replace('-', '_') + .replace(' ', '_') + .replace(Regex("([a-z])([A-Z])"), "$1_$2") + .uppercase(Locale.US) + } +} + +internal object XcmMultiLocationParser { + private val xJunctionRegex = Regex("^X([1-8])\\((.*)\\)$") + private val junctionRegex = Regex("^([A-Za-z][A-Za-z0-9]*)(?:\\((.*)\\))?$") + + fun requireValidInterior(interior: String, fieldName: String): List { + val value = interior.trim() + if (value == "Here") return emptyList() + + val match = xJunctionRegex.matchEntire(value) + ?: throw IllegalArgumentException("XCM execution spec field $fieldName must be Here or X1..X8 junctions") + val expectedCount = match.groupValues[1].toInt() + val junctionValues = splitTopLevel(match.groupValues[2]) + + require(junctionValues.size == expectedCount) { + "XCM execution spec field $fieldName declares X$expectedCount but contains ${junctionValues.size} junctions" + } + + return junctionValues.mapIndexed { index, junction -> + parseJunction(junction, "$fieldName junction ${index + 1}") + } + } + + private fun parseJunction(value: String, fieldName: String): XcmJunctionSpec { + val text = value.trim() + val match = junctionRegex.matchEntire(text) + ?: throw IllegalArgumentException("XCM execution spec field $fieldName has malformed junction") + val typeText = match.groupValues[1] + val argument = match.groupValues.getOrNull(2) + ?.takeIf { it.isNotEmpty() } + ?.trim() + + return when (typeText.normalizedJunctionType()) { + "PARACHAIN" -> XcmJunctionSpec( + XcmJunctionType.PARACHAIN, + argument.requiredUnsignedInteger("$fieldName Parachain") + ) + "PALLET_INSTANCE" -> XcmJunctionSpec( + XcmJunctionType.PALLET_INSTANCE, + argument.requiredUnsignedInteger("$fieldName PalletInstance") + ) + "GENERAL_INDEX" -> XcmJunctionSpec( + XcmJunctionType.GENERAL_INDEX, + argument.requiredUnsignedInteger("$fieldName GeneralIndex") + ) + "GENERAL_KEY" -> XcmJunctionSpec( + XcmJunctionType.GENERAL_KEY, + argument.requiredJunctionArgument("$fieldName GeneralKey") + ) + "ACCOUNT_ID32" -> XcmJunctionSpec( + XcmJunctionType.ACCOUNT_ID32, + argument.requiredAccountPlaceholder("$fieldName AccountId32") + ) + "ACCOUNT_KEY20" -> XcmJunctionSpec( + XcmJunctionType.ACCOUNT_KEY20, + argument.requiredAccountPlaceholder("$fieldName AccountKey20") + ) + else -> throw IllegalArgumentException("XCM execution spec field $fieldName has unsupported junction $typeText") + } + } + + private fun splitTopLevel(value: String): List { + val parts = mutableListOf() + var start = 0 + var parentheses = 0 + var braces = 0 + var brackets = 0 + + value.forEachIndexed { index, char -> + when (char) { + '(' -> parentheses += 1 + ')' -> { + parentheses -= 1 + require(parentheses >= 0) { "XCM execution spec multilocation has unbalanced parentheses" } + } + '{' -> braces += 1 + '}' -> { + braces -= 1 + require(braces >= 0) { "XCM execution spec multilocation has unbalanced braces" } + } + '[' -> brackets += 1 + ']' -> { + brackets -= 1 + require(brackets >= 0) { "XCM execution spec multilocation has unbalanced brackets" } + } + ',' -> if (parentheses == 0 && braces == 0 && brackets == 0) { + parts += value.substring(start, index).trim() + start = index + 1 + } + } + } + + require(parentheses == 0 && braces == 0 && brackets == 0) { + "XCM execution spec multilocation has unbalanced delimiters" + } + + parts += value.substring(start).trim() + return parts.filter(String::isNotEmpty) + } + + private fun String.normalizedJunctionType(): String { + return trim() + .replace('-', '_') + .replace(' ', '_') + .replace(Regex("([a-z])([A-Z])"), "$1_$2") + .uppercase(Locale.US) + } + + private fun String?.requiredUnsignedInteger(fieldName: String): String { + val value = requiredJunctionArgument(fieldName) + require(value.matches(Regex("^(0|[1-9][0-9]*)$"))) { + "XCM execution spec field $fieldName must be a non-negative integer" + } + return value + } + + private fun String?.requiredAccountPlaceholder(fieldName: String): String { + val value = requiredJunctionArgument(fieldName) + require(value.contains("")) { + "XCM execution spec field $fieldName must include recipient placeholder" + } + return value + } + + private fun String?.requiredJunctionArgument(fieldName: String): String { + val value = this?.trim().orEmpty() + require(value.isNotEmpty()) { "XCM execution spec field $fieldName must include a value" } + return value + } +} diff --git a/public-shared-features-xcm/src/test/java/jp/co/soramitsu/xcm/SubstrateXcmTransferEngineTest.kt b/public-shared-features-xcm/src/test/java/jp/co/soramitsu/xcm/SubstrateXcmTransferEngineTest.kt new file mode 100644 index 0000000000..ca695cc6ef --- /dev/null +++ b/public-shared-features-xcm/src/test/java/jp/co/soramitsu/xcm/SubstrateXcmTransferEngineTest.kt @@ -0,0 +1,532 @@ +package jp.co.soramitsu.xcm + +import jp.co.soramitsu.core.extrinsic.keypair_provider.KeypairProvider +import jp.co.soramitsu.core.models.CryptoType +import jp.co.soramitsu.core.models.Ecosystem +import jp.co.soramitsu.fearless_utils.encrypt.keypair.Keypair +import jp.co.soramitsu.runtime.multiNetwork.chain.model.Chain +import jp.co.soramitsu.xcm.domain.XcmArgumentShape +import jp.co.soramitsu.xcm.domain.XcmDestinationFeeMode +import jp.co.soramitsu.xcm.domain.XcmDestinationFeeSpec +import jp.co.soramitsu.xcm.domain.XcmExecutionSpec +import jp.co.soramitsu.xcm.domain.XcmMultiLocationParser +import jp.co.soramitsu.xcm.domain.XcmMultiLocationSpec +import jp.co.soramitsu.xcm.domain.XcmTransferType +import jp.co.soramitsu.xcm.domain.XcmWeightLimitSpec +import jp.co.soramitsu.xcm.domain.XcmWeightLimitType +import kotlinx.coroutines.runBlocking +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertSame +import org.junit.Assert.assertThrows +import org.junit.Assert.assertTrue +import org.junit.Test +import java.math.BigDecimal +import java.math.BigInteger +import jp.co.soramitsu.core.models.Asset as CoreAsset + +class SubstrateXcmTransferEngineTest { + + @Test + fun `submits limited reserve transfer call with versioned xcm arguments`() = runBlocking { + val submitter = RecordingSubmitter() + val engine = SubstrateXcmTransferEngine(submitter) + val provider = FakeKeypairProvider() + engine.updateKeypairProvider("origin", provider) + + val hash = engine.transfer( + XcmTransferRequest( + originChain = chain("origin"), + destinationChain = chain("destination"), + asset = coreAsset("DOT"), + senderAccountId = byteArrayOf(1, 2, 3), + recipientAddress = "5Destination", + amount = BigInteger.TEN, + executionSpec = executionSpec() + ) + ) + + assertEquals("0xhash", hash) + assertEquals("origin", submitter.submitChain!!.id) + assertEquals(listOf(1.toByte(), 2.toByte(), 3.toByte()), submitter.submitAccountId!!.toList()) + assertSame(provider, submitter.submitKeypairProvider) + + val call = requireNotNull(submitter.submitCall) + assertEquals("PolkadotXcm", call.moduleName) + assertEquals("limitedReserveTransferAssets", call.callName) + assertTrue(call.arguments.containsKey("weight_limit")) + assertTrue(call.arguments["dest"].toString().contains("Parachain=2000")) + assertTrue(call.arguments["beneficiary"].toString().contains("5Destination")) + assertFalse(call.arguments["beneficiary"].toString().contains("")) + assertTrue(call.arguments["assets"].toString().contains("Fungible=10")) + assertEquals(0, call.arguments["fee_asset_item"]) + } + + @Test + fun `builds non-limited transfer call without weight limit`() { + val engine = SubstrateXcmTransferEngine(RecordingSubmitter()) + + val call = engine.buildTransferCall( + executionSpec = executionSpec( + transferType = XcmTransferType.RESERVE_TRANSFER_ASSETS, + callName = "reserveTransferAssets" + ), + recipientAddress = "5Destination", + amount = BigInteger.TEN + ) + + assertFalse(call.arguments.containsKey("weight_limit")) + assertEquals("reserveTransferAssets", call.callName) + } + + @Test + fun `builds XTokens transfer multiasset call with explicit argument shape`() { + val engine = SubstrateXcmTransferEngine(RecordingSubmitter()) + + val call = engine.buildTransferCall( + executionSpec = executionSpec( + palletName = "XTokens", + callName = "transferMultiasset", + transferType = XcmTransferType.X_TOKENS_TRANSFER_MULTIASSET, + argumentShape = XcmArgumentShape.X_TOKENS_TRANSFER_MULTIASSET + ), + recipientAddress = "5Destination", + amount = BigInteger.TEN + ) + + assertEquals("XTokens", call.moduleName) + assertEquals("transferMultiasset", call.callName) + assertTrue(call.arguments["asset"].toString().contains("Fungible=10")) + assertTrue(call.arguments["dest"].toString().contains("5Destination")) + assertTrue(call.arguments.containsKey("dest_weight_limit")) + assertFalse(call.arguments.containsKey("beneficiary")) + assertFalse(call.arguments.containsKey("assets")) + assertFalse(call.arguments.containsKey("fee_asset_item")) + } + + @Test + fun `rejects XCM argument shape and pallet mismatches before building calls`() { + val engine = SubstrateXcmTransferEngine(RecordingSubmitter()) + + assertThrows(IllegalArgumentException::class.java) { + engine.buildTransferCall( + executionSpec = executionSpec( + palletName = "XTokens", + callName = "limitedReserveTransferAssets" + ), + recipientAddress = "5Destination", + amount = BigInteger.TEN + ) + } + + assertThrows(IllegalArgumentException::class.java) { + engine.buildTransferCall( + executionSpec = executionSpec( + palletName = "PolkadotXcm", + callName = "transferMultiasset", + transferType = XcmTransferType.X_TOKENS_TRANSFER_MULTIASSET, + argumentShape = XcmArgumentShape.X_TOKENS_TRANSFER_MULTIASSET + ), + recipientAddress = "5Destination", + amount = BigInteger.TEN + ) + } + } + + @Test + fun `builds AccountKey20 beneficiary for EVM destination routes`() { + val engine = SubstrateXcmTransferEngine(RecordingSubmitter()) + + val call = engine.buildTransferCall( + executionSpec = executionSpec( + beneficiaryLocation = multiLocation(0, "X1(AccountKey20({network: Any, key: }))") + ), + recipientAddress = "0x1111111111111111111111111111111111111111", + amount = BigInteger.TEN + ) + + assertTrue(call.arguments["beneficiary"].toString().contains("AccountKey20")) + assertTrue(call.arguments["beneficiary"].toString().contains("0x1111111111111111111111111111111111111111")) + assertFalse(call.arguments["beneficiary"].toString().contains("")) + } + + @Test + fun `estimates origin fee through submitter with built transfer call`() = runBlocking { + val submitter = RecordingSubmitter(estimatedFee = BigInteger("12345")) + val engine = SubstrateXcmTransferEngine(submitter) + val provider = FakeKeypairProvider() + engine.updateKeypairProvider("origin", provider) + + val fee = engine.getOriginFee( + originChain = chain("origin"), + originChainId = "origin", + destinationChainId = "destination", + asset = coreAsset("DOT"), + originFeeAsset = coreAsset("DOT"), + address = "5Destination", + amount = BigInteger.TEN, + executionSpec = executionSpec() + ) + + assertEquals(BigDecimal("0.000000012345"), fee) + assertEquals("origin", submitter.estimateChain!!.id) + assertSame(provider, submitter.estimateKeypairProvider) + assertTrue(requireNotNull(submitter.estimateCall).arguments["beneficiary"].toString().contains("5Destination")) + } + + @Test + fun `scales origin fee with origin fee asset precision instead of transfer asset precision`() = runBlocking { + val submitter = RecordingSubmitter(estimatedFee = BigInteger("12345")) + val engine = SubstrateXcmTransferEngine(submitter) + engine.updateKeypairProvider("origin", FakeKeypairProvider()) + + val fee = engine.getOriginFee( + originChain = chain("origin"), + originChainId = "origin", + destinationChainId = "destination", + asset = coreAsset("USDT", precision = 6), + originFeeAsset = coreAsset("DOT", precision = 12), + address = "5Destination", + amount = BigInteger.TEN, + executionSpec = executionSpec() + ) + + assertEquals(BigDecimal("0.000000012345"), fee) + } + + @Test + fun `rejects missing or invalid keypair provider before submitter call`() { + val submitter = RecordingSubmitter() + val engine = SubstrateXcmTransferEngine(submitter) + + assertThrows(IllegalArgumentException::class.java) { + engine.updateKeypairProvider("origin", Any()) + } + + assertThrows(IllegalStateException::class.java) { + runBlocking { + engine.transfer( + XcmTransferRequest( + originChain = chain("origin"), + destinationChain = chain("destination"), + asset = coreAsset("DOT"), + senderAccountId = byteArrayOf(1), + recipientAddress = "5Destination", + amount = BigInteger.TEN, + executionSpec = executionSpec() + ) + ) + } + } + assertEquals(0, submitter.submitCount) + } + + @Test + fun `rejects invalid xcm version and blank recipient before submitter call`() { + val submitter = RecordingSubmitter() + val engine = SubstrateXcmTransferEngine(submitter) + engine.updateKeypairProvider("origin", FakeKeypairProvider()) + + assertThrows(IllegalArgumentException::class.java) { + engine.buildTransferCall( + executionSpec = executionSpec(xcmVersion = "three"), + recipientAddress = "5Destination", + amount = BigInteger.TEN + ) + } + + assertThrows(IllegalArgumentException::class.java) { + engine.buildTransferCall( + executionSpec = executionSpec(), + recipientAddress = " ", + amount = BigInteger.TEN + ) + } + assertEquals(0, submitter.submitCount) + } + + @Test + fun `returns zero when destination fee is included`() = runBlocking { + val engine = SubstrateXcmTransferEngine(RecordingSubmitter()) + + val fee = engine.getDestinationFee( + originChainId = "origin", + destinationChainId = "destination", + asset = coreAsset("DOT"), + executionSpec = executionSpec(destinationFeeMode = XcmDestinationFeeMode.INCLUDED) + ) + + assertEquals(BigDecimal.ZERO, fee) + } + + @Test + fun `returns fixed destination fee normalized by asset precision`() = runBlocking { + val engine = SubstrateXcmTransferEngine(RecordingSubmitter()) + + val fee = engine.getDestinationFee( + originChainId = "origin", + destinationChainId = "destination", + asset = coreAsset("DOT"), + executionSpec = executionSpec( + destinationFeeMode = XcmDestinationFeeMode.FIXED, + destinationFeeAmount = BigInteger("123450000000") + ) + ) + + assertEquals(BigDecimal("0.123450000000"), fee) + } + + @Test + fun `rejects estimated destination fee until destination estimator is configured`() { + val engine = SubstrateXcmTransferEngine(RecordingSubmitter()) + + assertThrows(UnsupportedOperationException::class.java) { + runBlocking { + engine.getDestinationFee( + originChainId = "origin", + destinationChainId = "destination", + asset = coreAsset("DOT"), + executionSpec = executionSpec(destinationFeeMode = XcmDestinationFeeMode.ESTIMATED) + ) + } + } + } + + @Test + fun `rejects destination fee asset mismatch before returning fixed fee`() { + val engine = SubstrateXcmTransferEngine(RecordingSubmitter()) + + assertThrows(IllegalArgumentException::class.java) { + runBlocking { + engine.getDestinationFee( + originChainId = "origin", + destinationChainId = "destination", + asset = coreAsset("KSM"), + executionSpec = executionSpec( + destinationFeeMode = XcmDestinationFeeMode.FIXED, + destinationFeeAmount = BigInteger("1000") + ) + ) + } + } + } + + @Test + fun `rejects origin fee chain mismatch before submitter call`() { + val submitter = RecordingSubmitter() + val engine = SubstrateXcmTransferEngine(submitter) + engine.updateKeypairProvider("origin", FakeKeypairProvider()) + + assertThrows(IllegalArgumentException::class.java) { + runBlocking { + engine.getOriginFee( + originChain = chain("other"), + originChainId = "origin", + destinationChainId = "destination", + asset = coreAsset("DOT"), + originFeeAsset = coreAsset("DOT"), + address = "5Destination", + amount = BigInteger.TEN, + executionSpec = executionSpec() + ) + } + } + assertEquals(0, submitter.estimateCount) + } + + @Test + fun `rejects negative origin fee asset precision before submitter call`() { + val submitter = RecordingSubmitter() + val engine = SubstrateXcmTransferEngine(submitter) + engine.updateKeypairProvider("origin", FakeKeypairProvider()) + + assertThrows(IllegalArgumentException::class.java) { + runBlocking { + engine.getOriginFee( + originChain = chain("origin"), + originChainId = "origin", + destinationChainId = "destination", + asset = coreAsset("DOT"), + originFeeAsset = coreAsset("DOT", precision = -1), + address = "5Destination", + amount = BigInteger.TEN, + executionSpec = executionSpec() + ) + } + } + assertEquals(0, submitter.estimateCount) + } + + @Test + fun `rejects origin fee asset from a different chain before submitter call`() { + val submitter = RecordingSubmitter() + val engine = SubstrateXcmTransferEngine(submitter) + engine.updateKeypairProvider("origin", FakeKeypairProvider()) + + assertThrows(IllegalArgumentException::class.java) { + runBlocking { + engine.getOriginFee( + originChain = chain("origin"), + originChainId = "origin", + destinationChainId = "destination", + asset = coreAsset("DOT"), + originFeeAsset = coreAsset("DOT", chainId = "other"), + address = "5Destination", + amount = BigInteger.TEN, + executionSpec = executionSpec() + ) + } + } + assertEquals(0, submitter.estimateCount) + } + + private fun executionSpec( + palletName: String = "PolkadotXcm", + transferType: XcmTransferType = XcmTransferType.LIMITED_RESERVE_TRANSFER_ASSETS, + callName: String = "limitedReserveTransferAssets", + argumentShape: XcmArgumentShape = XcmArgumentShape.POLKADOT_XCM_TRANSFER_ASSETS, + xcmVersion: String = "v3", + destinationFeeMode: XcmDestinationFeeMode = XcmDestinationFeeMode.ESTIMATED, + destinationFeeAmount: BigInteger? = null, + beneficiaryLocation: XcmMultiLocationSpec = multiLocation(0, "X1(AccountId32({network: Any, id: }))") + ) = XcmExecutionSpec( + palletName = palletName, + callName = callName, + transferType = transferType, + argumentShape = argumentShape, + xcmVersion = xcmVersion, + destinationLocation = multiLocation(1, "X1(Parachain(2000))"), + assetLocation = multiLocation(1, "X2(Parachain(1000), GeneralKey(dot))"), + beneficiaryLocation = beneficiaryLocation, + feeAssetLocation = multiLocation(1, "Here"), + feeAssetItem = 0, + weightLimit = XcmWeightLimitSpec( + type = XcmWeightLimitType.LIMITED, + refTime = BigInteger("6000000000"), + proofSize = BigInteger("65536") + ), + destinationFee = XcmDestinationFeeSpec( + mode = destinationFeeMode, + assetSymbol = "DOT", + amount = destinationFeeAmount + ), + bridge = null + ) + + private fun multiLocation(parents: Int, interior: String) = XcmMultiLocationSpec( + parents = parents, + interior = interior, + junctions = XcmMultiLocationParser.requireValidInterior(interior, "test") + ) + + private fun coreAsset( + symbol: String, + precision: Int = 12, + chainId: String = "origin" + ) = CoreAsset( + id = "asset-$symbol", + name = symbol, + symbol = symbol, + iconUrl = "", + chainId = chainId, + chainName = chainId, + chainIcon = null, + isTestNet = false, + priceId = null, + precision = precision, + staking = CoreAsset.StakingType.UNSUPPORTED, + purchaseProviders = null, + supportStakingPool = false, + isUtility = false, + type = null, + currencyId = null, + existentialDeposit = null, + color = null, + isNative = null, + priceProvider = null, + coinbaseUrl = null + ) + + private fun chain(id: String) = Chain( + id = id, + paraId = null, + rank = null, + name = id, + minSupportedVersion = null, + assets = emptyList(), + nodes = emptyList(), + explorers = emptyList(), + externalApi = null, + icon = "", + addressPrefix = 0, + isEthereumBased = false, + isTestNet = false, + hasCrowdloans = false, + parentId = null, + supportStakingPool = false, + isEthereumChain = false, + chainlinkProvider = false, + supportNft = false, + isUsesAppId = false, + identityChain = null, + ecosystem = Ecosystem.Substrate, + androidMinAppVersion = null, + remoteAssetsSource = null, + tonBridgeUrl = null, + xcm = null + ) + + private class RecordingSubmitter( + private val estimatedFee: BigInteger = BigInteger("99") + ) : XcmExtrinsicSubmitter { + var submitCount = 0 + var estimateCount = 0 + var submitChain: Chain? = null + var estimateChain: Chain? = null + var submitAccountId: ByteArray? = null + var submitKeypairProvider: KeypairProvider? = null + var estimateKeypairProvider: KeypairProvider? = null + var submitCall: XcmExtrinsicCall? = null + var estimateCall: XcmExtrinsicCall? = null + + override suspend fun submit( + chain: Chain, + accountId: ByteArray, + keypairProvider: KeypairProvider, + call: XcmExtrinsicCall + ): String { + submitCount += 1 + submitChain = chain + submitAccountId = accountId + submitKeypairProvider = keypairProvider + submitCall = call + return "0xhash" + } + + override suspend fun estimateFee( + chain: Chain, + accountId: ByteArray, + keypairProvider: KeypairProvider, + call: XcmExtrinsicCall + ): BigInteger { + estimateCount += 1 + estimateChain = chain + estimateKeypairProvider = keypairProvider + estimateCall = call + return estimatedFee + } + } + + private class FakeKeypairProvider : KeypairProvider { + override suspend fun getCryptoTypeFor( + chain: jp.co.soramitsu.core.models.IChain, + accountId: ByteArray + ): CryptoType { + error("Keypair lookup should be delegated to production submitter") + } + + override suspend fun getKeypairFor(chain: jp.co.soramitsu.core.models.IChain, accountId: ByteArray): Keypair { + error("Keypair lookup should be delegated to production submitter") + } + } +} diff --git a/public-shared-features-xcm/src/test/java/jp/co/soramitsu/xcm/XcmServiceTest.kt b/public-shared-features-xcm/src/test/java/jp/co/soramitsu/xcm/XcmServiceTest.kt new file mode 100644 index 0000000000..968d37a478 --- /dev/null +++ b/public-shared-features-xcm/src/test/java/jp/co/soramitsu/xcm/XcmServiceTest.kt @@ -0,0 +1,680 @@ +package jp.co.soramitsu.xcm + +import jp.co.soramitsu.core.models.ChainId +import jp.co.soramitsu.core.models.ChainIdWithMetadata +import jp.co.soramitsu.core.models.Ecosystem +import jp.co.soramitsu.runtime.multiNetwork.chain.model.Chain +import jp.co.soramitsu.xcm.domain.XcmArgumentShape +import jp.co.soramitsu.xcm.domain.XcmEntitiesFetcher +import jp.co.soramitsu.xcm.domain.XcmJunctionType +import jp.co.soramitsu.xcm.domain.XcmTransferType +import jp.co.soramitsu.xcm.domain.XcmWeightLimitType +import kotlinx.coroutines.runBlocking +import org.junit.Assert.assertArrayEquals +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertThrows +import org.junit.Assert.assertTrue +import org.junit.Test +import java.math.BigDecimal +import java.math.BigInteger +import jp.co.soramitsu.core.models.Asset as CoreAsset + +class XcmServiceTest { + + @Test + fun `public service does not advertise transfer support even when route metadata exists`() = runBlocking { + val service = serviceWithRoute() + + assertFalse(service.isXcmSupportAsset(originChainId = "origin", assetSymbol = "DOT")) + } + + @Test + fun `advertises transfer support with available engine`() = runBlocking { + val service = serviceWithRoute(RecordingXcmTransferEngine()) + + assertTrue(service.isXcmSupportAsset(originChainId = "origin", assetSymbol = "xcdot")) + assertFalse(service.isXcmSupportAsset(originChainId = "origin", assetSymbol = "KSM")) + assertFalse(service.isXcmSupportAsset(originChainId = "missing", assetSymbol = "DOT")) + } + + @Test + fun `public service exposes configured min amount`() = runBlocking { + val service = serviceWithRoute() + + assertEquals( + BigInteger.TEN, + service.getAmountMinLimit( + originChainId = "origin", + destinationChainId = "destination", + asset = coreAsset("xcdot") + ) + ) + } + + @Test + fun `public service delegates keypair provider and metadata to transfer engine`() { + val engine = RecordingXcmTransferEngine() + val service = serviceWithRoute(engine) + val keypairProvider = Any() + val metadata = ChainIdWithMetadata(chainId = "origin", metadata = "0x010203") + + service.updateKeypairProvider(chainId = "origin", keypairProvider = keypairProvider) + service.addPreloadedMetadata(metadata) + + assertEquals("origin", engine.keypairChainId) + assertTrue(engine.keypairProvider === keypairProvider) + assertEquals(listOf(metadata), engine.preloadedMetadata) + } + + @Test + fun `public service delegates transfer after validating route and amount`() = runBlocking { + val engine = RecordingXcmTransferEngine() + val service = serviceWithRoute(engine) + val senderAccountId = byteArrayOf(1, 2, 3) + + val extrinsicHash = service.transfer( + originChain = chain("origin"), + destinationChain = chain("destination"), + asset = coreAsset("DOT"), + senderAccountId = senderAccountId, + address = "5Destination", + amount = BigInteger.TEN + ) + + assertEquals("0xhash", extrinsicHash) + val request = requireNotNull(engine.transferRequest) + assertEquals("origin", request.originChain.id) + assertEquals("destination", request.destinationChain.id) + assertEquals("DOT", request.asset.symbol) + assertArrayEquals(senderAccountId, request.senderAccountId) + assertEquals("5Destination", request.recipientAddress) + assertEquals(BigInteger.TEN, request.amount) + assertEquals("PolkadotXcm", request.executionSpec.palletName) + assertEquals("limitedReserveTransferAssets", request.executionSpec.callName) + assertEquals(XcmTransferType.LIMITED_RESERVE_TRANSFER_ASSETS, request.executionSpec.transferType) + assertEquals(XcmArgumentShape.POLKADOT_XCM_TRANSFER_ASSETS, request.executionSpec.argumentShape) + assertEquals("v3", request.executionSpec.xcmVersion) + assertEquals(XcmJunctionType.PARACHAIN, request.executionSpec.destinationLocation.junctions.single().type) + assertEquals("X2(Parachain(1000), GeneralKey(dot))", request.executionSpec.assetLocation.interior) + assertEquals(XcmJunctionType.PARACHAIN, request.executionSpec.assetLocation.junctions[0].type) + assertEquals(XcmJunctionType.ACCOUNT_ID32, request.executionSpec.beneficiaryLocation.junctions.single().type) + assertEquals(XcmWeightLimitType.LIMITED, request.executionSpec.weightLimit.type) + } + + @Test + fun `public service rejects transfer below route min amount before engine call`() { + val engine = RecordingXcmTransferEngine() + val service = serviceWithRoute(engine) + + assertThrows(IllegalArgumentException::class.java) { + runBlocking { + service.transfer( + originChain = chain("origin"), + destinationChain = chain("destination"), + asset = coreAsset("DOT"), + senderAccountId = byteArrayOf(1), + address = "5Destination", + amount = BigInteger("9") + ) + } + } + assertEquals(null, engine.transferRequest) + } + + @Test + fun `public service rejects missing execution spec before engine call`() { + val engine = RecordingXcmTransferEngine() + val service = serviceWithRoute(engine = engine, execution = null) + + assertThrows(IllegalStateException::class.java) { + runBlocking { + service.transfer( + originChain = chain("origin"), + destinationChain = chain("destination"), + asset = coreAsset("DOT"), + senderAccountId = byteArrayOf(1), + address = "5Destination", + amount = BigInteger.TEN + ) + } + } + assertEquals(null, engine.transferRequest) + } + + @Test + fun `public service rejects malformed execution spec before engine call`() { + val engine = RecordingXcmTransferEngine() + val service = serviceWithRoute( + engine = engine, + execution = executableRouteSpec(palletName = " ") + ) + + assertThrows(IllegalArgumentException::class.java) { + runBlocking { + service.transfer( + originChain = chain("origin"), + destinationChain = chain("destination"), + asset = coreAsset("DOT"), + senderAccountId = byteArrayOf(1), + address = "5Destination", + amount = BigInteger.TEN + ) + } + } + assertEquals(null, engine.transferRequest) + } + + @Test + fun `public service rejects mismatched argument shape before engine call`() { + val engine = RecordingXcmTransferEngine() + val service = serviceWithRoute( + engine = engine, + execution = executableRouteSpec( + callName = "transferMultiasset", + transferType = "xTokensTransferMultiasset", + argumentShape = "xTokensTransferMultiasset" + ) + ) + + assertThrows(IllegalArgumentException::class.java) { + runBlocking { + service.transfer( + originChain = chain("origin"), + destinationChain = chain("destination"), + asset = coreAsset("DOT"), + senderAccountId = byteArrayOf(1), + address = "5Destination", + amount = BigInteger.TEN + ) + } + } + assertEquals(null, engine.transferRequest) + } + + @Test + @Suppress("FunctionSignature") + fun `does not advertise transfer support when execution spec is missing or malformed`() = runBlocking { + val missingSpecService = serviceWithRoute(engine = RecordingXcmTransferEngine(), execution = null) + val malformedSpecService = serviceWithRoute( + engine = RecordingXcmTransferEngine(), + execution = executableRouteSpec(weightLimit = Chain.Xcm.WeightLimit(type = "Limited", refTime = null, proofSize = "0")) + ) + val malformedMultilocationService = serviceWithRoute( + engine = RecordingXcmTransferEngine(), + execution = executableRouteSpec( + beneficiaryLocation = Chain.Xcm.MultiLocation( + parents = 0, + interior = "X1(AccountId32({network: Any}))" + ) + ) + ) + val unsupportedArgumentShapeService = serviceWithRoute( + engine = RecordingXcmTransferEngine(), + execution = executableRouteSpec(argumentShape = "operatorAlias") + ) + + assertFalse(missingSpecService.isXcmSupportAsset(originChainId = "origin", assetSymbol = "DOT")) + assertFalse(malformedSpecService.isXcmSupportAsset(originChainId = "origin", assetSymbol = "DOT")) + assertFalse(malformedMultilocationService.isXcmSupportAsset(originChainId = "origin", assetSymbol = "DOT")) + assertFalse(unsupportedArgumentShapeService.isXcmSupportAsset(originChainId = "origin", assetSymbol = "DOT")) + } + + @Test + fun `public service rejects unsupported route asset before engine call`() { + val engine = RecordingXcmTransferEngine() + val service = serviceWithRoute(engine) + + assertThrows(IllegalArgumentException::class.java) { + runBlocking { + service.transfer( + originChain = chain("origin"), + destinationChain = chain("destination"), + asset = coreAsset("KSM"), + senderAccountId = byteArrayOf(1), + address = "5Destination", + amount = BigInteger.TEN + ) + } + } + assertEquals(null, engine.transferRequest) + } + + @Test + fun `public service rejects blank recipient before engine call`() { + val engine = RecordingXcmTransferEngine() + val service = serviceWithRoute(engine) + + assertThrows(IllegalArgumentException::class.java) { + runBlocking { + service.transfer( + originChain = chain("origin"), + destinationChain = chain("destination"), + asset = coreAsset("DOT"), + senderAccountId = byteArrayOf(1), + address = " ", + amount = BigInteger.TEN + ) + } + } + assertEquals(null, engine.transferRequest) + } + + @Test + fun `public service delegates AccountKey20 transfer after validating EVM recipient`() = runBlocking { + val engine = RecordingXcmTransferEngine() + val service = serviceWithRoute( + engine = engine, + execution = executableRouteSpec( + beneficiaryLocation = Chain.Xcm.MultiLocation( + parents = 0, + interior = "X1(AccountKey20({network: Any, key: }))" + ) + ) + ) + + service.transfer( + originChain = chain("origin"), + destinationChain = chain("destination"), + asset = coreAsset("DOT"), + senderAccountId = byteArrayOf(1), + address = VALID_EVM_RECIPIENT, + amount = BigInteger.TEN + ) + + val request = requireNotNull(engine.transferRequest) + assertEquals(VALID_EVM_RECIPIENT, request.recipientAddress) + assertEquals(XcmJunctionType.ACCOUNT_KEY20, request.executionSpec.beneficiaryLocation.junctions.single().type) + } + + @Test + fun `public service rejects malformed AccountKey20 recipient before engine call`() { + val invalidRecipients = listOf( + "5Destination", + "1111111111111111111111111111111111111111", + "0x111111111111111111111111111111111111111", + "0x11111111111111111111111111111111111111111", + "0x11111111111111111111111111111111111111zz" + ) + + invalidRecipients.forEach { recipient -> + val engine = RecordingXcmTransferEngine() + val service = serviceWithRoute( + engine = engine, + execution = executableRouteSpec( + beneficiaryLocation = Chain.Xcm.MultiLocation( + parents = 0, + interior = "X1(AccountKey20({network: Any, key: }))" + ) + ) + ) + + assertThrows("recipient $recipient should be rejected", IllegalArgumentException::class.java) { + runBlocking { + service.transfer( + originChain = chain("origin"), + destinationChain = chain("destination"), + asset = coreAsset("DOT"), + senderAccountId = byteArrayOf(1), + address = recipient, + amount = BigInteger.TEN + ) + } + } + assertEquals(null, engine.transferRequest) + } + } + + @Test + fun `public service rejects malformed AccountKey20 origin fee recipient before engine call`() { + val engine = RecordingXcmTransferEngine() + val service = serviceWithRoute( + engine = engine, + execution = executableRouteSpec( + beneficiaryLocation = Chain.Xcm.MultiLocation( + parents = 0, + interior = "X1(AccountKey20({network: Any, key: }))" + ) + ) + ) + + assertThrows(IllegalArgumentException::class.java) { + runBlocking { + service.getXcmOriginFee( + originChain = chain("origin"), + destinationChainId = "destination", + asset = coreAsset("DOT"), + address = "not-evm", + amount = BigInteger.TEN + ) + } + } + assertEquals(null, engine.originFeeRequest) + } + + @Test + fun `public service rejects empty sender before engine call`() { + val engine = RecordingXcmTransferEngine() + val service = serviceWithRoute(engine) + + assertThrows(IllegalArgumentException::class.java) { + runBlocking { + service.transfer( + originChain = chain("origin"), + destinationChain = chain("destination"), + asset = coreAsset("DOT"), + senderAccountId = byteArrayOf(), + address = "5Destination", + amount = BigInteger.TEN + ) + } + } + assertEquals(null, engine.transferRequest) + } + + @Test + fun `public service rejects same origin and destination before engine call`() { + val engine = RecordingXcmTransferEngine() + val service = serviceWithRoute(engine) + + assertThrows(IllegalArgumentException::class.java) { + runBlocking { + service.transfer( + originChain = chain("origin"), + destinationChain = chain("origin"), + asset = coreAsset("DOT"), + senderAccountId = byteArrayOf(1), + address = "5Destination", + amount = BigInteger.TEN + ) + } + } + assertEquals(null, engine.transferRequest) + } + + @Test + fun `public service rejects destination fee estimation`() { + val service = serviceWithRoute() + + assertThrows(UnsupportedOperationException::class.java) { + runBlocking { + service.getXcmDestinationFee( + originChainId = "origin", + destinationChainId = "destination", + asset = coreAsset("DOT") + ) + } + } + } + + @Test + fun `public service rejects origin fee estimation`() { + val service = serviceWithRoute() + + assertThrows(UnsupportedOperationException::class.java) { + runBlocking { + service.getXcmOriginFee( + originChain = chain("origin"), + destinationChainId = "destination", + asset = coreAsset("DOT"), + address = "address", + amount = BigInteger.TEN + ) + } + } + } + + @Test + fun `public service delegates fee estimation when engine is available`() = runBlocking { + val engine = RecordingXcmTransferEngine() + val service = serviceWithRoute(engine) + + assertEquals( + BigDecimal("0.01"), + service.getXcmDestinationFee( + originChainId = "origin", + destinationChainId = "destination", + asset = coreAsset("DOT") + ) + ) + assertEquals( + BigDecimal("0.02"), + service.getXcmOriginFee( + originChain = chain("origin"), + destinationChainId = "destination", + asset = coreAsset("DOT"), + originFeeAsset = coreAsset("KSM"), + address = "5Destination", + amount = BigInteger.TEN + ) + ) + assertEquals( + DestinationFeeRequest("origin", "destination", "DOT"), + engine.destinationFeeRequest + ) + assertEquals( + OriginFeeRequest("origin", "destination", "DOT", "KSM", "5Destination", BigInteger.TEN), + engine.originFeeRequest + ) + assertEquals(XcmTransferType.LIMITED_RESERVE_TRANSFER_ASSETS, engine.originFeeExecutionSpec?.transferType) + } + + @Test + fun `public service rejects transfer submission`() { + val service = serviceWithRoute() + + assertThrows(UnsupportedOperationException::class.java) { + runBlocking { + service.transfer( + originChain = chain("origin"), + destinationChain = chain("destination"), + asset = coreAsset("DOT"), + senderAccountId = byteArrayOf(1), + address = "address", + amount = BigInteger.TEN + ) + } + } + } + + private fun serviceWithRoute( + engine: XcmTransferEngine = UnavailableXcmTransferEngine, + execution: Chain.Xcm.Execution? = executableRouteSpec() + ): XcmService { + val origin = chain( + id = "origin", + xcm = Chain.Xcm( + chainId = null, + xcmVersion = "v3", + availableAssets = listOf(Chain.Xcm.Asset(id = "dot", symbol = "DOT", minAmount = null)), + availableDestinations = listOf( + Chain.Xcm.Destination( + chainId = "destination", + assets = listOf(Chain.Xcm.Asset(id = "dot-route", symbol = "DOT", minAmount = "10")), + bridgeParachainId = null, + execution = execution + ) + ) + ) + ) + + return XcmService(XcmEntitiesFetcher { listOf(origin) }, engine) + } + + private fun executableRouteSpec( + palletName: String? = "PolkadotXcm", + callName: String? = "limitedReserveTransferAssets", + transferType: String? = "limitedReserveTransferAssets", + argumentShape: String? = null, + destinationLocation: Chain.Xcm.MultiLocation? = Chain.Xcm.MultiLocation( + parents = 1, + interior = "X1(Parachain(2000))" + ), + assetLocation: Chain.Xcm.MultiLocation? = Chain.Xcm.MultiLocation( + parents = 1, + interior = "X2(Parachain(1000), GeneralKey(dot))" + ), + beneficiaryLocation: Chain.Xcm.MultiLocation? = Chain.Xcm.MultiLocation( + parents = 0, + interior = "X1(AccountId32({network: Any, id: }))" + ), + feeAssetLocation: Chain.Xcm.MultiLocation? = Chain.Xcm.MultiLocation( + parents = 1, + interior = "X2(Parachain(1000), GeneralKey(dot))" + ), + feeAssetItem: Int? = 0, + weightLimit: Chain.Xcm.WeightLimit? = Chain.Xcm.WeightLimit( + type = "Limited", + refTime = "6000000000", + proofSize = "65536" + ), + destinationFee: Chain.Xcm.DestinationFee? = Chain.Xcm.DestinationFee( + mode = "Estimated", + assetSymbol = "DOT", + amount = null + ), + bridge: Chain.Xcm.Bridge? = null + ) = Chain.Xcm.Execution( + palletName = palletName, + callName = callName, + transferType = transferType, + argumentShape = argumentShape, + destinationLocation = destinationLocation, + assetLocation = assetLocation, + beneficiaryLocation = beneficiaryLocation, + feeAssetLocation = feeAssetLocation, + feeAssetItem = feeAssetItem, + weightLimit = weightLimit, + destinationFee = destinationFee, + bridge = bridge + ) + + private fun coreAsset(symbol: String) = CoreAsset( + id = "asset-$symbol", + name = symbol, + symbol = symbol, + iconUrl = "", + chainId = "origin", + chainName = "origin", + chainIcon = null, + isTestNet = false, + priceId = null, + precision = 12, + staking = CoreAsset.StakingType.UNSUPPORTED, + purchaseProviders = null, + supportStakingPool = false, + isUtility = false, + type = null, + currencyId = null, + existentialDeposit = null, + color = null, + isNative = null, + priceProvider = null, + coinbaseUrl = null + ) + + private fun chain(id: String, xcm: Chain.Xcm? = null) = Chain( + id = id, + paraId = null, + rank = null, + name = id, + minSupportedVersion = null, + assets = emptyList(), + nodes = emptyList(), + explorers = emptyList(), + externalApi = null, + icon = "", + addressPrefix = 0, + isEthereumBased = false, + isTestNet = false, + hasCrowdloans = false, + parentId = null, + supportStakingPool = false, + isEthereumChain = false, + chainlinkProvider = false, + supportNft = false, + isUsesAppId = false, + identityChain = null, + ecosystem = Ecosystem.Substrate, + androidMinAppVersion = null, + remoteAssetsSource = null, + tonBridgeUrl = null, + xcm = xcm + ) + + private companion object { + const val VALID_EVM_RECIPIENT = "0x1111111111111111111111111111111111111111" + } + + private data class OriginFeeRequest( + val originChainId: ChainId, + val destinationChainId: ChainId, + val assetSymbol: String, + val originFeeAssetSymbol: String, + val address: String, + val amount: BigInteger + ) + + private data class DestinationFeeRequest( + val originChainId: ChainId, + val destinationChainId: ChainId, + val assetSymbol: String + ) + + private class RecordingXcmTransferEngine : XcmTransferEngine { + override val isAvailable: Boolean = true + var keypairChainId: ChainId? = null + var keypairProvider: Any? = null + var preloadedMetadata = emptyList() + var transferRequest: XcmTransferRequest? = null + var destinationFeeRequest: DestinationFeeRequest? = null + var originFeeRequest: OriginFeeRequest? = null + var originFeeExecutionSpec: jp.co.soramitsu.xcm.domain.XcmExecutionSpec? = null + + override fun updateKeypairProvider(chainId: ChainId, keypairProvider: Any) { + keypairChainId = chainId + this.keypairProvider = keypairProvider + } + + override fun addPreloadedMetadata(vararg chainMetadatas: ChainIdWithMetadata) { + preloadedMetadata = chainMetadatas.toList() + } + + override suspend fun transfer(request: XcmTransferRequest): String { + transferRequest = request + return "0xhash" + } + + override suspend fun getDestinationFee( + originChainId: ChainId, + destinationChainId: ChainId, + asset: CoreAsset, + executionSpec: jp.co.soramitsu.xcm.domain.XcmExecutionSpec + ): BigDecimal { + destinationFeeRequest = DestinationFeeRequest(originChainId, destinationChainId, asset.symbol) + return BigDecimal("0.01") + } + + override suspend fun getOriginFee( + originChain: Chain, + originChainId: ChainId, + destinationChainId: ChainId, + asset: CoreAsset, + originFeeAsset: CoreAsset, + address: String, + amount: BigInteger, + executionSpec: jp.co.soramitsu.xcm.domain.XcmExecutionSpec + ): BigDecimal { + originFeeRequest = OriginFeeRequest( + originChainId, + destinationChainId, + asset.symbol, + originFeeAsset.symbol, + address, + amount + ) + originFeeExecutionSpec = executionSpec + return BigDecimal("0.02") + } + } +} diff --git a/public-shared-features-xcm/src/test/java/jp/co/soramitsu/xcm/domain/XcmEntitiesFetcherTest.kt b/public-shared-features-xcm/src/test/java/jp/co/soramitsu/xcm/domain/XcmEntitiesFetcherTest.kt new file mode 100644 index 0000000000..99119966dc --- /dev/null +++ b/public-shared-features-xcm/src/test/java/jp/co/soramitsu/xcm/domain/XcmEntitiesFetcherTest.kt @@ -0,0 +1,542 @@ +package jp.co.soramitsu.xcm.domain + +import jp.co.soramitsu.core.models.Ecosystem +import jp.co.soramitsu.runtime.multiNetwork.chain.model.Chain +import kotlinx.coroutines.runBlocking +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertThrows +import org.junit.Assert.assertTrue +import org.junit.Test +import java.math.BigInteger + +class XcmEntitiesFetcherTest { + + @Test + fun `filters origin chains by destination and normalized asset symbol`() = runBlocking { + val fetcher = fetcher( + chain( + id = "origin-a", + xcm = xcm( + availableAssets = listOf(xcmAsset("DOT")), + destinations = listOf( + destination("destination-a", xcmAsset("DOT")), + destination("destination-b", xcmAsset("KSM")) + ) + ) + ), + chain( + id = "origin-b", + xcm = xcm( + availableAssets = listOf(xcmAsset("KSM")), + destinations = listOf(destination("destination-a", xcmAsset("KSM"))) + ) + ), + chain(id = "not-xcm") + ) + + assertEquals( + listOf("origin-a"), + fetcher.getAvailableOriginChains(assetSymbol = "xcdot", destinationChainId = "destination-a") + ) + assertEquals( + listOf("origin-a", "origin-b"), + fetcher.getAvailableOriginChains(assetSymbol = null, destinationChainId = "destination-a") + ) + } + + @Test + fun `returns destination filtered assets with parsed min amount`() = runBlocking { + val fetcher = fetcher( + chain( + id = "origin", + xcm = xcm( + destinations = listOf( + destination( + "destination", + xcmAsset(symbol = "xcKSM", id = "ksm-route", minAmount = "12000000000"), + xcmAsset(symbol = "DOT", id = "dot-route", minAmount = "0") + ), + destination("other", xcmAsset("HDX")) + ) + ) + ) + ) + + val assets = fetcher.getAvailableAssets(originChainId = "origin", destinationChainId = "destination") + + assertEquals(listOf("KSM", "DOT"), assets.map { it.symbol }) + assertEquals("ksm-route", assets[0].id) + assertEquals(BigInteger("12000000000"), assets[0].minAmount) + assertEquals(BigInteger.ZERO, assets[1].minAmount) + } + + @Test + fun `falls back to origin available assets when no route assets exist`() = runBlocking { + val fetcher = fetcher( + chain( + id = "origin", + xcm = xcm( + availableAssets = listOf(xcmAsset("DOT"), xcmAsset("xcKSM")) + ) + ) + ) + + assertEquals( + listOf("DOT", "KSM"), + fetcher.getAvailableAssets(originChainId = "origin", destinationChainId = null).map { it.symbol } + ) + } + + @Test + fun `filters destination chains by normalized asset symbol`() = runBlocking { + val fetcher = fetcher( + chain( + id = "origin", + xcm = xcm( + destinations = listOf( + destination("dot-destination", xcmAsset("DOT")), + destination("ksm-destination", xcmAsset("xcKSM")), + destination("all-destination", xcmAsset("DOT"), xcmAsset("KSM")) + ) + ) + ) + ) + + assertEquals( + listOf("ksm-destination", "all-destination"), + fetcher.getAvailableDestinationChains(originChainId = "origin", assetSymbol = "ksm") + ) + } + + @Test + fun `ignores malformed destinations assets and min amounts`() = runBlocking { + val fetcher = fetcher( + chain( + id = "origin", + xcm = xcm( + destinations = listOf( + Chain.Xcm.Destination( + chainId = null, + assets = listOf(xcmAsset("DOT")), + bridgeParachainId = null + ), + destination( + "destination", + xcmAsset(symbol = " ", minAmount = "100"), + xcmAsset(symbol = "KSM", minAmount = "-1"), + xcmAsset(symbol = "DOT", minAmount = "not-a-number") + ) + ) + ) + ) + ) + + assertEquals( + listOf("destination"), + fetcher.getAvailableDestinationChains(originChainId = "origin", assetSymbol = null) + ) + + val assets = fetcher.getAvailableAssets(originChainId = "origin", destinationChainId = "destination") + assertEquals(listOf("KSM", "DOT"), assets.map { it.symbol }) + assertEquals(null, assets[0].minAmount) + assertEquals(null, assets[1].minAmount) + } + + @Test + fun `returns validated executable route spec`() = runBlocking { + val fetcher = fetcher( + chain( + id = "origin", + xcm = xcm( + destinations = listOf( + destination("destination", xcmAsset("DOT"), execution = executableRouteSpec()) + ) + ) + ) + ) + + val route = requireNotNull( + fetcher.getExecutableRoute( + originChainId = "origin", + destinationChainId = "destination", + assetSymbol = "xcdot" + ) + ) + + assertEquals("DOT", route.asset.symbol) + assertEquals(XcmTransferType.LIMITED_RESERVE_TRANSFER_ASSETS, route.executionSpec.transferType) + assertEquals(XcmArgumentShape.POLKADOT_XCM_TRANSFER_ASSETS, route.executionSpec.argumentShape) + assertEquals("v3", route.executionSpec.xcmVersion) + assertEquals(XcmWeightLimitType.LIMITED, route.executionSpec.weightLimit.type) + assertEquals(XcmJunctionType.PARACHAIN, route.executionSpec.destinationLocation.junctions.single().type) + assertEquals(XcmJunctionType.PARACHAIN, route.executionSpec.assetLocation.junctions[0].type) + assertEquals("1000", route.executionSpec.assetLocation.junctions[0].value) + assertEquals(XcmJunctionType.ACCOUNT_ID32, route.executionSpec.beneficiaryLocation.junctions.single().type) + assertTrue(route.executionSpec.beneficiaryLocation.junctions.single().value!!.contains("")) + assertTrue(fetcher.hasExecutableRouteAsset(originChainId = "origin", assetSymbol = "DOT")) + } + + @Test + fun `returns validated executable AccountKey20 route spec`() = runBlocking { + val fetcher = fetcher( + chain( + id = "origin", + xcm = xcm( + destinations = listOf( + destination( + "evm-destination", + xcmAsset("DOT"), + execution = executableRouteSpec( + beneficiaryLocation = Chain.Xcm.MultiLocation( + parents = 0, + interior = "X1(AccountKey20({network: Any, key: }))" + ) + ) + ) + ) + ) + ) + ) + + val route = requireNotNull( + fetcher.getExecutableRoute( + originChainId = "origin", + destinationChainId = "evm-destination", + assetSymbol = "DOT" + ) + ) + + assertEquals(XcmJunctionType.ACCOUNT_KEY20, route.executionSpec.beneficiaryLocation.junctions.single().type) + assertTrue(route.executionSpec.beneficiaryLocation.junctions.single().value!!.contains("")) + assertTrue(fetcher.hasExecutableRouteAsset(originChainId = "origin", assetSymbol = "DOT")) + } + + @Test + fun `missing execution spec is not executable`() = runBlocking { + val fetcher = fetcher( + chain( + id = "origin", + xcm = xcm(destinations = listOf(destination("destination", xcmAsset("DOT"), execution = null))) + ) + ) + + assertThrows(IllegalStateException::class.java) { + runBlocking { + fetcher.getExecutableRoute( + originChainId = "origin", + destinationChainId = "destination", + assetSymbol = "DOT" + ) + } + } + assertFalse(fetcher.hasExecutableRouteAsset(originChainId = "origin", assetSymbol = "DOT")) + } + + @Test + fun `malformed execution spec is not executable`() = runBlocking { + val fetcher = fetcher( + chain( + id = "origin", + xcm = xcm( + destinations = listOf( + destination( + "destination", + xcmAsset("DOT"), + execution = executableRouteSpec( + weightLimit = Chain.Xcm.WeightLimit(type = "Limited", refTime = "1", proofSize = null) + ) + ) + ) + ) + ) + ) + + assertThrows(IllegalArgumentException::class.java) { + runBlocking { + fetcher.getExecutableRoute( + originChainId = "origin", + destinationChainId = "destination", + assetSymbol = "DOT" + ) + } + } + assertFalse(fetcher.hasExecutableRouteAsset(originChainId = "origin", assetSymbol = "DOT")) + } + + @Test + fun `malformed multilocation interiors are not executable`() = runBlocking { + val malformedBeneficiary = fetcher( + chain( + id = "origin", + xcm = xcm( + destinations = listOf( + destination( + "destination", + xcmAsset("DOT"), + execution = executableRouteSpec( + beneficiaryLocation = Chain.Xcm.MultiLocation( + parents = 0, + interior = "X1(AccountId32({network: Any}))" + ) + ) + ) + ) + ) + ) + ) + val wrongJunctionCount = fetcher( + chain( + id = "origin", + xcm = xcm( + destinations = listOf( + destination( + "destination", + xcmAsset("DOT"), + execution = executableRouteSpec( + assetLocation = Chain.Xcm.MultiLocation( + parents = 1, + interior = "X2(Parachain(1000))" + ) + ) + ) + ) + ) + ) + ) + val unsupportedJunction = fetcher( + chain( + id = "origin", + xcm = xcm( + destinations = listOf( + destination( + "destination", + xcmAsset("DOT"), + execution = executableRouteSpec( + assetLocation = Chain.Xcm.MultiLocation( + parents = 1, + interior = "X1(Unsupported(1))" + ) + ) + ) + ) + ) + ) + ) + + assertThrows(IllegalArgumentException::class.java) { + runBlocking { + malformedBeneficiary.getExecutableRoute("origin", "destination", "DOT") + } + } + assertThrows(IllegalArgumentException::class.java) { + runBlocking { + wrongJunctionCount.getExecutableRoute("origin", "destination", "DOT") + } + } + assertThrows(IllegalArgumentException::class.java) { + runBlocking { + unsupportedJunction.getExecutableRoute("origin", "destination", "DOT") + } + } + + assertFalse(malformedBeneficiary.hasExecutableRouteAsset(originChainId = "origin", assetSymbol = "DOT")) + assertFalse(wrongJunctionCount.hasExecutableRouteAsset(originChainId = "origin", assetSymbol = "DOT")) + assertFalse(unsupportedJunction.hasExecutableRouteAsset(originChainId = "origin", assetSymbol = "DOT")) + } + + @Test + fun `bridge routes require matching bridge execution spec`() = runBlocking { + val fetcher = fetcher( + chain( + id = "origin", + xcm = xcm( + destinations = listOf( + destination( + "destination", + xcmAsset("DOT"), + bridgeParachainId = "2000", + execution = executableRouteSpec( + bridge = Chain.Xcm.Bridge( + parachainId = "3000", + feeAssetLocation = Chain.Xcm.MultiLocation(parents = 1, interior = "Here"), + feeAssetItem = 0 + ) + ) + ) + ) + ) + ) + ) + + assertThrows(IllegalArgumentException::class.java) { + runBlocking { + fetcher.getExecutableRoute( + originChainId = "origin", + destinationChainId = "destination", + assetSymbol = "DOT" + ) + } + } + assertFalse(fetcher.hasExecutableRouteAsset(originChainId = "origin", assetSymbol = "DOT")) + } + + @Test + fun `execution argument shapes reject unsupported and mismatched call contracts`() = runBlocking { + val unsupportedShape = fetcher( + chain( + id = "origin", + xcm = xcm( + destinations = listOf( + destination( + "destination", + xcmAsset("DOT"), + execution = executableRouteSpec(argumentShape = "operatorAlias") + ) + ) + ) + ) + ) + val mismatchedXTokensShape = fetcher( + chain( + id = "origin", + xcm = xcm( + destinations = listOf( + destination( + "destination", + xcmAsset("DOT"), + execution = executableRouteSpec( + palletName = "PolkadotXcm", + callName = "transferMultiasset", + transferType = "xTokensTransferMultiasset", + argumentShape = "xTokensTransferMultiasset" + ) + ) + ) + ) + ) + ) + + assertThrows(IllegalArgumentException::class.java) { + runBlocking { + unsupportedShape.getExecutableRoute("origin", "destination", "DOT") + } + } + assertThrows(IllegalArgumentException::class.java) { + runBlocking { + mismatchedXTokensShape.getExecutableRoute("origin", "destination", "DOT") + } + } + assertFalse(unsupportedShape.hasExecutableRouteAsset(originChainId = "origin", assetSymbol = "DOT")) + assertFalse(mismatchedXTokensShape.hasExecutableRouteAsset(originChainId = "origin", assetSymbol = "DOT")) + } + + private fun fetcher(vararg chains: Chain) = XcmEntitiesFetcher { chains.toList() } + + private fun xcm( + availableAssets: List = emptyList(), + destinations: List = emptyList() + ) = Chain.Xcm( + chainId = null, + xcmVersion = "v3", + availableAssets = availableAssets, + availableDestinations = destinations + ) + + private fun destination( + chainId: String, + vararg assets: Chain.Xcm.Asset, + bridgeParachainId: String? = null, + execution: Chain.Xcm.Execution? = null + ) = Chain.Xcm.Destination( + chainId = chainId, + assets = assets.toList(), + bridgeParachainId = bridgeParachainId, + execution = execution + ) + + private fun executableRouteSpec( + palletName: String? = "PolkadotXcm", + callName: String? = "limitedReserveTransferAssets", + transferType: String? = "limitedReserveTransferAssets", + argumentShape: String? = null, + destinationLocation: Chain.Xcm.MultiLocation? = Chain.Xcm.MultiLocation( + parents = 1, + interior = "X1(Parachain(2000))" + ), + assetLocation: Chain.Xcm.MultiLocation? = Chain.Xcm.MultiLocation( + parents = 1, + interior = "X2(Parachain(1000), GeneralKey(dot))" + ), + beneficiaryLocation: Chain.Xcm.MultiLocation? = Chain.Xcm.MultiLocation( + parents = 0, + interior = "X1(AccountId32({network: Any, id: }))" + ), + feeAssetLocation: Chain.Xcm.MultiLocation? = Chain.Xcm.MultiLocation( + parents = 1, + interior = "X2(Parachain(1000), GeneralKey(dot))" + ), + weightLimit: Chain.Xcm.WeightLimit? = Chain.Xcm.WeightLimit( + type = "Limited", + refTime = "6000000000", + proofSize = "65536" + ), + bridge: Chain.Xcm.Bridge? = null + ) = Chain.Xcm.Execution( + palletName = palletName, + callName = callName, + transferType = transferType, + argumentShape = argumentShape, + destinationLocation = destinationLocation, + assetLocation = assetLocation, + beneficiaryLocation = beneficiaryLocation, + feeAssetLocation = feeAssetLocation, + feeAssetItem = 0, + weightLimit = weightLimit, + destinationFee = Chain.Xcm.DestinationFee( + mode = "Estimated", + assetSymbol = "DOT", + amount = null + ), + bridge = bridge + ) + + private fun xcmAsset( + symbol: String, + id: String? = null, + minAmount: String? = null + ) = Chain.Xcm.Asset( + id = id, + symbol = symbol, + minAmount = minAmount + ) + + private fun chain(id: String, xcm: Chain.Xcm? = null) = Chain( + id = id, + paraId = null, + rank = null, + name = id, + minSupportedVersion = null, + assets = emptyList(), + nodes = emptyList(), + explorers = emptyList(), + externalApi = null, + icon = "", + addressPrefix = 0, + isEthereumBased = false, + isTestNet = false, + hasCrowdloans = false, + parentId = null, + supportStakingPool = false, + isEthereumChain = false, + chainlinkProvider = false, + supportNft = false, + isUsesAppId = false, + identityChain = null, + ecosystem = Ecosystem.Substrate, + androidMinAppVersion = null, + remoteAssetsSource = null, + tonBridgeUrl = null, + xcm = xcm + ) +} diff --git a/public-ui-core/build.gradle b/public-ui-core/build.gradle new file mode 100644 index 0000000000..cd6f204017 --- /dev/null +++ b/public-ui-core/build.gradle @@ -0,0 +1,31 @@ +apply plugin: 'com.android.library' +apply plugin: 'kotlin-android' +apply plugin: 'org.jetbrains.kotlin.plugin.compose' + +android { + namespace = 'jp.co.soramitsu.ui_core' + compileSdkVersion rootProject.compileSdkVersion + + defaultConfig { + minSdkVersion rootProject.minSdkVersion + targetSdkVersion rootProject.targetSdkVersion + } + + buildFeatures { + compose = true + } + + compileOptions { + sourceCompatibility = JavaVersion.VERSION_21 + targetCompatibility = JavaVersion.VERSION_21 + } + + kotlinOptions { + jvmTarget = JavaVersion.VERSION_21.toString() + } +} + +dependencies { + api libs.kotlin.stdlib.jdk7 + api libs.bundles.compose +} diff --git a/public-ui-core/src/main/java/jp/co/soramitsu/ui_core/component/button/properties/Size.kt b/public-ui-core/src/main/java/jp/co/soramitsu/ui_core/component/button/properties/Size.kt new file mode 100644 index 0000000000..3ac67d3a4d --- /dev/null +++ b/public-ui-core/src/main/java/jp/co/soramitsu/ui_core/component/button/properties/Size.kt @@ -0,0 +1,8 @@ +package jp.co.soramitsu.ui_core.component.button.properties + +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp + +object Size { + val ExtraSmall: Dp = 32.dp +} diff --git a/public-ui-core/src/main/java/jp/co/soramitsu/ui_core/component/input/number/BasicNumberInput.kt b/public-ui-core/src/main/java/jp/co/soramitsu/ui_core/component/input/number/BasicNumberInput.kt new file mode 100644 index 0000000000..752f0b36d4 --- /dev/null +++ b/public-ui-core/src/main/java/jp/co/soramitsu/ui_core/component/input/number/BasicNumberInput.kt @@ -0,0 +1,82 @@ +package jp.co.soramitsu.ui_core.component.input.number + +import androidx.compose.foundation.text.KeyboardActions +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.material.TextField +import androidx.compose.material.TextFieldDefaults +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.focus.FocusRequester +import androidx.compose.ui.focus.focusRequester +import androidx.compose.ui.focus.onFocusChanged +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.text.input.ImeAction +import androidx.compose.ui.text.input.KeyboardType +import java.math.BigDecimal + +@Composable +fun BasicNumberInput( + modifier: Modifier = Modifier, + onFocusChanged: (Boolean) -> Unit = {}, + textStyle: TextStyle = TextStyle.Default, + enabled: Boolean = true, + precision: Int = 8, + initial: BigDecimal = BigDecimal.ZERO, + onValueChanged: (BigDecimal) -> Unit = {}, + focusRequester: FocusRequester? = null, + cursorColor: Color = Color.Unspecified, + placeholder: @Composable (() -> Unit)? = null, + onKeyboardDone: () -> Unit = {} +) { + var text by remember { mutableStateOf(initial.stripTrailingZeros().toPlainString()) } + val focusModifier = focusRequester?.let { Modifier.focusRequester(it) } ?: Modifier + + LaunchedEffect(initial) { + val next = initial.stripTrailingZeros().toPlainString() + if (next != text) { + text = next + } + } + + TextField( + modifier = modifier + .then(focusModifier) + .onFocusChanged { onFocusChanged(it.isFocused) }, + value = text, + onValueChange = { candidate -> + val normalized = candidate.replace(',', '.') + if (normalized.isBlank()) { + text = "" + onValueChanged(BigDecimal.ZERO) + return@TextField + } + if (!normalized.matches(Regex("""\d*(\.\d{0,$precision})?"""))) { + return@TextField + } + text = normalized + normalized.toBigDecimalOrNull()?.let(onValueChanged) + }, + enabled = enabled, + textStyle = textStyle, + singleLine = true, + placeholder = placeholder, + keyboardOptions = KeyboardOptions( + keyboardType = KeyboardType.Decimal, + imeAction = ImeAction.Done + ), + keyboardActions = KeyboardActions(onDone = { onKeyboardDone() }), + colors = TextFieldDefaults.textFieldColors( + cursorColor = cursorColor.takeUnless { it == Color.Unspecified } ?: Color.White, + backgroundColor = Color.Transparent, + focusedIndicatorColor = Color.Transparent, + unfocusedIndicatorColor = Color.Transparent, + disabledIndicatorColor = Color.Transparent + ) + ) +} diff --git a/public-ui-core/src/main/java/jp/co/soramitsu/ui_core/modifier/ModifierExt.kt b/public-ui-core/src/main/java/jp/co/soramitsu/ui_core/modifier/ModifierExt.kt new file mode 100644 index 0000000000..68f12870ba --- /dev/null +++ b/public-ui-core/src/main/java/jp/co/soramitsu/ui_core/modifier/ModifierExt.kt @@ -0,0 +1,7 @@ +package jp.co.soramitsu.ui_core.modifier + +import androidx.compose.ui.Modifier + +inline fun Modifier.applyIf(condition: Boolean, block: Modifier.() -> Modifier): Modifier { + return if (condition) block() else this +} diff --git a/public-ui-core/src/main/java/jp/co/soramitsu/ui_core/resources/Dimens.kt b/public-ui-core/src/main/java/jp/co/soramitsu/ui_core/resources/Dimens.kt new file mode 100644 index 0000000000..db6f153512 --- /dev/null +++ b/public-ui-core/src/main/java/jp/co/soramitsu/ui_core/resources/Dimens.kt @@ -0,0 +1,8 @@ +package jp.co.soramitsu.ui_core.resources + +import androidx.compose.ui.unit.dp + +object Dimens { + val x1 = 8.dp + val x1_5 = 12.dp +} diff --git a/public-ui-core/src/main/java/jp/co/soramitsu/ui_core/theme/Theme.kt b/public-ui-core/src/main/java/jp/co/soramitsu/ui_core/theme/Theme.kt new file mode 100644 index 0000000000..650addbf07 --- /dev/null +++ b/public-ui-core/src/main/java/jp/co/soramitsu/ui_core/theme/Theme.kt @@ -0,0 +1,301 @@ +package jp.co.soramitsu.ui_core.theme + +import androidx.compose.material.MaterialTheme +import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.runtime.ReadOnlyComposable +import androidx.compose.runtime.Stable +import androidx.compose.runtime.staticCompositionLocalOf +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.unit.Dp + +@Stable +data class CustomColors( + val accentPrimary: Color, + val accentPrimaryContainer: Color, + val accentSecondary: Color, + val accentSecondaryContainer: Color, + val accentTertiary: Color, + val accentTertiaryContainer: Color, + val bgPage: Color, + val bgSurface: Color, + val bgSurfaceVariant: Color, + val bgSurfaceInverted: Color, + val fgPrimary: Color, + val fgSecondary: Color, + val fgTertiary: Color, + val fgInverted: Color, + val fgOutline: Color, + val statusSuccess: Color, + val statusSuccessContainer: Color, + val statusWarning: Color, + val statusWarningContainer: Color, + val statusError: Color, + val statusErrorContainer: Color +) + +@Stable +data class BorderRadius( + val s: Dp, + val m: Dp, + val ml: Dp, + val xl: Dp +) + +@Stable +data class CustomTypography( + val displayL: TextStyle, + val displayM: TextStyle, + val displayS: TextStyle, + val headline1: TextStyle, + val headline2: TextStyle, + val headline3: TextStyle, + val headline4: TextStyle, + val textL: TextStyle, + val textM: TextStyle, + val textS: TextStyle, + val textXS: TextStyle, + val textLBold: TextStyle, + val textMBold: TextStyle, + val textSBold: TextStyle, + val textXSBold: TextStyle, + val paragraphL: TextStyle, + val paragraphM: TextStyle, + val paragraphS: TextStyle, + val paragraphXS: TextStyle, + val paragraphLBold: TextStyle, + val paragraphMBold: TextStyle, + val paragraphSBold: TextStyle, + val paragraphXSBold: TextStyle, + val buttonM: TextStyle +) + +private val LocalCustomColors = staticCompositionLocalOf { + error("CustomColors are not provided") +} +private val LocalCustomTypography = staticCompositionLocalOf { + error("CustomTypography is not provided") +} +private val LocalBorderRadius = staticCompositionLocalOf { + error("BorderRadius is not provided") +} + +val MaterialTheme.customTypography: CustomTypography + @Composable + @ReadOnlyComposable + get() = LocalCustomTypography.current + +val MaterialTheme.customColors: CustomColors + @Composable + @ReadOnlyComposable + get() = LocalCustomColors.current + +val MaterialTheme.borderRadius: BorderRadius + @Composable + @ReadOnlyComposable + get() = LocalBorderRadius.current + +@Composable +fun AppTheme( + darkTheme: Boolean, + lightColors: CustomColors, + darkColors: CustomColors, + typography: CustomTypography, + borderRadius: BorderRadius, + content: @Composable () -> Unit +) { + CompositionLocalProvider( + LocalCustomColors provides if (darkTheme) darkColors else lightColors, + LocalCustomTypography provides typography, + LocalBorderRadius provides borderRadius + ) { + val colors = if (darkTheme) darkColors else lightColors + MaterialTheme( + colors = if (darkTheme) { + androidx.compose.material.darkColors( + primary = colors.accentPrimary, + primaryVariant = colors.accentPrimaryContainer, + secondary = colors.accentSecondary, + background = colors.bgPage, + surface = colors.bgSurface, + error = colors.statusError, + onPrimary = colors.fgInverted, + onSecondary = colors.fgInverted, + onBackground = colors.fgPrimary, + onSurface = colors.fgPrimary, + onError = colors.fgInverted + ) + } else { + androidx.compose.material.lightColors( + primary = colors.accentPrimary, + primaryVariant = colors.accentPrimaryContainer, + secondary = colors.accentSecondary, + background = colors.bgPage, + surface = colors.bgSurface, + error = colors.statusError, + onPrimary = colors.fgInverted, + onSecondary = colors.fgInverted, + onBackground = colors.fgPrimary, + onSurface = colors.fgPrimary, + onError = colors.fgInverted + ) + }, + content = content + ) + } +} + +@Suppress("LongParameterList") +fun lightColors( + accentPrimary: Color, + accentPrimaryContainer: Color, + accentSecondary: Color, + accentSecondaryContainer: Color, + accentTertiary: Color, + accentTertiaryContainer: Color, + bgPage: Color, + bgSurface: Color, + bgSurfaceVariant: Color, + bgSurfaceInverted: Color, + fgPrimary: Color, + fgSecondary: Color, + fgTertiary: Color, + fgInverted: Color, + fgOutline: Color, + statusSuccess: Color, + statusSuccessContainer: Color, + statusWarning: Color, + statusWarningContainer: Color, + statusError: Color, + statusErrorContainer: Color +): CustomColors = CustomColors( + accentPrimary, + accentPrimaryContainer, + accentSecondary, + accentSecondaryContainer, + accentTertiary, + accentTertiaryContainer, + bgPage, + bgSurface, + bgSurfaceVariant, + bgSurfaceInverted, + fgPrimary, + fgSecondary, + fgTertiary, + fgInverted, + fgOutline, + statusSuccess, + statusSuccessContainer, + statusWarning, + statusWarningContainer, + statusError, + statusErrorContainer +) + +@Suppress("LongParameterList") +fun darkColors( + accentPrimary: Color, + accentPrimaryContainer: Color, + accentSecondary: Color, + accentSecondaryContainer: Color, + accentTertiary: Color, + accentTertiaryContainer: Color, + bgPage: Color, + bgSurface: Color, + bgSurfaceVariant: Color, + bgSurfaceInverted: Color, + fgPrimary: Color, + fgSecondary: Color, + fgTertiary: Color, + fgInverted: Color, + fgOutline: Color, + statusSuccess: Color, + statusSuccessContainer: Color, + statusWarning: Color, + statusWarningContainer: Color, + statusError: Color, + statusErrorContainer: Color +): CustomColors = lightColors( + accentPrimary, + accentPrimaryContainer, + accentSecondary, + accentSecondaryContainer, + accentTertiary, + accentTertiaryContainer, + bgPage, + bgSurface, + bgSurfaceVariant, + bgSurfaceInverted, + fgPrimary, + fgSecondary, + fgTertiary, + fgInverted, + fgOutline, + statusSuccess, + statusSuccessContainer, + statusWarning, + statusWarningContainer, + statusError, + statusErrorContainer +) + +fun borderRadiuses( + s: Dp, + m: Dp, + ml: Dp, + xl: Dp +): BorderRadius = BorderRadius(s, m, ml, xl) + +@Suppress("LongParameterList") +fun defaultCustomTypography( + displayL: TextStyle, + displayM: TextStyle, + displayS: TextStyle, + headline1: TextStyle, + headline2: TextStyle, + headline3: TextStyle, + headline4: TextStyle, + textL: TextStyle, + textM: TextStyle, + textS: TextStyle, + textXS: TextStyle, + textLBold: TextStyle, + textMBold: TextStyle, + textSBold: TextStyle, + textXSBold: TextStyle, + paragraphL: TextStyle, + paragraphM: TextStyle, + paragraphS: TextStyle, + paragraphXS: TextStyle, + paragraphLBold: TextStyle, + paragraphMBold: TextStyle, + paragraphSBold: TextStyle, + paragraphXSBold: TextStyle, + buttonM: TextStyle +): CustomTypography = CustomTypography( + displayL, + displayM, + displayS, + headline1, + headline2, + headline3, + headline4, + textL, + textM, + textS, + textXS, + textLBold, + textMBold, + textSBold, + textXSBold, + paragraphL, + paragraphM, + paragraphS, + paragraphXS, + paragraphLBold, + paragraphMBold, + paragraphSBold, + paragraphXSBold, + buttonM +) diff --git a/public-xnetworking/build.gradle b/public-xnetworking/build.gradle new file mode 100644 index 0000000000..cb1d21e37d --- /dev/null +++ b/public-xnetworking/build.gradle @@ -0,0 +1,28 @@ +apply plugin: 'com.android.library' +apply plugin: 'kotlin-android' + +android { + namespace = 'jp.co.soramitsu.xnetworking' + compileSdkVersion rootProject.compileSdkVersion + + defaultConfig { + minSdkVersion rootProject.minSdkVersion + targetSdkVersion rootProject.targetSdkVersion + } + + compileOptions { + sourceCompatibility = JavaVersion.VERSION_21 + targetCompatibility = JavaVersion.VERSION_21 + } + + kotlinOptions { + jvmTarget = JavaVersion.VERSION_21.toString() + } +} + +dependencies { + api libs.kotlin.stdlib.jdk7 + api libs.coroutines.core + api libs.coroutines.android + api libs.kotlinx.serialization.json +} diff --git a/public-xnetworking/src/main/java/jp/co/soramitsu/xnetworking/lib/datasources/blockexplorer/api/BlockExplorerRepository.kt b/public-xnetworking/src/main/java/jp/co/soramitsu/xnetworking/lib/datasources/blockexplorer/api/BlockExplorerRepository.kt new file mode 100644 index 0000000000..62e28d6760 --- /dev/null +++ b/public-xnetworking/src/main/java/jp/co/soramitsu/xnetworking/lib/datasources/blockexplorer/api/BlockExplorerRepository.kt @@ -0,0 +1,16 @@ +package jp.co.soramitsu.xnetworking.lib.datasources.blockexplorer.api + +data class BlockExplorerValue( + val id: String, + val value: String +) + +interface BlockExplorerRepository { + suspend fun getApy(chainId: String): List + suspend fun getStakingRewarded(chainId: String, accountAddress: String): List + suspend fun getValidatorsList( + chainId: String, + stashAccountAddress: String, + historicalRange: List + ): List +} diff --git a/public-xnetworking/src/main/java/jp/co/soramitsu/xnetworking/lib/datasources/blockexplorer/impl/BlockExplorerRepositoryImpl.kt b/public-xnetworking/src/main/java/jp/co/soramitsu/xnetworking/lib/datasources/blockexplorer/impl/BlockExplorerRepositoryImpl.kt new file mode 100644 index 0000000000..a91edb3bc0 --- /dev/null +++ b/public-xnetworking/src/main/java/jp/co/soramitsu/xnetworking/lib/datasources/blockexplorer/impl/BlockExplorerRepositoryImpl.kt @@ -0,0 +1,23 @@ +package jp.co.soramitsu.xnetworking.lib.datasources.blockexplorer.impl + +import jp.co.soramitsu.xnetworking.lib.datasources.blockexplorer.api.BlockExplorerRepository +import jp.co.soramitsu.xnetworking.lib.datasources.blockexplorer.api.BlockExplorerValue +import jp.co.soramitsu.xnetworking.lib.datasources.chainsconfig.api.ConfigDAO +import jp.co.soramitsu.xnetworking.lib.datasources.txhistory.api.TxHistoryRepository +import jp.co.soramitsu.xnetworking.lib.engines.rest.api.RestClient + +class BlockExplorerRepositoryImpl( + val configDAO: ConfigDAO, + val restClient: RestClient, + val txHistoryRepository: TxHistoryRepository +) : BlockExplorerRepository { + override suspend fun getApy(chainId: String): List = emptyList() + + override suspend fun getStakingRewarded(chainId: String, accountAddress: String): List = emptyList() + + override suspend fun getValidatorsList( + chainId: String, + stashAccountAddress: String, + historicalRange: List + ): List = emptyList() +} diff --git a/public-xnetworking/src/main/java/jp/co/soramitsu/xnetworking/lib/datasources/chainsconfig/api/ConfigDAO.kt b/public-xnetworking/src/main/java/jp/co/soramitsu/xnetworking/lib/datasources/chainsconfig/api/ConfigDAO.kt new file mode 100644 index 0000000000..f1441e3123 --- /dev/null +++ b/public-xnetworking/src/main/java/jp/co/soramitsu/xnetworking/lib/datasources/chainsconfig/api/ConfigDAO.kt @@ -0,0 +1,3 @@ +package jp.co.soramitsu.xnetworking.lib.datasources.chainsconfig.api + +interface ConfigDAO diff --git a/public-xnetworking/src/main/java/jp/co/soramitsu/xnetworking/lib/datasources/chainsconfig/api/data/ConfigParser.kt b/public-xnetworking/src/main/java/jp/co/soramitsu/xnetworking/lib/datasources/chainsconfig/api/data/ConfigParser.kt new file mode 100644 index 0000000000..b181cf7623 --- /dev/null +++ b/public-xnetworking/src/main/java/jp/co/soramitsu/xnetworking/lib/datasources/chainsconfig/api/data/ConfigParser.kt @@ -0,0 +1,3 @@ +package jp.co.soramitsu.xnetworking.lib.datasources.chainsconfig.api.data + +interface ConfigParser diff --git a/public-xnetworking/src/main/java/jp/co/soramitsu/xnetworking/lib/datasources/chainsconfig/impl/SuperWalletConfigDAOImpl.kt b/public-xnetworking/src/main/java/jp/co/soramitsu/xnetworking/lib/datasources/chainsconfig/impl/SuperWalletConfigDAOImpl.kt new file mode 100644 index 0000000000..b961505a19 --- /dev/null +++ b/public-xnetworking/src/main/java/jp/co/soramitsu/xnetworking/lib/datasources/chainsconfig/impl/SuperWalletConfigDAOImpl.kt @@ -0,0 +1,8 @@ +package jp.co.soramitsu.xnetworking.lib.datasources.chainsconfig.impl + +import jp.co.soramitsu.xnetworking.lib.datasources.chainsconfig.api.ConfigDAO +import jp.co.soramitsu.xnetworking.lib.datasources.chainsconfig.api.data.ConfigParser + +class SuperWalletConfigDAOImpl( + val configParser: ConfigParser +) : ConfigDAO diff --git a/public-xnetworking/src/main/java/jp/co/soramitsu/xnetworking/lib/datasources/chainsconfig/impl/data/RemoteConfigParserImpl.kt b/public-xnetworking/src/main/java/jp/co/soramitsu/xnetworking/lib/datasources/chainsconfig/impl/data/RemoteConfigParserImpl.kt new file mode 100644 index 0000000000..b9ffeb13b0 --- /dev/null +++ b/public-xnetworking/src/main/java/jp/co/soramitsu/xnetworking/lib/datasources/chainsconfig/impl/data/RemoteConfigParserImpl.kt @@ -0,0 +1,9 @@ +package jp.co.soramitsu.xnetworking.lib.datasources.chainsconfig.impl.data + +import jp.co.soramitsu.xnetworking.lib.datasources.chainsconfig.api.data.ConfigParser +import jp.co.soramitsu.xnetworking.lib.engines.rest.api.RestClient + +class RemoteConfigParserImpl( + val restClient: RestClient, + val chainsRequestUrl: String +) : ConfigParser diff --git a/public-xnetworking/src/main/java/jp/co/soramitsu/xnetworking/lib/datasources/txhistory/api/HistoryItemsFilter.kt b/public-xnetworking/src/main/java/jp/co/soramitsu/xnetworking/lib/datasources/txhistory/api/HistoryItemsFilter.kt new file mode 100644 index 0000000000..0f6c74d77a --- /dev/null +++ b/public-xnetworking/src/main/java/jp/co/soramitsu/xnetworking/lib/datasources/txhistory/api/HistoryItemsFilter.kt @@ -0,0 +1,8 @@ +package jp.co.soramitsu.xnetworking.lib.datasources.txhistory.api + +import jp.co.soramitsu.xnetworking.lib.datasources.txhistory.api.models.TxHistoryItem + +interface HistoryItemsFilter { + fun List.filterCachedHistoryItems(): List + fun List.filterPagedHistoryItems(): List +} diff --git a/public-xnetworking/src/main/java/jp/co/soramitsu/xnetworking/lib/datasources/txhistory/api/TxHistoryRepository.kt b/public-xnetworking/src/main/java/jp/co/soramitsu/xnetworking/lib/datasources/txhistory/api/TxHistoryRepository.kt new file mode 100644 index 0000000000..7d2ad7473a --- /dev/null +++ b/public-xnetworking/src/main/java/jp/co/soramitsu/xnetworking/lib/datasources/txhistory/api/TxHistoryRepository.kt @@ -0,0 +1,3 @@ +package jp.co.soramitsu.xnetworking.lib.datasources.txhistory.api + +interface TxHistoryRepository diff --git a/public-xnetworking/src/main/java/jp/co/soramitsu/xnetworking/lib/datasources/txhistory/api/adapters/HistoryInfoRemoteLoader.kt b/public-xnetworking/src/main/java/jp/co/soramitsu/xnetworking/lib/datasources/txhistory/api/adapters/HistoryInfoRemoteLoader.kt new file mode 100644 index 0000000000..6a1233a6bb --- /dev/null +++ b/public-xnetworking/src/main/java/jp/co/soramitsu/xnetworking/lib/datasources/txhistory/api/adapters/HistoryInfoRemoteLoader.kt @@ -0,0 +1,15 @@ +package jp.co.soramitsu.xnetworking.lib.datasources.txhistory.api.adapters + +import jp.co.soramitsu.xnetworking.lib.datasources.txhistory.api.models.ChainInfo +import jp.co.soramitsu.xnetworking.lib.datasources.txhistory.api.models.TxFilter +import jp.co.soramitsu.xnetworking.lib.datasources.txhistory.api.models.TxHistoryInfo + +interface HistoryInfoRemoteLoader { + suspend fun loadHistoryInfo( + pageCount: Int, + cursor: String?, + signAddress: String, + chainInfo: ChainInfo, + filters: Set + ): TxHistoryInfo +} diff --git a/public-xnetworking/src/main/java/jp/co/soramitsu/xnetworking/lib/datasources/txhistory/api/models/ChainInfo.kt b/public-xnetworking/src/main/java/jp/co/soramitsu/xnetworking/lib/datasources/txhistory/api/models/ChainInfo.kt new file mode 100644 index 0000000000..5417aeef3b --- /dev/null +++ b/public-xnetworking/src/main/java/jp/co/soramitsu/xnetworking/lib/datasources/txhistory/api/models/ChainInfo.kt @@ -0,0 +1,5 @@ +package jp.co.soramitsu.xnetworking.lib.datasources.txhistory.api.models + +sealed class ChainInfo { + data class Simple(val chainId: String) : ChainInfo() +} diff --git a/public-xnetworking/src/main/java/jp/co/soramitsu/xnetworking/lib/datasources/txhistory/api/models/TxFilter.kt b/public-xnetworking/src/main/java/jp/co/soramitsu/xnetworking/lib/datasources/txhistory/api/models/TxFilter.kt new file mode 100644 index 0000000000..56a53cf250 --- /dev/null +++ b/public-xnetworking/src/main/java/jp/co/soramitsu/xnetworking/lib/datasources/txhistory/api/models/TxFilter.kt @@ -0,0 +1,7 @@ +package jp.co.soramitsu.xnetworking.lib.datasources.txhistory.api.models + +enum class TxFilter { + TRANSFER, + REWARD, + EXTRINSIC +} diff --git a/public-xnetworking/src/main/java/jp/co/soramitsu/xnetworking/lib/datasources/txhistory/api/models/TxHistoryModels.kt b/public-xnetworking/src/main/java/jp/co/soramitsu/xnetworking/lib/datasources/txhistory/api/models/TxHistoryModels.kt new file mode 100644 index 0000000000..848e66f916 --- /dev/null +++ b/public-xnetworking/src/main/java/jp/co/soramitsu/xnetworking/lib/datasources/txhistory/api/models/TxHistoryModels.kt @@ -0,0 +1,31 @@ +package jp.co.soramitsu.xnetworking.lib.datasources.txhistory.api.models + +data class TxHistoryInfo( + val endCursor: String?, + val endReached: Boolean, + val items: List +) + +data class TxHistoryItem( + val id: String, + val blockHash: String, + val module: String, + val method: String, + val timestamp: String, + val networkFee: String, + val success: Boolean, + val data: List?, + val nestedData: List? +) + +data class TxHistoryItemParam( + val paramName: String, + val paramValue: String +) + +data class TxHistoryItemNested( + val module: String, + val method: String, + val hash: String, + val data: List +) diff --git a/public-xnetworking/src/main/java/jp/co/soramitsu/xnetworking/lib/datasources/txhistory/impl/TxHistoryRepositoryImpl.kt b/public-xnetworking/src/main/java/jp/co/soramitsu/xnetworking/lib/datasources/txhistory/impl/TxHistoryRepositoryImpl.kt new file mode 100644 index 0000000000..645750c950 --- /dev/null +++ b/public-xnetworking/src/main/java/jp/co/soramitsu/xnetworking/lib/datasources/txhistory/impl/TxHistoryRepositoryImpl.kt @@ -0,0 +1,14 @@ +package jp.co.soramitsu.xnetworking.lib.datasources.txhistory.impl + +import jp.co.soramitsu.xnetworking.lib.datasources.chainsconfig.api.ConfigDAO +import jp.co.soramitsu.xnetworking.lib.datasources.txhistory.api.HistoryItemsFilter +import jp.co.soramitsu.xnetworking.lib.datasources.txhistory.api.TxHistoryRepository +import jp.co.soramitsu.xnetworking.lib.datasources.txhistory.impl.builder.ExpectActualDBDriverFactory +import jp.co.soramitsu.xnetworking.lib.engines.rest.api.RestClient + +class TxHistoryRepositoryImpl( + val historyItemsFilter: HistoryItemsFilter, + val restClient: RestClient, + val configDAO: ConfigDAO, + val databaseDriverFactory: ExpectActualDBDriverFactory +) : TxHistoryRepository diff --git a/public-xnetworking/src/main/java/jp/co/soramitsu/xnetworking/lib/datasources/txhistory/impl/builder/ExpectActualDBDriverFactory.kt b/public-xnetworking/src/main/java/jp/co/soramitsu/xnetworking/lib/datasources/txhistory/impl/builder/ExpectActualDBDriverFactory.kt new file mode 100644 index 0000000000..e1ff80fc9b --- /dev/null +++ b/public-xnetworking/src/main/java/jp/co/soramitsu/xnetworking/lib/datasources/txhistory/impl/builder/ExpectActualDBDriverFactory.kt @@ -0,0 +1,8 @@ +package jp.co.soramitsu.xnetworking.lib.datasources.txhistory.impl.builder + +import android.content.Context + +class ExpectActualDBDriverFactory( + val context: Context, + val name: String +) diff --git a/public-xnetworking/src/main/java/jp/co/soramitsu/xnetworking/lib/datasources/txhistory/impl/domain/adapters/HistoryInfoRemoteLoaderFacade.kt b/public-xnetworking/src/main/java/jp/co/soramitsu/xnetworking/lib/datasources/txhistory/impl/domain/adapters/HistoryInfoRemoteLoaderFacade.kt new file mode 100644 index 0000000000..abeb3e8ffe --- /dev/null +++ b/public-xnetworking/src/main/java/jp/co/soramitsu/xnetworking/lib/datasources/txhistory/impl/domain/adapters/HistoryInfoRemoteLoaderFacade.kt @@ -0,0 +1,25 @@ +package jp.co.soramitsu.xnetworking.lib.datasources.txhistory.impl.domain.adapters + +import jp.co.soramitsu.xnetworking.lib.datasources.chainsconfig.api.ConfigDAO +import jp.co.soramitsu.xnetworking.lib.datasources.txhistory.api.adapters.HistoryInfoRemoteLoader +import jp.co.soramitsu.xnetworking.lib.datasources.txhistory.api.models.ChainInfo +import jp.co.soramitsu.xnetworking.lib.datasources.txhistory.api.models.TxFilter +import jp.co.soramitsu.xnetworking.lib.datasources.txhistory.api.models.TxHistoryInfo +import jp.co.soramitsu.xnetworking.lib.engines.rest.api.RestClient + +class HistoryInfoRemoteLoaderFacade( + val configDAO: ConfigDAO, + val restClient: RestClient +) : HistoryInfoRemoteLoader { + override suspend fun loadHistoryInfo( + pageCount: Int, + cursor: String?, + signAddress: String, + chainInfo: ChainInfo, + filters: Set + ): TxHistoryInfo = TxHistoryInfo( + endCursor = null, + endReached = true, + items = emptyList() + ) +} diff --git a/public-xnetworking/src/main/java/jp/co/soramitsu/xnetworking/lib/engines/rest/api/RestClient.kt b/public-xnetworking/src/main/java/jp/co/soramitsu/xnetworking/lib/engines/rest/api/RestClient.kt new file mode 100644 index 0000000000..fe9bc641bd --- /dev/null +++ b/public-xnetworking/src/main/java/jp/co/soramitsu/xnetworking/lib/engines/rest/api/RestClient.kt @@ -0,0 +1,7 @@ +package jp.co.soramitsu.xnetworking.lib.engines.rest.api + +import jp.co.soramitsu.xnetworking.lib.engines.rest.api.models.AbstractRestClientConfig + +interface RestClient { + val config: AbstractRestClientConfig +} diff --git a/public-xnetworking/src/main/java/jp/co/soramitsu/xnetworking/lib/engines/rest/api/models/AbstractRestClientConfig.kt b/public-xnetworking/src/main/java/jp/co/soramitsu/xnetworking/lib/engines/rest/api/models/AbstractRestClientConfig.kt new file mode 100644 index 0000000000..f1c41a9e72 --- /dev/null +++ b/public-xnetworking/src/main/java/jp/co/soramitsu/xnetworking/lib/engines/rest/api/models/AbstractRestClientConfig.kt @@ -0,0 +1,11 @@ +package jp.co.soramitsu.xnetworking.lib.engines.rest.api.models + +import kotlinx.serialization.json.Json + +abstract class AbstractRestClientConfig { + abstract fun getConnectTimeoutMillis(): Long + abstract fun getOrCreateJsonConfig(): Json + abstract fun getRequestTimeoutMillis(): Long + abstract fun getSocketTimeoutMillis(): Long + abstract fun isLoggingEnabled(): Boolean +} diff --git a/public-xnetworking/src/main/java/jp/co/soramitsu/xnetworking/lib/engines/rest/impl/RestClientImpl.kt b/public-xnetworking/src/main/java/jp/co/soramitsu/xnetworking/lib/engines/rest/impl/RestClientImpl.kt new file mode 100644 index 0000000000..025502feb4 --- /dev/null +++ b/public-xnetworking/src/main/java/jp/co/soramitsu/xnetworking/lib/engines/rest/impl/RestClientImpl.kt @@ -0,0 +1,10 @@ +package jp.co.soramitsu.xnetworking.lib.engines.rest.impl + +import jp.co.soramitsu.xnetworking.lib.engines.rest.api.RestClient +import jp.co.soramitsu.xnetworking.lib.engines.rest.api.models.AbstractRestClientConfig + +class RestClientImpl( + restClientConfig: AbstractRestClientConfig +) : RestClient { + override val config: AbstractRestClientConfig = restClientConfig +} diff --git a/runtime/build.gradle b/runtime/build.gradle index 3c53d70e21..ad569c59f9 100644 --- a/runtime/build.gradle +++ b/runtime/build.gradle @@ -15,8 +15,8 @@ android { // Allow overriding runtime/type sources to align with specific Polkadot SDK releases def TYPES_URL_DEFAULT = "https://raw.githubusercontent.com/soramitsu/shared-features-utils/master/chains/all_chains_types_android.json" def DEFAULT_V13_TYPES_URL_DEFAULT = "https://raw.githubusercontent.com/soramitsu/shared-features-utils/master/chains/default_v13_types.json" - def typesUrl = readSecret("TYPES_URL_OVERRIDE") ?: TYPES_URL_DEFAULT - def defaultV13TypesUrl = readSecret("DEFAULT_V13_TYPES_URL_OVERRIDE") ?: DEFAULT_V13_TYPES_URL_DEFAULT + def typesUrl = readOptionalSecret("TYPES_URL_OVERRIDE") ?: TYPES_URL_DEFAULT + def defaultV13TypesUrl = readOptionalSecret("DEFAULT_V13_TYPES_URL_OVERRIDE") ?: DEFAULT_V13_TYPES_URL_DEFAULT buildConfigField "String", "TYPES_URL", "\"${typesUrl}\"" buildConfigField "String", "DEFAULT_V13_TYPES_URL", "\"${defaultV13TypesUrl}\"" } @@ -24,13 +24,13 @@ android { buildTypes { debug { def CHAINS_URL_DEBUG_DEFAULT = "https://raw.githubusercontent.com/soramitsu/shared-features-utils/develop-free/chains/v13/chains_dev_prod.json" - def chainsUrlDebug = readSecret("CHAINS_URL_DEBUG_OVERRIDE") ?: (readSecret("CHAINS_URL_OVERRIDE") ?: CHAINS_URL_DEBUG_DEFAULT) + def chainsUrlDebug = readOptionalSecret("CHAINS_URL_DEBUG_OVERRIDE") ?: (readOptionalSecret("CHAINS_URL_OVERRIDE") ?: CHAINS_URL_DEBUG_DEFAULT) buildConfigField "String", "CHAINS_URL", "\"${chainsUrlDebug}\"" } release { def CHAINS_URL_RELEASE_DEFAULT = "https://raw.githubusercontent.com/soramitsu/shared-features-utils/master/chains/v13/chains.json" - def chainsUrlRelease = readSecret("CHAINS_URL_RELEASE_OVERRIDE") ?: (readSecret("CHAINS_URL_OVERRIDE") ?: CHAINS_URL_RELEASE_DEFAULT) + def chainsUrlRelease = readOptionalSecret("CHAINS_URL_RELEASE_OVERRIDE") ?: (readOptionalSecret("CHAINS_URL_OVERRIDE") ?: CHAINS_URL_RELEASE_DEFAULT) buildConfigField "String", "CHAINS_URL", "\"${chainsUrlRelease}\"" } } diff --git a/runtime/src/main/assets/local_chains.json b/runtime/src/main/assets/local_chains.json index acaf8a17da..6fa40e7854 100644 --- a/runtime/src/main/assets/local_chains.json +++ b/runtime/src/main/assets/local_chains.json @@ -77,7 +77,36 @@ "id": "99e66d4f-00cd-4d73-bd1b-3adadcacffb2", "symbol": "DOT" } - ] + ], + "execution": { + "palletName": "PolkadotXcm", + "callName": "limitedReserveTransferAssets", + "transferType": "limitedReserveTransferAssets", + "destinationLocation": { + "parents": 0, + "interior": "X1(Parachain(1000))" + }, + "assetLocation": { + "parents": 0, + "interior": "Here" + }, + "beneficiaryLocation": { + "parents": 0, + "interior": "X1(AccountId32({network: Any, id: }))" + }, + "feeAssetLocation": { + "parents": 0, + "interior": "Here" + }, + "feeAssetItem": 0, + "weightLimit": { + "type": "Unlimited" + }, + "destinationFee": { + "mode": "Included", + "assetSymbol": "DOT" + } + } }, { "chainId": "fe58ea77779b7abda7da4ec526d14db9b1e9cd40a217c34892af80a9b332b76d", @@ -86,7 +115,36 @@ "id": "99e66d4f-00cd-4d73-bd1b-3adadcacffb2", "symbol": "DOT" } - ] + ], + "execution": { + "palletName": "PolkadotXcm", + "callName": "limitedReserveTransferAssets", + "transferType": "limitedReserveTransferAssets", + "destinationLocation": { + "parents": 0, + "interior": "X1(Parachain(2004))" + }, + "assetLocation": { + "parents": 0, + "interior": "Here" + }, + "beneficiaryLocation": { + "parents": 0, + "interior": "X1(AccountKey20({network: Any, key: }))" + }, + "feeAssetLocation": { + "parents": 0, + "interior": "Here" + }, + "feeAssetItem": 0, + "weightLimit": { + "type": "Unlimited" + }, + "destinationFee": { + "mode": "Included", + "assetSymbol": "DOT" + } + } }, { "chainId": "fc41b9bd8ef8fe53d58c7ea67c794c7ec9a73daf05e6d54b14ff6342c99ba64c", @@ -95,7 +153,36 @@ "id": "99e66d4f-00cd-4d73-bd1b-3adadcacffb2", "symbol": "DOT" } - ] + ], + "execution": { + "palletName": "PolkadotXcm", + "callName": "limitedReserveTransferAssets", + "transferType": "limitedReserveTransferAssets", + "destinationLocation": { + "parents": 0, + "interior": "X1(Parachain(2000))" + }, + "assetLocation": { + "parents": 0, + "interior": "Here" + }, + "beneficiaryLocation": { + "parents": 0, + "interior": "X1(AccountId32({network: Any, id: }))" + }, + "feeAssetLocation": { + "parents": 0, + "interior": "Here" + }, + "feeAssetItem": 0, + "weightLimit": { + "type": "Unlimited" + }, + "destinationFee": { + "mode": "Included", + "assetSymbol": "DOT" + } + } }, { "chainId": "e61a41c53f5dcd0beb09df93b34402aada44cb05117b71059cce40a2723a4e97", @@ -104,7 +191,36 @@ "id": "99e66d4f-00cd-4d73-bd1b-3adadcacffb2", "symbol": "DOT" } - ] + ], + "execution": { + "palletName": "PolkadotXcm", + "callName": "limitedReserveTransferAssets", + "transferType": "limitedReserveTransferAssets", + "destinationLocation": { + "parents": 0, + "interior": "X1(Parachain(2012))" + }, + "assetLocation": { + "parents": 0, + "interior": "Here" + }, + "beneficiaryLocation": { + "parents": 0, + "interior": "X1(AccountId32({network: Any, id: }))" + }, + "feeAssetLocation": { + "parents": 0, + "interior": "Here" + }, + "feeAssetItem": 0, + "weightLimit": { + "type": "Unlimited" + }, + "destinationFee": { + "mode": "Included", + "assetSymbol": "DOT" + } + } }, { "chainId": "7e4e32d0feafd4f9c9414b0be86373f9a1efa904809b683453a9af6856d38ad5", @@ -213,7 +329,36 @@ "id": "0ceffe96-8090-404e-815c-91118ee5dd65", "symbol": "KSM" } - ] + ], + "execution": { + "palletName": "PolkadotXcm", + "callName": "limitedReserveTransferAssets", + "transferType": "limitedReserveTransferAssets", + "destinationLocation": { + "parents": 0, + "interior": "X1(Parachain(1000))" + }, + "assetLocation": { + "parents": 0, + "interior": "Here" + }, + "beneficiaryLocation": { + "parents": 0, + "interior": "X1(AccountId32({network: Any, id: }))" + }, + "feeAssetLocation": { + "parents": 0, + "interior": "Here" + }, + "feeAssetItem": 0, + "weightLimit": { + "type": "Unlimited" + }, + "destinationFee": { + "mode": "Included", + "assetSymbol": "KSM" + } + } }, { "chainId": "401a1f9dca3da46f5c4091016c8a2f26dcea05865116b286f60f668207d1474b", @@ -222,7 +367,36 @@ "id": "0ceffe96-8090-404e-815c-91118ee5dd65", "symbol": "KSM" } - ] + ], + "execution": { + "palletName": "PolkadotXcm", + "callName": "limitedReserveTransferAssets", + "transferType": "limitedReserveTransferAssets", + "destinationLocation": { + "parents": 0, + "interior": "X1(Parachain(2023))" + }, + "assetLocation": { + "parents": 0, + "interior": "Here" + }, + "beneficiaryLocation": { + "parents": 0, + "interior": "X1(AccountKey20({network: Any, key: }))" + }, + "feeAssetLocation": { + "parents": 0, + "interior": "Here" + }, + "feeAssetItem": 0, + "weightLimit": { + "type": "Unlimited" + }, + "destinationFee": { + "mode": "Included", + "assetSymbol": "KSM" + } + } }, { "chainId": "baf5aabe40646d11f0ee8abbdc64f4a4b7674925cba08e4a05ff9ebed6e2126b", @@ -231,7 +405,36 @@ "id": "0ceffe96-8090-404e-815c-91118ee5dd65", "symbol": "KSM" } - ] + ], + "execution": { + "palletName": "PolkadotXcm", + "callName": "limitedReserveTransferAssets", + "transferType": "limitedReserveTransferAssets", + "destinationLocation": { + "parents": 0, + "interior": "X1(Parachain(2000))" + }, + "assetLocation": { + "parents": 0, + "interior": "Here" + }, + "beneficiaryLocation": { + "parents": 0, + "interior": "X1(AccountId32({network: Any, id: }))" + }, + "feeAssetLocation": { + "parents": 0, + "interior": "Here" + }, + "feeAssetItem": 0, + "weightLimit": { + "type": "Unlimited" + }, + "destinationFee": { + "mode": "Included", + "assetSymbol": "KSM" + } + } }, { "chainId": "7e4e32d0feafd4f9c9414b0be86373f9a1efa904809b683453a9af6856d38ad5", @@ -411,7 +614,36 @@ "id": "0ceffe96-8090-404e-815c-91118ee5dd65", "symbol": "KSM" } - ] + ], + "execution": { + "palletName": "PolkadotXcm", + "callName": "limitedReserveTransferAssets", + "transferType": "limitedReserveTransferAssets", + "destinationLocation": { + "parents": 1, + "interior": "Here" + }, + "assetLocation": { + "parents": 1, + "interior": "Here" + }, + "beneficiaryLocation": { + "parents": 0, + "interior": "X1(AccountId32({network: Any, id: }))" + }, + "feeAssetLocation": { + "parents": 1, + "interior": "Here" + }, + "feeAssetItem": 0, + "weightLimit": { + "type": "Unlimited" + }, + "destinationFee": { + "mode": "Included", + "assetSymbol": "KSM" + } + } }, { "chainId": "401a1f9dca3da46f5c4091016c8a2f26dcea05865116b286f60f668207d1474b", @@ -580,7 +812,36 @@ "id": "99e66d4f-00cd-4d73-bd1b-3adadcacffb2", "symbol": "DOT" } - ] + ], + "execution": { + "palletName": "PolkadotXcm", + "callName": "limitedReserveTransferAssets", + "transferType": "limitedReserveTransferAssets", + "destinationLocation": { + "parents": 1, + "interior": "Here" + }, + "assetLocation": { + "parents": 1, + "interior": "Here" + }, + "beneficiaryLocation": { + "parents": 0, + "interior": "X1(AccountId32({network: Any, id: }))" + }, + "feeAssetLocation": { + "parents": 1, + "interior": "Here" + }, + "feeAssetItem": 0, + "weightLimit": { + "type": "Unlimited" + }, + "destinationFee": { + "mode": "Included", + "assetSymbol": "DOT" + } + } }, { "chainId": "fe58ea77779b7abda7da4ec526d14db9b1e9cd40a217c34892af80a9b332b76d", @@ -827,7 +1088,36 @@ "id": "99e66d4f-00cd-4d73-bd1b-3adadcacffb2", "symbol": "DOT" } - ] + ], + "execution": { + "palletName": "PolkadotXcm", + "callName": "limitedReserveTransferAssets", + "transferType": "limitedReserveTransferAssets", + "destinationLocation": { + "parents": 1, + "interior": "Here" + }, + "assetLocation": { + "parents": 1, + "interior": "Here" + }, + "beneficiaryLocation": { + "parents": 0, + "interior": "X1(AccountId32({network: Any, id: }))" + }, + "feeAssetLocation": { + "parents": 1, + "interior": "Here" + }, + "feeAssetItem": 0, + "weightLimit": { + "type": "Unlimited" + }, + "destinationFee": { + "mode": "Included", + "assetSymbol": "DOT" + } + } }, { "chainId": "fe58ea77779b7abda7da4ec526d14db9b1e9cd40a217c34892af80a9b332b76d", @@ -1286,7 +1576,36 @@ "id": "0ceffe96-8090-404e-815c-91118ee5dd65", "symbol": "KSM" } - ] + ], + "execution": { + "palletName": "PolkadotXcm", + "callName": "limitedReserveTransferAssets", + "transferType": "limitedReserveTransferAssets", + "destinationLocation": { + "parents": 1, + "interior": "Here" + }, + "assetLocation": { + "parents": 1, + "interior": "Here" + }, + "beneficiaryLocation": { + "parents": 0, + "interior": "X1(AccountId32({network: Any, id: }))" + }, + "feeAssetLocation": { + "parents": 1, + "interior": "Here" + }, + "feeAssetItem": 0, + "weightLimit": { + "type": "Unlimited" + }, + "destinationFee": { + "mode": "Included", + "assetSymbol": "KSM" + } + } }, { "chainId": "48239ef607d7928874027a43a67689209727dfb3d3dc5e5b03a39bdc2eda771a", @@ -1519,7 +1838,36 @@ "id": "0ceffe96-8090-404e-815c-91118ee5dd65", "symbol": "KSM" } - ] + ], + "execution": { + "palletName": "PolkadotXcm", + "callName": "limitedReserveTransferAssets", + "transferType": "limitedReserveTransferAssets", + "destinationLocation": { + "parents": 1, + "interior": "Here" + }, + "assetLocation": { + "parents": 1, + "interior": "Here" + }, + "beneficiaryLocation": { + "parents": 0, + "interior": "X1(AccountId32({network: Any, id: }))" + }, + "feeAssetLocation": { + "parents": 1, + "interior": "Here" + }, + "feeAssetItem": 0, + "weightLimit": { + "type": "Unlimited" + }, + "destinationFee": { + "mode": "Included", + "assetSymbol": "KSM" + } + } }, { "chainId": "9f28c6a68e0fc9646eff64935684f6eeeece527e37bbe1f213d22caa1d9d6bed", @@ -1838,7 +2186,36 @@ "id": "0ceffe96-8090-404e-815c-91118ee5dd65", "symbol": "KSM" } - ] + ], + "execution": { + "palletName": "PolkadotXcm", + "callName": "limitedReserveTransferAssets", + "transferType": "limitedReserveTransferAssets", + "destinationLocation": { + "parents": 1, + "interior": "Here" + }, + "assetLocation": { + "parents": 1, + "interior": "Here" + }, + "beneficiaryLocation": { + "parents": 0, + "interior": "X1(AccountId32({network: Any, id: }))" + }, + "feeAssetLocation": { + "parents": 1, + "interior": "Here" + }, + "feeAssetItem": 0, + "weightLimit": { + "type": "Unlimited" + }, + "destinationFee": { + "mode": "Included", + "assetSymbol": "KSM" + } + } }, { "chainId": "48239ef607d7928874027a43a67689209727dfb3d3dc5e5b03a39bdc2eda771a", @@ -2778,7 +3155,36 @@ "id": "99e66d4f-00cd-4d73-bd1b-3adadcacffb2", "symbol": "DOT" } - ] + ], + "execution": { + "palletName": "PolkadotXcm", + "callName": "limitedReserveTransferAssets", + "transferType": "limitedReserveTransferAssets", + "destinationLocation": { + "parents": 1, + "interior": "Here" + }, + "assetLocation": { + "parents": 1, + "interior": "Here" + }, + "beneficiaryLocation": { + "parents": 0, + "interior": "X1(AccountId32({network: Any, id: }))" + }, + "feeAssetLocation": { + "parents": 1, + "interior": "Here" + }, + "feeAssetItem": 0, + "weightLimit": { + "type": "Unlimited" + }, + "destinationFee": { + "mode": "Included", + "assetSymbol": "DOT" + } + } }, { "chainId": "fc41b9bd8ef8fe53d58c7ea67c794c7ec9a73daf05e6d54b14ff6342c99ba64c", @@ -3074,7 +3480,36 @@ "id": "99e66d4f-00cd-4d73-bd1b-3adadcacffb2", "symbol": "DOT" } - ] + ], + "execution": { + "palletName": "PolkadotXcm", + "callName": "limitedReserveTransferAssets", + "transferType": "limitedReserveTransferAssets", + "destinationLocation": { + "parents": 1, + "interior": "Here" + }, + "assetLocation": { + "parents": 1, + "interior": "Here" + }, + "beneficiaryLocation": { + "parents": 0, + "interior": "X1(AccountId32({network: Any, id: }))" + }, + "feeAssetLocation": { + "parents": 1, + "interior": "Here" + }, + "feeAssetItem": 0, + "weightLimit": { + "type": "Unlimited" + }, + "destinationFee": { + "mode": "Included", + "assetSymbol": "DOT" + } + } }, { "chainId": "fc41b9bd8ef8fe53d58c7ea67c794c7ec9a73daf05e6d54b14ff6342c99ba64c", @@ -5606,7 +6041,7 @@ "currencyId": "0x02000a0000000000000000000000000000000000000000000000000000000000", "precision": 18, "icon": "https://raw.githubusercontent.com/soramitsu/fearless-utils/master/icons/tokens/coloured/TBCD.svg", - "color":"6D8954", + "color": "6D8954", "type": "soraAsset", "isNative": true, "priceProvider": { @@ -5621,7 +6056,7 @@ "currencyId": "0x002d4e9e03f192cc33b128319a049f353db98fbf4d98f717fd0b7f66a0462142", "precision": 18, "icon": "https://raw.githubusercontent.com/soramitsu/fearless-utils/master/icons/tokens/coloured/HMX.svg", - "color":"FFFFFF", + "color": "FFFFFF", "type": "soraAsset", "isNative": true, "priceProvider": { @@ -8878,7 +9313,7 @@ "externalApi": { "history": { "type": "ton", - "url": "https://keeper.tonapi.io" + "url": "https://ti.soramitsu.io" }, "explorers": [ { @@ -8906,13 +9341,15 @@ ], "nodes": [ { - "url": "https://keeper.tonapi.io", - "name": "Keeper api" + "url": "https://ti.soramitsu.io", + "name": "Soramitsu TON indexer" } ], "icon": "https://raw.githubusercontent.com/soramitsu/shared-features-utils/master/icons/tokens/coloured/TON.svg", "addressPrefix": 0, - "options": ["remoteAssets"] + "options": [ + "remoteAssets" + ] }, { "disabled": false, @@ -8923,7 +9360,7 @@ "externalApi": { "history": { "type": "ton", - "url": "https://keeper.tonapi.io" + "url": "https://ti.soramitsu.io" }, "explorers": [ { @@ -8962,4 +9399,4 @@ "testnet" ] } -] \ No newline at end of file +] diff --git a/runtime/src/main/java/jp/co/soramitsu/runtime/di/ChainRegistryModule.kt b/runtime/src/main/java/jp/co/soramitsu/runtime/di/ChainRegistryModule.kt index b0c5fd8268..c056205e73 100644 --- a/runtime/src/main/java/jp/co/soramitsu/runtime/di/ChainRegistryModule.kt +++ b/runtime/src/main/java/jp/co/soramitsu/runtime/di/ChainRegistryModule.kt @@ -30,7 +30,7 @@ import jp.co.soramitsu.runtime.multiNetwork.runtime.RuntimeSubscriptionPool import jp.co.soramitsu.runtime.multiNetwork.runtime.RuntimeSyncService import jp.co.soramitsu.runtime.multiNetwork.runtime.types.TypesFetcher import jp.co.soramitsu.runtime.storage.NodesSettingsStorage -import jp.co.soramitsu.shared_utils.wsrpc.SocketService +import jp.co.soramitsu.fearless_utils.wsrpc.SocketService import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.serialization.json.Json import javax.inject.Provider diff --git a/runtime/src/main/java/jp/co/soramitsu/runtime/di/RuntimeModule.kt b/runtime/src/main/java/jp/co/soramitsu/runtime/di/RuntimeModule.kt index 00665e93d2..ff6f12a53f 100644 --- a/runtime/src/main/java/jp/co/soramitsu/runtime/di/RuntimeModule.kt +++ b/runtime/src/main/java/jp/co/soramitsu/runtime/di/RuntimeModule.kt @@ -85,9 +85,9 @@ class RuntimeModule { @Provides @Singleton fun provideMortalityProvider( - chainRegistry: ChainRegistry, - rpcCalls: RpcCalls - ) = MortalityConstructor(rpcCalls, chainRegistry) + rpcCalls: RpcCalls, + chainStateRepository: IChainStateRepository + ) = MortalityConstructor(rpcCalls, chainStateRepository) @Provides @Singleton diff --git a/runtime/src/main/java/jp/co/soramitsu/runtime/ext/ChainExt.kt b/runtime/src/main/java/jp/co/soramitsu/runtime/ext/ChainExt.kt index 0450898cb4..7ed8e9e64c 100644 --- a/runtime/src/main/java/jp/co/soramitsu/runtime/ext/ChainExt.kt +++ b/runtime/src/main/java/jp/co/soramitsu/runtime/ext/ChainExt.kt @@ -7,10 +7,10 @@ import jp.co.soramitsu.core.models.Ecosystem import jp.co.soramitsu.core.models.MultiAddress import jp.co.soramitsu.runtime.multiNetwork.chain.model.Chain import jp.co.soramitsu.runtime.multiNetwork.chain.ton.V4R2WalletContract -import jp.co.soramitsu.shared_utils.extensions.fromHex -import jp.co.soramitsu.shared_utils.extensions.toHexString -import jp.co.soramitsu.shared_utils.ss58.SS58Encoder.toAccountId -import jp.co.soramitsu.shared_utils.ss58.SS58Encoder.toAddress +import jp.co.soramitsu.fearless_utils.extensions.fromHex +import jp.co.soramitsu.fearless_utils.extensions.toHexString +import jp.co.soramitsu.fearless_utils.ss58.SS58Encoder.toAccountId +import jp.co.soramitsu.fearless_utils.ss58.SS58Encoder.toAddress fun Chain.addressOf(accountId: ByteArray): String { return when (ecosystem) { diff --git a/runtime/src/main/java/jp/co/soramitsu/runtime/ext/UniversalWalletBitcoinExt.kt b/runtime/src/main/java/jp/co/soramitsu/runtime/ext/UniversalWalletBitcoinExt.kt new file mode 100644 index 0000000000..06872afd82 --- /dev/null +++ b/runtime/src/main/java/jp/co/soramitsu/runtime/ext/UniversalWalletBitcoinExt.kt @@ -0,0 +1,58 @@ +package jp.co.soramitsu.runtime.ext + +import jp.co.soramitsu.common.data.network.bitcoin.BitcoinIndexerRoutes +import jp.co.soramitsu.common.model.UniversalWalletRegistry +import jp.co.soramitsu.common.utils.BitcoinKeyDerivation +import jp.co.soramitsu.runtime.multiNetwork.chain.model.Chain + +fun Chain.isUniversalWalletBitcoin(): Boolean { + return id in BITCOIN_CHAIN_IDS || externalApi?.history?.type == Chain.ExternalApi.Section.Type.BITCOIN +} + +fun Chain.universalWalletBitcoinKeyNetwork(): BitcoinKeyDerivation.Network? { + return when { + id in BITCOIN_TESTNET_CHAIN_IDS -> BitcoinKeyDerivation.Network.Testnet + id in BITCOIN_MAINNET_CHAIN_IDS -> BitcoinKeyDerivation.Network.Mainnet + externalApi?.history?.type == Chain.ExternalApi.Section.Type.BITCOIN && isTestNet -> BitcoinKeyDerivation.Network.Testnet + externalApi?.history?.type == Chain.ExternalApi.Section.Type.BITCOIN -> BitcoinKeyDerivation.Network.Mainnet + else -> null + } +} + +fun Chain.universalWalletBitcoinIndexerNetwork(): BitcoinIndexerRoutes.Network? { + return when (universalWalletBitcoinKeyNetwork()) { + BitcoinKeyDerivation.Network.Mainnet -> BitcoinIndexerRoutes.Network.Mainnet + BitcoinKeyDerivation.Network.Testnet -> BitcoinIndexerRoutes.Network.Testnet + null -> null + } +} + +fun Chain.bitcoinAddressFromPublicKey(publicKey: ByteArray): String? { + val keyNetwork = universalWalletBitcoinKeyNetwork() ?: return null + val indexerNetwork = universalWalletBitcoinIndexerNetwork() ?: return null + + return runCatching { + val address = BitcoinKeyDerivation.addressFromPublicKey(publicKey, keyNetwork) + BitcoinIndexerRoutes.normalizeAddress(address, indexerNetwork) + }.getOrNull() +} + +fun Chain.normalizedBitcoinAddress(address: String): String? { + val indexerNetwork = universalWalletBitcoinIndexerNetwork() ?: return null + + return runCatching { + BitcoinIndexerRoutes.normalizeAddress(address, indexerNetwork) + }.getOrNull() +} + +private val BITCOIN_MAINNET_CHAIN_IDS = setOf( + UniversalWalletRegistry.bitcoinMainnet.id, + UniversalWalletRegistry.bitcoinMainnet.chainId +) + +private val BITCOIN_TESTNET_CHAIN_IDS = setOf( + UniversalWalletRegistry.bitcoinTestnet.id, + UniversalWalletRegistry.bitcoinTestnet.chainId +) + +private val BITCOIN_CHAIN_IDS = BITCOIN_MAINNET_CHAIN_IDS + BITCOIN_TESTNET_CHAIN_IDS diff --git a/runtime/src/main/java/jp/co/soramitsu/runtime/ext/UniversalWalletIrohaExt.kt b/runtime/src/main/java/jp/co/soramitsu/runtime/ext/UniversalWalletIrohaExt.kt new file mode 100644 index 0000000000..096109b5b0 --- /dev/null +++ b/runtime/src/main/java/jp/co/soramitsu/runtime/ext/UniversalWalletIrohaExt.kt @@ -0,0 +1,47 @@ +package jp.co.soramitsu.runtime.ext + +import jp.co.soramitsu.common.model.UniversalWalletRegistry +import jp.co.soramitsu.common.utils.IrohaAddressCodec +import jp.co.soramitsu.fearless_utils.extensions.toHexString +import jp.co.soramitsu.runtime.multiNetwork.chain.model.Chain + +fun Chain.isUniversalWalletIroha(): Boolean { + return id in IROHA_CHAIN_IDS || externalApi?.history?.type == Chain.ExternalApi.Section.Type.IROHA +} + +fun Chain.universalWalletIrohaNetwork(): UniversalWalletRegistry.IrohaNetwork? { + return when { + id in TAIRA_CHAIN_IDS -> UniversalWalletRegistry.taira + id in NEXUS_CHAIN_IDS -> UniversalWalletRegistry.nexus + externalApi?.history?.type == Chain.ExternalApi.Section.Type.IROHA && isTestNet -> UniversalWalletRegistry.taira + else -> null + } +} + +fun Chain.irohaAddressFromPublicKey(publicKey: ByteArray): String? { + val network = universalWalletIrohaNetwork() ?: return null + + return runCatching { + IrohaAddressCodec.encode(publicKey.toHexString(withPrefix = false), network.chainDiscriminant) + }.getOrNull() +} + +fun Chain.normalizedIrohaAddress(address: String): String? { + val network = universalWalletIrohaNetwork() ?: return null + + return runCatching { + IrohaAddressCodec.parse(address, network.chainDiscriminant).i105 + }.getOrNull() +} + +private val TAIRA_CHAIN_IDS = setOf( + UniversalWalletRegistry.taira.id, + UniversalWalletRegistry.taira.chainId +) + +private val NEXUS_CHAIN_IDS = setOf( + UniversalWalletRegistry.nexus.id, + UniversalWalletRegistry.nexus.chainId +) + +private val IROHA_CHAIN_IDS = TAIRA_CHAIN_IDS + NEXUS_CHAIN_IDS diff --git a/runtime/src/main/java/jp/co/soramitsu/runtime/ext/UniversalWalletSolanaExt.kt b/runtime/src/main/java/jp/co/soramitsu/runtime/ext/UniversalWalletSolanaExt.kt new file mode 100644 index 0000000000..309b5cfc27 --- /dev/null +++ b/runtime/src/main/java/jp/co/soramitsu/runtime/ext/UniversalWalletSolanaExt.kt @@ -0,0 +1,48 @@ +package jp.co.soramitsu.runtime.ext + +import jp.co.soramitsu.common.data.network.solana.SolanaIndexerRoutes +import jp.co.soramitsu.common.model.UniversalWalletRegistry +import jp.co.soramitsu.common.utils.SolanaKeyDerivation +import jp.co.soramitsu.runtime.multiNetwork.chain.model.Chain + +fun Chain.isUniversalWalletSolana(): Boolean { + return id in SOLANA_CHAIN_IDS || externalApi?.history?.type == Chain.ExternalApi.Section.Type.SOLANA +} + +fun Chain.universalWalletSolanaIndexerNetwork(): UniversalWalletRegistry.SolanaNetwork? { + return when { + id in SOLANA_DEVNET_CHAIN_IDS -> UniversalWalletRegistry.solanaDevnet + id in SOLANA_MAINNET_CHAIN_IDS -> UniversalWalletRegistry.solanaMainnet + externalApi?.history?.type == Chain.ExternalApi.Section.Type.SOLANA && isTestNet -> UniversalWalletRegistry.solanaDevnet + externalApi?.history?.type == Chain.ExternalApi.Section.Type.SOLANA && !isTestNet -> UniversalWalletRegistry.solanaMainnet + else -> null + } +} + +fun Chain.solanaAddressFromPublicKey(publicKey: ByteArray): String? { + return runCatching { + val address = SolanaKeyDerivation.addressFromPublicKey(publicKey) + normalizedSolanaAddress(address) + }.getOrNull() +} + +fun Chain.normalizedSolanaAddress(address: String): String? { + val baseUrl = externalApi?.history?.url ?: UniversalWalletRegistry.SOLANA_INDEXER_BASE_URL + + return runCatching { + SolanaIndexerRoutes.balancesUrl(address, baseUrl) + address + }.getOrNull() +} + +private val SOLANA_MAINNET_CHAIN_IDS = setOf( + UniversalWalletRegistry.solanaMainnet.id, + UniversalWalletRegistry.solanaMainnet.chainId +) + +private val SOLANA_DEVNET_CHAIN_IDS = setOf( + UniversalWalletRegistry.solanaDevnet.id, + UniversalWalletRegistry.solanaDevnet.chainId +) + +private val SOLANA_CHAIN_IDS = SOLANA_MAINNET_CHAIN_IDS + SOLANA_DEVNET_CHAIN_IDS diff --git a/runtime/src/main/java/jp/co/soramitsu/runtime/multiNetwork/ChainRegistry.kt b/runtime/src/main/java/jp/co/soramitsu/runtime/multiNetwork/ChainRegistry.kt index 2b49279c5d..de8f1ae1ca 100644 --- a/runtime/src/main/java/jp/co/soramitsu/runtime/multiNetwork/ChainRegistry.kt +++ b/runtime/src/main/java/jp/co/soramitsu/runtime/multiNetwork/ChainRegistry.kt @@ -26,7 +26,7 @@ import jp.co.soramitsu.runtime.multiNetwork.runtime.RuntimeProvider import jp.co.soramitsu.runtime.multiNetwork.runtime.RuntimeProviderPool import jp.co.soramitsu.runtime.multiNetwork.runtime.RuntimeSubscriptionPool import jp.co.soramitsu.runtime.multiNetwork.runtime.RuntimeSyncService -import jp.co.soramitsu.shared_utils.runtime.RuntimeSnapshot +import jp.co.soramitsu.fearless_utils.runtime.RuntimeSnapshot import kotlinx.coroutines.CoroutineDispatcher import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Deferred diff --git a/runtime/src/main/java/jp/co/soramitsu/runtime/multiNetwork/chain/Mappers.kt b/runtime/src/main/java/jp/co/soramitsu/runtime/multiNetwork/chain/Mappers.kt index 547c50246d..23a77849bd 100644 --- a/runtime/src/main/java/jp/co/soramitsu/runtime/multiNetwork/chain/Mappers.kt +++ b/runtime/src/main/java/jp/co/soramitsu/runtime/multiNetwork/chain/Mappers.kt @@ -16,6 +16,14 @@ import jp.co.soramitsu.runtime.multiNetwork.chain.model.Chain import jp.co.soramitsu.runtime.multiNetwork.chain.remote.model.ChainAssetRemote import jp.co.soramitsu.runtime.multiNetwork.chain.remote.model.ChainExternalApiRemote import jp.co.soramitsu.runtime.multiNetwork.chain.remote.model.ChainRemote +import jp.co.soramitsu.runtime.multiNetwork.chain.remote.model.ChainXcmAssetRemote +import jp.co.soramitsu.runtime.multiNetwork.chain.remote.model.ChainXcmBridgeRemote +import jp.co.soramitsu.runtime.multiNetwork.chain.remote.model.ChainXcmDestinationFeeRemote +import jp.co.soramitsu.runtime.multiNetwork.chain.remote.model.ChainXcmDestinationRemote +import jp.co.soramitsu.runtime.multiNetwork.chain.remote.model.ChainXcmExecutionRemote +import jp.co.soramitsu.runtime.multiNetwork.chain.remote.model.ChainXcmMultiLocationRemote +import jp.co.soramitsu.runtime.multiNetwork.chain.remote.model.ChainXcmRemote +import jp.co.soramitsu.runtime.multiNetwork.chain.remote.model.ChainXcmWeightLimitRemote private const val ETHEREUM_BASED_OPTION = "ethereumBased" private const val ETHEREUM_OPTION = "ethereum" @@ -42,6 +50,9 @@ private fun mapSectionTypeRemoteToSectionType(section: String) = when (section) "vicscan" -> Chain.ExternalApi.Section.Type.VICSCAN "zchain" -> Chain.ExternalApi.Section.Type.ZCHAINS "ton" -> Chain.ExternalApi.Section.Type.TON + "bitcoin" -> Chain.ExternalApi.Section.Type.BITCOIN + "solana" -> Chain.ExternalApi.Section.Type.SOLANA + "iroha" -> Chain.ExternalApi.Section.Type.IROHA else -> Chain.ExternalApi.Section.Type.UNKNOWN } @@ -158,6 +169,69 @@ private fun mapSectionToSectionLocal(sectionLocal: Chain.ExternalApi.Section?) = private const val DEFAULT_PRECISION = 10 +private fun ChainXcmAssetRemote.toXcmAsset() = Chain.Xcm.Asset( + id = id, + symbol = symbol, + minAmount = minAmount +) + +private fun ChainXcmMultiLocationRemote.toXcmMultiLocation() = + Chain.Xcm.MultiLocation( + parents = parents, + interior = interior + ) + +private fun ChainXcmWeightLimitRemote.toXcmWeightLimit() = + Chain.Xcm.WeightLimit( + type = type, + refTime = refTime, + proofSize = proofSize + ) + +private fun ChainXcmDestinationFeeRemote.toXcmDestinationFee() = + Chain.Xcm.DestinationFee( + mode = mode, + assetSymbol = assetSymbol, + amount = amount + ) + +private fun ChainXcmBridgeRemote.toXcmBridge() = + Chain.Xcm.Bridge( + parachainId = parachainId, + feeAssetLocation = feeAssetLocation?.toXcmMultiLocation(), + feeAssetItem = feeAssetItem + ) + +private fun ChainXcmExecutionRemote.toXcmExecution() = + Chain.Xcm.Execution( + palletName = palletName, + callName = callName, + transferType = transferType, + argumentShape = argumentShape, + destinationLocation = destinationLocation?.toXcmMultiLocation(), + assetLocation = assetLocation?.toXcmMultiLocation(), + beneficiaryLocation = beneficiaryLocation?.toXcmMultiLocation(), + feeAssetLocation = feeAssetLocation?.toXcmMultiLocation(), + feeAssetItem = feeAssetItem, + weightLimit = weightLimit?.toXcmWeightLimit(), + destinationFee = destinationFee?.toXcmDestinationFee(), + bridge = bridge?.toXcmBridge() + ) + +private fun ChainXcmDestinationRemote.toXcmDestination() = Chain.Xcm.Destination( + chainId = chainId, + assets = assets?.map { it.toXcmAsset() }, + bridgeParachainId = bridgeParachainId, + execution = execution?.toXcmExecution() +) + +private fun ChainXcmRemote.toXcm() = Chain.Xcm( + chainId = chainId, + xcmVersion = xcmVersion, + availableAssets = availableAssets?.map { it.toXcmAsset() }, + availableDestinations = availableDestinations?.map { it.toXcmDestination() } +) + fun ChainRemote.toChain(): Chain { val nodes = this.nodes?.mapIndexed { index, node -> ChainNode( @@ -211,7 +285,8 @@ fun ChainRemote.toChain(): Chain { else -> null }, ecosystem = Ecosystem.fromString(ecosystem), - tonBridgeUrl = tonBridgeUrl + tonBridgeUrl = tonBridgeUrl, + xcm = xcm?.toXcm() ) } @@ -255,6 +330,7 @@ fun mapNodeLocalToNode(nodeLocal: ChainNodeLocal) = ChainNode( ) fun mapChainLocalToChain(chainLocal: JoinedChainInfo): Chain { + val gson by lazy { Gson() } val nodes = chainLocal.nodes.map(::mapNodeLocalToNode) val assets = chainLocal.assets.map { @@ -324,7 +400,8 @@ fun mapChainLocalToChain(chainLocal: JoinedChainInfo): Chain { identityChain = identityChain, remoteAssetsSource = remoteAssetsSource?.let { Chain.RemoteAssetsSource.valueOf(it) }, ecosystem = Ecosystem.fromString(ecosystem), - tonBridgeUrl = tonBridgeUrl + tonBridgeUrl = tonBridgeUrl, + xcm = xcm?.let { runCatching { gson.fromJson(it, Chain.Xcm::class.java) }.getOrNull() } ) } } @@ -404,7 +481,8 @@ fun mapChainToChainLocal(chain: Chain): JoinedChainInfo { remoteAssetsSource = remoteAssetsSource?.name, ecosystem = ecosystem.name, androidMinAppVersion = null, - tonBridgeUrl = tonBridgeUrl + tonBridgeUrl = tonBridgeUrl, + xcm = xcm?.let { gson.toJson(it) } ) } @@ -420,4 +498,4 @@ private fun mapToList(json: String?) = json?.let { Gson().fromJson>(it, object : TypeToken>() {}.type) } fun mapToPriceProvider(json: String?) = - json?.let { Gson().fromJson(it, object : TypeToken() {}.type) } \ No newline at end of file + json?.let { Gson().fromJson(it, object : TypeToken() {}.type) } diff --git a/runtime/src/main/java/jp/co/soramitsu/runtime/multiNetwork/chain/model/Chain.kt b/runtime/src/main/java/jp/co/soramitsu/runtime/multiNetwork/chain/model/Chain.kt index d15cf4cdcc..de85652b9b 100644 --- a/runtime/src/main/java/jp/co/soramitsu/runtime/multiNetwork/chain/model/Chain.kt +++ b/runtime/src/main/java/jp/co/soramitsu/runtime/multiNetwork/chain/model/Chain.kt @@ -60,7 +60,8 @@ data class Chain( override val ecosystem: Ecosystem, val androidMinAppVersion: String? = null, val remoteAssetsSource: RemoteAssetsSource?, - val tonBridgeUrl: String? = null + val tonBridgeUrl: String? = null, + val xcm: Xcm? = null ) : IChain { val assetsById = assets.associateBy(CoreAsset::id) @@ -74,9 +75,9 @@ data class Chain( ) { data class Section(val type: Type, val url: String) { enum class Type { - SUBQUERY, SORA, SUBSQUID, GIANTSQUID, ETHERSCAN, OKLINK, BLOCKSCOUT, REEF, KLAYTN, FIRE, VICSCAN, ZCHAINS, UNKNOWN, GITHUB, TON; + SUBQUERY, SORA, SUBSQUID, GIANTSQUID, ETHERSCAN, OKLINK, BLOCKSCOUT, REEF, KLAYTN, FIRE, VICSCAN, ZCHAINS, UNKNOWN, GITHUB, TON, BITCOIN, SOLANA, IROHA; - fun isHistory() = this in listOf(SUBQUERY, SORA, SUBSQUID, GIANTSQUID, ETHERSCAN, OKLINK, BLOCKSCOUT, REEF, KLAYTN, FIRE, VICSCAN, ZCHAINS, TON) + fun isHistory() = this in listOf(SUBQUERY, SORA, SUBSQUID, GIANTSQUID, ETHERSCAN, OKLINK, BLOCKSCOUT, REEF, KLAYTN, FIRE, VICSCAN, ZCHAINS, TON, BITCOIN, SOLANA, IROHA) } } } @@ -124,6 +125,7 @@ data class Chain( if (supportNft != other.supportNft) return false if (isUsesAppId != other.isUsesAppId) return false if (identityChain != other.identityChain) return false + if (xcm != other.xcm) return false // custom comparison logic val defaultNodes = nodes.filter { it.isDefault } @@ -158,8 +160,67 @@ data class Chain( result = 31 * result + supportNft.hashCode() result = 31 * result + isUsesAppId.hashCode() result = 31 * result + (identityChain?.hashCode() ?: 0) + result = 31 * result + (xcm?.hashCode() ?: 0) return result } + + data class Xcm( + val chainId: String?, + val xcmVersion: String?, + val availableAssets: List?, + val availableDestinations: List? + ) { + data class Asset( + val id: String?, + val symbol: String?, + val minAmount: String? + ) + + data class Destination( + val chainId: String?, + val assets: List?, + val bridgeParachainId: String?, + val execution: Execution? = null + ) + + data class Execution( + val palletName: String?, + val callName: String?, + val transferType: String?, + val argumentShape: String? = null, + val destinationLocation: MultiLocation?, + val assetLocation: MultiLocation?, + val beneficiaryLocation: MultiLocation?, + val feeAssetLocation: MultiLocation?, + val feeAssetItem: Int?, + val weightLimit: WeightLimit?, + val destinationFee: DestinationFee?, + val bridge: Bridge? + ) + + data class MultiLocation( + val parents: Int?, + val interior: String? + ) + + data class WeightLimit( + val type: String?, + val refTime: String?, + val proofSize: String? + ) + + data class DestinationFee( + val mode: String?, + val assetSymbol: String?, + val amount: String? + ) + + data class Bridge( + val parachainId: String?, + val feeAssetLocation: MultiLocation?, + val feeAssetItem: Int? + ) + } } fun Chain.updateNodesActive(localVersion: Chain): Chain = when (val activeNode = localVersion.nodes.firstOrNull { it.isActive }) { @@ -243,4 +304,4 @@ val Chain.alchemyNftId: String? BSCTestnetChainId -> null polygonChainId -> "polygon-mainnet" else -> null - } \ No newline at end of file + } diff --git a/runtime/src/main/java/jp/co/soramitsu/runtime/multiNetwork/chain/remote/TonRemoteSource.kt b/runtime/src/main/java/jp/co/soramitsu/runtime/multiNetwork/chain/remote/TonRemoteSource.kt index 78d281d3cc..aa7ce829cb 100644 --- a/runtime/src/main/java/jp/co/soramitsu/runtime/multiNetwork/chain/remote/TonRemoteSource.kt +++ b/runtime/src/main/java/jp/co/soramitsu/runtime/multiNetwork/chain/remote/TonRemoteSource.kt @@ -42,7 +42,9 @@ import jp.co.soramitsu.common.data.network.ton.TonTransferAction import jp.co.soramitsu.common.data.network.ton.Trace import jp.co.soramitsu.common.data.network.ton.Transaction import jp.co.soramitsu.common.domain.GetAvailableFiatCurrencies +import jp.co.soramitsu.common.model.UniversalWalletRegistry import jp.co.soramitsu.runtime.multiNetwork.chain.model.Chain +import jp.co.soramitsu.runtime.multiNetwork.chain.model.tonMainnetChainId import jp.co.soramitsu.runtime.multiNetwork.chain.ton.model.JettonTransferPayload import kotlinx.coroutines.CancellationException @@ -59,6 +61,7 @@ class TonRemoteSource( suspend fun loadAccountData(chain: Chain, accountId: String): TonAccountData { return withIndexerFallback( + chain = chain, block = { indexerBase -> val encodedAccountId = encodePathSegment(accountId) val balances = tonApi.getIndexerBalances("$indexerBase/api/indexer/v1/accounts/$encodedAccountId/balances") @@ -74,6 +77,7 @@ class TonRemoteSource( suspend fun loadJettonBalances(chain: Chain, accountId: String): JettonsBalances { return withIndexerFallback( + chain = chain, block = { indexerBase -> val encodedAccountId = encodePathSegment(accountId) val balances = tonApi.getIndexerBalances("$indexerBase/api/indexer/v1/accounts/$encodedAccountId/balances") @@ -89,6 +93,7 @@ class TonRemoteSource( suspend fun getSeqno(chain: Chain, accountId: String): Int { return withIndexerFallback( + chain = chain, block = { indexerBase -> val encodedAccountId = encodePathSegment(accountId) val state = tonApi.getIndexerState("$indexerBase/api/indexer/v1/accounts/$encodedAccountId/state") @@ -112,6 +117,7 @@ class TonRemoteSource( suspend fun getRawTime(chain: Chain, accountId: String? = null): Int { return withIndexerFallback( + chain = chain, block = { indexerBase -> val resolvedAccountId = accountId.takeIfNotBlank() ?: error("Account id is required for indexer time lookup") @@ -145,6 +151,7 @@ class TonRemoteSource( suspend fun getPublicKey(chain: Chain, accountId: String): String { return withIndexerFallback( + chain = chain, block = { indexerBase -> val response = tonApi.runIndexerGetMethod( "$indexerBase/api/indexer/v1/runGetMethod", @@ -165,6 +172,7 @@ class TonRemoteSource( suspend fun getJettonTransferPayload(chain: Chain, accountId: String, jettonId: String): JettonTransferPayload { return withIndexerFallback( + chain = chain, block = { indexerBase -> val encodedJettonId = encodePathSegment(jettonId) val encodedAccountId = encodePathSegment(accountId) @@ -184,6 +192,7 @@ class TonRemoteSource( suspend fun sendBlockchainMessage(chain: Chain, request: SendBlockchainMessageRequest): String { return withIndexerFallback( + chain = chain, block = { indexerBase -> val bocs = request.batch.orEmpty().mapNotNull { it.takeIfNotBlank() } .ifEmpty { listOfNotNull(request.boc.takeIfNotBlank()) } @@ -207,6 +216,7 @@ class TonRemoteSource( suspend fun emulateBlockchainMessageRequest(chain: Chain, request: EmulateMessageToWalletRequest): MessageConsequences { return withIndexerFallback( + chain = chain, block = { indexerBase -> val estimate = estimateMessageFeeViaIndexer(indexerBase, request) buildSyntheticMessageConsequences( @@ -230,6 +240,7 @@ class TonRemoteSource( limit: Int = 100 ): AccountEvents { return withIndexerFallback( + chain = chain, block = { indexerBase -> val transactions = loadIndexerTransactions( indexerBase = indexerBase, @@ -729,7 +740,11 @@ class TonRemoteSource( return URLEncoder.encode(value, StandardCharsets.UTF_8.toString()) } - private fun indexerBaseUrlOrNull(): String? { + private fun indexerBaseUrlOrNull(chain: Chain): String? { + if (chain.id == tonMainnetChainId && !chain.isTestNet) { + return UniversalWalletRegistry.TON_INDEXER_BASE_URL + } + val normalized = tonIndexerUrl.trim().removeSuffix("/") return normalized.takeIf { it.isNotEmpty() } } @@ -751,10 +766,11 @@ class TonRemoteSource( } private suspend fun withIndexerFallback( + chain: Chain, block: suspend (indexerBaseUrl: String) -> T, fallback: suspend () -> T ): T { - val indexerBaseUrl = indexerBaseUrlOrNull() + val indexerBaseUrl = indexerBaseUrlOrNull(chain) if (indexerBaseUrl != null) { val indexerResult = try { diff --git a/runtime/src/main/java/jp/co/soramitsu/runtime/multiNetwork/chain/remote/model/ChainRemote.kt b/runtime/src/main/java/jp/co/soramitsu/runtime/multiNetwork/chain/remote/model/ChainRemote.kt index 68dac7961c..9dd1832669 100644 --- a/runtime/src/main/java/jp/co/soramitsu/runtime/multiNetwork/chain/remote/model/ChainRemote.kt +++ b/runtime/src/main/java/jp/co/soramitsu/runtime/multiNetwork/chain/remote/model/ChainRemote.kt @@ -17,5 +17,64 @@ data class ChainRemote( val identityChain: String? = null, val ecosystem: String, val androidMinAppVersion: String? = null, - val tonBridgeUrl: String? = null + val tonBridgeUrl: String? = null, + val xcm: ChainXcmRemote? = null +) + +data class ChainXcmRemote( + val chainId: String?, + val xcmVersion: String?, + val availableAssets: List?, + val availableDestinations: List? +) + +data class ChainXcmAssetRemote( + val id: String?, + val symbol: String?, + val minAmount: String? +) + +data class ChainXcmDestinationRemote( + val chainId: String?, + val assets: List?, + val bridgeParachainId: String?, + val execution: ChainXcmExecutionRemote? +) + +data class ChainXcmExecutionRemote( + val palletName: String?, + val callName: String?, + val transferType: String?, + val argumentShape: String?, + val destinationLocation: ChainXcmMultiLocationRemote?, + val assetLocation: ChainXcmMultiLocationRemote?, + val beneficiaryLocation: ChainXcmMultiLocationRemote?, + val feeAssetLocation: ChainXcmMultiLocationRemote?, + val feeAssetItem: Int?, + val weightLimit: ChainXcmWeightLimitRemote?, + val destinationFee: ChainXcmDestinationFeeRemote?, + val bridge: ChainXcmBridgeRemote? +) + +data class ChainXcmMultiLocationRemote( + val parents: Int?, + val interior: String? +) + +data class ChainXcmWeightLimitRemote( + val type: String?, + val refTime: String?, + val proofSize: String? +) + +data class ChainXcmDestinationFeeRemote( + val mode: String?, + val assetSymbol: String?, + val amount: String? +) + +data class ChainXcmBridgeRemote( + val parachainId: String?, + val feeAssetLocation: ChainXcmMultiLocationRemote?, + val feeAssetItem: Int? ) diff --git a/runtime/src/main/java/jp/co/soramitsu/runtime/multiNetwork/chain/ton/Extensions.kt b/runtime/src/main/java/jp/co/soramitsu/runtime/multiNetwork/chain/ton/Extensions.kt index 4a91a6e2ad..91873e0e22 100644 --- a/runtime/src/main/java/jp/co/soramitsu/runtime/multiNetwork/chain/ton/Extensions.kt +++ b/runtime/src/main/java/jp/co/soramitsu/runtime/multiNetwork/chain/ton/Extensions.kt @@ -4,7 +4,7 @@ import android.util.Base64 import jp.co.soramitsu.common.data.network.ton.AccountStatus import jp.co.soramitsu.common.data.network.ton.TonAccountData import jp.co.soramitsu.common.utils.Punycode -import jp.co.soramitsu.shared_utils.extensions.toHexString +import jp.co.soramitsu.fearless_utils.extensions.toHexString import org.ton.bigint.BigInt import org.ton.block.AddrStd import org.ton.block.Coins diff --git a/runtime/src/main/java/jp/co/soramitsu/runtime/multiNetwork/chain/ton/V4R2WalletContract.kt b/runtime/src/main/java/jp/co/soramitsu/runtime/multiNetwork/chain/ton/V4R2WalletContract.kt index d2614d353c..1463fcaf78 100644 --- a/runtime/src/main/java/jp/co/soramitsu/runtime/multiNetwork/chain/ton/V4R2WalletContract.kt +++ b/runtime/src/main/java/jp/co/soramitsu/runtime/multiNetwork/chain/ton/V4R2WalletContract.kt @@ -1,7 +1,7 @@ package jp.co.soramitsu.runtime.multiNetwork.chain.ton import jp.co.soramitsu.common.utils.toWalletAddress -import jp.co.soramitsu.shared_utils.extensions.fromHex +import jp.co.soramitsu.fearless_utils.extensions.fromHex import org.ton.api.pub.PublicKeyEd25519 import org.ton.block.AddrStd import org.ton.block.StateInit diff --git a/runtime/src/main/java/jp/co/soramitsu/runtime/multiNetwork/configurator/SubstrateEnvironmentConfigurator.kt b/runtime/src/main/java/jp/co/soramitsu/runtime/multiNetwork/configurator/SubstrateEnvironmentConfigurator.kt index 26160456ae..116adaf7d7 100644 --- a/runtime/src/main/java/jp/co/soramitsu/runtime/multiNetwork/configurator/SubstrateEnvironmentConfigurator.kt +++ b/runtime/src/main/java/jp/co/soramitsu/runtime/multiNetwork/configurator/SubstrateEnvironmentConfigurator.kt @@ -7,7 +7,7 @@ import jp.co.soramitsu.runtime.multiNetwork.connection.ConnectionPool import jp.co.soramitsu.runtime.multiNetwork.runtime.RuntimeProviderPool import jp.co.soramitsu.runtime.multiNetwork.runtime.RuntimeSubscriptionPool import jp.co.soramitsu.runtime.multiNetwork.runtime.RuntimeSyncService -import jp.co.soramitsu.shared_utils.wsrpc.state.SocketStateMachine +import jp.co.soramitsu.fearless_utils.wsrpc.state.SocketStateMachine import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch diff --git a/runtime/src/main/java/jp/co/soramitsu/runtime/multiNetwork/connection/ConnectionPool.kt b/runtime/src/main/java/jp/co/soramitsu/runtime/multiNetwork/connection/ConnectionPool.kt index 52e2dabd51..543f4b461a 100644 --- a/runtime/src/main/java/jp/co/soramitsu/runtime/multiNetwork/connection/ConnectionPool.kt +++ b/runtime/src/main/java/jp/co/soramitsu/runtime/multiNetwork/connection/ConnectionPool.kt @@ -12,8 +12,8 @@ import jp.co.soramitsu.runtime.multiNetwork.ChainsStateTracker import jp.co.soramitsu.runtime.multiNetwork.chain.model.Chain import jp.co.soramitsu.runtime.multiNetwork.chain.model.ChainId import jp.co.soramitsu.runtime.storage.NodesSettingsStorage -import jp.co.soramitsu.shared_utils.wsrpc.SocketService -import jp.co.soramitsu.shared_utils.wsrpc.state.SocketStateMachine +import jp.co.soramitsu.fearless_utils.wsrpc.SocketService +import jp.co.soramitsu.fearless_utils.wsrpc.state.SocketStateMachine import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.MutableStateFlow diff --git a/runtime/src/main/java/jp/co/soramitsu/runtime/multiNetwork/runtime/RuntimeProvider.kt b/runtime/src/main/java/jp/co/soramitsu/runtime/multiNetwork/runtime/RuntimeProvider.kt index 217de2d1d1..fcd7c8a462 100644 --- a/runtime/src/main/java/jp/co/soramitsu/runtime/multiNetwork/runtime/RuntimeProvider.kt +++ b/runtime/src/main/java/jp/co/soramitsu/runtime/multiNetwork/runtime/RuntimeProvider.kt @@ -8,7 +8,7 @@ import jp.co.soramitsu.runtime.multiNetwork.ChainState import jp.co.soramitsu.runtime.multiNetwork.ChainsStateTracker import jp.co.soramitsu.runtime.multiNetwork.chain.model.Chain import jp.co.soramitsu.runtime.multiNetwork.chain.model.reefChainId -import jp.co.soramitsu.shared_utils.runtime.RuntimeSnapshot +import jp.co.soramitsu.fearless_utils.runtime.RuntimeSnapshot import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Job diff --git a/runtime/src/main/java/jp/co/soramitsu/runtime/multiNetwork/runtime/RuntimeSyncService.kt b/runtime/src/main/java/jp/co/soramitsu/runtime/multiNetwork/runtime/RuntimeSyncService.kt index 2079bcc881..db54085574 100644 --- a/runtime/src/main/java/jp/co/soramitsu/runtime/multiNetwork/runtime/RuntimeSyncService.kt +++ b/runtime/src/main/java/jp/co/soramitsu/runtime/multiNetwork/runtime/RuntimeSyncService.kt @@ -14,9 +14,9 @@ import jp.co.soramitsu.runtime.multiNetwork.chain.model.Chain import jp.co.soramitsu.runtime.multiNetwork.connection.ConnectionPool import jp.co.soramitsu.runtime.multiNetwork.runtime.types.TypesFetcher import jp.co.soramitsu.runtime.network.executeAsyncCatching -import jp.co.soramitsu.shared_utils.runtime.metadata.GetMetadataRequest -import jp.co.soramitsu.shared_utils.wsrpc.mappers.nonNull -import jp.co.soramitsu.shared_utils.wsrpc.mappers.pojo +import jp.co.soramitsu.fearless_utils.runtime.metadata.GetMetadataRequest +import jp.co.soramitsu.fearless_utils.wsrpc.mappers.nonNull +import jp.co.soramitsu.fearless_utils.wsrpc.mappers.pojo import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Job diff --git a/runtime/src/main/java/jp/co/soramitsu/runtime/multiNetwork/runtime/RuntimeVersionSubscription.kt b/runtime/src/main/java/jp/co/soramitsu/runtime/multiNetwork/runtime/RuntimeVersionSubscription.kt index d91224018f..fbf579dd5d 100644 --- a/runtime/src/main/java/jp/co/soramitsu/runtime/multiNetwork/runtime/RuntimeVersionSubscription.kt +++ b/runtime/src/main/java/jp/co/soramitsu/runtime/multiNetwork/runtime/RuntimeVersionSubscription.kt @@ -6,16 +6,16 @@ import jp.co.soramitsu.core.runtime.ChainConnection import jp.co.soramitsu.coredb.dao.ChainDao import jp.co.soramitsu.runtime.multiNetwork.ChainState import jp.co.soramitsu.runtime.multiNetwork.ChainsStateTracker -import jp.co.soramitsu.shared_utils.wsrpc.executeAsync -import jp.co.soramitsu.shared_utils.wsrpc.mappers.nonNull -import jp.co.soramitsu.shared_utils.wsrpc.mappers.pojo -import jp.co.soramitsu.shared_utils.wsrpc.request.runtime.chain.RuntimeVersion -import jp.co.soramitsu.shared_utils.wsrpc.request.runtime.chain.RuntimeVersionRequest -import jp.co.soramitsu.shared_utils.wsrpc.request.runtime.chain.StateRuntimeVersionRequest -import jp.co.soramitsu.shared_utils.wsrpc.request.runtime.chain.SubscribeRuntimeVersionRequest -import jp.co.soramitsu.shared_utils.wsrpc.request.runtime.chain.SubscribeStateRuntimeVersionRequest -import jp.co.soramitsu.shared_utils.wsrpc.request.runtime.chain.runtimeVersionChange -import jp.co.soramitsu.shared_utils.wsrpc.subscriptionFlow +import jp.co.soramitsu.fearless_utils.wsrpc.executeAsync +import jp.co.soramitsu.fearless_utils.wsrpc.mappers.nonNull +import jp.co.soramitsu.fearless_utils.wsrpc.mappers.pojo +import jp.co.soramitsu.fearless_utils.wsrpc.request.runtime.chain.RuntimeVersion +import jp.co.soramitsu.fearless_utils.wsrpc.request.runtime.chain.RuntimeVersionRequest +import jp.co.soramitsu.fearless_utils.wsrpc.request.runtime.chain.StateRuntimeVersionRequest +import jp.co.soramitsu.fearless_utils.wsrpc.request.runtime.chain.SubscribeRuntimeVersionRequest +import jp.co.soramitsu.fearless_utils.wsrpc.request.runtime.chain.SubscribeStateRuntimeVersionRequest +import jp.co.soramitsu.fearless_utils.wsrpc.request.runtime.chain.runtimeVersionChange +import jp.co.soramitsu.fearless_utils.wsrpc.subscriptionFlow import kotlinx.coroutines.CoroutineDispatcher import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers diff --git a/runtime/src/main/java/jp/co/soramitsu/runtime/network/SocketServiceExtensions.kt b/runtime/src/main/java/jp/co/soramitsu/runtime/network/SocketServiceExtensions.kt index 88a356c184..ac37d80d1a 100644 --- a/runtime/src/main/java/jp/co/soramitsu/runtime/network/SocketServiceExtensions.kt +++ b/runtime/src/main/java/jp/co/soramitsu/runtime/network/SocketServiceExtensions.kt @@ -1,12 +1,12 @@ package jp.co.soramitsu.runtime.network -import jp.co.soramitsu.shared_utils.wsrpc.SocketService -import jp.co.soramitsu.shared_utils.wsrpc.executeAsync -import jp.co.soramitsu.shared_utils.wsrpc.mappers.ResponseMapper -import jp.co.soramitsu.shared_utils.wsrpc.request.DeliveryType -import jp.co.soramitsu.shared_utils.wsrpc.request.runtime.RuntimeRequest -import jp.co.soramitsu.shared_utils.wsrpc.request.runtime.UnsubscribeMethodResolver -import jp.co.soramitsu.shared_utils.wsrpc.subscription.response.SubscriptionChange +import jp.co.soramitsu.fearless_utils.wsrpc.SocketService +import jp.co.soramitsu.fearless_utils.wsrpc.executeAsync +import jp.co.soramitsu.fearless_utils.wsrpc.mappers.ResponseMapper +import jp.co.soramitsu.fearless_utils.wsrpc.request.DeliveryType +import jp.co.soramitsu.fearless_utils.wsrpc.request.runtime.RuntimeRequest +import jp.co.soramitsu.fearless_utils.wsrpc.request.runtime.UnsubscribeMethodResolver +import jp.co.soramitsu.fearless_utils.wsrpc.subscription.response.SubscriptionChange import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.channels.awaitClose import kotlinx.coroutines.flow.Flow diff --git a/runtime/src/main/java/jp/co/soramitsu/runtime/network/updaters/BlockNumberUpdater.kt b/runtime/src/main/java/jp/co/soramitsu/runtime/network/updaters/BlockNumberUpdater.kt index 7359f64d01..ba382aa564 100644 --- a/runtime/src/main/java/jp/co/soramitsu/runtime/network/updaters/BlockNumberUpdater.kt +++ b/runtime/src/main/java/jp/co/soramitsu/runtime/network/updaters/BlockNumberUpdater.kt @@ -6,9 +6,9 @@ import jp.co.soramitsu.common.utils.system import jp.co.soramitsu.core.storage.StorageCache import jp.co.soramitsu.core.updater.GlobalUpdaterScope import jp.co.soramitsu.runtime.multiNetwork.ChainRegistry -import jp.co.soramitsu.shared_utils.runtime.RuntimeSnapshot -import jp.co.soramitsu.shared_utils.runtime.metadata.storage -import jp.co.soramitsu.shared_utils.runtime.metadata.storageKey +import jp.co.soramitsu.fearless_utils.runtime.RuntimeSnapshot +import jp.co.soramitsu.fearless_utils.runtime.metadata.storage +import jp.co.soramitsu.fearless_utils.runtime.metadata.storageKey class BlockNumberUpdater( chainRegistry: ChainRegistry, diff --git a/runtime/src/main/java/jp/co/soramitsu/runtime/network/updaters/SingleChainUpdateSystem.kt b/runtime/src/main/java/jp/co/soramitsu/runtime/network/updaters/SingleChainUpdateSystem.kt index a1934faa0a..a5b7c50de5 100644 --- a/runtime/src/main/java/jp/co/soramitsu/runtime/network/updaters/SingleChainUpdateSystem.kt +++ b/runtime/src/main/java/jp/co/soramitsu/runtime/network/updaters/SingleChainUpdateSystem.kt @@ -6,7 +6,7 @@ import jp.co.soramitsu.core.updater.UpdateSystem import jp.co.soramitsu.core.updater.Updater import jp.co.soramitsu.runtime.multiNetwork.ChainRegistry import jp.co.soramitsu.runtime.multiNetwork.chain.model.Chain -import jp.co.soramitsu.shared_utils.wsrpc.request.runtime.storage.subscribeUsing +import jp.co.soramitsu.fearless_utils.wsrpc.request.runtime.storage.subscribeUsing import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.emptyFlow diff --git a/runtime/src/main/java/jp/co/soramitsu/runtime/network/updaters/SingleStorageKeyUpdater.kt b/runtime/src/main/java/jp/co/soramitsu/runtime/network/updaters/SingleStorageKeyUpdater.kt index 71a5a06291..38a8622910 100644 --- a/runtime/src/main/java/jp/co/soramitsu/runtime/network/updaters/SingleStorageKeyUpdater.kt +++ b/runtime/src/main/java/jp/co/soramitsu/runtime/network/updaters/SingleStorageKeyUpdater.kt @@ -8,7 +8,7 @@ import jp.co.soramitsu.core.updater.SubscriptionBuilder import jp.co.soramitsu.core.updater.UpdateScope import jp.co.soramitsu.core.updater.Updater import jp.co.soramitsu.runtime.multiNetwork.ChainRegistry -import jp.co.soramitsu.shared_utils.runtime.RuntimeSnapshot +import jp.co.soramitsu.fearless_utils.runtime.RuntimeSnapshot import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.emptyFlow import kotlinx.coroutines.flow.map diff --git a/runtime/src/main/java/jp/co/soramitsu/runtime/repository/ChainStateRepository.kt b/runtime/src/main/java/jp/co/soramitsu/runtime/repository/ChainStateRepository.kt index 72d1bd12ac..5236cfb788 100644 --- a/runtime/src/main/java/jp/co/soramitsu/runtime/repository/ChainStateRepository.kt +++ b/runtime/src/main/java/jp/co/soramitsu/runtime/repository/ChainStateRepository.kt @@ -13,9 +13,9 @@ import jp.co.soramitsu.runtime.multiNetwork.chain.model.ChainId import jp.co.soramitsu.runtime.storage.source.StorageDataSource import jp.co.soramitsu.runtime.storage.source.observeNonNull import jp.co.soramitsu.runtime.storage.source.queryNonNull -import jp.co.soramitsu.shared_utils.runtime.RuntimeSnapshot -import jp.co.soramitsu.shared_utils.runtime.metadata.storage -import jp.co.soramitsu.shared_utils.runtime.metadata.storageKey +import jp.co.soramitsu.fearless_utils.runtime.RuntimeSnapshot +import jp.co.soramitsu.fearless_utils.runtime.metadata.storage +import jp.co.soramitsu.fearless_utils.runtime.metadata.storageKey import kotlinx.coroutines.flow.Flow import java.math.BigInteger import javax.inject.Inject diff --git a/runtime/src/main/java/jp/co/soramitsu/runtime/storage/source/BaseStorageSource.kt b/runtime/src/main/java/jp/co/soramitsu/runtime/storage/source/BaseStorageSource.kt index 791975a4cc..aa5965c1dc 100644 --- a/runtime/src/main/java/jp/co/soramitsu/runtime/storage/source/BaseStorageSource.kt +++ b/runtime/src/main/java/jp/co/soramitsu/runtime/storage/source/BaseStorageSource.kt @@ -5,7 +5,7 @@ import jp.co.soramitsu.common.data.network.runtime.binding.Binder import jp.co.soramitsu.common.data.network.runtime.binding.BinderWithKey import jp.co.soramitsu.common.data.network.runtime.binding.BlockHash import jp.co.soramitsu.runtime.multiNetwork.ChainRegistry -import jp.co.soramitsu.shared_utils.runtime.RuntimeSnapshot +import jp.co.soramitsu.fearless_utils.runtime.RuntimeSnapshot import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.emitAll diff --git a/runtime/src/main/java/jp/co/soramitsu/runtime/storage/source/RemoteStorageSource.kt b/runtime/src/main/java/jp/co/soramitsu/runtime/storage/source/RemoteStorageSource.kt index 66a49f6cdb..39af96f021 100644 --- a/runtime/src/main/java/jp/co/soramitsu/runtime/storage/source/RemoteStorageSource.kt +++ b/runtime/src/main/java/jp/co/soramitsu/runtime/storage/source/RemoteStorageSource.kt @@ -7,9 +7,9 @@ import jp.co.soramitsu.common.data.network.runtime.binding.BlockHash import jp.co.soramitsu.core.runtime.models.requests.GetChildStateRequest import jp.co.soramitsu.runtime.multiNetwork.ChainRegistry import jp.co.soramitsu.runtime.network.subscriptionFlowCatching -import jp.co.soramitsu.shared_utils.wsrpc.executeAsync -import jp.co.soramitsu.shared_utils.wsrpc.request.runtime.storage.SubscribeStorageRequest -import jp.co.soramitsu.shared_utils.wsrpc.request.runtime.storage.storageChange +import jp.co.soramitsu.fearless_utils.wsrpc.executeAsync +import jp.co.soramitsu.fearless_utils.wsrpc.request.runtime.storage.SubscribeStorageRequest +import jp.co.soramitsu.fearless_utils.wsrpc.request.runtime.storage.storageChange import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.map diff --git a/runtime/src/main/java/jp/co/soramitsu/runtime/storage/source/StorageDataSource.kt b/runtime/src/main/java/jp/co/soramitsu/runtime/storage/source/StorageDataSource.kt index 84f9999421..99ab87a666 100644 --- a/runtime/src/main/java/jp/co/soramitsu/runtime/storage/source/StorageDataSource.kt +++ b/runtime/src/main/java/jp/co/soramitsu/runtime/storage/source/StorageDataSource.kt @@ -4,7 +4,7 @@ import jp.co.soramitsu.common.data.network.runtime.binding.Binder import jp.co.soramitsu.common.data.network.runtime.binding.BinderWithKey import jp.co.soramitsu.common.data.network.runtime.binding.BlockHash import jp.co.soramitsu.common.data.network.runtime.binding.NonNullBinder -import jp.co.soramitsu.shared_utils.runtime.RuntimeSnapshot +import jp.co.soramitsu.fearless_utils.runtime.RuntimeSnapshot import kotlinx.coroutines.flow.Flow import java.io.OutputStream diff --git a/runtime/src/test/java/jp/co/soramitsu/runtime/multiNetwork/chain/remote/TonLocalChainsRegistryTest.kt b/runtime/src/test/java/jp/co/soramitsu/runtime/multiNetwork/chain/remote/TonLocalChainsRegistryTest.kt new file mode 100644 index 0000000000..530a609222 --- /dev/null +++ b/runtime/src/test/java/jp/co/soramitsu/runtime/multiNetwork/chain/remote/TonLocalChainsRegistryTest.kt @@ -0,0 +1,54 @@ +package jp.co.soramitsu.runtime.multiNetwork.chain.remote + +import com.google.gson.JsonObject +import com.google.gson.JsonParser +import java.io.File +import jp.co.soramitsu.common.model.UniversalWalletRegistry +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Test + +class TonLocalChainsRegistryTest { + + @Test + fun `bundled TON mainnet should use shared TI endpoint`() { + val file = localChainsFile() + val json = file.readText() + val chains = JsonParser.parseString(json).asJsonArray + val mainnet = chains.firstObject { it["chainId"].asString == "-239" } + + assertFalse(json.contains("keeper.tonapi.io")) + assertEquals( + UniversalWalletRegistry.TON_INDEXER_BASE_URL, + mainnet["externalApi"].asJsonObject["history"].asJsonObject["url"].asString + ) + assertEquals( + UniversalWalletRegistry.TON_INDEXER_BASE_URL, + mainnet["nodes"].asJsonArray.first().asJsonObject["url"].asString + ) + } + + @Test + fun `bundled TON testnet history should not point at old mainnet tonapi host`() { + val chains = JsonParser.parseString(localChainsFile().readText()).asJsonArray + val testnet = chains.firstObject { it["chainId"].asString == "-3" } + + assertEquals( + UniversalWalletRegistry.TON_INDEXER_BASE_URL, + testnet["externalApi"].asJsonObject["history"].asJsonObject["url"].asString + ) + } + + private fun localChainsFile(): File { + return listOf( + File("runtime/src/main/assets/local_chains.json"), + File("src/main/assets/local_chains.json") + ).first { it.isFile } + } + + private fun Iterable.firstObject( + predicate: (JsonObject) -> Boolean + ): JsonObject { + return first { predicate(it.asJsonObject) }.asJsonObject + } +} diff --git a/runtime/src/test/java/jp/co/soramitsu/runtime/multiNetwork/chain/remote/TonRemoteSourceTest.kt b/runtime/src/test/java/jp/co/soramitsu/runtime/multiNetwork/chain/remote/TonRemoteSourceTest.kt index 1c0ff1eb79..4e8df55090 100644 --- a/runtime/src/test/java/jp/co/soramitsu/runtime/multiNetwork/chain/remote/TonRemoteSourceTest.kt +++ b/runtime/src/test/java/jp/co/soramitsu/runtime/multiNetwork/chain/remote/TonRemoteSourceTest.kt @@ -87,6 +87,22 @@ class TonRemoteSourceTest { ecosystem = "Ton" ).toChain() + private val tonTestnetChain: Chain = ChainRemote( + chainId = "ton:testnet", + paraId = null, + rank = null, + name = "TON Testnet", + minSupportedVersion = null, + assets = emptyList(), + nodes = listOf(ChainNodeRemote(url = "https://history.example", name = "history")), + externalApi = null, + icon = null, + addressPrefix = 0, + options = listOf("ethereumBased", "testnet"), + parentId = null, + ecosystem = "Ton" + ).toChain() + @Before fun setup() { tonApi = FakeTonApi() @@ -124,6 +140,42 @@ class TonRemoteSourceTest { } } + @Test + fun `mainnet indexer should ignore configured override and use shared TI endpoint`() { + kotlinx.coroutines.runBlocking { + tonApi.indexerJsonRpcResults["getAddressInformation"] = Result.success( + TonIndexerJsonRpcResponse( + result = JsonParser.parseString("""{"sync_utime":1700000000}""") + ) + ) + val sourceWithOverride = TonRemoteSource( + tonApi = tonApi, + availableFiatCurrencies = GetAvailableFiatCurrencies(FakeCoingeckoApi()), + gson = Gson(), + tonIndexerUrl = "https://evil.example" + ) + + sourceWithOverride.getRawTime(chainWithoutTonApi, "addr") + + assertEquals("https://ti.soramitsu.io/jsonRPC", tonApi.lastIndexerJsonRpcUrl) + } + } + + @Test + fun `non mainnet indexer should use configured endpoint`() { + kotlinx.coroutines.runBlocking { + tonApi.indexerJsonRpcResults["getAddressInformation"] = Result.success( + TonIndexerJsonRpcResponse( + result = JsonParser.parseString("""{"sync_utime":1700000000}""") + ) + ) + + source.getRawTime(tonTestnetChain, "addr") + + assertEquals("https://indexer.example/jsonRPC", tonApi.lastIndexerJsonRpcUrl) + } + } + @Test fun `loadAccountData should fallback to tonapi when indexer fails`() { kotlinx.coroutines.runBlocking { @@ -194,7 +246,7 @@ class TonRemoteSourceTest { val time = source.getRawTime(chain, "addr") assertEquals(1700000000, time) - assertEquals("https://indexer.example/jsonRPC", tonApi.lastIndexerJsonRpcUrl) + assertEquals("https://ti.soramitsu.io/jsonRPC", tonApi.lastIndexerJsonRpcUrl) assertEquals("getAddressInformation", tonApi.lastIndexerJsonRpcMethod) assertEquals(null, tonApi.lastGetRequestUrl) } @@ -224,7 +276,7 @@ class TonRemoteSourceTest { source.sendBlockchainMessage(chain, SendBlockchainMessageRequest(boc = "boc-data")) - assertEquals("https://indexer.example/jsonRPC", tonApi.lastIndexerJsonRpcUrl) + assertEquals("https://ti.soramitsu.io/jsonRPC", tonApi.lastIndexerJsonRpcUrl) assertEquals("sendBoc", tonApi.lastIndexerJsonRpcMethod) assertEquals(null, tonApi.lastSendBlockchainMessageUrl) } @@ -243,7 +295,7 @@ class TonRemoteSourceTest { source.getJettonTransferPayload(chain, "owner", "jetton") assertEquals( - "https://indexer.example/api/indexer/v1/jettons/jetton/transfer/owner/payload", + "https://ti.soramitsu.io/api/indexer/v1/jettons/jetton/transfer/owner/payload", tonApi.lastIndexerJettonTransferPayloadUrl ) assertEquals(null, tonApi.lastGetRequestUrl) @@ -349,7 +401,7 @@ class TonRemoteSourceTest { val events = source.getAccountEvents(chain, "https://tonapi.io", "addr", beforeLt = null, limit = 10) assertEquals(1, events.events.size) - assertEquals("https://indexer.example/api/indexer/v1/accounts/addr/txs", tonApi.lastIndexerTransactionsUrl) + assertEquals("https://ti.soramitsu.io/api/indexer/v1/accounts/addr/txs", tonApi.lastIndexerTransactionsUrl) assertEquals(null, tonApi.lastAccountEventsUrl) assertEquals("100:hash1", events.events.first().eventId) assertEquals("sender", events.events.first().actions.first().tonTransfer?.sender?.address) diff --git a/runtime/src/test/java/jp/co/soramitsu/runtime/multiNetwork/chain/remote/XcmLocalChainsRegistryTest.kt b/runtime/src/test/java/jp/co/soramitsu/runtime/multiNetwork/chain/remote/XcmLocalChainsRegistryTest.kt new file mode 100644 index 0000000000..8f2b4c0995 --- /dev/null +++ b/runtime/src/test/java/jp/co/soramitsu/runtime/multiNetwork/chain/remote/XcmLocalChainsRegistryTest.kt @@ -0,0 +1,146 @@ +package jp.co.soramitsu.runtime.multiNetwork.chain.remote + +import com.google.gson.JsonObject +import com.google.gson.JsonParser +import java.io.File +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Test + +class XcmLocalChainsRegistryTest { + + @Test + fun `bundled XCM registry contains executable relay native routes`() { + val chains = JsonParser.parseString(localChainsFile().readText()).asJsonArray + + executableRelayRoutes.forEach { route -> + val origin = chains.firstObject { it["chainId"].asString == route.originChainId } + val destination = origin["xcm"] + .asJsonObject["availableDestinations"] + .asJsonArray + .firstObject { it["chainId"].asString == route.destinationChainId } + + assertTrue( + "${route.assetSymbol} route ${route.originChainId} -> ${route.destinationChainId} is missing asset metadata", + destination["assets"].asJsonArray.any { it.asJsonObject["symbol"].asString == route.assetSymbol } + ) + + val execution = destination["execution"].asJsonObject + assertEquals("PolkadotXcm", execution["palletName"].asString) + assertEquals("limitedReserveTransferAssets", execution["callName"].asString) + assertEquals("limitedReserveTransferAssets", execution["transferType"].asString) + assertEquals(route.destinationParents, execution.location("destinationLocation")["parents"].asInt) + assertEquals(route.destinationInterior, execution.location("destinationLocation")["interior"].asString) + assertEquals(route.assetParents, execution.location("assetLocation")["parents"].asInt) + assertEquals(route.assetInterior, execution.location("assetLocation")["interior"].asString) + assertEquals(route.feeAssetParents, execution.location("feeAssetLocation")["parents"].asInt) + assertEquals(route.feeAssetInterior, execution.location("feeAssetLocation")["interior"].asString) + assertEquals(route.beneficiaryParents, execution.location("beneficiaryLocation")["parents"].asInt) + assertEquals(route.beneficiaryInterior, execution.location("beneficiaryLocation")["interior"].asString) + assertTrue(execution.location("beneficiaryLocation")["interior"].asString.contains("")) + assertEquals("Unlimited", execution["weightLimit"].asJsonObject["type"].asString) + assertEquals("Included", execution["destinationFee"].asJsonObject["mode"].asString) + assertEquals(route.assetSymbol, execution["destinationFee"].asJsonObject["assetSymbol"].asString) + } + } + + private fun JsonObject.location(name: String): JsonObject = get(name).asJsonObject + + private fun localChainsFile(): File { + return listOf( + File("runtime/src/main/assets/local_chains.json"), + File("src/main/assets/local_chains.json") + ).first { it.isFile } + } + + private fun Iterable.firstObject( + predicate: (JsonObject) -> Boolean + ): JsonObject { + return first { predicate(it.asJsonObject) }.asJsonObject + } + + private companion object { + const val POLKADOT_CHAIN_ID = "91b171bb158e2d3848fa23a9f1c25182fb8e20313b2c1eb49219da7a70ce90c3" + const val POLKADOT_ASSET_HUB_CHAIN_ID = "68d56f15f85d3136970ec16946040bc1752654e906147f7e43e9d539d7c3de2f" + const val MOONBEAM_CHAIN_ID = "fe58ea77779b7abda7da4ec526d14db9b1e9cd40a217c34892af80a9b332b76d" + const val ACALA_CHAIN_ID = "fc41b9bd8ef8fe53d58c7ea67c794c7ec9a73daf05e6d54b14ff6342c99ba64c" + const val PARALLEL_CHAIN_ID = "e61a41c53f5dcd0beb09df93b34402aada44cb05117b71059cce40a2723a4e97" + const val KUSAMA_CHAIN_ID = "b0a8d493285c2df73290dfb7e61f870f17b41801197a149ca93654499ea3dafe" + const val KUSAMA_ASSET_HUB_CHAIN_ID = "48239ef607d7928874027a43a67689209727dfb3d3dc5e5b03a39bdc2eda771a" + const val MOONRIVER_CHAIN_ID = "401a1f9dca3da46f5c4091016c8a2f26dcea05865116b286f60f668207d1474b" + const val KARURA_CHAIN_ID = "baf5aabe40646d11f0ee8abbdc64f4a4b7674925cba08e4a05ff9ebed6e2126b" + const val BIFROST_CHAIN_ID = "9f28c6a68e0fc9646eff64935684f6eeeece527e37bbe1f213d22caa1d9d6bed" + const val ACCOUNT_ID32_BENEFICIARY = "X1(AccountId32({network: Any, id: }))" + const val ACCOUNT_KEY20_BENEFICIARY = "X1(AccountKey20({network: Any, key: }))" + + val executableRelayRoutes = listOf( + relayToParachainRoute(POLKADOT_CHAIN_ID, POLKADOT_ASSET_HUB_CHAIN_ID, "DOT", "1000", ACCOUNT_ID32_BENEFICIARY), + relayToParachainRoute(POLKADOT_CHAIN_ID, MOONBEAM_CHAIN_ID, "DOT", "2004", ACCOUNT_KEY20_BENEFICIARY), + relayToParachainRoute(POLKADOT_CHAIN_ID, ACALA_CHAIN_ID, "DOT", "2000", ACCOUNT_ID32_BENEFICIARY), + relayToParachainRoute(POLKADOT_CHAIN_ID, PARALLEL_CHAIN_ID, "DOT", "2012", ACCOUNT_ID32_BENEFICIARY), + relayToParachainRoute(KUSAMA_CHAIN_ID, KUSAMA_ASSET_HUB_CHAIN_ID, "KSM", "1000", ACCOUNT_ID32_BENEFICIARY), + relayToParachainRoute(KUSAMA_CHAIN_ID, MOONRIVER_CHAIN_ID, "KSM", "2023", ACCOUNT_KEY20_BENEFICIARY), + relayToParachainRoute(KUSAMA_CHAIN_ID, KARURA_CHAIN_ID, "KSM", "2000", ACCOUNT_ID32_BENEFICIARY), + parachainToRelayRoute(POLKADOT_ASSET_HUB_CHAIN_ID, POLKADOT_CHAIN_ID, "DOT"), + parachainToRelayRoute(ACALA_CHAIN_ID, POLKADOT_CHAIN_ID, "DOT"), + parachainToRelayRoute(PARALLEL_CHAIN_ID, POLKADOT_CHAIN_ID, "DOT"), + parachainToRelayRoute(MOONBEAM_CHAIN_ID, POLKADOT_CHAIN_ID, "DOT"), + parachainToRelayRoute(KUSAMA_ASSET_HUB_CHAIN_ID, KUSAMA_CHAIN_ID, "KSM"), + parachainToRelayRoute(KARURA_CHAIN_ID, KUSAMA_CHAIN_ID, "KSM"), + parachainToRelayRoute(MOONRIVER_CHAIN_ID, KUSAMA_CHAIN_ID, "KSM"), + parachainToRelayRoute(BIFROST_CHAIN_ID, KUSAMA_CHAIN_ID, "KSM") + ) + + fun relayToParachainRoute( + originChainId: String, + destinationChainId: String, + assetSymbol: String, + destinationParaId: String, + beneficiaryInterior: String + ) = ExecutableRelayRoute( + originChainId = originChainId, + destinationChainId = destinationChainId, + assetSymbol = assetSymbol, + destinationParents = 0, + destinationInterior = "X1(Parachain($destinationParaId))", + assetParents = 0, + assetInterior = "Here", + beneficiaryParents = 0, + beneficiaryInterior = beneficiaryInterior, + feeAssetParents = 0, + feeAssetInterior = "Here" + ) + + fun parachainToRelayRoute( + originChainId: String, + destinationChainId: String, + assetSymbol: String + ) = ExecutableRelayRoute( + originChainId = originChainId, + destinationChainId = destinationChainId, + assetSymbol = assetSymbol, + destinationParents = 1, + destinationInterior = "Here", + assetParents = 1, + assetInterior = "Here", + beneficiaryParents = 0, + beneficiaryInterior = ACCOUNT_ID32_BENEFICIARY, + feeAssetParents = 1, + feeAssetInterior = "Here" + ) + } + + private data class ExecutableRelayRoute( + val originChainId: String, + val destinationChainId: String, + val assetSymbol: String, + val destinationParents: Int, + val destinationInterior: String, + val assetParents: Int, + val assetInterior: String, + val beneficiaryParents: Int, + val beneficiaryInterior: String, + val feeAssetParents: Int, + val feeAssetInterior: String + ) +} diff --git a/runtime/src/test/java/jp/co/soramitsu/runtime/multiNetwork/runtime/RuntimeProviderTest.kt b/runtime/src/test/java/jp/co/soramitsu/runtime/multiNetwork/runtime/RuntimeProviderTest.kt index 5bf93d0950..ef68277285 100644 --- a/runtime/src/test/java/jp/co/soramitsu/runtime/multiNetwork/runtime/RuntimeProviderTest.kt +++ b/runtime/src/test/java/jp/co/soramitsu/runtime/multiNetwork/runtime/RuntimeProviderTest.kt @@ -6,7 +6,7 @@ import jp.co.soramitsu.core.runtime.RuntimeFactory import jp.co.soramitsu.coredb.dao.ChainDao import jp.co.soramitsu.coredb.model.chain.ChainRuntimeInfoLocal import jp.co.soramitsu.runtime.multiNetwork.chain.model.Chain -import jp.co.soramitsu.shared_utils.runtime.RuntimeSnapshot +import jp.co.soramitsu.fearless_utils.runtime.RuntimeSnapshot import jp.co.soramitsu.testshared.any import jp.co.soramitsu.testshared.eq import jp.co.soramitsu.testshared.thenThrowUnsafe @@ -20,8 +20,11 @@ import org.junit.Assert.assertEquals import org.junit.Before import org.junit.Test import org.junit.runner.RunWith +import org.mockito.ArgumentMatchers.anyInt import org.mockito.Mock +import org.mockito.Mockito.after import org.mockito.Mockito.times +import org.mockito.Mockito.timeout import org.mockito.Mockito.verify import org.mockito.junit.MockitoJUnitRunner @@ -63,7 +66,7 @@ class RuntimeProviderTest { chainSyncFlow = MutableSharedFlow() whenever(constructedRuntime.runtime).thenReturn(runtime) - whenever(runtimeFactory.constructRuntime(any(), any(), any())).thenReturn(constructedRuntime) + whenever(runtimeFactory.constructRuntime(any(), any(), anyInt())).thenReturn(constructedRuntime) whenever(runtimeSyncService.syncResultFlow(eq(chain.id))).thenAnswer { chainSyncFlow } whenever(chainDao.runtimeInfo(any())).thenAnswer { @@ -79,13 +82,13 @@ class RuntimeProviderTest { runBlocking { initProvider() - val returnedRuntime = withTimeout(timeMillis = 10) { + val returnedRuntime = withTimeout(timeMillis = 1_000) { runtimeProvider.get() } assertEquals(returnedRuntime, runtime) - verify(runtimeFactory, times(1)).constructRuntime(any(), any(), any()) + verify(runtimeFactory, times(1)).constructRuntime(any(), any(), anyInt()) } } @@ -149,7 +152,7 @@ class RuntimeProviderTest { @Test fun `should wait until current job is finished before consider reconstructing runtime on runtime sync event`() { runBlocking { - whenever(runtimeFactory.constructRuntime(any(), any(), any())).thenAnswer { + whenever(runtimeFactory.constructRuntime(any(), any(), anyInt())).thenAnswer { runBlocking { chainSyncFlow.first() } // ensure runtime wont be returned until chainSyncFlow event constructedRuntime @@ -184,7 +187,7 @@ class RuntimeProviderTest { } private suspend fun withRuntimeFactoryFailing(exception: Exception = ChainInfoNotInCacheException, block: suspend () -> Unit) { - whenever(runtimeFactory.constructRuntime(any(), any(), any())).thenThrowUnsafe(exception) + whenever(runtimeFactory.constructRuntime(any(), any(), anyInt())).thenThrowUnsafe(exception) initProvider() @@ -194,10 +197,15 @@ class RuntimeProviderTest { } private suspend fun verifyReconstructionAfterInit(times: Int) { - delay(10) - // + 1 since it is called once in init (cache) - verify(runtimeFactory, times(times + 1)).constructRuntime(any(), any(), any()) + val expectedCalls = times + 1 + val verification = if (times == 0) { + after(100).times(expectedCalls) + } else { + timeout(1_000).times(expectedCalls) + } + + verify(runtimeFactory, verification).constructRuntime(any(), any(), anyInt()) } private fun currentMetadataHash(hash: String?) { diff --git a/runtime/src/test/java/jp/co/soramitsu/runtime/multiNetwork/runtime/RuntimeSyncServiceTest.kt b/runtime/src/test/java/jp/co/soramitsu/runtime/multiNetwork/runtime/RuntimeSyncServiceTest.kt index 00488995bd..905090a670 100644 --- a/runtime/src/test/java/jp/co/soramitsu/runtime/multiNetwork/runtime/RuntimeSyncServiceTest.kt +++ b/runtime/src/test/java/jp/co/soramitsu/runtime/multiNetwork/runtime/RuntimeSyncServiceTest.kt @@ -16,10 +16,10 @@ import jp.co.soramitsu.coredb.model.chain.ChainRuntimeInfoLocal import jp.co.soramitsu.runtime.multiNetwork.chain.model.Chain import jp.co.soramitsu.runtime.multiNetwork.connection.ConnectionPool import jp.co.soramitsu.runtime.multiNetwork.runtime.types.TypesFetcher -import jp.co.soramitsu.shared_utils.runtime.metadata.GetMetadataRequest -import jp.co.soramitsu.shared_utils.wsrpc.SocketService -import jp.co.soramitsu.shared_utils.wsrpc.request.runtime.RuntimeRequest -import jp.co.soramitsu.shared_utils.wsrpc.response.RpcResponse +import jp.co.soramitsu.fearless_utils.runtime.metadata.GetMetadataRequest +import jp.co.soramitsu.fearless_utils.wsrpc.SocketService +import jp.co.soramitsu.fearless_utils.wsrpc.request.runtime.RuntimeRequest +import jp.co.soramitsu.fearless_utils.wsrpc.response.RpcResponse import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.first diff --git a/scripts/audit-branch-flow.sh b/scripts/audit-branch-flow.sh new file mode 100755 index 0000000000..5298d01b8b --- /dev/null +++ b/scripts/audit-branch-flow.sh @@ -0,0 +1,59 @@ +#!/usr/bin/env bash +set -euo pipefail + +ROOT_DIR="${1:-$(cd "$(dirname "$0")/.." && pwd)}" +FAILURES=0 + +record_failure() { + echo "[branch-flow-audit] ERROR: $*" >&2 + FAILURES=1 +} + +scan_for_staging() { + local file="$1" + local matches + matches="$(grep -nE '(^|[^[:alnum:]_])staging([^[:alnum:]_]|$)' "$file" || true)" + if [[ -n "$matches" ]]; then + record_failure "staging branch reference found in ${file#$ROOT_DIR/}:" + printf '%s\n' "$matches" >&2 + fi +} + +if [[ ! -d "$ROOT_DIR" ]]; then + record_failure "repo root does not exist: $ROOT_DIR" +fi + +JENKINSFILE="$ROOT_DIR/Jenkinsfile" +if [[ ! -f "$JENKINSFILE" ]]; then + record_failure "Jenkinsfile is missing" +else + scan_for_staging "$JENKINSFILE" + UPLOAD_LINE="$(grep -nE 'uploadToNexusFor[[:space:]]*:' "$JENKINSFILE" || true)" + if [[ -z "$UPLOAD_LINE" ]]; then + record_failure "Jenkinsfile must declare uploadToNexusFor for release upload branches" + elif [[ "$UPLOAD_LINE" != *master* || "$UPLOAD_LINE" != *develop* ]]; then + record_failure "Jenkinsfile uploadToNexusFor must include only releasable master and integration develop branches" + printf '%s\n' "$UPLOAD_LINE" >&2 + fi +fi + +WORKFLOW_DIR="$ROOT_DIR/.github/workflows" +if [[ ! -d "$WORKFLOW_DIR" ]]; then + record_failure ".github/workflows is missing" +else + WORKFLOW_COUNT=0 + while IFS= read -r -d '' workflow; do + WORKFLOW_COUNT=$((WORKFLOW_COUNT + 1)) + scan_for_staging "$workflow" + done < <(find "$WORKFLOW_DIR" -type f \( -name '*.yml' -o -name '*.yaml' \) -print0) + + if [[ "$WORKFLOW_COUNT" -eq 0 ]]; then + record_failure "no GitHub workflow files found" + fi +fi + +if [[ "$FAILURES" -ne 0 ]]; then + exit 1 +fi + +echo "[branch-flow-audit] Branch flow audit passed." diff --git a/scripts/audit-private-overlay-boundary.sh b/scripts/audit-private-overlay-boundary.sh new file mode 100755 index 0000000000..9f7e93aee6 --- /dev/null +++ b/scripts/audit-private-overlay-boundary.sh @@ -0,0 +1,84 @@ +#!/usr/bin/env bash +set -euo pipefail + +cd "$(dirname "$0")/.." + +PUBLIC_REPO_DIR="${PUBLIC_REPO_DIR:-$PWD}" +PRIVATE_REPO_DIR="${PRIVATE_REPO_DIR:-../fearless-Android-priv}" +MAX_REPORT_LINES="${MAX_REPORT_LINES:-120}" +PRIVATE_OVERLAY_REPORT="${PRIVATE_OVERLAY_REPORT:-$PUBLIC_REPO_DIR/build/reports/private-overlay-boundary.tsv}" + +fail() { + echo "[private-overlay-audit][error] $*" >&2 + exit 1 +} + +info() { + echo "[private-overlay-audit] $*" +} + +[[ -d "$PUBLIC_REPO_DIR/.git" ]] || fail "PUBLIC_REPO_DIR is not a Git checkout: $PUBLIC_REPO_DIR" +[[ -d "$PRIVATE_REPO_DIR/.git" ]] || fail "PRIVATE_REPO_DIR is not a Git checkout: $PRIVATE_REPO_DIR" + +is_allowed_overlay_path() { + case "$1" in + README.md|LICENSE|.gitignore|Jenkinsfile) + return 0 + ;; + .github/*|docs/release*|docs/rollback*|docs/private-overlay*|metadata/*|play/*|release/*|fastlane/*) + return 0 + ;; + app/src/release/*|app/src/release/**) + return 0 + ;; + scripts/restore-release-overlays.sh|scripts/audit-private-overlay-boundary.sh) + return 0 + ;; + *) + return 1 + ;; + esac +} + +tmpdir="$(mktemp -d)" +trap 'rm -rf "$tmpdir"' EXIT + +private_files="$tmpdir/private-files" +unexpected="$tmpdir/unexpected" + +git -C "$PRIVATE_REPO_DIR" ls-files | sort > "$private_files" + +: > "$unexpected" + +while IFS= read -r path || [[ -n "$path" ]]; do + [[ -n "$path" ]] || continue + is_allowed_overlay_path "$path" && continue + + public_path="$PUBLIC_REPO_DIR/$path" + private_path="$PRIVATE_REPO_DIR/$path" + if [[ ! -f "$public_path" ]]; then + printf 'A\t%s\n' "$path" >> "$unexpected" + elif [[ ! -f "$private_path" ]]; then + printf 'A\t%s\n' "$path" >> "$unexpected" + elif ! cmp -s "$public_path" "$private_path"; then + printf 'M\t%s\n' "$path" >> "$unexpected" + else + printf 'T\t%s\n' "$path" >> "$unexpected" + fi +done < "$private_files" + +if [[ -s "$unexpected" ]]; then + count="$(wc -l < "$unexpected" | tr -d '[:space:]')" + mkdir -p "$(dirname "$PRIVATE_OVERLAY_REPORT")" + cp "$unexpected" "$PRIVATE_OVERLAY_REPORT" + echo "[private-overlay-audit][error] private Android repo is not overlay-only." >&2 + echo "[private-overlay-audit][error] Unexpected added or modified paths: $count" >&2 + echo "[private-overlay-audit][error] Full report: $PRIVATE_OVERLAY_REPORT" >&2 + sed -n "1,${MAX_REPORT_LINES}p" "$unexpected" >&2 + if (( count > MAX_REPORT_LINES )); then + echo "[private-overlay-audit][error] Output truncated at $MAX_REPORT_LINES paths." >&2 + fi + fail "move product code and public config back to the public repo; keep only release overlays in private storage" +fi + +info "Private Android overlay boundary passed." diff --git a/scripts/audit-public-artifacts.sh b/scripts/audit-public-artifacts.sh new file mode 100755 index 0000000000..98ea3b1da5 --- /dev/null +++ b/scripts/audit-public-artifacts.sh @@ -0,0 +1,157 @@ +#!/usr/bin/env bash +set -euo pipefail + +RELEASE_MODE=false +STRICT_PROVENANCE=false + +for arg in "$@"; do + case "$arg" in + --release) + RELEASE_MODE=true + ;; + --strict-provenance) + STRICT_PROVENANCE=true + ;; + *) + echo "Unknown argument: $arg" >&2 + exit 2 + ;; + esac +done + +log() { echo "[artifact-audit] $*"; } +warn() { echo "[artifact-audit][warn] $*" >&2; } +fail() { + echo "[artifact-audit][error] $*" >&2 + exit 1 +} + +cd "$(dirname "$0")/.." + +if [[ ! -d .git ]]; then + fail "Run from a Git checkout." +fi + +sha256() { + if command -v sha256sum >/dev/null 2>&1; then + sha256sum "$1" | awk '{print $1}' + else + shasum -a 256 "$1" | awk '{print $1}' + fi +} + +expected_checksum() { + case "$1" in + gradle/wrapper/gradle-wrapper.jar) echo "81a82aaea5abcc8ff68b3dfcb58b3c3c429378efd98e7433460610fecd7ae45f" ;; + app/src/main/jniLibs/arm64-v8a/libsodium.so) echo "939ba2865a93abe39c4d6a29802419b36b1bfd0b320396eeaccd4d660468a664" ;; + app/src/main/jniLibs/armeabi-v7a/libsodium.so) echo "8bcd304a813f3d814793c8553cd6a8814b64b2c188d66e8462f82053d7855343" ;; + app/src/main/jniLibs/x86/libsodium.so) echo "a6cd7f654ff400d080b16b3e794971b2c2b2cbc12bf0cee444f7e2ea950839ae" ;; + app/src/main/jniLibs/x86_64/libsodium.so) echo "a8b52ce229d18338b9f0b0f4d2396078582c9742d771367394b8e9b3cbabb81c" ;; + app/src/main/jniLibs/arm64-v8a/libsr25519java.so) echo "a37f031de78b841dab6a8c69900a64fe427d53d2a4f77d8f3c059caab5849c12" ;; + app/src/main/jniLibs/armeabi-v7a/libsr25519java.so) echo "d217b2511bf2c7f90ea29879d5ecad86c62d95a3ee2dd412fbb0f6d804e5f3de" ;; + app/src/main/jniLibs/x86/libsr25519java.so) echo "56f26fe8e7e55026cf36be6c22a62037389d4f59cc8ee35d6bb295b5dc5441aa" ;; + app/src/main/jniLibs/x86_64/libsr25519java.so) echo "b024dcc2ebf236f21d329e3d637a6fdd39746ad1dc0e0ccc99143279617946dc" ;; + *) echo "" ;; + esac +} + +check_checksum() { + local path="$1" + local expected + expected="$(expected_checksum "$path")" + if [[ -z "$expected" ]]; then + return 1 + fi + + local actual + actual="$(sha256 "$path")" + if [[ "$actual" != "$expected" ]]; then + fail "$path checksum changed: expected $expected, got $actual" + fi + + return 0 +} + +check_public_google_services() { + local path="$1" + + grep -q '"project_id"[[:space:]]*:[[:space:]]*"fearless-public"' "$path" || + fail "$path must use the checked-in fearless-public Firebase placeholder project." + + if grep -Eq '"current_key"[[:space:]]*:[[:space:]]*"[^"]+"' "$path"; then + fail "$path contains a non-empty Firebase API key. Commit only placeholder Firebase config." + fi + + if grep -q '"certificate_hash"' "$path"; then + fail "$path contains OAuth certificate hashes. Commit only placeholder Firebase config." + fi +} + +check_release_google_services() { + local path="app/src/release/google-services.json" + + [[ -s "$path" ]] || fail "$path is required for release mode." + + if grep -q '"project_id"[[:space:]]*:[[:space:]]*"fearless-public"' "$path"; then + fail "$path still points at the public placeholder project in release mode." + fi + + grep -Eq '"current_key"[[:space:]]*:[[:space:]]*"[^"]+"' "$path" || + fail "$path must contain release Firebase API keys restored from CI secrets." +} + +check_required_release_env() { + local missing=() + local name + for name in \ + CI_KEYSTORE_PATH \ + CI_KEYSTORE_PASS \ + CI_KEYSTORE_KEY_ALIAS \ + CI_KEYSTORE_KEY_PASS \ + CI_PLAY_KEY \ + MOONPAY_PRODUCTION_SECRET \ + RAMP_TOKEN_RELEASE \ + WALLET_CONNECT_PROJECT_ID; do + if [[ -z "${!name:-}" ]]; then + missing+=("$name") + fi + done + + if (( ${#missing[@]} > 0 )); then + fail "Release mode is missing required environment variables: ${missing[*]}" + fi + + [[ -s "$CI_KEYSTORE_PATH" ]] || fail "CI_KEYSTORE_PATH does not point to a readable keystore." + [[ -s "$CI_PLAY_KEY" ]] || fail "CI_PLAY_KEY does not point to a readable Play service-account JSON." +} + +while IFS= read -r -d '' path; do + [[ -e "$path" ]] || continue + + case "$path" in + *.jks|*.keystore|*.p12|*.p8|*.pem|*.mobileprovision|*.provisionprofile) + fail "$path is tracked private release material. Keep it in CI/local overlays only." + ;; + */google-services.json) + if [[ "$RELEASE_MODE" == false ]]; then + check_public_google_services "$path" + fi + ;; + feature-wallet-impl/libs/pushpayment-core-sdk-*.jar) + fail "$path must not be vendored. The CBDC QR parser is implemented from source." + ;; + *.jar|*.aar|*.so|*.a|*.dylib|*.wasm|*.framework/*|*.xcframework/*) + check_checksum "$path" || fail "$path is a tracked binary without an allowlisted checksum/provenance entry." + ;; + esac +done < <(git ls-files -z -- \ + '*.jar' '*.aar' '*.so' '*.a' '*.dylib' '*.framework/**' '*.xcframework/**' '*.wasm' \ + '*.keystore' '*.jks' '*.mobileprovision' '*.p12' '*.p8' '*.pem' '*.provisionprofile' \ + '*/google-services.json') + +if [[ "$RELEASE_MODE" == true ]]; then + check_release_google_services + check_required_release_env +fi + +log "Public artifact audit passed." diff --git a/scripts/audit-todo-debt.sh b/scripts/audit-todo-debt.sh new file mode 100755 index 0000000000..fedd7a5688 --- /dev/null +++ b/scripts/audit-todo-debt.sh @@ -0,0 +1,138 @@ +#!/usr/bin/env bash +set -euo pipefail + +ROOT_DIR="${TODO_AUDIT_ROOT:-$(cd "$(dirname "$0")/.." && pwd)}" +BASELINE_FILE="${TODO_AUDIT_BASELINE:-$ROOT_DIR/config/todo-debt-baseline.tsv}" + +log() { echo "[todo-audit] $*"; } +fail() { + echo "[todo-audit][error] $*" >&2 + exit 1 +} + +if [[ ! -d "$ROOT_DIR" ]]; then + fail "Root directory does not exist: $ROOT_DIR" +fi + +if [[ ! -f "$BASELINE_FILE" ]]; then + fail "Baseline file does not exist: $BASELINE_FILE" +fi + +tmp_dir="$(mktemp -d)" +trap 'rm -rf "$tmp_dir"' EXIT + +current="$tmp_dir/current.tsv" +baseline_raw="$tmp_dir/baseline-raw.tsv" +baseline="$tmp_dir/baseline.tsv" +duplicate_baseline="$tmp_dir/duplicate-baseline.tsv" +new_markers="$tmp_dir/new.tsv" +stale_markers="$tmp_dir/stale.tsv" +executable_todos="$tmp_dir/executable_todos.txt" + +grep_source_files() { + local pattern="$1" + + ( + cd "$ROOT_DIR" + find . \ + \( -path './build' -o -path './build/*' \ + -o -path '*/build' -o -path '*/build/*' \ + -o -path './.gradle' -o -path './.gradle/*' \ + -o -path '*/.gradle' -o -path '*/.gradle/*' \ + -o -path './fearless-utils-Android' -o -path './fearless-utils-Android/*' \ + -o -path './.git' -o -path './.git/*' \ + -o -path '*/src/main/res/values*' -o -path '*/src/main/res/values*/*' \) -prune \ + -o -type f \ + \( -name '*.kt' -o -name '*.kts' -o -name '*.java' -o -name '*.xml' -o -name '*.gradle' -o -name '*.gradle.kts' \) \ + -print0 | + xargs -0 grep -HInEi "$pattern" || true + ) +} + +scan_marker_debt() { + if command -v rg >/dev/null 2>&1; then + ( + cd "$ROOT_DIR" + rg -n --no-heading -i '\b(todo|fixme|stopship)\b' \ + --glob '*.kt' \ + --glob '*.kts' \ + --glob '*.java' \ + --glob '*.xml' \ + --glob '*.gradle' \ + --glob '*.gradle.kts' \ + --glob '!**/build/**' \ + --glob '!**/.gradle/**' \ + --glob '!fearless-utils-Android/**' \ + --glob '!**/.git/**' \ + --glob '!**/src/main/res/values*/**/*.xml' \ + . || true + ) + else + grep_source_files '(^|[^[:alnum:]_])(todo|fixme|stopship)([^[:alnum:]_]|$)' + fi | awk -F: ' + { + line = $0 + sub(/^[^:]+:[0-9]+:/, "", line) + path = $1 + sub(/^\.\//, "", path) + gsub(/^[[:space:]]+|[[:space:]]+$/, "", line) + print path "\t" line + } + ' | LC_ALL=C sort -u +} + +scan_executable_todos() { + if command -v rg >/dev/null 2>&1; then + ( + cd "$ROOT_DIR" + rg -n --no-heading '(^|[^[:alnum:]_])TODO[[:space:]]*\(' \ + --glob '*.kt' \ + --glob '*.kts' \ + --glob '*.java' \ + --glob '*.gradle' \ + --glob '*.gradle.kts' \ + --glob '!**/build/**' \ + --glob '!**/.gradle/**' \ + --glob '!fearless-utils-Android/**' \ + --glob '!**/.git/**' \ + . || true + ) + else + grep_source_files '(^|[^[:alnum:]_])TODO[[:space:]]*\(' + fi +} + +awk 'NF && $0 !~ /^#/' "$BASELINE_FILE" > "$baseline_raw" +LC_ALL=C sort "$baseline_raw" > "$baseline" +LC_ALL=C sort "$baseline_raw" | uniq -d > "$duplicate_baseline" +scan_marker_debt > "$current" +scan_executable_todos > "$executable_todos" + +if [[ -s "$duplicate_baseline" ]]; then + echo "Duplicate TODO debt baseline entries are forbidden:" >&2 + sed -n '1,40p' "$duplicate_baseline" >&2 + fail "Remove duplicate entries from config/todo-debt-baseline.tsv." +fi + +if [[ -s "$executable_todos" ]]; then + echo "Executable TODO calls are forbidden because they can crash runtime or preview paths:" >&2 + sed -n '1,40p' "$executable_todos" >&2 + fail "Remove TODO(...) calls instead of baselining them." +fi + +comm -23 "$current" "$baseline" > "$new_markers" +comm -13 "$current" "$baseline" > "$stale_markers" + +if [[ -s "$new_markers" ]]; then + echo "New TODO/FIXME/STOPSHIP markers found outside the baseline:" >&2 + sed -n '1,80p' "$new_markers" >&2 + fail "Resolve the markers or intentionally update config/todo-debt-baseline.tsv." +fi + +if [[ -s "$stale_markers" ]]; then + echo "Baseline entries no longer exist in source:" >&2 + sed -n '1,80p' "$stale_markers" >&2 + fail "Remove stale entries from config/todo-debt-baseline.tsv." +fi + +log "TODO/FIXME debt matches baseline." diff --git a/scripts/audit-xcm-production-evidence.sh b/scripts/audit-xcm-production-evidence.sh new file mode 100755 index 0000000000..541570ebd1 --- /dev/null +++ b/scripts/audit-xcm-production-evidence.sh @@ -0,0 +1,535 @@ +#!/usr/bin/env bash +set -euo pipefail + +ROOT_DIR="${XCM_PRODUCTION_EVIDENCE_ROOT:-$(cd "$(dirname "$0")/.." && pwd)}" +EVIDENCE_FILE="$ROOT_DIR/scripts/xcm-production-evidence.json" +REQUIRED_ROUTE_FILE="$ROOT_DIR/scripts/xcm-required-routes.tsv" +DISCOVERY_GAP_FILE="$ROOT_DIR/scripts/xcm-discovery-only-routes.tsv" +REQUIRE_READY=false + +usage() { + cat <<'USAGE' +Usage: scripts/audit-xcm-production-evidence.sh [--evidence ] [--required-route-file ] [--discovery-gap-file ] [--require-ready] + +Validates the Android XCM production evidence manifest. The default audit allows +the current blocked state, but rejects any release-enabled or ready claim unless +all required route evidence is present and no discovery-only XCM gaps remain. + +--require-ready additionally fails unless the manifest is marked ready for broad +production XCM release. +USAGE +} + +while (($#)); do + case "$1" in + --evidence) + [[ $# -ge 2 ]] || { echo "[xcm-production-evidence][error] --evidence requires a path" >&2; exit 2; } + EVIDENCE_FILE="$2" + shift 2 + ;; + --required-route-file) + [[ $# -ge 2 ]] || { echo "[xcm-production-evidence][error] --required-route-file requires a path" >&2; exit 2; } + REQUIRED_ROUTE_FILE="$2" + shift 2 + ;; + --discovery-gap-file) + [[ $# -ge 2 ]] || { echo "[xcm-production-evidence][error] --discovery-gap-file requires a path" >&2; exit 2; } + DISCOVERY_GAP_FILE="$2" + shift 2 + ;; + --require-ready) + REQUIRE_READY=true + shift + ;; + -h|--help) + usage + exit 0 + ;; + *) + echo "[xcm-production-evidence][error] Unknown argument: $1" >&2 + usage >&2 + exit 2 + ;; + esac +done + +if ! command -v node >/dev/null 2>&1; then + echo "[xcm-production-evidence][error] node is required for structured JSON validation" >&2 + exit 1 +fi + +node - "$EVIDENCE_FILE" "$REQUIRED_ROUTE_FILE" "$DISCOVERY_GAP_FILE" "$REQUIRE_READY" <<'NODE' +const fs = require('fs'); + +const [evidenceFile, requiredRouteFile, discoveryGapFile, requireReadyRaw] = process.argv.slice(2); +const requireReady = requireReadyRaw === 'true'; +const errors = []; +const MAX_CLOCK_SKEW_MS = 5 * 60 * 1000; + +const REQUIRED_BLOCKERS = [ + 'e2e-transfer-evidence-missing', + 'all-routes-executable-gate-not-green', + 'discovery-only-routes-remain' +]; + +const REQUIRED_EVIDENCE_FIELDS = [ + 'originChainId', + 'destinationChainId', + 'assetSymbol', + 'extrinsicHash', + 'sender', + 'recipient', + 'amount', + 'timestamp', + 'environment', + 'operator' +]; + +const ALLOWED_MANIFEST_FIELDS = [ + 'schemaVersion', + 'scope', + 'status', + 'releaseEnabled', + 'lastReviewed', + 'currentState', + 'blockers', + 'routeManifests', + 'readyVerificationCommands', + 'requiredEvidenceFields', + 'evidence' +]; + +const ALLOWED_CURRENT_STATE_FIELDS = [ + 'requiredExecutableRouteCount', + 'discoveryOnlyRouteCount' +]; + +const ALLOWED_ROUTE_MANIFEST_FIELDS = [ + 'requiredExecutableRoutes', + 'discoveryOnlyRoutes', + 'gapReport' +]; + +const REQUIRED_READY_COMMAND_MARKERS = [ + 'test-xcm-production-evidence-template.sh', + 'test-xcm-production-evidence-audit.sh', + 'audit-xcm-production-evidence.sh --require-ready', + 'audit-xcm-registry-metadata.sh --require-executable --require-all-routes-executable', + '--require-route-file scripts/xcm-required-routes.tsv', + '--require-gap-file scripts/xcm-discovery-only-routes.tsv', + 'public-shared-features-xcm:testDebugUnitTest', + 'XcmLocalChainsRegistryTest' +]; + +function fail(message) { + errors.push(message); +} + +function readText(file, description) { + if (!fs.existsSync(file)) { + fail(`${description} missing: ${file}`); + return null; + } + + try { + return fs.readFileSync(file, 'utf8'); + } catch (error) { + fail(`${description} could not be read: ${error.message}`); + return null; + } +} + +function parseJson(file, description) { + const text = readText(file, description); + if (text === null) { + return null; + } + + try { + return JSON.parse(text); + } catch (error) { + fail(`${description} must be valid JSON: ${error.message}`); + return null; + } +} + +function nonEmptyString(value) { + return typeof value === 'string' && value.trim().length > 0; +} + +function normalizeAssetSymbol(value) { + return String(value || '') + .trim() + .replace(/^xc/i, '') + .toUpperCase(); +} + +function isRepeatedHexPlaceholder(value) { + const normalized = String(value || '') + .trim() + .toLowerCase() + .replace(/^0x/, ''); + return /^[0-9a-f]{8,}$/.test(normalized) && new Set(normalized).size === 1; +} + +function isPlaceholderText(value) { + const normalized = String(value || '').trim().toLowerCase(); + return /^(todo|tbd|placeholder|example|sample|dummy|unknown|n\/a)(?:$|[_\-\s:])/u.test(normalized); +} + +function isIsoUtcSecond(value) { + return /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}Z$/.test(String(value || '')); +} + +function isFutureTimestamp(value) { + const millis = Date.parse(value); + return Number.isFinite(millis) && millis > Date.now() + MAX_CLOCK_SKEW_MS; +} + +function secretLikeKeyReason(value, path = '$') { + if (!value || typeof value !== 'object') { + return null; + } + + if (Array.isArray(value)) { + for (let index = 0; index < value.length; index += 1) { + const reason = secretLikeKeyReason(value[index], `${path}[${index}]`); + if (reason) { + return reason; + } + } + return null; + } + + for (const [key, nested] of Object.entries(value)) { + const nestedPath = `${path}.${key}`; + if (/(private[-_]?key|mnemonic|seed|secret|password|authorization|credential|clientDataJSON)/iu.test(key)) { + return `${nestedPath} must not be included in public XCM production evidence`; + } + const reason = secretLikeKeyReason(nested, nestedPath); + if (reason) { + return reason; + } + } + + return null; +} + +function parseRequiredRoutes(file) { + const text = readText(file, 'required route file'); + if (text === null) { + return []; + } + + const routes = []; + text.split(/\r?\n/).forEach((line, index) => { + const trimmed = line.replace(/\s+#.*$/, '').trim(); + if (!trimmed || trimmed.startsWith('#')) { + return; + } + + const parts = trimmed.split(/\s+/); + if (parts.length !== 3 || parts.some((part) => !nonEmptyString(part))) { + fail(`Invalid required route file line ${index + 1} in ${file}: expected origin destination asset`); + return; + } + + routes.push({ + originChainId: parts[0], + destinationChainId: parts[1], + assetSymbol: normalizeAssetSymbol(parts[2]) + }); + }); + return routes; +} + +function parseDiscoveryGaps(file) { + const text = readText(file, 'discovery-only gap file'); + if (text === null) { + return []; + } + + const gaps = []; + text.split(/\r?\n/).forEach((line, index) => { + const trimmed = line.replace(/\s+#.*$/, '').trim(); + if (!trimmed || trimmed.startsWith('#')) { + return; + } + + const parts = trimmed.split(/\s+/); + if (parts.length !== 5 || parts.some((part) => !nonEmptyString(part))) { + fail(`Invalid discovery-only gap file line ${index + 1} in ${file}: expected origin destination assets reason bridgeParachainId`); + return; + } + + gaps.push({ + originChainId: parts[0], + destinationChainId: parts[1], + assetSymbols: parts[2].split(',').map(normalizeAssetSymbol).filter(Boolean), + reason: parts[3], + bridgeParachainId: parts[4] + }); + }); + return gaps; +} + +function routeKey(route) { + return `${route.originChainId}|${route.destinationChainId}|${normalizeAssetSymbol(route.assetSymbol)}`; +} + +function requireArray(value, name) { + if (!Array.isArray(value)) { + fail(`${name} must be an array`); + return []; + } + + return value; +} + +function requireObject(value, name) { + if (!value || typeof value !== 'object' || Array.isArray(value)) { + fail(`${name} must be an object`); + return {}; + } + + return value; +} + +function assertAllowedKeys(value, allowedKeys, unsupportedFieldPrefix) { + if (!value || typeof value !== 'object' || Array.isArray(value)) { + return; + } + + const allowed = new Set(allowedKeys); + for (const key of Object.keys(value)) { + if (!allowed.has(key)) { + fail(`${unsupportedFieldPrefix}: ${key}`); + } + } +} + +const manifest = parseJson(evidenceFile, 'XCM production evidence manifest'); +const requiredRoutes = parseRequiredRoutes(requiredRouteFile); +const discoveryGaps = parseDiscoveryGaps(discoveryGapFile); + +if (manifest) { + const secretReason = secretLikeKeyReason(manifest); + if (secretReason) { + fail(secretReason); + } + + assertAllowedKeys(manifest, ALLOWED_MANIFEST_FIELDS, 'unsupported XCM production evidence manifest field'); + + if (manifest.schemaVersion !== 1) { + fail('schemaVersion must be 1'); + } + + if (manifest.scope !== 'android-xcm-production-readiness') { + fail('scope must be android-xcm-production-readiness'); + } + + if (!['blocked', 'ready'].includes(manifest.status)) { + fail('status must be blocked or ready'); + } + + if (typeof manifest.releaseEnabled !== 'boolean') { + fail('releaseEnabled must be a boolean'); + } + + if (manifest.lastReviewed !== undefined && !/^\d{4}-\d{2}-\d{2}$/.test(String(manifest.lastReviewed))) { + fail('lastReviewed must be YYYY-MM-DD when present'); + } + + const currentState = requireObject(manifest.currentState, 'currentState'); + assertAllowedKeys(currentState, ALLOWED_CURRENT_STATE_FIELDS, 'unsupported XCM currentState field'); + if (currentState.requiredExecutableRouteCount !== requiredRoutes.length) { + fail(`currentState.requiredExecutableRouteCount must match required route manifest count ${requiredRoutes.length}`); + } + if (currentState.discoveryOnlyRouteCount !== discoveryGaps.length) { + fail(`currentState.discoveryOnlyRouteCount must match discovery-only manifest count ${discoveryGaps.length}`); + } + + const routeManifests = requireObject(manifest.routeManifests, 'routeManifests'); + assertAllowedKeys(routeManifests, ALLOWED_ROUTE_MANIFEST_FIELDS, 'unsupported XCM routeManifests field'); + if (routeManifests.requiredExecutableRoutes !== 'scripts/xcm-required-routes.tsv') { + fail('routeManifests.requiredExecutableRoutes must be scripts/xcm-required-routes.tsv'); + } + if (routeManifests.discoveryOnlyRoutes !== 'scripts/xcm-discovery-only-routes.tsv') { + fail('routeManifests.discoveryOnlyRoutes must be scripts/xcm-discovery-only-routes.tsv'); + } + if (routeManifests.gapReport !== 'build/reports/xcm-registry-gap-report.json') { + fail('routeManifests.gapReport must be build/reports/xcm-registry-gap-report.json'); + } + + const manifestBlockers = requireArray(manifest.blockers, 'blockers'); + const blockers = new Set(manifestBlockers); + const commandList = requireArray(manifest.readyVerificationCommands, 'readyVerificationCommands'); + const commands = commandList.join('\n'); + const requiredEvidenceFieldList = requireArray(manifest.requiredEvidenceFields, 'requiredEvidenceFields'); + const requiredEvidenceFields = new Set(requiredEvidenceFieldList); + const evidence = requireArray(manifest.evidence, 'evidence'); + + if (blockers.size !== manifestBlockers.length) { + fail('duplicate XCM production evidence blocker'); + } + if (new Set(commandList).size !== commandList.length) { + fail('duplicate XCM production evidence verification command'); + } + if (requiredEvidenceFields.size !== requiredEvidenceFieldList.length) { + fail('duplicate XCM production evidence required field'); + } + + for (const blocker of blockers) { + if (!REQUIRED_BLOCKERS.includes(blocker)) { + fail(`unsupported XCM production evidence blocker: ${blocker}`); + } + } + + for (const marker of REQUIRED_READY_COMMAND_MARKERS) { + if (!commands.includes(marker)) { + fail(`readyVerificationCommands missing ${marker}`); + } + } + + for (const field of requiredEvidenceFields) { + if (!REQUIRED_EVIDENCE_FIELDS.includes(field)) { + fail(`unsupported required XCM production evidence field: ${field}`); + } + } + + for (const field of REQUIRED_EVIDENCE_FIELDS) { + if (!requiredEvidenceFields.has(field)) { + fail(`requiredEvidenceFields missing ${field}`); + } + } + + if (manifest.status === 'blocked' && manifest.releaseEnabled) { + fail('releaseEnabled must remain false while status is blocked'); + } + + if (requireReady && manifest.status !== 'ready') { + fail('status must be ready when --require-ready is used'); + } + + const readyClaimed = manifest.status === 'ready' || manifest.releaseEnabled || requireReady; + const evidenceByRoute = new Map(); + const requiredRouteKeys = new Set(requiredRoutes.map(routeKey)); + const extrinsicHashes = new Set(); + + evidence.forEach((entry, index) => { + if (!entry || typeof entry !== 'object' || Array.isArray(entry)) { + fail(`evidence[${index}] must be an object`); + return; + } + + assertAllowedKeys(entry, REQUIRED_EVIDENCE_FIELDS, `unsupported XCM production evidence[${index}] field`); + + for (const field of REQUIRED_EVIDENCE_FIELDS) { + if (!nonEmptyString(entry[field])) { + fail(`evidence[${index}].${field} must not be blank`); + } + } + + const key = routeKey(entry); + if (!requiredRouteKeys.has(key)) { + fail(`evidence[${index}] route is not declared in required route manifest: ${entry.originChainId} -> ${entry.destinationChainId} ${normalizeAssetSymbol(entry.assetSymbol)}`); + } + + if (evidenceByRoute.has(key)) { + fail(`duplicate E2E transfer evidence for route ${entry.originChainId} -> ${entry.destinationChainId} ${normalizeAssetSymbol(entry.assetSymbol)}`); + } else { + evidenceByRoute.set(key, entry); + } + + if (!/^0x[0-9a-fA-F]{64}$/.test(String(entry.extrinsicHash || ''))) { + fail(`evidence[${index}].extrinsicHash must be a 0x-prefixed 32-byte hash`); + } else { + const normalizedExtrinsicHash = String(entry.extrinsicHash).toLowerCase(); + if (isRepeatedHexPlaceholder(normalizedExtrinsicHash)) { + fail(`evidence[${index}].extrinsicHash must not be a placeholder extrinsic hash`); + } + if (extrinsicHashes.has(normalizedExtrinsicHash)) { + fail(`duplicate E2E transfer extrinsicHash: ${normalizedExtrinsicHash}`); + } else { + extrinsicHashes.add(normalizedExtrinsicHash); + } + } + + if (!/^\d+(\.\d+)?$/.test(String(entry.amount || ''))) { + fail(`evidence[${index}].amount must be a positive decimal string`); + } else if (Number(entry.amount) <= 0) { + fail(`evidence[${index}].amount must be greater than zero`); + } + + if (!isIsoUtcSecond(entry.timestamp)) { + fail(`evidence[${index}].timestamp must be an ISO-8601 UTC second timestamp`); + } else if (isFutureTimestamp(entry.timestamp)) { + fail(`evidence[${index}].timestamp must not be in the future`); + } + + if (!['mainnet', 'testnet'].includes(String(entry.environment || ''))) { + fail(`evidence[${index}].environment must be mainnet or testnet`); + } + if (readyClaimed && entry.environment !== 'mainnet') { + fail(`evidence[${index}].environment must be mainnet when XCM production evidence is ready`); + } + if (isPlaceholderText(entry.sender)) { + fail(`evidence[${index}].sender must not be a placeholder public address`); + } + if (isPlaceholderText(entry.recipient)) { + fail(`evidence[${index}].recipient must not be a placeholder public address`); + } + if (String(entry.sender || '').trim() === String(entry.recipient || '').trim()) { + fail(`evidence[${index}].sender and recipient must differ`); + } + if (isPlaceholderText(entry.operator)) { + fail(`evidence[${index}].operator must not be a placeholder operator`); + } + }); + + const missingEvidenceRoutes = requiredRoutes.filter((route) => !evidenceByRoute.has(routeKey(route))); + const expectedBlockedReasons = new Map(); + expectedBlockedReasons.set('e2e-transfer-evidence-missing', missingEvidenceRoutes.length > 0); + expectedBlockedReasons.set('all-routes-executable-gate-not-green', discoveryGaps.length > 0); + expectedBlockedReasons.set('discovery-only-routes-remain', discoveryGaps.length > 0); + + if (manifest.status === 'blocked') { + for (const [blocker, active] of expectedBlockedReasons.entries()) { + if (active && !blockers.has(blocker)) { + fail(`blocked evidence missing blocker ${blocker}`); + } + if (!active && blockers.has(blocker)) { + fail(`blocked evidence has stale blocker ${blocker}`); + } + } + } + + if (readyClaimed) { + if (!manifest.releaseEnabled) { + fail('releaseEnabled must be true when status is ready or --require-ready is used'); + } + if (Array.isArray(manifest.blockers) && manifest.blockers.length > 0) { + fail('blockers must be empty when XCM production evidence is ready'); + } + + if (discoveryGaps.length > 0) { + fail(`ready evidence cannot have discovery-only routes remaining: ${discoveryGaps.length}`); + } + + for (const route of missingEvidenceRoutes) { + fail(`ready evidence missing E2E transfer evidence for route ${route.originChainId} -> ${route.destinationChainId} ${route.assetSymbol}`); + } + } +} + +if (errors.length > 0) { + for (const error of errors) { + console.error(`[xcm-production-evidence][error] ${error}`); + } + process.exit(1); +} + +const status = manifest ? manifest.status : 'unknown'; +const releaseEnabled = manifest ? manifest.releaseEnabled : false; +console.log(`[xcm-production-evidence] status=${status} releaseEnabled=${releaseEnabled} requiredRoutes=${requiredRoutes.length} discoveryOnlyRoutes=${discoveryGaps.length}`); +NODE diff --git a/scripts/audit-xcm-registry-metadata.sh b/scripts/audit-xcm-registry-metadata.sh new file mode 100755 index 0000000000..0b42bdafc3 --- /dev/null +++ b/scripts/audit-xcm-registry-metadata.sh @@ -0,0 +1,793 @@ +#!/usr/bin/env bash +set -euo pipefail + +ROOT_DIR="${XCM_REGISTRY_ROOT:-$(cd "$(dirname "$0")/.." && pwd)}" +REGISTRY_FILE="$ROOT_DIR/runtime/src/main/assets/local_chains.json" +REQUIRE_EXECUTABLE=false +REQUIRE_ALL_ROUTES_EXECUTABLE=false +GAP_REPORT_FILE="" +REQUIRED_ROUTES=() +REQUIRED_ROUTE_FILES=() +REQUIRED_GAP_FILES=() + +usage() { + cat <<'USAGE' +Usage: scripts/audit-xcm-registry-metadata.sh [--registry ] [--require-executable] [--require-all-routes-executable] [--write-gap-report ] [--require-route ] [--require-route-file ] [--require-gap-file ] + +Validates Android local_chains.json XCM route metadata and any committed +execution specs. By default the audit validates metadata shape and fails on any +malformed execution spec. Release hardening can add --require-executable and +--require-all-routes-executable to fail until real executable route specs are +committed for the public XCM backend. + +--require-route can be repeated to require a specific executable route asset. +--require-route-file reads required executable routes from a whitespace-separated +file with origin chain id, destination chain id, and asset symbol columns. +--require-gap-file reads expected discovery-only routes from a +whitespace-separated file with origin chain id, destination chain id, +comma-separated asset symbols, reason category, and bridge parachain id columns. +--write-gap-report writes a deterministic JSON report listing advertised XCM +routes that are still discovery-only because they do not have execution specs. +USAGE +} + +while (($#)); do + case "$1" in + --registry) + [[ $# -ge 2 ]] || { echo "[xcm-registry][error] --registry requires a path" >&2; exit 2; } + REGISTRY_FILE="$2" + shift 2 + ;; + --require-executable) + REQUIRE_EXECUTABLE=true + shift + ;; + --require-all-routes-executable) + REQUIRE_ALL_ROUTES_EXECUTABLE=true + shift + ;; + --write-gap-report) + [[ $# -ge 2 ]] || { echo "[xcm-registry][error] --write-gap-report requires a path" >&2; exit 2; } + GAP_REPORT_FILE="$2" + shift 2 + ;; + --require-route) + [[ $# -ge 4 ]] || { echo "[xcm-registry][error] --require-route requires origin, destination, and asset" >&2; exit 2; } + REQUIRED_ROUTES+=("$2|$3|$4") + shift 4 + ;; + --require-route-file) + [[ $# -ge 2 ]] || { echo "[xcm-registry][error] --require-route-file requires a path" >&2; exit 2; } + REQUIRED_ROUTE_FILES+=("$2") + shift 2 + ;; + --require-gap-file) + [[ $# -ge 2 ]] || { echo "[xcm-registry][error] --require-gap-file requires a path" >&2; exit 2; } + REQUIRED_GAP_FILES+=("$2") + shift 2 + ;; + -h|--help) + usage + exit 0 + ;; + *) + echo "[xcm-registry][error] Unknown argument: $1" >&2 + usage >&2 + exit 2 + ;; + esac +done + +if ! command -v node >/dev/null 2>&1; then + echo "[xcm-registry][error] node is required for structured JSON validation" >&2 + exit 1 +fi + +required_routes_raw="" +if ((${#REQUIRED_ROUTES[@]} > 0)); then + required_routes_raw="$(printf '%s\n' "${REQUIRED_ROUTES[@]}")" +fi + +required_route_files_raw="" +if ((${#REQUIRED_ROUTE_FILES[@]} > 0)); then + required_route_files_raw="$(printf '%s\n' "${REQUIRED_ROUTE_FILES[@]}")" +fi + +required_gap_files_raw="" +if ((${#REQUIRED_GAP_FILES[@]} > 0)); then + required_gap_files_raw="$(printf '%s\n' "${REQUIRED_GAP_FILES[@]}")" +fi + +node - "$REGISTRY_FILE" "$REQUIRE_EXECUTABLE" "$REQUIRE_ALL_ROUTES_EXECUTABLE" "$required_routes_raw" "$GAP_REPORT_FILE" "$required_route_files_raw" "$required_gap_files_raw" <<'NODE' +const fs = require('fs'); +const path = require('path'); + +const [ + registryFile, + requireExecutableRaw, + requireAllRoutesExecutableRaw, + requiredRoutesRaw = '', + gapReportFile = '', + requiredRouteFilesRaw = '', + requiredGapFilesRaw = '' +] = process.argv.slice(2); +const requireExecutable = requireExecutableRaw === 'true'; +const requireAllRoutesExecutable = requireAllRoutesExecutableRaw === 'true'; +const errors = []; +const executableRouteKeys = new Set(); +const missingExecutableDestinations = []; +const requiredGaps = []; +const allowedGapReasons = new Set(['bridge-sora', 'non-native-asset', 'cross-parachain-native']); +const requireGapManifest = requiredGapFilesRaw.split('\n').filter(Boolean).length > 0; + +function fail(message) { + errors.push(message); +} + +function label(...parts) { + return parts.filter(Boolean).join(' '); +} + +function nonEmptyString(value) { + return typeof value === 'string' && value.trim().length > 0; +} + +function requiredString(value, field, context) { + if (!nonEmptyString(value)) { + fail(`${context}: ${field} must not be blank`); + return null; + } + + return value.trim(); +} + +function nonNegativeInteger(value, field, context) { + if (!Number.isInteger(value) || value < 0) { + fail(`${context}: ${field} must be a non-negative integer`); + return null; + } + + return value; +} + +function nonNegativeIntegerString(value, field, context) { + if (value === undefined || value === null) { + return null; + } + + if (typeof value !== 'string' || !/^(0|[1-9][0-9]*)$/.test(value.trim())) { + fail(`${context}: ${field} must be a non-negative integer string`); + return null; + } + + return value.trim(); +} + +function normalizedEnum(value) { + return String(value) + .trim() + .replace(/-/g, '_') + .replace(/\s+/g, '_') + .replace(/([a-z])([A-Z])/g, '$1_$2') + .toUpperCase(); +} + +function normalizeAssetSymbol(value) { + return String(value) + .trim() + .replace(/^xc/i, '') + .toUpperCase(); +} + +function normalizeAssetSymbols(values) { + return values + .map(normalizeAssetSymbol) + .filter(Boolean) + .sort() + .join(','); +} + +const requiredRoutes = requiredRoutesRaw + .split('\n') + .filter(Boolean) + .map((route) => { + const parts = route.split('|'); + if (parts.length !== 3 || parts.some((part) => !nonEmptyString(part))) { + fail(`Invalid required route declaration: ${route}`); + return null; + } + return { + originId: parts[0].trim(), + destinationId: parts[1].trim(), + assetSymbol: normalizeAssetSymbol(parts[2]) + }; + }) + .filter(Boolean); + +function parseRequiredRouteLine(line, source, lineNumber) { + const trimmed = line.replace(/\s+#.*$/, '').trim(); + if (!trimmed || trimmed.startsWith('#')) { + return null; + } + + const parts = trimmed.split(/\s+/); + if (parts.length !== 3 || parts.some((part) => !nonEmptyString(part))) { + fail(`Invalid required route file line ${lineNumber} in ${source}: expected origin destination asset`); + return null; + } + + return { + originId: parts[0].trim(), + destinationId: parts[1].trim(), + assetSymbol: normalizeAssetSymbol(parts[2]) + }; +} + +function parseRequiredGapLine(line, source, lineNumber) { + const trimmed = line.replace(/\s+#.*$/, '').trim(); + if (!trimmed || trimmed.startsWith('#')) { + return null; + } + + const parts = trimmed.split(/\s+/); + if (parts.length !== 5 || parts.some((part) => !nonEmptyString(part))) { + fail(`Invalid required gap file line ${lineNumber} in ${source}: expected origin destination assets reason bridgeParachainId`); + return null; + } + + const reason = parts[3].trim(); + if (!allowedGapReasons.has(reason)) { + fail(`Invalid required gap file line ${lineNumber} in ${source}: unsupported reason ${reason}`); + return null; + } + + const assetSymbols = parts[2] + .split(',') + .map((symbol) => symbol.trim()) + .filter(Boolean); + if (assetSymbols.length === 0) { + fail(`Invalid required gap file line ${lineNumber} in ${source}: assets must not be blank`); + return null; + } + + const bridgeParachainId = parts[4].trim(); + return { + originId: parts[0].trim(), + destinationId: parts[1].trim(), + assetSymbols: normalizeAssetSymbols(assetSymbols), + reason, + bridgeParachainId: bridgeParachainId === '-' ? null : bridgeParachainId + }; +} + +for (const routeFile of requiredRouteFilesRaw.split('\n').filter(Boolean)) { + if (!fs.existsSync(routeFile)) { + fail(`Required route file missing: ${routeFile}`); + continue; + } + + const routeFileContents = fs.readFileSync(routeFile, 'utf8'); + routeFileContents.split(/\r?\n/).forEach((line, index) => { + const route = parseRequiredRouteLine(line, routeFile, index + 1); + if (route !== null) { + requiredRoutes.push(route); + } + }); +} + +for (const gapFile of requiredGapFilesRaw.split('\n').filter(Boolean)) { + if (!fs.existsSync(gapFile)) { + fail(`Required gap file missing: ${gapFile}`); + continue; + } + + const gapFileContents = fs.readFileSync(gapFile, 'utf8'); + gapFileContents.split(/\r?\n/).forEach((line, index) => { + const gap = parseRequiredGapLine(line, gapFile, index + 1); + if (gap !== null) { + requiredGaps.push(gap); + } + }); +} + +function gapKey(gap) { + return `${gap.originId}|${gap.destinationId}|${gap.assetSymbols}`; +} + +function classifyMissingDestination(destination) { + if (destination.bridgeParachainId !== null) { + return 'bridge-sora'; + } + + const assetSymbols = destination.assetSymbols.map(normalizeAssetSymbol); + if (assetSymbols.some((symbol) => !['DOT', 'KSM'].includes(symbol))) { + return 'non-native-asset'; + } + + return 'cross-parachain-native'; +} + +function requiredEnum(value, field, allowed, context) { + const text = requiredString(value, field, context); + if (text === null) return null; + + const normalized = normalizedEnum(text); + if (!allowed.includes(normalized)) { + fail(`${context}: ${field} is unsupported (${text})`); + return null; + } + + return normalized; +} + +function optionalEnum(value, field, allowed, defaultValue, context) { + if (value === undefined || value === null || String(value).trim() === '') { + return defaultValue; + } + + const normalized = normalizedEnum(value); + if (!allowed.includes(normalized)) { + const fieldMessage = field === 'argumentShape' ? 'argumentShape is unsupported' : `${field} is unsupported`; + fail(`${context}: ${fieldMessage} (${value})`); + return null; + } + + return normalized; +} + +function polkadotXcmCallName(transferType) { + if (transferType === 'RESERVE_TRANSFER_ASSETS') return 'reserveTransferAssets'; + if (transferType === 'LIMITED_RESERVE_TRANSFER_ASSETS') return 'limitedReserveTransferAssets'; + if (transferType === 'TELEPORT_ASSETS') return 'teleportAssets'; + if (transferType === 'LIMITED_TELEPORT_ASSETS') return 'limitedTeleportAssets'; + return null; +} + +function validateCallShape(palletName, callName, transferType, argumentShape, context) { + if ([palletName, callName, transferType, argumentShape].some((value) => value === null)) { + return; + } + + if (argumentShape === 'POLKADOT_XCM_TRANSFER_ASSETS') { + if (palletName !== 'PolkadotXcm') { + fail(`${context}: PolkadotXcm transfer-assets argumentShape requires palletName PolkadotXcm`); + } + if (callName !== polkadotXcmCallName(transferType)) { + fail(`${context}: PolkadotXcm transfer-assets argumentShape callName must match transferType`); + } + return; + } + + if (argumentShape === 'X_TOKENS_TRANSFER_MULTIASSET') { + if (palletName !== 'XTokens') { + fail(`${context}: XTokens transferMultiasset argumentShape requires palletName XTokens`); + } + if (callName !== 'transferMultiasset') { + fail(`${context}: XTokens transferMultiasset argumentShape requires callName transferMultiasset`); + } + if (transferType !== 'X_TOKENS_TRANSFER_MULTIASSET') { + fail(`${context}: XTokens transferMultiasset argumentShape requires transferType xTokensTransferMultiasset`); + } + } +} + +function requiredMultiLocation(value, field, context) { + const mlContext = `${context}: ${field}`; + if (!value || typeof value !== 'object' || Array.isArray(value)) { + fail(`${mlContext} is required`); + return; + } + + nonNegativeInteger(value.parents, 'parents', mlContext); + const interior = requiredString(value.interior, 'interior', mlContext); + if (interior !== null) { + validateMultiLocationInterior(interior, `${mlContext}.interior`); + } +} + +function splitTopLevel(value, context) { + const parts = []; + let start = 0; + let parentheses = 0; + let braces = 0; + let brackets = 0; + + for (let index = 0; index < value.length; index += 1) { + const char = value[index]; + if (char === '(') parentheses += 1; + if (char === ')') parentheses -= 1; + if (char === '{') braces += 1; + if (char === '}') braces -= 1; + if (char === '[') brackets += 1; + if (char === ']') brackets -= 1; + + if (parentheses < 0 || braces < 0 || brackets < 0) { + fail(`${context} has unbalanced delimiters`); + return null; + } + + if (char === ',' && parentheses === 0 && braces === 0 && brackets === 0) { + parts.push(value.slice(start, index).trim()); + start = index + 1; + } + } + + if (parentheses !== 0 || braces !== 0 || brackets !== 0) { + fail(`${context} has unbalanced delimiters`); + return null; + } + + parts.push(value.slice(start).trim()); + return parts.filter(Boolean); +} + +function validateJunction(value, context) { + const match = /^([A-Za-z][A-Za-z0-9]*)(?:\((.*)\))?$/.exec(value.trim()); + if (!match) { + fail(`${context} has malformed junction`); + return; + } + + const type = normalizedEnum(match[1]); + const argument = typeof match[2] === 'string' ? match[2].trim() : null; + const requireArgument = (name) => { + if (!argument) { + fail(`${context} ${name} must include a value`); + return null; + } + return argument; + }; + const requireUnsignedInteger = (name) => { + const parsed = requireArgument(name); + if (parsed !== null && !/^(0|[1-9][0-9]*)$/.test(parsed)) { + fail(`${context} ${name} must be a non-negative integer`); + } + }; + + if (['PARACHAIN', 'PALLET_INSTANCE', 'GENERAL_INDEX'].includes(type)) { + requireUnsignedInteger(match[1]); + } else if (type === 'GENERAL_KEY') { + requireArgument(match[1]); + } else if (type === 'ACCOUNT_ID32' || type === 'ACCOUNT_KEY20') { + const parsed = requireArgument(match[1]); + if (parsed !== null && !parsed.includes('')) { + fail(`${context} ${match[1]} must include recipient placeholder`); + } + } else { + fail(`${context} has unsupported junction ${match[1]}`); + } +} + +function validateMultiLocationInterior(value, context) { + if (value === 'Here') return; + + const match = /^X([1-8])\((.*)\)$/.exec(value); + if (!match) { + fail(`${context} must be Here or X1..X8 junctions`); + return; + } + + const expectedCount = Number(match[1]); + const junctions = splitTopLevel(match[2], context); + if (junctions === null) return; + + if (junctions.length !== expectedCount) { + fail(`${context} declares X${expectedCount} but contains ${junctions.length} junctions`); + return; + } + + junctions.forEach((junction, index) => { + validateJunction(junction, `${context} junction ${index + 1}`); + }); +} + +function validateWeightLimit(value, context) { + const weightContext = `${context}: weightLimit`; + if (!value || typeof value !== 'object' || Array.isArray(value)) { + fail(`${weightContext} is required`); + return; + } + + const type = requiredEnum(value.type, 'type', ['UNLIMITED', 'LIMITED'], weightContext); + const refTime = nonNegativeIntegerString(value.refTime, 'refTime', weightContext); + const proofSize = nonNegativeIntegerString(value.proofSize, 'proofSize', weightContext); + + if (type === 'LIMITED' && (refTime === null || proofSize === null)) { + fail(`${weightContext}: limited weight requires refTime and proofSize`); + } + + if (type === 'UNLIMITED' && (refTime !== null || proofSize !== null)) { + fail(`${weightContext}: unlimited weight must not include refTime or proofSize`); + } +} + +function validateDestinationFee(value, context) { + const feeContext = `${context}: destinationFee`; + if (!value || typeof value !== 'object' || Array.isArray(value)) { + fail(`${feeContext} is required`); + return; + } + + const mode = requiredEnum(value.mode, 'mode', ['INCLUDED', 'ESTIMATED', 'FIXED'], feeContext); + requiredString(value.assetSymbol, 'assetSymbol', feeContext); + const amount = nonNegativeIntegerString(value.amount, 'amount', feeContext); + + if (mode === 'FIXED' && amount === null) { + fail(`${feeContext}: fixed destination fee requires amount`); + } + + if (mode !== null && mode !== 'FIXED' && amount !== null) { + fail(`${feeContext}: amount is only valid for fixed destination fees`); + } +} + +function validateExecutionSpec(execution, destination, xcmVersion, context) { + if (!execution || typeof execution !== 'object' || Array.isArray(execution)) { + fail(`${context}: execution must be an object`); + return; + } + + requiredString(xcmVersion, 'xcmVersion', context); + const palletName = requiredString(execution.palletName, 'palletName', context); + const callName = requiredString(execution.callName, 'callName', context); + const transferType = requiredEnum( + execution.transferType, + 'transferType', + ['RESERVE_TRANSFER_ASSETS', 'LIMITED_RESERVE_TRANSFER_ASSETS', 'TELEPORT_ASSETS', 'LIMITED_TELEPORT_ASSETS', 'X_TOKENS_TRANSFER_MULTIASSET'], + context + ); + const argumentShape = optionalEnum( + execution.argumentShape, + 'argumentShape', + ['POLKADOT_XCM_TRANSFER_ASSETS', 'X_TOKENS_TRANSFER_MULTIASSET'], + 'POLKADOT_XCM_TRANSFER_ASSETS', + context + ); + validateCallShape(palletName, callName, transferType, argumentShape, context); + requiredMultiLocation(execution.destinationLocation, 'destinationLocation', context); + requiredMultiLocation(execution.assetLocation, 'assetLocation', context); + requiredMultiLocation(execution.beneficiaryLocation, 'beneficiaryLocation', context); + requiredMultiLocation(execution.feeAssetLocation, 'feeAssetLocation', context); + nonNegativeInteger(execution.feeAssetItem, 'feeAssetItem', context); + validateWeightLimit(execution.weightLimit, context); + validateDestinationFee(execution.destinationFee, context); + + if (execution.bridge !== undefined && execution.bridge !== null) { + const bridgeContext = `${context}: bridge`; + if (typeof execution.bridge !== 'object' || Array.isArray(execution.bridge)) { + fail(`${bridgeContext} must be an object`); + } else { + const bridgeParachainId = requiredString(execution.bridge.parachainId, 'parachainId', bridgeContext); + requiredMultiLocation(execution.bridge.feeAssetLocation, 'feeAssetLocation', bridgeContext); + nonNegativeInteger(execution.bridge.feeAssetItem, 'feeAssetItem', bridgeContext); + + const routeBridgeParachainId = typeof destination.bridgeParachainId === 'string' + ? destination.bridgeParachainId.trim() + : ''; + if (routeBridgeParachainId && bridgeParachainId !== null && bridgeParachainId !== routeBridgeParachainId) { + fail(`${context}: bridge.parachainId must match destination.bridgeParachainId`); + } + } + } +} + +function validateRouteAsset(asset, context) { + if (!asset || typeof asset !== 'object' || Array.isArray(asset)) { + fail(`${context}: route asset must be an object`); + return; + } + + requiredString(asset.symbol, 'symbol', context); + nonNegativeIntegerString(asset.minAmount, 'minAmount', context); +} + +let registry; +try { + registry = JSON.parse(fs.readFileSync(registryFile, 'utf8')); +} catch (error) { + console.error(`[xcm-registry][error] Failed to parse ${registryFile}: ${error.message}`); + process.exit(1); +} + +const chains = Array.isArray(registry) + ? registry + : Array.isArray(registry.chains) + ? registry.chains + : null; + +if (!chains) { + console.error('[xcm-registry][error] Registry root must be an array or contain a chains array'); + process.exit(1); +} + +let xcmChainCount = 0; +let destinationCount = 0; +let routeAssetCount = 0; +let executableDestinationCount = 0; +let executableRouteAssetCount = 0; +const chainNamesById = new Map(); + +for (const chain of Array.isArray(chains) ? chains : []) { + if (!chain || typeof chain !== 'object' || Array.isArray(chain)) { + continue; + } + + const chainId = chain.chainId || chain.id; + if (nonEmptyString(chainId)) { + chainNamesById.set(chainId.trim(), nonEmptyString(chain.name) ? chain.name.trim() : null); + } +} + +for (const chain of chains) { + if (!chain || typeof chain !== 'object' || Array.isArray(chain) || !chain.xcm) { + continue; + } + + xcmChainCount += 1; + const originId = chain.chainId || chain.id || chain.name || ''; + const xcm = chain.xcm; + const destinations = Array.isArray(xcm.availableDestinations) ? xcm.availableDestinations : []; + + if (xcm.availableDestinations !== undefined && !Array.isArray(xcm.availableDestinations)) { + fail(`${originId}: xcm.availableDestinations must be an array`); + } + + if (xcm.availableAssets !== undefined && !Array.isArray(xcm.availableAssets)) { + fail(`${originId}: xcm.availableAssets must be an array`); + } + + for (const asset of Array.isArray(xcm.availableAssets) ? xcm.availableAssets : []) { + validateRouteAsset(asset, `${originId}: availableAssets`); + } + + for (const destination of destinations) { + destinationCount += 1; + if (!destination || typeof destination !== 'object' || Array.isArray(destination)) { + fail(`${originId}: destination must be an object`); + continue; + } + + const destinationId = requiredString(destination.chainId, 'chainId', `${originId}: destination`) || ''; + const context = label(originId, '->', destinationId); + const assets = Array.isArray(destination.assets) ? destination.assets : []; + + if (destination.assets !== undefined && !Array.isArray(destination.assets)) { + fail(`${context}: destination.assets must be an array`); + } + + for (const asset of assets) { + routeAssetCount += 1; + validateRouteAsset(asset, context); + } + + if (destination.execution) { + executableDestinationCount += 1; + executableRouteAssetCount += assets.length; + const errorCountBeforeExecutionValidation = errors.length; + validateExecutionSpec(destination.execution, destination, xcm.xcmVersion, `${context}: execution`); + if (errors.length === errorCountBeforeExecutionValidation) { + for (const asset of assets) { + if (nonEmptyString(asset?.symbol)) { + executableRouteKeys.add(`${originId}|${destinationId}|${normalizeAssetSymbol(asset.symbol)}`); + } + } + } + } else if (assets.length > 0) { + missingExecutableDestinations.push({ + originChainId: String(originId), + originName: nonEmptyString(chain.name) ? chain.name.trim() : null, + destinationChainId: String(destinationId), + destinationName: chainNamesById.get(destinationId) || null, + assetSymbols: assets + .map((asset) => nonEmptyString(asset?.symbol) ? normalizeAssetSymbol(asset.symbol) : null) + .filter(Boolean), + bridgeParachainId: nonEmptyString(destination.bridgeParachainId) ? destination.bridgeParachainId.trim() : null, + reason: 'missingExecutionSpec' + }); + + if (requireAllRoutesExecutable) { + fail(`${context}: executable route metadata is required`); + } + } + } +} + +if (requireExecutable && executableDestinationCount === 0) { + fail('No executable XCM route metadata found'); +} + +for (const route of requiredRoutes) { + const key = `${route.originId}|${route.destinationId}|${route.assetSymbol}`; + if (!executableRouteKeys.has(key)) { + fail(`Required executable XCM route missing: ${route.originId} -> ${route.destinationId} ${route.assetSymbol}`); + } +} + +if (requireGapManifest) { + const actualGaps = new Map(); + for (const destination of missingExecutableDestinations) { + const gap = { + originId: destination.originChainId, + destinationId: destination.destinationChainId, + assetSymbols: normalizeAssetSymbols(destination.assetSymbols), + bridgeParachainId: destination.bridgeParachainId, + reason: classifyMissingDestination(destination) + }; + const key = gapKey(gap); + if (actualGaps.has(key)) { + fail(`Duplicate generated discovery-only XCM gap: ${gap.originId} -> ${gap.destinationId} ${gap.assetSymbols}`); + } + actualGaps.set(key, gap); + } + + const expectedGaps = new Map(); + for (const gap of requiredGaps) { + const key = gapKey(gap); + if (expectedGaps.has(key)) { + fail(`Duplicate required discovery-only XCM gap: ${gap.originId} -> ${gap.destinationId} ${gap.assetSymbols}`); + continue; + } + expectedGaps.set(key, gap); + + const actual = actualGaps.get(key); + if (!actual) { + fail(`Required discovery-only XCM gap is stale or executable: ${gap.originId} -> ${gap.destinationId} ${gap.assetSymbols}`); + continue; + } + + if (gap.reason !== actual.reason) { + fail(`Required discovery-only XCM gap reason mismatch: ${gap.originId} -> ${gap.destinationId} ${gap.assetSymbols} expected ${gap.reason} actual ${actual.reason}`); + } + + if (gap.bridgeParachainId !== actual.bridgeParachainId) { + fail(`Required discovery-only XCM gap bridge mismatch: ${gap.originId} -> ${gap.destinationId} ${gap.assetSymbols}`); + } + } + + for (const [key, actual] of actualGaps.entries()) { + if (!expectedGaps.has(key)) { + fail(`Untracked discovery-only XCM gap: ${actual.originId} -> ${actual.destinationId} ${actual.assetSymbols}`); + } + } +} + +if (errors.length > 0) { + console.error('[xcm-registry][error] XCM registry metadata audit failed:'); + for (const error of errors) { + console.error(` - ${error}`); + } + process.exit(1); +} + +if (gapReportFile) { + const report = { + schemaVersion: 1, + registryFile: path.relative(process.cwd(), registryFile), + summary: { + chains: chains.length, + xcmChains: xcmChainCount, + destinations: destinationCount, + routeAssets: routeAssetCount, + executableDestinations: executableDestinationCount, + executableRouteAssets: executableRouteAssetCount, + remainingDiscoveryOnlyDestinations: missingExecutableDestinations.length + }, + missingExecutableDestinations + }; + + try { + fs.mkdirSync(path.dirname(gapReportFile), { recursive: true }); + fs.writeFileSync(gapReportFile, `${JSON.stringify(report, null, 2)}\n`); + } catch (error) { + console.error(`[xcm-registry][error] Failed to write XCM gap report ${gapReportFile}: ${error.message}`); + process.exit(1); + } +} + +console.log( + `[xcm-registry] XCM metadata audit passed: chains=${chains.length}, xcmChains=${xcmChainCount}, ` + + `destinations=${destinationCount}, routeAssets=${routeAssetCount}, ` + + `executableDestinations=${executableDestinationCount}, executableRouteAssets=${executableRouteAssetCount}, ` + + `remainingDiscoveryOnlyDestinations=${missingExecutableDestinations.length}` +); +NODE diff --git a/scripts/build-sr25519.sh b/scripts/build-sr25519.sh new file mode 100755 index 0000000000..b083895ec7 --- /dev/null +++ b/scripts/build-sr25519.sh @@ -0,0 +1,62 @@ +#!/usr/bin/env bash +set -euo pipefail + +EXPECTED_SOURCE_COMMIT="7500809f33243ee47ecb2ec8563fc284ac4de0d6" +UTILS_PATH="${FEARLESS_UTILS_ANDROID_PATH:-$(cd "$(dirname "$0")/../.." && pwd)/fearless-utils-Android}" + +log() { echo "[build-sr25519] $*"; } +fail() { + echo "[build-sr25519][error] $*" >&2 + exit 1 +} + +cd "$(dirname "$0")/.." + +[[ -d "$UTILS_PATH/.git" ]] || fail "fearless-utils-Android checkout not found at $UTILS_PATH" + +source_commit="$(git -C "$UTILS_PATH" rev-parse HEAD)" +if [[ "$source_commit" != "$EXPECTED_SOURCE_COMMIT" && "${ALLOW_SR25519_SOURCE_DRIFT:-}" != "true" ]]; then + fail "fearless-utils-Android must be at $EXPECTED_SOURCE_COMMIT, found $source_commit. Set ALLOW_SR25519_SOURCE_DRIFT=true only for an intentional source bump." +fi + +log "Building sr25519java from $UTILS_PATH@$source_commit" +( + cd "$UTILS_PATH" + ./gradlew :fearless-utils:cargoBuild --no-daemon --console=plain +) + +tmp_dir="$(mktemp -d)" +trap 'rm -rf "$tmp_dir"' EXIT + +find "$UTILS_PATH" -type f -name 'libsr25519java.so' -print > "$tmp_dir/libs.txt" + +resolve_source() { + local abi="$1" + local pattern="$2" + local source + source="$(grep -E "$pattern" "$tmp_dir/libs.txt" | head -n 1 || true)" + [[ -n "$source" ]] || fail "Built libsr25519java.so for $abi was not found." + printf '%s' "$source" +} + +copy_lib() { + local abi="$1" + local pattern="$2" + local source + source="$(resolve_source "$abi" "$pattern")" + mkdir -p "app/src/main/jniLibs/$abi" + cp "$source" "app/src/main/jniLibs/$abi/libsr25519java.so" + log "Copied $abi from $source" +} + +copy_lib "arm64-v8a" 'aarch64-linux-android|arm64-v8a' +copy_lib "armeabi-v7a" 'armv7-linux-androideabi|armeabi-v7a' +copy_lib "x86" 'i686-linux-android|/x86/' +copy_lib "x86_64" 'x86_64-linux-android|x86_64' + +log "Updated checksums:" +if command -v sha256sum >/dev/null 2>&1; then + sha256sum app/src/main/jniLibs/*/libsr25519java.so +else + shasum -a 256 app/src/main/jniLibs/*/libsr25519java.so +fi diff --git a/scripts/check-iroha-mobile-sdk-release-assets.sh b/scripts/check-iroha-mobile-sdk-release-assets.sh new file mode 100755 index 0000000000..4ae25396f8 --- /dev/null +++ b/scripts/check-iroha-mobile-sdk-release-assets.sh @@ -0,0 +1,309 @@ +#!/usr/bin/env bash +set -euo pipefail + +usage() { + cat <<'USAGE' +Usage: + scripts/check-iroha-mobile-sdk-release-assets.sh --release-dir [--version ] + scripts/check-iroha-mobile-sdk-release-assets.sh --download --tag [--repo ] + scripts/check-iroha-mobile-sdk-release-assets.sh --self-test + +Validates the Android Iroha mobile SDK release assets produced by ../iroha: + - iroha-mobile-sdk-android-.zip + - SHA256SUMS-android-.txt or SHA256SUMS-all-.txt + - mobile-sdk-android-.artifacts.json or mobile-sdk-all-.artifacts.json + +The Android zip must contain raw core/client/offline artifacts, native bridge +libraries for arm64-v8a and x86_64, and a versioned Maven repository tree under +maven/org/hyperledger/iroha/sdk. +USAGE +} + +RELEASE_DIR="" +VERSION="" +DOWNLOAD=0 +SELF_TEST=0 +REPO="${IROHA_MOBILE_SDK_RELEASE_REPO:-hyperledger/iroha}" +TAG="${IROHA_MOBILE_SDK_RELEASE_TAG:-}" + +while [[ $# -gt 0 ]]; do + case "$1" in + --release-dir) + shift + RELEASE_DIR="${1:-}" + ;; + --release-dir=*) + RELEASE_DIR="${1#*=}" + ;; + --version) + shift + VERSION="${1:-}" + ;; + --version=*) + VERSION="${1#*=}" + ;; + --download) + DOWNLOAD=1 + ;; + --tag) + shift + TAG="${1:-}" + ;; + --tag=*) + TAG="${1#*=}" + ;; + --repo) + shift + REPO="${1:-}" + ;; + --repo=*) + REPO="${1#*=}" + ;; + --self-test) + SELF_TEST=1 + ;; + -h|--help) + usage + exit 0 + ;; + *) + echo "[iroha-sdk-assets] ERROR: unexpected argument: $1" >&2 + usage >&2 + exit 64 + ;; + esac + shift +done + +fail() { + echo "[iroha-sdk-assets] ERROR: $*" >&2 + exit 1 +} + +hash_file() { + local path="$1" + if command -v shasum >/dev/null 2>&1; then + shasum -a 256 "$path" | awk '{print $1}' + elif command -v sha256sum >/dev/null 2>&1; then + sha256sum "$path" | awk '{print $1}' + else + fail "shasum or sha256sum is required" + fi +} + +require_tool() { + command -v "$1" >/dev/null 2>&1 || fail "$1 is required" +} + +zip_entries() { + unzip -Z1 "$1" 2>/dev/null || fail "not a readable zip archive: $1" +} + +find_zip_entry() { + local archive="$1" + local pattern="$2" + local label="$3" + local entries entry + entries="$(zip_entries "$archive")" + entry="$(grep -E "$pattern" <<<"$entries" | head -n1 || true)" + [[ -n "$entry" ]] || fail "missing $label in $(basename "$archive")" + printf '%s' "$entry" +} + +require_zip_entry() { + find_zip_entry "$1" "$2" "$3" >/dev/null +} + +validate_client_aar() { + local android_zip="$1" + local tmp_dir="$2" + local client_aar_entry="$3" + local client_aar="$tmp_dir/client-android-release.aar" + + unzip -p "$android_zip" "$client_aar_entry" > "$client_aar" || fail "unable to extract $client_aar_entry" + require_zip_entry "$client_aar" '^AndroidManifest\.xml$' "client AAR manifest" + require_zip_entry "$client_aar" '^classes\.jar$' "client AAR classes.jar" + require_zip_entry "$client_aar" '^jni/arm64-v8a/libconnect_norito_bridge\.so$' "client AAR arm64 native bridge" + require_zip_entry "$client_aar" '^jni/x86_64/libconnect_norito_bridge\.so$' "client AAR x86_64 native bridge" +} + +infer_version() { + local dir="$1" + local files=() + local file base + while IFS= read -r file; do + files+=("$file") + done < <(find "$dir" -maxdepth 1 -type f -name 'iroha-mobile-sdk-android-*.zip' | sort) + + [[ ${#files[@]} -eq 1 ]] || fail "expected exactly one iroha-mobile-sdk-android-*.zip in $dir, found ${#files[@]}" + base="$(basename "${files[0]}")" + base="${base#iroha-mobile-sdk-android-}" + printf '%s' "${base%.zip}" +} + +download_release_assets() { + [[ -n "$TAG" ]] || fail "--download requires --tag or IROHA_MOBILE_SDK_RELEASE_TAG" + require_tool gh + RELEASE_DIR="$(mktemp -d "${TMPDIR:-/tmp}/iroha-android-sdk-assets.XXXXXX")" + VERSION="$TAG" + gh release download "$TAG" \ + --repo "$REPO" \ + --dir "$RELEASE_DIR" \ + --pattern "iroha-mobile-sdk-android-${TAG}.zip" \ + --pattern "SHA256SUMS-android-${TAG}.txt" \ + --pattern "mobile-sdk-android-${TAG}.artifacts.json" +} + +validate_release_dir() { + require_tool unzip + + [[ -n "$RELEASE_DIR" ]] || fail "--release-dir is required unless --download is used" + [[ -d "$RELEASE_DIR" ]] || fail "release dir does not exist: $RELEASE_DIR" + + if [[ -z "$VERSION" ]]; then + VERSION="$(infer_version "$RELEASE_DIR")" + fi + + local android_zip="$RELEASE_DIR/iroha-mobile-sdk-android-${VERSION}.zip" + local checksums="$RELEASE_DIR/SHA256SUMS-android-${VERSION}.txt" + local manifest="$RELEASE_DIR/mobile-sdk-android-${VERSION}.artifacts.json" + local tmp_dir sha client_aar_entry + + [[ -f "$android_zip" ]] || fail "missing Android SDK zip: $android_zip" + if [[ ! -f "$checksums" ]]; then + checksums="$RELEASE_DIR/SHA256SUMS-all-${VERSION}.txt" + fi + [[ -f "$checksums" ]] || fail "missing checksum file for version $VERSION" + if [[ ! -f "$manifest" ]]; then + manifest="$RELEASE_DIR/mobile-sdk-all-${VERSION}.artifacts.json" + fi + [[ -f "$manifest" ]] || fail "missing artifact manifest for version $VERSION" + + sha="$(hash_file "$android_zip")" + grep -F "$(basename "$android_zip")" "$checksums" | grep -Fq "$sha" || + fail "checksum file does not match $(basename "$android_zip")" + grep -Fq "\"version\": \"$VERSION\"" "$manifest" || fail "manifest version mismatch" + grep -Fq "$(basename "$android_zip")" "$manifest" || fail "manifest does not list Android SDK zip" + grep -Eq '"sha256"[[:space:]]*:[[:space:]]*"[[:xdigit:]]{64}"' "$manifest" || + fail "manifest does not contain SHA-256 artifact hashes" + + require_zip_entry "$android_zip" "^iroha-mobile-sdk-android-${VERSION}/core-jvm/core-jvm-.+\\.jar$" "raw core-jvm jar" + client_aar_entry="$(find_zip_entry "$android_zip" "^iroha-mobile-sdk-android-${VERSION}/client-android/client-android-release\\.aar$" "raw client Android AAR")" + require_zip_entry "$android_zip" "^iroha-mobile-sdk-android-${VERSION}/offline-wallet-android/offline-wallet-android-release\\.aar$" "raw offline-wallet Android AAR" + require_zip_entry "$android_zip" "^iroha-mobile-sdk-android-${VERSION}/native/arm64-v8a/libconnect_norito_bridge\\.so$" "raw arm64 native bridge" + require_zip_entry "$android_zip" "^iroha-mobile-sdk-android-${VERSION}/native/x86_64/libconnect_norito_bridge\\.so$" "raw x86_64 native bridge" + require_zip_entry "$android_zip" "^iroha-mobile-sdk-android-${VERSION}/maven/org/hyperledger/iroha/sdk/core-jvm/[^/]+/core-jvm-.+\\.pom$" "Android Maven core-jvm POM" + require_zip_entry "$android_zip" "^iroha-mobile-sdk-android-${VERSION}/maven/org/hyperledger/iroha/sdk/client-android/[^/]+/client-android-.+\\.aar$" "Android Maven client AAR" + require_zip_entry "$android_zip" "^iroha-mobile-sdk-android-${VERSION}/maven/org/hyperledger/iroha/sdk/offline-wallet-android/[^/]+/offline-wallet-android-.+\\.aar$" "Android Maven offline-wallet AAR" + + tmp_dir="$(mktemp -d "${TMPDIR:-/tmp}/iroha-android-aar.XXXXXX")" + validate_client_aar "$android_zip" "$tmp_dir" "$client_aar_entry" + rm -rf "$tmp_dir" + + echo "[iroha-sdk-assets] Android Iroha SDK assets validated for version $VERSION" +} + +make_aar() { + local archive="$1" + local omit_x86="${2:-0}" + local stage + stage="$(mktemp -d "${TMPDIR:-/tmp}/iroha-aar-fixture.XXXXXX")" + mkdir -p "$stage/jni/arm64-v8a" "$stage/jni/x86_64" + printf '\n' > "$stage/AndroidManifest.xml" + printf 'classes\n' > "$stage/classes.jar" + printf 'so\n' > "$stage/jni/arm64-v8a/libconnect_norito_bridge.so" + if [[ "$omit_x86" != "1" ]]; then + printf 'so\n' > "$stage/jni/x86_64/libconnect_norito_bridge.so" + fi + (cd "$stage" && zip -qr "$archive" .) + rm -rf "$stage" +} + +make_fixture() { + local dir="$1" + local version="$2" + local omit_maven="${3:-0}" + local omit_x86="${4:-0}" + local stage="$dir/iroha-mobile-sdk-android-${version}" + local zip_path="$dir/iroha-mobile-sdk-android-${version}.zip" + local sha + + mkdir -p "$stage/core-jvm" "$stage/client-android" "$stage/offline-wallet-android" "$stage/native/arm64-v8a" "$stage/native/x86_64" + printf 'jar\n' > "$stage/core-jvm/core-jvm-${version#v}.jar" + make_aar "$stage/client-android/client-android-release.aar" "$omit_x86" + printf 'aar\n' > "$stage/offline-wallet-android/offline-wallet-android-release.aar" + printf 'so\n' > "$stage/native/arm64-v8a/libconnect_norito_bridge.so" + printf 'so\n' > "$stage/native/x86_64/libconnect_norito_bridge.so" + + if [[ "$omit_maven" != "1" ]]; then + mkdir -p \ + "$stage/maven/org/hyperledger/iroha/sdk/core-jvm/${version#v}" \ + "$stage/maven/org/hyperledger/iroha/sdk/client-android/${version#v}" \ + "$stage/maven/org/hyperledger/iroha/sdk/offline-wallet-android/${version#v}" + printf '\n' > "$stage/maven/org/hyperledger/iroha/sdk/core-jvm/${version#v}/core-jvm-${version#v}.pom" + cp "$stage/client-android/client-android-release.aar" "$stage/maven/org/hyperledger/iroha/sdk/client-android/${version#v}/client-android-${version#v}.aar" + printf 'aar\n' > "$stage/maven/org/hyperledger/iroha/sdk/offline-wallet-android/${version#v}/offline-wallet-android-${version#v}.aar" + fi + + (cd "$dir" && zip -qr "$(basename "$zip_path")" "$(basename "$stage")") + sha="$(hash_file "$zip_path")" + printf '%s %s\n' "$sha" "$zip_path" > "$dir/SHA256SUMS-android-${version}.txt" + cat > "$dir/mobile-sdk-android-${version}.artifacts.json" </dev/null + + missing_maven="$tmp/missing-maven" + mkdir -p "$missing_maven" + make_fixture "$missing_maven" "v0.1.0" 1 + if bash "$0" --release-dir "$missing_maven" --version "v0.1.0" >/dev/null 2>&1; then + fail "self-test expected missing Maven repository validation to fail" + fi + + bad_aar="$tmp/bad-aar" + mkdir -p "$bad_aar" + make_fixture "$bad_aar" "v0.1.0" 0 1 + if bash "$0" --release-dir "$bad_aar" --version "v0.1.0" >/dev/null 2>&1; then + fail "self-test expected missing x86_64 native AAR entry to fail" + fi + + missing_checksums="$tmp/missing-checksums" + mkdir -p "$missing_checksums" + make_fixture "$missing_checksums" "v0.1.0" + rm -f "$missing_checksums/SHA256SUMS-android-v0.1.0.txt" + if bash "$0" --release-dir "$missing_checksums" --version "v0.1.0" >/dev/null 2>&1; then + fail "self-test expected missing checksum validation to fail" + fi + + echo "[iroha-sdk-assets-test] Android release asset checks passed" +} + +if [[ "$SELF_TEST" == "1" ]]; then + run_self_test + exit 0 +fi + +if [[ "$DOWNLOAD" == "1" ]]; then + download_release_assets +fi + +validate_release_dir diff --git a/scripts/ensure-fearless-utils.sh b/scripts/ensure-fearless-utils.sh new file mode 100755 index 0000000000..f2045b39cc --- /dev/null +++ b/scripts/ensure-fearless-utils.sh @@ -0,0 +1,49 @@ +#!/usr/bin/env bash +set -euo pipefail + +EXPECTED_COMMIT="${FEARLESS_UTILS_COMMIT:-7500809f33243ee47ecb2ec8563fc284ac4de0d6}" +ALLOW_DRIFT="${ALLOW_FEARLESS_UTILS_DRIFT:-false}" + +repo_root="$(cd "$(dirname "$0")/.." && pwd)" + +if [[ -n "${FEARLESS_UTILS_PATH:-}" ]]; then + utils_path="$FEARLESS_UTILS_PATH" +elif [[ -d "$repo_root/fearless-utils-Android/.git" ]]; then + utils_path="$repo_root/fearless-utils-Android" +else + utils_path="$(cd "$repo_root/.." && pwd)/fearless-utils-Android" +fi + +fail() { + echo "[fearless-utils][error] $*" >&2 + exit 1 +} + +apply_library_only_overlay() { + [[ "${FEARLESS_UTILS_LIBRARY_ONLY:-false}" == "true" ]] || return 0 + + local patch_file="$repo_root/scripts/fearless-utils-library-only.patch" + [[ -f "$patch_file" ]] || fail "library-only overlay patch not found at $patch_file" + + if git -C "$utils_path" apply --reverse --check "$patch_file" >/dev/null 2>&1; then + echo "[fearless-utils] Library-only overlay already applied" + return 0 + fi + + if ! git -C "$utils_path" apply --check "$patch_file"; then + fail "Unable to apply library-only overlay to $utils_path. Reset fearless-utils-Android to $EXPECTED_COMMIT or update $patch_file for the new pinned source." + fi + + git -C "$utils_path" apply "$patch_file" + echo "[fearless-utils] Applied library-only overlay for wallet CI" +} + +[[ -d "$utils_path/.git" ]] || fail "fearless-utils-Android checkout not found at $utils_path. Clone https://github.com/soramitsu/fearless-utils-Android.git or set FEARLESS_UTILS_PATH." + +actual_commit="$(git -C "$utils_path" rev-parse HEAD)" +if [[ "$actual_commit" != "$EXPECTED_COMMIT" && "$ALLOW_DRIFT" != "true" ]]; then + fail "fearless-utils-Android must be at $EXPECTED_COMMIT, found $actual_commit. Set ALLOW_FEARLESS_UTILS_DRIFT=true only for an intentional source bump." +fi + +echo "[fearless-utils] Using $utils_path at $actual_commit" +apply_library_only_overlay diff --git a/scripts/export-public-dependency-upstream-delta.sh b/scripts/export-public-dependency-upstream-delta.sh new file mode 100755 index 0000000000..111106a52b --- /dev/null +++ b/scripts/export-public-dependency-upstream-delta.sh @@ -0,0 +1,325 @@ +#!/usr/bin/env bash +set -euo pipefail + +ROOT_DIR="$(cd "$(dirname "$0")/.." && pwd)" +OUTPUT_DIR="$ROOT_DIR/build/reports/public-dependency-upstream-delta" + +usage() { + cat <<'USAGE' +Usage: scripts/export-public-dependency-upstream-delta.sh [--root ] [--output ] + +Exports a deterministic handoff bundle for Android public dependency deltas: +the pinned fearless-utils overlay, compatibility-module source hashes, and +release-review docs needed to upstream or retire the carried public shims. +USAGE +} + +while (($# > 0)); do + case "$1" in + --root) + [[ $# -ge 2 ]] || { echo "[public-dependency-handoff][error] --root requires a path" >&2; exit 2; } + ROOT_DIR="$2" + shift 2 + ;; + --output) + [[ $# -ge 2 ]] || { echo "[public-dependency-handoff][error] --output requires a path" >&2; exit 2; } + OUTPUT_DIR="$2" + shift 2 + ;; + -h|--help) + usage + exit 0 + ;; + *) + echo "[public-dependency-handoff][error] Unknown argument: $1" >&2 + usage >&2 + exit 2 + ;; + esac +done + +if ! command -v node >/dev/null 2>&1; then + echo "[public-dependency-handoff][error] node is required for deterministic manifest generation" >&2 + exit 1 +fi + +ROOT_DIR="$(cd "$ROOT_DIR" && pwd)" +mkdir -p "$OUTPUT_DIR" +OUTPUT_DIR="$(cd "$OUTPUT_DIR" && pwd)" + +ROOT_DIR="$ROOT_DIR" OUTPUT_DIR="$OUTPUT_DIR" node <<'NODE' +const fs = require('fs'); +const path = require('path'); +const crypto = require('crypto'); +const childProcess = require('child_process'); + +const rootDir = process.env.ROOT_DIR; +const outputDir = process.env.OUTPUT_DIR; + +const compatibilityModules = [ + 'public-android-foundation', + 'public-ui-core', + 'public-xnetworking', + 'public-shared-features-core', + 'public-shared-features-xcm', + 'public-shared-features-backup', +]; + +const copiedFiles = [ + { + source: 'scripts/fearless-utils-library-only.patch', + output: 'fearless-utils/fearless-utils-library-only.patch', + label: 'fearless-utils library-only overlay patch', + }, + { + source: 'scripts/ensure-fearless-utils.sh', + output: 'fearless-utils/ensure-fearless-utils.sh', + label: 'fearless-utils source guard', + }, + { + source: 'docs/public-dependency-audit.md', + output: 'docs/public-dependency-audit.md', + label: 'public dependency audit docs', + }, + { + source: 'docs/release-checklist.md', + output: 'docs/release-checklist.md', + label: 'Android release checklist', + }, + { + source: 'settings.gradle', + output: 'gradle/settings.gradle', + label: 'Gradle dependency substitution settings', + }, + { + source: '.github/workflows/android-ci.yml', + output: 'ci/android-ci.yml', + label: 'Android CI workflow', + }, + { + source: '.github/workflows/android-release.yml', + output: 'ci/android-release.yml', + label: 'Android release workflow', + }, +]; + +function fail(message) { + console.error(`[public-dependency-handoff][error] ${message}`); + process.exit(1); +} + +function repoPath(relativePath) { + return path.join(rootDir, relativePath); +} + +function outputPath(relativePath) { + return path.join(outputDir, relativePath); +} + +function readFile(relativePath, label = relativePath) { + const fullPath = repoPath(relativePath); + if (!fs.existsSync(fullPath) || !fs.statSync(fullPath).isFile()) { + fail(`${label} missing: ${relativePath}`); + } + return fs.readFileSync(fullPath); +} + +function sha256Buffer(buffer) { + return crypto.createHash('sha256').update(buffer).digest('hex'); +} + +function sha256File(relativePath) { + return sha256Buffer(readFile(relativePath)); +} + +function copyFile(source, destination) { + const sourcePath = repoPath(source); + const destinationPath = outputPath(destination); + fs.mkdirSync(path.dirname(destinationPath), { recursive: true }); + fs.copyFileSync(sourcePath, destinationPath); +} + +function listFiles(dir) { + const files = []; + function walk(current) { + for (const entry of fs.readdirSync(current, { withFileTypes: true })) { + if (entry.name === 'build' || entry.name === '.gradle') { + continue; + } + const fullPath = path.join(current, entry.name); + if (entry.isDirectory()) { + walk(fullPath); + } else if (entry.isFile()) { + files.push(path.relative(rootDir, fullPath).split(path.sep).join('/')); + } + } + } + walk(dir); + return files.sort(); +} + +function directoryDigest(files) { + const hash = crypto.createHash('sha256'); + for (const relativePath of files) { + hash.update(relativePath); + hash.update('\0'); + hash.update(sha256File(relativePath)); + hash.update('\n'); + } + return hash.digest('hex'); +} + +function gitRevision() { + try { + return childProcess.execFileSync('git', ['-C', rootDir, 'rev-parse', 'HEAD'], { + encoding: 'utf8', + stdio: ['ignore', 'pipe', 'ignore'], + }).trim(); + } catch (_) { + return null; + } +} + +function assertContains(relativePath, pattern, description) { + const content = readFile(relativePath).toString('utf8'); + if (!pattern.test(content)) { + fail(`${description} missing in ${relativePath}`); + } + return content; +} + +for (const file of copiedFiles) { + readFile(file.source, file.label); +} + +const ensureScript = assertContains( + 'scripts/ensure-fearless-utils.sh', + /EXPECTED_COMMIT="\$\{FEARLESS_UTILS_COMMIT:-[0-9a-f]{40}\}"/, + 'pinned fearless-utils commit' +); +const expectedCommit = ensureScript.match(/EXPECTED_COMMIT="\$\{FEARLESS_UTILS_COMMIT:-([0-9a-f]{40})\}"/)?.[1]; +if (!expectedCommit) { + fail('Unable to parse pinned fearless-utils commit from scripts/ensure-fearless-utils.sh'); +} + +assertContains( + 'scripts/ensure-fearless-utils.sh', + /FEARLESS_UTILS_LIBRARY_ONLY/, + 'library-only overlay mode' +); +assertContains( + 'settings.gradle', + /jp\.co\.soramitsu\.fearless-utils:fearless-utils/, + 'fearless-utils Gradle module substitution' +); +assertContains( + 'docs/public-dependency-audit.md', + /soramitsu\/fearless-utils-Android/, + 'fearless-utils upstream source docs' +); +assertContains( + 'docs/release-checklist.md', + /export-public-dependency-upstream-delta\.sh/, + 'release checklist handoff export command' +); + +const patch = readFile('scripts/fearless-utils-library-only.patch', 'fearless-utils library-only overlay patch').toString('utf8'); +const patchTouchedPaths = [...patch.matchAll(/^diff --git a\/(.+?) b\/(.+)$/gm)] + .map((match) => match[2]) + .sort(); +if (patchTouchedPaths.length === 0) { + fail('fearless-utils library-only overlay patch must contain at least one diff'); +} +if (!patchTouchedPaths.every((patchedPath) => patchedPath.startsWith('fearless-utils/') || patchedPath === 'settings.gradle' || patchedPath === 'build.gradle')) { + fail('fearless-utils library-only overlay patch touches paths outside fearless-utils/root Gradle files'); +} + +const modules = compatibilityModules.map((modulePath) => { + const fullPath = repoPath(modulePath); + if (!fs.existsSync(fullPath) || !fs.statSync(fullPath).isDirectory()) { + fail(`public compatibility module missing: ${modulePath}`); + } + if (!fs.existsSync(path.join(fullPath, 'build.gradle'))) { + fail(`public compatibility module build.gradle missing: ${modulePath}`); + } + const files = listFiles(fullPath); + if (files.length === 0) { + fail(`public compatibility module has no source files: ${modulePath}`); + } + return { + name: modulePath.replace(/^public-/, ''), + path: modulePath, + fileCount: files.length, + sha256: directoryDigest(files), + }; +}); + +fs.rmSync(outputDir, { recursive: true, force: true }); +fs.mkdirSync(outputDir, { recursive: true }); + +for (const file of copiedFiles) { + copyFile(file.source, file.output); +} + +const handoffFiles = copiedFiles.map((file) => ({ + path: file.output, + source: file.source, + sha256: sha256Buffer(fs.readFileSync(outputPath(file.output))), +})); + +const readme = `# Android Public Dependency Upstream Delta + +This bundle captures the public dependency compatibility work that still needs +upstream ownership before Android can remove local checkout mutation and +compatibility shims. + +## Source Pin + +- fearless-utils repository: https://github.com/soramitsu/fearless-utils-Android +- expected revision: ${expectedCommit} +- overlay patch: fearless-utils/fearless-utils-library-only.patch + +## Review + +1. Review handoff-manifest.json for source hashes and compatibility modules. +2. Apply the fearless-utils overlay to the pinned upstream checkout or port the + same changes directly upstream. +3. Replace local compatibility modules only after public artifacts expose the + same source-backed API contracts and Android CI passes without local + substitutions. +`; + +fs.writeFileSync(outputPath('README.md'), readme); +handoffFiles.push({ + path: 'README.md', + source: 'generated', + sha256: sha256Buffer(fs.readFileSync(outputPath('README.md'))), +}); + +const manifest = { + schemaVersion: 1, + scope: 'android-public-dependency-upstream-delta', + walletRevision: gitRevision(), + fearlessUtils: { + repository: 'https://github.com/soramitsu/fearless-utils-Android', + expectedRevision: expectedCommit, + libraryOnlyPatch: 'fearless-utils/fearless-utils-library-only.patch', + patchSha256: sha256Buffer(Buffer.from(patch)), + patchTouchedPathCount: patchTouchedPaths.length, + patchTouchedPaths, + }, + publicCompatibilityModules: modules, + requiredReviewCommands: [ + 'FEARLESS_UTILS_PATH=../fearless-utils-Android ./scripts/ensure-fearless-utils.sh', + 'bash ./scripts/test-public-dependency-upstream-delta-export.sh', + 'bash ./scripts/export-public-dependency-upstream-delta.sh --output build/reports/public-dependency-upstream-delta', + './scripts/audit-public-artifacts.sh', + ], + files: handoffFiles.sort((left, right) => left.path.localeCompare(right.path)), +}; + +fs.writeFileSync(outputPath('handoff-manifest.json'), `${JSON.stringify(manifest, null, 2)}\n`); + +console.log(`[public-dependency-handoff] wrote ${outputPath('handoff-manifest.json')}`); +console.log(`[public-dependency-handoff] modules=${modules.length} patchPaths=${patchTouchedPaths.length}`); +NODE diff --git a/scripts/fearless-utils-library-only.patch b/scripts/fearless-utils-library-only.patch new file mode 100644 index 0000000000..e492523803 --- /dev/null +++ b/scripts/fearless-utils-library-only.patch @@ -0,0 +1,353 @@ +diff --git a/build.gradle b/build.gradle +index 20a5d27..b66ede9 100644 +--- a/build.gradle ++++ b/build.gradle +@@ -18,10 +18,10 @@ buildscript { + } + } + dependencies { +- classpath 'com.android.tools.build:gradle:8.0.0' ++ classpath 'com.android.tools.build:gradle:8.9.1' + classpath 'org.mozilla.rust-android-gradle:plugin:0.9.0' +- classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:1.8.20" +- classpath "org.jetbrains.kotlin:kotlin-serialization:1.8.20" ++ classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:2.1.10" ++ classpath "org.jetbrains.kotlin:kotlin-serialization:2.1.10" + classpath "org.sonarsource.scanner.gradle:sonarqube-gradle-plugin:4.4.1.3373" + } + } +diff --git a/fearless-utils/build.gradle b/fearless-utils/build.gradle +index 299edac..c97da66 100644 +--- a/fearless-utils/build.gradle ++++ b/fearless-utils/build.gradle +@@ -2,9 +2,18 @@ apply plugin: 'com.android.library' + apply plugin: 'kotlin-android' + apply plugin: 'kotlin-kapt' + apply plugin: 'kotlinx-serialization' +-apply plugin: 'org.mozilla.rust-android-gradle.rust-android' + apply from: '../maven-publish-helper.gradle' + ++def skipRustPlugin = ( ++ System.getenv("FEARLESS_UTILS_SKIP_RUST_PLUGIN") ++ ?: System.getenv("FEARLESS_UTILS_LIBRARY_ONLY") ++ ?: "false" ++).toBoolean() ++ ++if (!skipRustPlugin) { ++ apply plugin: 'org.mozilla.rust-android-gradle.rust-android' ++} ++ + publishing { + publications { + release(MavenPublication) { +@@ -51,16 +60,24 @@ android { + namespace 'jp.co.soramitsu.fearless_utils' + } + +-cargo { +- module = "../sr25519-java/" +- libname = "sr25519java" +- targets = ["arm", "arm64", "x86", "x86_64"] +- profile = "release" ++tasks.withType(org.jetbrains.kotlin.gradle.tasks.KotlinCompile).configureEach { ++ compilerOptions { ++ jvmTarget = org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_17 ++ } + } + +-tasks.whenTaskAdded { task -> +- if ((task.name == 'javaPreCompileDebug' || task.name == 'javaPreCompileRelease')) { +- task.dependsOn 'cargoBuild' ++if (!skipRustPlugin) { ++ cargo { ++ module = "../sr25519-java/" ++ libname = "sr25519java" ++ targets = ["arm", "arm64", "x86", "x86_64"] ++ profile = "release" ++ } ++ ++ tasks.whenTaskAdded { task -> ++ if ((task.name == 'javaPreCompileDebug' || task.name == 'javaPreCompileRelease')) { ++ task.dependsOn 'cargoBuild' ++ } + } + } + +@@ -78,14 +95,12 @@ task createJar(type: Copy) { + createJar.dependsOn(deleteJar, build) + + dependencies { +- implementation fileTree(dir: 'libs', include: ['*.jar']) ++ api fileTree(dir: 'libs', include: ['*.jar']) + + implementation "com.madgag.spongycastle:bcpkix-jdk15on:1.58.0.0" + implementation "com.madgag.spongycastle:bcpg-jdk15on:1.58.0.0" + implementation "net.i2p.crypto:eddsa:0.3.0" + implementation "com.caverock:androidsvg-aar:1.4" +- implementation "io.github.novacrypto:BIP39:2019.01.27" +- implementation "io.github.novacrypto:securestring:2019.01.27@jar" + implementation "com.neovisionaries:nv-websocket-client:2.14" + implementation "com.google.code.gson:gson:2.10.1" + implementation "org.web3j:crypto:4.6.0-android" +@@ -96,6 +111,8 @@ dependencies { + implementation "org.jetbrains.kotlinx:kotlinx-serialization-json:1.5.0" + + testImplementation "junit:junit:4.13.2" ++ testImplementation "net.bytebuddy:byte-buddy:1.18.10" ++ testImplementation "net.bytebuddy:byte-buddy-agent:1.18.10" + testImplementation "org.mockito:mockito-inline:5.2.0" + + androidTestImplementation "androidx.test:runner:1.5.2" +diff --git a/fearless-utils/src/main/java/jp/co/soramitsu/fearless_utils/encrypt/mnemonic/EnglishWordList.kt b/fearless-utils/src/main/java/jp/co/soramitsu/fearless_utils/encrypt/mnemonic/EnglishWordList.kt +index 645fd95..113edac 100644 +--- a/fearless-utils/src/main/java/jp/co/soramitsu/fearless_utils/encrypt/mnemonic/EnglishWordList.kt ++++ b/fearless-utils/src/main/java/jp/co/soramitsu/fearless_utils/encrypt/mnemonic/EnglishWordList.kt +@@ -1,11 +1,9 @@ + package jp.co.soramitsu.fearless_utils.encrypt.mnemonic + +-import io.github.novacrypto.bip39.WordList +- +-enum class EnglishWordList : WordList { ++enum class EnglishWordList { + INSTANCE; + +- override fun getWord(index: Int): String { ++ fun getWord(index: Int): String { + return words[index] + } + +@@ -13,9 +11,7 @@ enum class EnglishWordList : WordList { + return words.indexOf(word) + } + +- override fun getSpace(): Char { +- return ' ' +- } ++ val space = ' ' + + companion object { + private val words = arrayOf( +diff --git a/fearless-utils/src/main/java/jp/co/soramitsu/fearless_utils/encrypt/mnemonic/MnemonicCreator.kt b/fearless-utils/src/main/java/jp/co/soramitsu/fearless_utils/encrypt/mnemonic/MnemonicCreator.kt +index 0b766bc..6ee28f3 100644 +--- a/fearless-utils/src/main/java/jp/co/soramitsu/fearless_utils/encrypt/mnemonic/MnemonicCreator.kt ++++ b/fearless-utils/src/main/java/jp/co/soramitsu/fearless_utils/encrypt/mnemonic/MnemonicCreator.kt +@@ -1,9 +1,7 @@ + package jp.co.soramitsu.fearless_utils.encrypt.mnemonic + +-import io.github.novacrypto.SecureCharBuffer +-import io.github.novacrypto.bip39.MnemonicGenerator +-import io.github.novacrypto.hashing.Sha256 + import jp.co.soramitsu.fearless_utils.exceptions.Bip39Exception ++import java.security.MessageDigest + import java.security.SecureRandom + import java.text.Normalizer + import java.text.Normalizer.normalize +@@ -15,17 +13,15 @@ private val SPACE = EnglishWordList.INSTANCE.space.toString() + + object MnemonicCreator { + +- fun randomMnemonic(length: Mnemonic.Length): Mnemonic = SecureCharBuffer().use { secure -> ++ fun randomMnemonic(length: Mnemonic.Length): Mnemonic { + val entropy = ByteArray(length.byteLength) + +- SecureRandom().nextBytes(entropy) +- MnemonicGenerator(EnglishWordList.INSTANCE) +- .createMnemonic(entropy, secure::append) +- Arrays.fill(entropy, 0.toByte()) +- +- val words = secure.toStringAble().toString() +- +- fromWords(words) ++ return try { ++ SecureRandom().nextBytes(entropy) ++ fromEntropy(entropy) ++ } finally { ++ Arrays.fill(entropy, 0.toByte()) ++ } + } + + fun fromWords(words: String): Mnemonic { +@@ -39,12 +35,13 @@ object MnemonicCreator { + } + + fun fromEntropy(entropy: ByteArray): Mnemonic { +- val words = generateWords(entropy) ++ val entropyCopy = entropy.copyOf() ++ val words = generateWords(entropyCopy) + + return Mnemonic( + words = words, + wordList = toWordList(words), +- entropy = entropy ++ entropy = entropyCopy + ) + } + +@@ -56,16 +53,23 @@ object MnemonicCreator { + val startIndex = normalized.indexOfFirst { it.isLetter() } + val endIndex = normalized.indexOfLast { it.isLetter() } + ++ if (startIndex == -1 || endIndex == -1) { ++ throw Bip39Exception() ++ } ++ + return normalized.substring(startIndex, endIndex + 1) + .replace(DELIMITER_REGEX, SPACE) + } + + private fun generateWords(entropy: ByteArray): String { +- SecureCharBuffer().use { secure -> +- MnemonicGenerator(EnglishWordList.INSTANCE) +- .createMnemonic(entropy, secure::append) +- Arrays.fill(entropy, 0.toByte()) +- return secure.toStringAble().toString() ++ validateEntropySize(entropy) ++ ++ val entropyBits = bytesToBinaryString(entropy) ++ val checksumBits = deriveChecksumBits(entropy) ++ val mnemonicBits = entropyBits + checksumBits ++ ++ return mnemonicBits.chunked(11).joinToString(SPACE) { bits -> ++ EnglishWordList.INSTANCE.getWord(Integer.parseInt(bits, 2)) + } + } + +@@ -140,11 +144,17 @@ object MnemonicCreator { + private fun deriveChecksumBits(entropy: ByteArray): String { + val ent = entropy.size * 8 + val cs = ent / 32 +- val hash = Sha256.sha256(entropy) ++ val hash = MessageDigest.getInstance("SHA-256").digest(entropy) + + return bytesToBinaryString(hash).substring(0, cs) + } + ++ private fun validateEntropySize(entropy: ByteArray) { ++ if (entropy.size < 16 || entropy.size > 32 || entropy.size % 4 != 0) { ++ throw Bip39Exception() ++ } ++ } ++ + private fun bytesToBinaryString(bytes: ByteArray): String { + return bytes.toUByteArray().joinToString("") { x -> + x.toString(2).padStart(length = 8, padChar = '0') +diff --git a/fearless-utils/src/main/java/jp/co/soramitsu/fearless_utils/runtime/definitions/types/generics/ExtrinsicExt.kt b/fearless-utils/src/main/java/jp/co/soramitsu/fearless_utils/runtime/definitions/types/generics/ExtrinsicExt.kt +index aeadc36..ea36057 100644 +--- a/fearless-utils/src/main/java/jp/co/soramitsu/fearless_utils/runtime/definitions/types/generics/ExtrinsicExt.kt ++++ b/fearless-utils/src/main/java/jp/co/soramitsu/fearless_utils/runtime/definitions/types/generics/ExtrinsicExt.kt +@@ -2,6 +2,7 @@ package jp.co.soramitsu.fearless_utils.runtime.definitions.types.generics + + import jp.co.soramitsu.fearless_utils.encrypt.EncryptionType + import jp.co.soramitsu.fearless_utils.runtime.definitions.types.composite.DictEnum ++import java.util.Locale + + class MultiSignature(val encryptionType: EncryptionType, val value: ByteArray) + +@@ -10,13 +11,15 @@ fun Extrinsic.Signature.tryExtractMultiSignature(): MultiSignature? { + val value = enumEntry.value as? ByteArray ?: return null + + val encryptionType = +- EncryptionType.fromStringOrNull(enumEntry.name.toLowerCase()) ?: return null ++ EncryptionType.fromStringOrNull(enumEntry.name.lowercase(Locale.ROOT)) ?: return null + + return MultiSignature(encryptionType, value) + } + + private val EncryptionType.multiSignatureName +- get() = rawName.capitalize() ++ get() = rawName.replaceFirstChar { ++ if (it.isLowerCase()) it.titlecase(Locale.ROOT) else it.toString() ++ } + + fun MultiSignature.prepareForEncoding(): Any { + return DictEnum.Entry(encryptionType.multiSignatureName, value) +diff --git a/fearless-utils/src/main/java/jp/co/soramitsu/fearless_utils/runtime/extrinsic/ExtrinsicBuilder.kt b/fearless-utils/src/main/java/jp/co/soramitsu/fearless_utils/runtime/extrinsic/ExtrinsicBuilder.kt +index 006ba0c..dc78d5b 100644 +--- a/fearless-utils/src/main/java/jp/co/soramitsu/fearless_utils/runtime/extrinsic/ExtrinsicBuilder.kt ++++ b/fearless-utils/src/main/java/jp/co/soramitsu/fearless_utils/runtime/extrinsic/ExtrinsicBuilder.kt +@@ -37,6 +37,7 @@ class ExtrinsicBuilder( + private val blockHash: ByteArray = genesisHash, + private val era: Era = Era.Immortal, + private val tip: BigInteger = DEFAULT_TIP, ++ private val appId: BigInteger? = null, + private val signatureConstructor: Type.InstanceConstructor = SignatureInstanceConstructor + ) { + +@@ -100,7 +101,7 @@ class ExtrinsicBuilder( + SignedExtras.ERA to era, + SignedExtras.NONCE to nonce, + SignedExtras.TIP to tip, +- SignedExtras.ASSET_TX_PAYMENT to listOf(DEFAULT_TIP, null) ++ SignedExtras.ASSET_TX_PAYMENT to assetTxPayment() + ) + + val additionalExtrasInstance = mapOf( +@@ -144,7 +145,9 @@ class ExtrinsicBuilder( + private fun buildSignedExtras(): ExtrinsicPayloadExtrasInstance = mapOf( + SignedExtras.ERA to era, + SignedExtras.TIP to tip, +- SignedExtras.ASSET_TX_PAYMENT to listOf(DEFAULT_TIP, null), ++ SignedExtras.ASSET_TX_PAYMENT to assetTxPayment(), + SignedExtras.NONCE to nonce + ) ++ ++ private fun assetTxPayment(): List = listOf(tip, appId) + } +diff --git a/fearless-utils/src/main/java/jp/co/soramitsu/fearless_utils/wsrpc/socket/RpcSocket.kt b/fearless-utils/src/main/java/jp/co/soramitsu/fearless_utils/wsrpc/socket/RpcSocket.kt +index 73ce3ad..f8c7b53 100644 +--- a/fearless-utils/src/main/java/jp/co/soramitsu/fearless_utils/wsrpc/socket/RpcSocket.kt ++++ b/fearless-utils/src/main/java/jp/co/soramitsu/fearless_utils/wsrpc/socket/RpcSocket.kt +@@ -11,6 +11,7 @@ import jp.co.soramitsu.fearless_utils.wsrpc.logging.Logger + import jp.co.soramitsu.fearless_utils.wsrpc.request.base.RpcRequest + import jp.co.soramitsu.fearless_utils.wsrpc.response.RpcResponse + import jp.co.soramitsu.fearless_utils.wsrpc.subscription.response.SubscriptionChange ++import java.util.Locale + import java.util.concurrent.TimeUnit + + interface RpcSocketListener { +@@ -106,7 +107,7 @@ class RpcSocket( + } + + private fun log(topic: String, message: Any?) { +- logger?.log("\t[SOCKET][${topic.toUpperCase()}] $message") ++ logger?.log("\t[SOCKET][${topic.uppercase(Locale.ROOT)}] $message") + } + + private fun isSubscriptionChange(string: String): Boolean { +diff --git a/settings.gradle b/settings.gradle +index cf3e82f..9637431 100644 +--- a/settings.gradle ++++ b/settings.gradle +@@ -54,5 +54,9 @@ if (dependencySubstitutionsClass != null && !dependencySubstitutionsClass.metaCl + } + + include ':fearless-utils' +-include ':app' ++ ++def libraryOnly = (System.getenv("FEARLESS_UTILS_LIBRARY_ONLY") ?: "false").toBoolean() ++if (!libraryOnly) { ++ include ':app' ++} + rootProject.name = "fearless-utils-android" +diff --git a/fearless-utils/src/main/java/jp/co/soramitsu/fearless_utils/wsrpc/request/CoroutinesRequestExecutor.kt b/fearless-utils/src/main/java/jp/co/soramitsu/fearless_utils/wsrpc/request/CoroutinesRequestExecutor.kt +new file mode 100644 +index 0000000..a2afbc8 +--- /dev/null ++++ b/fearless-utils/src/main/java/jp/co/soramitsu/fearless_utils/wsrpc/request/CoroutinesRequestExecutor.kt +@@ -0,0 +1,3 @@ ++package jp.co.soramitsu.fearless_utils.wsrpc.request ++ ++typealias CoroutinesRequestExecutor = RequestExecutor +diff --git a/fearless-utils/src/main/java/jp/co/soramitsu/fearless_utils/wsrpc/request/runtime/chain/StateRuntimeVersionRequest.kt b/fearless-utils/src/main/java/jp/co/soramitsu/fearless_utils/wsrpc/request/runtime/chain/StateRuntimeVersionRequest.kt +new file mode 100644 +index 0000000..97b4410 +--- /dev/null ++++ b/fearless-utils/src/main/java/jp/co/soramitsu/fearless_utils/wsrpc/request/runtime/chain/StateRuntimeVersionRequest.kt +@@ -0,0 +1,7 @@ ++package jp.co.soramitsu.fearless_utils.wsrpc.request.runtime.chain ++ ++import jp.co.soramitsu.fearless_utils.wsrpc.request.runtime.RuntimeRequest ++ ++class StateRuntimeVersionRequest : RuntimeRequest("state_getRuntimeVersion", listOf()) ++ ++object SubscribeStateRuntimeVersionRequest : RuntimeRequest("state_subscribeRuntimeVersion", listOf()) diff --git a/scripts/generate-xcm-production-evidence-template.sh b/scripts/generate-xcm-production-evidence-template.sh new file mode 100755 index 0000000000..3b2d6f8ec0 --- /dev/null +++ b/scripts/generate-xcm-production-evidence-template.sh @@ -0,0 +1,169 @@ +#!/usr/bin/env bash +set -euo pipefail + +ROOT_DIR="${XCM_PRODUCTION_EVIDENCE_ROOT:-$(cd "$(dirname "$0")/.." && pwd)}" +REQUIRED_ROUTE_FILE="$ROOT_DIR/scripts/xcm-required-routes.tsv" +OUTPUT_FILE="" + +usage() { + cat <<'USAGE' +Usage: scripts/generate-xcm-production-evidence-template.sh [--required-route-file ] [--output ] + +Generates a JSON template for XCM production E2E evidence from the required-route +manifest. The generated evidence records intentionally contain TODO placeholders +and must be completed before copying them into scripts/xcm-production-evidence.json. +USAGE +} + +while (($#)); do + case "$1" in + --required-route-file) + [[ $# -ge 2 ]] || { echo "[xcm-evidence-template][error] --required-route-file requires a path" >&2; exit 2; } + REQUIRED_ROUTE_FILE="$2" + shift 2 + ;; + --output) + [[ $# -ge 2 ]] || { echo "[xcm-evidence-template][error] --output requires a path" >&2; exit 2; } + OUTPUT_FILE="$2" + shift 2 + ;; + -h|--help) + usage + exit 0 + ;; + *) + echo "[xcm-evidence-template][error] Unknown argument: $1" >&2 + usage >&2 + exit 2 + ;; + esac +done + +if ! command -v node >/dev/null 2>&1; then + echo "[xcm-evidence-template][error] node is required for structured JSON generation" >&2 + exit 1 +fi + +tmp_file="$(mktemp)" +trap 'rm -f "$tmp_file"' EXIT + +node - "$REQUIRED_ROUTE_FILE" >"$tmp_file" <<'NODE' +const fs = require('fs'); + +const [requiredRouteFile] = process.argv.slice(2); +const errors = []; + +const REQUIRED_EVIDENCE_FIELDS = [ + 'originChainId', + 'destinationChainId', + 'assetSymbol', + 'extrinsicHash', + 'sender', + 'recipient', + 'amount', + 'timestamp', + 'environment', + 'operator' +]; + +function fail(message) { + errors.push(message); +} + +function nonEmptyString(value) { + return typeof value === 'string' && value.trim().length > 0; +} + +function normalizeAssetSymbol(value) { + return String(value || '') + .trim() + .replace(/^xc/i, '') + .toUpperCase(); +} + +function routeKey(route) { + return `${route.originChainId}|${route.destinationChainId}|${route.assetSymbol}`; +} + +if (!fs.existsSync(requiredRouteFile)) { + fail(`required route file missing: ${requiredRouteFile}`); +} else { + const routes = []; + const seenRoutes = new Set(); + const text = fs.readFileSync(requiredRouteFile, 'utf8'); + + text.split(/\r?\n/).forEach((line, index) => { + const trimmed = line.replace(/\s+#.*$/, '').trim(); + if (!trimmed || trimmed.startsWith('#')) { + return; + } + + const parts = trimmed.split(/\s+/); + if (parts.length !== 3 || parts.some((part) => !nonEmptyString(part))) { + fail(`Invalid required route file line ${index + 1}: expected origin destination asset`); + return; + } + + const route = { + originChainId: parts[0], + destinationChainId: parts[1], + assetSymbol: normalizeAssetSymbol(parts[2]) + }; + const key = routeKey(route); + if (seenRoutes.has(key)) { + fail(`duplicate required route: ${route.originChainId} -> ${route.destinationChainId} ${route.assetSymbol}`); + return; + } + seenRoutes.add(key); + routes.push(route); + }); + + if (routes.length === 0) { + fail('required route file must contain at least one route'); + } + + if (errors.length === 0) { + const template = { + schemaVersion: 1, + scope: 'android-xcm-production-evidence-template', + sourceRequiredRouteFile: requiredRouteFile, + requiredRouteCount: routes.length, + instructions: [ + 'Copy the evidence array into scripts/xcm-production-evidence.json only after replacing every TODO value.', + 'Do not include private keys, mnemonics, seeds, passwords, credentials, or authorization headers in public evidence.', + 'After every route has evidence and no discovery-only gaps remain, set status to ready, releaseEnabled to true, clear blockers, and run bash ./scripts/audit-xcm-production-evidence.sh --require-ready.' + ], + requiredEvidenceFields: REQUIRED_EVIDENCE_FIELDS, + evidence: routes.map((route) => ({ + originChainId: route.originChainId, + destinationChainId: route.destinationChainId, + assetSymbol: route.assetSymbol, + extrinsicHash: 'TODO_0x_prefixed_32_byte_hash', + sender: 'TODO_sender_public_address', + recipient: 'TODO_recipient_public_address', + amount: 'TODO_positive_decimal_amount', + timestamp: 'TODO_YYYY-MM-DDTHH:MM:SSZ', + environment: 'mainnet', + operator: 'TODO_operator_or_runbook_id' + })) + }; + + process.stdout.write(`${JSON.stringify(template, null, 2)}\n`); + } +} + +if (errors.length > 0) { + for (const error of errors) { + console.error(`[xcm-evidence-template][error] ${error}`); + } + process.exit(1); +} +NODE + +if [[ -n "$OUTPUT_FILE" ]]; then + mkdir -p "$(dirname "$OUTPUT_FILE")" + mv "$tmp_file" "$OUTPUT_FILE" + trap - EXIT +else + cat "$tmp_file" +fi diff --git a/scripts/post_merge_check.sh b/scripts/post_merge_check.sh index 362211ef1e..ba2efce899 100644 --- a/scripts/post_merge_check.sh +++ b/scripts/post_merge_check.sh @@ -30,7 +30,12 @@ fi run ./gradlew -version "${GRADLE_FLAGS[@]}" || true -# 2) Polkadot SDK alignment print +# 2) Verify branch-flow guards and staging branch deprecation readiness +echo "[post-merge] Auditing branch flow..." +run bash ./scripts/audit-branch-flow.sh +run bash ./scripts/test-branch-flow-audit.sh + +# 3) Polkadot SDK alignment print echo "[post-merge] Checking Polkadot SDK alignment output..." ALIGN_OUT=$(./gradlew printPolkadotSdkAlignment "${GRADLE_FLAGS[@]}" --no-parallel | tee /dev/stderr) echo "$ALIGN_OUT" | grep -q "Polkadot SDK alignment (effective):" || { @@ -41,17 +46,17 @@ echo "$ALIGN_OUT" | grep -q "CHAINS_URL (debug):" || { echo "CHAINS_URL (debug) echo "$ALIGN_OUT" | grep -q "CHAINS_URL (release):" || { echo "CHAINS_URL (release) not printed"; exit 1; } echo "$ALIGN_OUT" | grep -q "SHARED_FEATURES_VERSION_OVERRIDE:" || { echo "SHARED_FEATURES_VERSION_OVERRIDE not printed"; exit 1; } -# 3) Verify fearless-utils source mapping toggle messaging -echo "[post-merge] Verifying fearless-utils source mapping toggle..." -UTILS_OFF=$(./gradlew -q help --no-parallel "${GRADLE_FLAGS[@]}" 2>&1 | tee /dev/stderr || true) -echo "$UTILS_OFF" | grep -q "USE_REMOTE_UTILS not set; using published artifacts for fearless-utils" || { - echo "Expected message for USE_REMOTE_UTILS=false not found"; exit 1; } +# 4) Verify pinned fearless-utils source checkout +echo "[post-merge] Verifying fearless-utils source checkout..." +export FORCE_LOCAL_UTILS="${FORCE_LOCAL_UTILS:-true}" +export FEARLESS_UTILS_LIBRARY_ONLY="${FEARLESS_UTILS_LIBRARY_ONLY:-true}" +run ./scripts/ensure-fearless-utils.sh -UTILS_ON=$(USE_REMOTE_UTILS=true ./gradlew -q help --no-parallel "${GRADLE_FLAGS[@]}" 2>&1 | tee /dev/stderr || true) -echo "$UTILS_ON" | grep -q "Including remote fearless-utils from GitHub via sourceControl" || { - echo "Expected message for USE_REMOTE_UTILS=true not found"; exit 1; } +# 5) Verify release overlay boundary guard behavior +echo "[post-merge] Testing private overlay boundary guard..." +run bash ./scripts/test-private-overlay-boundary.sh -# 4) Static analysis + tests, then assemble, then lint (order matters) +# 6) Static analysis + tests, then assemble, then lint (order matters) echo "[post-merge] Running detekt + unit tests..." run ./gradlew runTest "${GRADLE_FLAGS[@]}" --stacktrace --info --no-parallel diff --git a/scripts/restore-release-overlays.sh b/scripts/restore-release-overlays.sh new file mode 100755 index 0000000000..06c9b5771a --- /dev/null +++ b/scripts/restore-release-overlays.sh @@ -0,0 +1,54 @@ +#!/usr/bin/env bash +set -euo pipefail + +log() { echo "[release-overlays] $*"; } +fail() { + echo "[release-overlays][error] $*" >&2 + exit 1 +} + +cd "$(dirname "$0")/.." + +require_env() { + local name="$1" + [[ -n "${!name:-}" ]] || fail "$name is required." +} + +decode_base64_to_file() { + local env_name="$1" + local output="$2" + + require_env "$env_name" + mkdir -p "$(dirname "$output")" + + if base64 --help 2>&1 | grep -q -- '-d'; then + printf '%s' "${!env_name}" | base64 -d > "$output" + else + printf '%s' "${!env_name}" | base64 -D > "$output" + fi + + [[ -s "$output" ]] || fail "$output was not restored." +} + +overlay_dir="${RELEASE_OVERLAY_DIR:-${RUNNER_TEMP:-$PWD/.release-overlays}}" +mkdir -p "$overlay_dir" + +release_google_services="app/src/release/google-services.json" +keystore_path="$overlay_dir/fearless-upload.jks" +play_key_path="$overlay_dir/play-service-account.json" + +decode_base64_to_file GOOGLE_SERVICES_RELEASE_JSON_B64 "$release_google_services" +decode_base64_to_file ANDROID_RELEASE_KEYSTORE_B64 "$keystore_path" +decode_base64_to_file PLAY_SERVICE_ACCOUNT_JSON_B64 "$play_key_path" + +if [[ -n "${GITHUB_ENV:-}" ]]; then + { + echo "CI_KEYSTORE_PATH=$keystore_path" + echo "CI_PLAY_KEY=$play_key_path" + } >> "$GITHUB_ENV" +else + log "Set CI_KEYSTORE_PATH=$keystore_path" + log "Set CI_PLAY_KEY=$play_key_path" +fi + +log "Release overlays restored." diff --git a/scripts/secrets.gradle b/scripts/secrets.gradle index 5a0a320692..c55e8fe2b3 100644 --- a/scripts/secrets.gradle +++ b/scripts/secrets.gradle @@ -15,6 +15,16 @@ ext.readSecret = { secretName -> return secret } +ext.readOptionalSecret = { secretName -> + def localPropSecret = localProperties.getProperty(secretName) + return (localPropSecret != null) ? localPropSecret : System.getenv(secretName) +} + +ext.readOptionalSecretInQuotes = { secretName -> + def secret = readOptionalSecret(secretName) + return maybeWrapInQuotes(secret) +} + ext.readSecretInQuotes = { secretName -> def secret = readSecret(secretName) return maybeWrapInQuotes(secret) diff --git a/scripts/test-branch-flow-audit.sh b/scripts/test-branch-flow-audit.sh new file mode 100755 index 0000000000..2d44e04a95 --- /dev/null +++ b/scripts/test-branch-flow-audit.sh @@ -0,0 +1,81 @@ +#!/usr/bin/env bash +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +AUDIT_SCRIPT="$SCRIPT_DIR/audit-branch-flow.sh" +TMP_DIR="$(mktemp -d)" +trap 'rm -rf "$TMP_DIR"' EXIT + +write_valid_repo() { + local repo="$1" + mkdir -p "$repo/.github/workflows" + cat > "$repo/Jenkinsfile" <<'EOF' +def pipeline = new org.android.AppPipeline( + uploadToNexusFor: ['master','develop'] +) +EOF + cat > "$repo/.github/workflows/android-ci.yml" <<'EOF' +name: Android CI +on: + push: + branches: [ master, develop ] + pull_request: + branches: + - develop + - master +EOF +} + +expect_pass() { + local repo="$1" + bash "$AUDIT_SCRIPT" "$repo" >/dev/null +} + +expect_fail() { + local repo="$1" + local label="$2" + local output="$TMP_DIR/$label.out" + if bash "$AUDIT_SCRIPT" "$repo" >"$output" 2>&1; then + echo "[branch-flow-audit-test] ERROR: expected failure for $label" >&2 + exit 1 + fi + if ! grep -q "ERROR" "$output"; then + echo "[branch-flow-audit-test] ERROR: $label failed without an audit error" >&2 + cat "$output" >&2 + exit 1 + fi +} + +VALID_REPO="$TMP_DIR/valid" +write_valid_repo "$VALID_REPO" +expect_pass "$VALID_REPO" + +JENKINS_STAGING_REPO="$TMP_DIR/jenkins-staging" +write_valid_repo "$JENKINS_STAGING_REPO" +cat > "$JENKINS_STAGING_REPO/Jenkinsfile" <<'EOF' +def pipeline = new org.android.AppPipeline( + uploadToNexusFor: ['master','develop','staging'] +) +EOF +expect_fail "$JENKINS_STAGING_REPO" "jenkins-staging" + +WORKFLOW_STAGING_REPO="$TMP_DIR/workflow-staging" +write_valid_repo "$WORKFLOW_STAGING_REPO" +cat > "$WORKFLOW_STAGING_REPO/.github/workflows/android-ci.yml" <<'EOF' +name: Android CI +on: + push: + branches: [ master, develop, staging ] +EOF +expect_fail "$WORKFLOW_STAGING_REPO" "workflow-staging" + +MISSING_UPLOAD_REPO="$TMP_DIR/missing-upload" +write_valid_repo "$MISSING_UPLOAD_REPO" +cat > "$MISSING_UPLOAD_REPO/Jenkinsfile" <<'EOF' +def pipeline = new org.android.AppPipeline( + publishCmd: 'publishReleaseApk' +) +EOF +expect_fail "$MISSING_UPLOAD_REPO" "missing-upload" + +echo "[branch-flow-audit-test] Branch flow audit self-test passed." diff --git a/scripts/test-private-overlay-boundary.sh b/scripts/test-private-overlay-boundary.sh new file mode 100755 index 0000000000..4cb9eaf425 --- /dev/null +++ b/scripts/test-private-overlay-boundary.sh @@ -0,0 +1,192 @@ +#!/usr/bin/env bash +set -euo pipefail + +ROOT_DIR="$(cd "$(dirname "$0")/.." && pwd)" +AUDIT_SCRIPT="$ROOT_DIR/scripts/audit-private-overlay-boundary.sh" + +fail() { + echo "[private-overlay-audit-test][error] $*" >&2 + exit 1 +} + +write_file() { + local repo="$1" + local path="$2" + local content="$3" + + mkdir -p "$(dirname "$repo/$path")" + printf '%s\n' "$content" > "$repo/$path" +} + +init_repo() { + local repo="$1" + git init -q "$repo" + git -C "$repo" config user.email "overlay-audit@example.invalid" + git -C "$repo" config user.name "Overlay Audit Test" +} + +track_all() { + local repo="$1" + git -C "$repo" add . +} + +run_audit() { + local public_repo="$1" + local private_repo="$2" + PUBLIC_REPO_DIR="$public_repo" PRIVATE_REPO_DIR="$private_repo" bash "$AUDIT_SCRIPT" +} + +run_audit_with_report() { + local public_repo="$1" + local private_repo="$2" + local report="$3" + shift 3 + + PUBLIC_REPO_DIR="$public_repo" PRIVATE_REPO_DIR="$private_repo" PRIVATE_OVERLAY_REPORT="$report" "$@" bash "$AUDIT_SCRIPT" +} + +make_pair() { + local dir="$1" + local public_repo="$dir/public" + local private_repo="$dir/private" + + mkdir -p "$public_repo" "$private_repo" + init_repo "$public_repo" + init_repo "$private_repo" + + write_file "$public_repo" "app/src/main/java/jp/co/soramitsu/PublicFeature.kt" "public feature" + write_file "$public_repo" "README.md" "public readme" + write_file "$private_repo" "README.md" "private release readme" + + track_all "$public_repo" + track_all "$private_repo" + + printf '%s\n' "$public_repo" + printf '%s\n' "$private_repo" +} + +test_allows_release_overlay_paths() { + local dir="$1/allowed" + local repos + repos="$(make_pair "$dir")" + local public_repo + public_repo="$(printf '%s\n' "$repos" | sed -n '1p')" + local private_repo + private_repo="$(printf '%s\n' "$repos" | sed -n '2p')" + + write_file "$private_repo" "app/src/release/google-services.json" '{"release":true}' + write_file "$private_repo" "release/play-service-account.json" '{"release":true}' + write_file "$private_repo" "scripts/restore-release-overlays.sh" "# release overlay restore" + track_all "$private_repo" + + run_audit "$public_repo" "$private_repo" >/dev/null +} + +test_rejects_private_only_product_code() { + local dir="$1/private-only-product" + local repos + repos="$(make_pair "$dir")" + local public_repo + public_repo="$(printf '%s\n' "$repos" | sed -n '1p')" + local private_repo + private_repo="$(printf '%s\n' "$repos" | sed -n '2p')" + local output="$dir/output.txt" + + write_file "$private_repo" "feature-wallet-impl/src/main/java/jp/co/soramitsu/PrivateTransfer.kt" "private product code" + track_all "$private_repo" + + if run_audit "$public_repo" "$private_repo" > "$output" 2>&1; then + fail "expected private-only product code to fail" + fi + + grep -q $'A\tfeature-wallet-impl/src/main/java/jp/co/soramitsu/PrivateTransfer.kt' "$output" || + fail "expected added product path in audit output" +} + +test_rejects_tracked_public_product_code() { + local dir="$1/tracked-public-product" + local repos + repos="$(make_pair "$dir")" + local public_repo + public_repo="$(printf '%s\n' "$repos" | sed -n '1p')" + local private_repo + private_repo="$(printf '%s\n' "$repos" | sed -n '2p')" + local output="$dir/output.txt" + + write_file "$private_repo" "app/src/main/java/jp/co/soramitsu/PublicFeature.kt" "public feature" + track_all "$private_repo" + + if run_audit "$public_repo" "$private_repo" > "$output" 2>&1; then + fail "expected tracked public product code to fail" + fi + + grep -q $'T\tapp/src/main/java/jp/co/soramitsu/PublicFeature.kt' "$output" || + fail "expected tracked public product path in audit output" +} + +test_rejects_modified_public_product_code() { + local dir="$1/modified-product" + local repos + repos="$(make_pair "$dir")" + local public_repo + public_repo="$(printf '%s\n' "$repos" | sed -n '1p')" + local private_repo + private_repo="$(printf '%s\n' "$repos" | sed -n '2p')" + local output="$dir/output.txt" + + write_file "$private_repo" "app/src/main/java/jp/co/soramitsu/PublicFeature.kt" "modified private feature" + track_all "$private_repo" + + if run_audit "$public_repo" "$private_repo" > "$output" 2>&1; then + fail "expected modified public product code to fail" + fi + + grep -q $'M\tapp/src/main/java/jp/co/soramitsu/PublicFeature.kt' "$output" || + fail "expected modified product path in audit output" +} + +test_writes_full_report_when_output_is_truncated() { + local dir="$1/full-report" + local repos + repos="$(make_pair "$dir")" + local public_repo + public_repo="$(printf '%s\n' "$repos" | sed -n '1p')" + local private_repo + private_repo="$(printf '%s\n' "$repos" | sed -n '2p')" + local output="$dir/output.txt" + local report="$dir/private-overlay-report.tsv" + + local i + for i in 1 2 3 4 5; do + write_file "$private_repo" "feature-wallet-impl/src/main/java/jp/co/soramitsu/PrivateTransfer$i.kt" "private product code $i" + done + track_all "$private_repo" + + if run_audit_with_report "$public_repo" "$private_repo" "$report" env MAX_REPORT_LINES=2 > "$output" 2>&1; then + fail "expected many private-only product paths to fail" + fi + + grep -q "Output truncated at 2 paths" "$output" || + fail "expected truncated console output notice" + grep -q "Full report: $report" "$output" || + fail "expected full report path in audit output" + [[ -f "$report" ]] || fail "expected full report file" + + local report_count + report_count="$(wc -l < "$report" | tr -d '[:space:]')" + [[ "$report_count" == "5" ]] || + fail "expected full report to include all 5 failures, got $report_count" + grep -q $'A\tfeature-wallet-impl/src/main/java/jp/co/soramitsu/PrivateTransfer5.kt' "$report" || + fail "expected full report to include entries omitted from console output" +} + +tmpdir="$(mktemp -d)" +trap 'rm -rf "$tmpdir"' EXIT + +test_allows_release_overlay_paths "$tmpdir" +test_rejects_private_only_product_code "$tmpdir" +test_rejects_tracked_public_product_code "$tmpdir" +test_rejects_modified_public_product_code "$tmpdir" +test_writes_full_report_when_output_is_truncated "$tmpdir" + +echo "[private-overlay-audit-test] Android overlay audit self-test passed." diff --git a/scripts/test-public-dependency-upstream-delta-export.sh b/scripts/test-public-dependency-upstream-delta-export.sh new file mode 100755 index 0000000000..6525ee6d04 --- /dev/null +++ b/scripts/test-public-dependency-upstream-delta-export.sh @@ -0,0 +1,159 @@ +#!/usr/bin/env bash +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +EXPORTER="$SCRIPT_DIR/export-public-dependency-upstream-delta.sh" + +fail() { + echo "[public-dependency-handoff-test][error] $*" >&2 + exit 1 +} + +write_file() { + local file="$1" + shift + mkdir -p "$(dirname "$file")" + printf '%s\n' "$@" > "$file" +} + +expect_failure() { + local label="$1" + local expected="$2" + shift 2 + local output + output="$(mktemp)" + if "$@" >"$output" 2>&1; then + cat "$output" >&2 + fail "expected failure for $label" + fi + if ! grep -q "$expected" "$output"; then + cat "$output" >&2 + fail "$label failed without expected message: $expected" + fi +} + +tmp_dir="$(mktemp -d)" +trap 'rm -rf "$tmp_dir"' EXIT + +modules=( + public-android-foundation + public-ui-core + public-xnetworking + public-shared-features-core + public-shared-features-xcm + public-shared-features-backup +) + +seed_fixture() { + local root="$1" + mkdir -p "$root/scripts" "$root/docs" "$root/.github/workflows" + write_file "$root/scripts/ensure-fearless-utils.sh" \ + '#!/usr/bin/env bash' \ + 'set -euo pipefail' \ + 'EXPECTED_COMMIT="${FEARLESS_UTILS_COMMIT:-7500809f33243ee47ecb2ec8563fc284ac4de0d6}"' \ + 'FEARLESS_UTILS_LIBRARY_ONLY="${FEARLESS_UTILS_LIBRARY_ONLY:-false}"' \ + 'echo "$EXPECTED_COMMIT $FEARLESS_UTILS_LIBRARY_ONLY"' + write_file "$root/scripts/fearless-utils-library-only.patch" \ + 'diff --git a/fearless-utils/build.gradle b/fearless-utils/build.gradle' \ + '--- a/fearless-utils/build.gradle' \ + '+++ b/fearless-utils/build.gradle' \ + '@@ -1 +1 @@' \ + '-plugins {}' \ + '+plugins { id "com.android.library" }' + write_file "$root/docs/public-dependency-audit.md" \ + '# Public Dependency Audit' \ + 'Uses soramitsu/fearless-utils-Android and scripts/ensure-fearless-utils.sh.' + write_file "$root/docs/release-checklist.md" \ + '# Release Checklist' \ + 'Run bash ./scripts/test-public-dependency-upstream-delta-export.sh.' \ + 'Run bash ./scripts/export-public-dependency-upstream-delta.sh --output build/reports/public-dependency-upstream-delta.' + write_file "$root/settings.gradle" \ + 'includeBuild("../fearless-utils-Android") {' \ + ' dependencySubstitution { substitute module("jp.co.soramitsu.fearless-utils:fearless-utils") using project(":fearless-utils") }' \ + '}' + write_file "$root/.github/workflows/android-ci.yml" \ + 'name: Android CI' \ + 'jobs:' \ + ' test:' \ + ' steps:' \ + ' - run: bash ./scripts/test-public-dependency-upstream-delta-export.sh' \ + ' - run: bash ./scripts/export-public-dependency-upstream-delta.sh --output build/reports/public-dependency-upstream-delta' + write_file "$root/.github/workflows/android-release.yml" \ + 'name: Android Release' \ + 'jobs:' \ + ' release:' \ + ' steps:' \ + ' - run: ./scripts/audit-public-artifacts.sh --release --strict-provenance' + + local module + for module in "${modules[@]}"; do + write_file "$root/$module/build.gradle" 'plugins { id "com.android.library" }' + write_file "$root/$module/src/main/java/example/${module//-/_}.kt" "package example" "object ${module//-/_}" + done +} + +fixture="$tmp_dir/repo" +seed_fixture "$fixture" + +output_dir="$tmp_dir/out" +bash "$EXPORTER" --root "$fixture" --output "$output_dir" >/dev/null + +node - "$output_dir/handoff-manifest.json" <<'NODE' +const fs = require('fs'); +const assert = require('assert'); +const manifest = JSON.parse(fs.readFileSync(process.argv[2], 'utf8')); + +assert.equal(manifest.schemaVersion, 1); +assert.equal(manifest.scope, 'android-public-dependency-upstream-delta'); +assert.equal(manifest.fearlessUtils.expectedRevision, '7500809f33243ee47ecb2ec8563fc284ac4de0d6'); +assert.equal(manifest.fearlessUtils.patchTouchedPathCount, 1); +assert.deepEqual(manifest.fearlessUtils.patchTouchedPaths, ['fearless-utils/build.gradle']); +assert.equal(manifest.publicCompatibilityModules.length, 6); +assert(manifest.publicCompatibilityModules.every((entry) => entry.fileCount === 2)); +assert(manifest.requiredReviewCommands.some((command) => command.includes('test-public-dependency-upstream-delta-export.sh'))); +assert(manifest.files.some((entry) => entry.path === 'handoff-manifest.json') === false); +assert(manifest.files.some((entry) => entry.path === 'README.md')); +assert(manifest.files.some((entry) => entry.path === 'fearless-utils/fearless-utils-library-only.patch')); +assert(manifest.files.every((entry) => /^[0-9a-f]{64}$/.test(entry.sha256))); +NODE + +[[ -s "$output_dir/README.md" ]] || fail "README.md was not generated" +[[ -s "$output_dir/fearless-utils/fearless-utils-library-only.patch" ]] || fail "overlay patch was not copied" + +missing_patch="$tmp_dir/missing-patch" +cp -R "$fixture" "$missing_patch" +rm "$missing_patch/scripts/fearless-utils-library-only.patch" +expect_failure "missing overlay patch" "library-only overlay patch missing" \ + bash "$EXPORTER" --root "$missing_patch" --output "$tmp_dir/missing-patch-out" + +missing_docs="$tmp_dir/missing-docs" +cp -R "$fixture" "$missing_docs" +rm "$missing_docs/docs/public-dependency-audit.md" +expect_failure "missing public dependency docs" "public dependency audit docs missing" \ + bash "$EXPORTER" --root "$missing_docs" --output "$tmp_dir/missing-docs-out" + +missing_module="$tmp_dir/missing-module" +cp -R "$fixture" "$missing_module" +rm -rf "$missing_module/public-xnetworking" +expect_failure "missing compatibility module" "public compatibility module missing: public-xnetworking" \ + bash "$EXPORTER" --root "$missing_module" --output "$tmp_dir/missing-module-out" + +bad_commit="$tmp_dir/bad-commit" +cp -R "$fixture" "$bad_commit" +perl -0pi -e 's/7500809f33243ee47ecb2ec8563fc284ac4de0d6/not-a-commit/' "$bad_commit/scripts/ensure-fearless-utils.sh" +expect_failure "unparseable utils pin" "pinned fearless-utils commit missing" \ + bash "$EXPORTER" --root "$bad_commit" --output "$tmp_dir/bad-commit-out" + +bad_patch_scope="$tmp_dir/bad-patch-scope" +cp -R "$fixture" "$bad_patch_scope" +perl -0pi -e 's#fearless-utils/build.gradle#app/build.gradle#g' "$bad_patch_scope/scripts/fearless-utils-library-only.patch" +expect_failure "patch outside upstream scope" "touches paths outside fearless-utils/root Gradle files" \ + bash "$EXPORTER" --root "$bad_patch_scope" --output "$tmp_dir/bad-patch-scope-out" + +missing_release_marker="$tmp_dir/missing-release-marker" +cp -R "$fixture" "$missing_release_marker" +write_file "$missing_release_marker/docs/release-checklist.md" '# Release Checklist' 'Run release checks.' +expect_failure "missing release handoff command" "release checklist handoff export command missing" \ + bash "$EXPORTER" --root "$missing_release_marker" --output "$tmp_dir/missing-release-marker-out" + +echo "[public-dependency-handoff-test] all assertions passed" diff --git a/scripts/test-todo-debt-audit.sh b/scripts/test-todo-debt-audit.sh new file mode 100755 index 0000000000..bce538e9b7 --- /dev/null +++ b/scripts/test-todo-debt-audit.sh @@ -0,0 +1,84 @@ +#!/usr/bin/env bash +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +AUDIT_SCRIPT="$SCRIPT_DIR/audit-todo-debt.sh" + +fail() { + echo "[todo-audit-test][error] $*" >&2 + exit 1 +} + +make_fixture_root() { + local root="$1" + mkdir -p "$root/app/src/main/java" "$root/config" +} + +run_audit() { + local root="$1" + TODO_AUDIT_ROOT="$root" TODO_AUDIT_BASELINE="$root/config/todo-debt-baseline.tsv" bash "$AUDIT_SCRIPT" +} + +expect_failure() { + local name="$1" + local root="$2" + local expected="$3" + local output + + set +e + output="$(run_audit "$root" 2>&1)" + local status=$? + set -e + + if [[ "$status" -eq 0 ]]; then + echo "$output" >&2 + fail "$name unexpectedly passed" + fi + + if [[ "$output" != *"$expected"* ]]; then + echo "$output" >&2 + fail "$name did not report expected text: $expected" + fi +} + +tmp_dir="$(mktemp -d)" +trap 'rm -rf "$tmp_dir"' EXIT + +pass_root="$tmp_dir/pass" +make_fixture_root "$pass_root" +printf '%s\n' 'class ExistingDebt { // TODO existing baseline marker' '}' > "$pass_root/app/src/main/java/ExistingDebt.kt" +mkdir -p "$pass_root/fearless-utils-Android/fearless-utils/src/main/java" +printf '%s\n' 'class ExternalCheckoutDebt { // TODO external dependency marker ignored' ' fun crash(): Nothing = TODO("external")' '}' > "$pass_root/fearless-utils-Android/fearless-utils/src/main/java/ExternalCheckoutDebt.kt" +printf '%s\t%s\n' 'app/src/main/java/ExistingDebt.kt' 'class ExistingDebt { // TODO existing baseline marker' > "$pass_root/config/todo-debt-baseline.tsv" +run_audit "$pass_root" >/dev/null +PATH="/usr/bin:/bin:/usr/sbin:/sbin" run_audit "$pass_root" >/dev/null + +new_root="$tmp_dir/new-marker" +make_fixture_root "$new_root" +printf '%s\n' 'class ExistingDebt { // TODO existing baseline marker' '}' > "$new_root/app/src/main/java/ExistingDebt.kt" +printf '%s\n' 'class NewDebt { // FIXME new marker must fail' '}' > "$new_root/app/src/main/java/NewDebt.kt" +printf '%s\t%s\n' 'app/src/main/java/ExistingDebt.kt' 'class ExistingDebt { // TODO existing baseline marker' > "$new_root/config/todo-debt-baseline.tsv" +expect_failure "new marker fixture" "$new_root" "New TODO/FIXME/STOPSHIP markers" + +stale_root="$tmp_dir/stale-baseline" +make_fixture_root "$stale_root" +printf '%s\n' 'class CleanSource' > "$stale_root/app/src/main/java/CleanSource.kt" +printf '%s\t%s\n' 'app/src/main/java/OldDebt.kt' 'class OldDebt { // TODO deleted marker' > "$stale_root/config/todo-debt-baseline.tsv" +expect_failure "stale baseline fixture" "$stale_root" "Baseline entries no longer exist" + +duplicate_root="$tmp_dir/duplicate-baseline" +make_fixture_root "$duplicate_root" +printf '%s\n' 'class DuplicateDebt { // TODO duplicate baseline marker' 'class DuplicateDebt { // TODO duplicate baseline marker' > "$duplicate_root/app/src/main/java/DuplicateDebt.kt" +printf '%s\t%s\n%s\t%s\n' \ + 'app/src/main/java/DuplicateDebt.kt' 'class DuplicateDebt { // TODO duplicate baseline marker' \ + 'app/src/main/java/DuplicateDebt.kt' 'class DuplicateDebt { // TODO duplicate baseline marker' \ + > "$duplicate_root/config/todo-debt-baseline.tsv" +expect_failure "duplicate baseline fixture" "$duplicate_root" "Duplicate TODO debt baseline entries are forbidden" + +executable_root="$tmp_dir/executable" +make_fixture_root "$executable_root" +printf '%s\n' 'class Crashy {' ' fun crash(): Nothing = TODO("boom")' '}' > "$executable_root/app/src/main/java/Crashy.kt" +printf '%s\t%s\n' 'app/src/main/java/Crashy.kt' 'fun crash(): Nothing = TODO("boom")' > "$executable_root/config/todo-debt-baseline.tsv" +expect_failure "executable TODO fixture" "$executable_root" "Executable TODO calls are forbidden" + +echo "[todo-audit-test] all tests passed" diff --git a/scripts/test-xcm-production-evidence-audit.sh b/scripts/test-xcm-production-evidence-audit.sh new file mode 100755 index 0000000000..6f5dbfba62 --- /dev/null +++ b/scripts/test-xcm-production-evidence-audit.sh @@ -0,0 +1,382 @@ +#!/usr/bin/env bash +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +AUDIT_SCRIPT="$SCRIPT_DIR/audit-xcm-production-evidence.sh" + +fail() { + echo "[xcm-production-evidence-test][error] $*" >&2 + exit 1 +} + +write_routes() { + local file="$1" + cat >"$file" <<'ROUTES' +# origin destination asset +origin destination DOT +destination origin DOT +ROUTES +} + +write_gaps() { + local file="$1" + cat >"$file" <<'GAPS' +# origin destination assets reason bridge +origin sora DOT bridge-sora bridgehub +GAPS +} + +write_blocked_manifest() { + local file="$1" + cat >"$file" <<'JSON' +{ + "schemaVersion": 1, + "scope": "android-xcm-production-readiness", + "status": "blocked", + "releaseEnabled": false, + "currentState": { + "requiredExecutableRouteCount": 2, + "discoveryOnlyRouteCount": 1 + }, + "blockers": [ + "e2e-transfer-evidence-missing", + "all-routes-executable-gate-not-green", + "discovery-only-routes-remain" + ], + "routeManifests": { + "requiredExecutableRoutes": "scripts/xcm-required-routes.tsv", + "discoveryOnlyRoutes": "scripts/xcm-discovery-only-routes.tsv", + "gapReport": "build/reports/xcm-registry-gap-report.json" + }, + "readyVerificationCommands": [ + "bash ./scripts/test-xcm-production-evidence-template.sh", + "bash ./scripts/test-xcm-production-evidence-audit.sh", + "bash ./scripts/audit-xcm-production-evidence.sh --require-ready", + "bash ./scripts/audit-xcm-registry-metadata.sh --require-executable --require-all-routes-executable --require-route-file scripts/xcm-required-routes.tsv --require-gap-file scripts/xcm-discovery-only-routes.tsv", + "./gradlew :public-shared-features-xcm:testDebugUnitTest", + "./gradlew :runtime:testDebugUnitTest --tests 'jp.co.soramitsu.runtime.multiNetwork.chain.remote.XcmLocalChainsRegistryTest'" + ], + "requiredEvidenceFields": [ + "originChainId", + "destinationChainId", + "assetSymbol", + "extrinsicHash", + "sender", + "recipient", + "amount", + "timestamp", + "environment", + "operator" + ], + "evidence": [] +} +JSON +} + +write_ready_manifest() { + local file="$1" + cat >"$file" <<'JSON' +{ + "schemaVersion": 1, + "scope": "android-xcm-production-readiness", + "status": "ready", + "releaseEnabled": true, + "currentState": { + "requiredExecutableRouteCount": 2, + "discoveryOnlyRouteCount": 0 + }, + "blockers": [], + "routeManifests": { + "requiredExecutableRoutes": "scripts/xcm-required-routes.tsv", + "discoveryOnlyRoutes": "scripts/xcm-discovery-only-routes.tsv", + "gapReport": "build/reports/xcm-registry-gap-report.json" + }, + "readyVerificationCommands": [ + "bash ./scripts/test-xcm-production-evidence-template.sh", + "bash ./scripts/test-xcm-production-evidence-audit.sh", + "bash ./scripts/audit-xcm-production-evidence.sh --require-ready", + "bash ./scripts/audit-xcm-registry-metadata.sh --require-executable --require-all-routes-executable --require-route-file scripts/xcm-required-routes.tsv --require-gap-file scripts/xcm-discovery-only-routes.tsv", + "./gradlew :public-shared-features-xcm:testDebugUnitTest", + "./gradlew :runtime:testDebugUnitTest --tests 'jp.co.soramitsu.runtime.multiNetwork.chain.remote.XcmLocalChainsRegistryTest'" + ], + "requiredEvidenceFields": [ + "originChainId", + "destinationChainId", + "assetSymbol", + "extrinsicHash", + "sender", + "recipient", + "amount", + "timestamp", + "environment", + "operator" + ], + "evidence": [ + { + "originChainId": "origin", + "destinationChainId": "destination", + "assetSymbol": "DOT", + "extrinsicHash": "0x0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef", + "sender": "sender-origin", + "recipient": "recipient-destination", + "amount": "1", + "timestamp": "2026-06-26T00:00:00Z", + "environment": "mainnet", + "operator": "release" + }, + { + "originChainId": "destination", + "destinationChainId": "origin", + "assetSymbol": "DOT", + "extrinsicHash": "0xfedcba9876543210fedcba9876543210fedcba9876543210fedcba9876543210", + "sender": "sender-destination", + "recipient": "recipient-origin", + "amount": "1", + "timestamp": "2026-06-26T00:00:00Z", + "environment": "mainnet", + "operator": "release" + } + ] +} +JSON +} + +run_audit() { + local manifest="$1" + local routes="$2" + local gaps="$3" + shift 3 + bash "$AUDIT_SCRIPT" --evidence "$manifest" --required-route-file "$routes" --discovery-gap-file "$gaps" "$@" +} + +expect_failure() { + local name="$1" + local expected="$2" + shift 2 + local output + + set +e + output="$("$@" 2>&1)" + local status=$? + set -e + + if [[ "$status" -eq 0 ]]; then + echo "$output" >&2 + fail "$name unexpectedly passed" + fi + + if [[ "$output" != *"$expected"* ]]; then + echo "$output" >&2 + fail "$name did not report expected text: $expected" + fi +} + +tmp_dir="$(mktemp -d)" +trap 'rm -rf "$tmp_dir"' EXIT + +routes="$tmp_dir/routes.tsv" +gaps="$tmp_dir/gaps.tsv" +blocked="$tmp_dir/blocked.json" +ready="$tmp_dir/ready.json" +empty_gaps="$tmp_dir/empty-gaps.tsv" + +write_routes "$routes" +write_gaps "$gaps" +write_blocked_manifest "$blocked" +write_ready_manifest "$ready" +printf '# none\n' >"$empty_gaps" + +run_audit "$blocked" "$routes" "$gaps" >/dev/null +run_audit "$ready" "$routes" "$empty_gaps" --require-ready >/dev/null + +expect_failure "missing production evidence manifest" "XCM production evidence manifest missing" run_audit "$tmp_dir/missing.json" "$routes" "$gaps" + +bad_json="$tmp_dir/bad-json.json" +printf '{' >"$bad_json" +expect_failure "invalid production evidence JSON" "must be valid JSON" run_audit "$bad_json" "$routes" "$gaps" + +bad_schema="$tmp_dir/bad-schema.json" +cp "$blocked" "$bad_schema" +perl -0pi -e 's/"schemaVersion": 1/"schemaVersion": 2/' "$bad_schema" +expect_failure "bad schema" "schemaVersion must be 1" run_audit "$bad_schema" "$routes" "$gaps" + +bad_last_reviewed="$tmp_dir/bad-last-reviewed.json" +cp "$blocked" "$bad_last_reviewed" +perl -0pi -e 's/"releaseEnabled": false,/"releaseEnabled": false,\n "lastReviewed": "today",/' "$bad_last_reviewed" +expect_failure "bad lastReviewed date" "lastReviewed must be YYYY-MM-DD" run_audit "$bad_last_reviewed" "$routes" "$gaps" + +release_enabled_blocked="$tmp_dir/release-enabled-blocked.json" +cp "$blocked" "$release_enabled_blocked" +perl -0pi -e 's/"releaseEnabled": false/"releaseEnabled": true/' "$release_enabled_blocked" +expect_failure "release enabled while blocked" "releaseEnabled must remain false while status is blocked" run_audit "$release_enabled_blocked" "$routes" "$gaps" + +missing_e2e_blocker="$tmp_dir/missing-e2e-blocker.json" +cp "$blocked" "$missing_e2e_blocker" +perl -0pi -e 's/"e2e-transfer-evidence-missing",\n //' "$missing_e2e_blocker" +expect_failure "blocked evidence missing E2E blocker" "blocked evidence missing blocker e2e-transfer-evidence-missing" run_audit "$missing_e2e_blocker" "$routes" "$gaps" + +missing_all_routes_command="$tmp_dir/missing-all-routes-command.json" +cp "$blocked" "$missing_all_routes_command" +perl -0pi -e 's/ --require-all-routes-executable//' "$missing_all_routes_command" +expect_failure "missing all-routes executable command" "readyVerificationCommands missing audit-xcm-registry-metadata.sh --require-executable --require-all-routes-executable" run_audit "$missing_all_routes_command" "$routes" "$gaps" + +missing_template_command="$tmp_dir/missing-template-command.json" +cp "$blocked" "$missing_template_command" +perl -0pi -e 's/ "bash \.\/scripts\/test-xcm-production-evidence-template\.sh",\n//' "$missing_template_command" +expect_failure "missing evidence template self-test command" "readyVerificationCommands missing test-xcm-production-evidence-template.sh" run_audit "$missing_template_command" "$routes" "$gaps" + +duplicate_verification_command="$tmp_dir/duplicate-verification-command.json" +cp "$blocked" "$duplicate_verification_command" +node - "$duplicate_verification_command" <<'NODE' +const fs = require('fs'); +const file = process.argv[2]; +const manifest = JSON.parse(fs.readFileSync(file, 'utf8')); +manifest.readyVerificationCommands.push('./gradlew :public-shared-features-xcm:testDebugUnitTest'); +fs.writeFileSync(file, `${JSON.stringify(manifest, null, 2)}\n`); +NODE +expect_failure "duplicate XCM production evidence verification command" "duplicate XCM production evidence verification command" run_audit "$duplicate_verification_command" "$routes" "$gaps" + +missing_required_field="$tmp_dir/missing-required-field.json" +cp "$blocked" "$missing_required_field" +perl -0pi -e 's/"extrinsicHash",\n //' "$missing_required_field" +expect_failure "missing required evidence field" "requiredEvidenceFields missing extrinsicHash" run_audit "$missing_required_field" "$routes" "$gaps" + +duplicate_required_field="$tmp_dir/duplicate-required-field.json" +cp "$blocked" "$duplicate_required_field" +node - "$duplicate_required_field" <<'NODE' +const fs = require('fs'); +const file = process.argv[2]; +const manifest = JSON.parse(fs.readFileSync(file, 'utf8')); +manifest.requiredEvidenceFields.push('operator'); +fs.writeFileSync(file, `${JSON.stringify(manifest, null, 2)}\n`); +NODE +expect_failure "duplicate XCM production evidence required field" "duplicate XCM production evidence required field" run_audit "$duplicate_required_field" "$routes" "$gaps" + +unsupported_top_level_field="$tmp_dir/unsupported-top-level-field.json" +cp "$blocked" "$unsupported_top_level_field" +perl -0pi -e 's/"releaseEnabled": false,/"releaseEnabled": false,\n "unsafeComment": "must fail",/' "$unsupported_top_level_field" +expect_failure "unsupported top-level XCM production evidence field" "unsupported XCM production evidence manifest field" run_audit "$unsupported_top_level_field" "$routes" "$gaps" + +unsupported_current_state_field="$tmp_dir/unsupported-current-state-field.json" +cp "$blocked" "$unsupported_current_state_field" +perl -0pi -e 's/"discoveryOnlyRouteCount": 1/"discoveryOnlyRouteCount": 1,\n "generatedRouteCount": 2/' "$unsupported_current_state_field" +expect_failure "unsupported currentState XCM production evidence field" "unsupported XCM currentState field" run_audit "$unsupported_current_state_field" "$routes" "$gaps" + +unsupported_route_manifest_field="$tmp_dir/unsupported-route-manifest-field.json" +cp "$blocked" "$unsupported_route_manifest_field" +perl -0pi -e 's/"gapReport": "build\/reports\/xcm-registry-gap-report.json"/"gapReport": "build\/reports\/xcm-registry-gap-report.json",\n "dashboardUrl": "https:\/\/example.invalid"/' "$unsupported_route_manifest_field" +expect_failure "unsupported routeManifests XCM production evidence field" "unsupported XCM routeManifests field" run_audit "$unsupported_route_manifest_field" "$routes" "$gaps" + +unsupported_required_evidence_field="$tmp_dir/unsupported-required-evidence-field.json" +cp "$blocked" "$unsupported_required_evidence_field" +perl -0pi -e 's/"operator"\n \]/"operator",\n "receiptUrl"\n \]/' "$unsupported_required_evidence_field" +expect_failure "unsupported required XCM production evidence field" "unsupported required XCM production evidence field" run_audit "$unsupported_required_evidence_field" "$routes" "$gaps" + +unsupported_blocker="$tmp_dir/unsupported-blocker.json" +cp "$blocked" "$unsupported_blocker" +perl -0pi -e 's/"discovery-only-routes-remain"/"discovery-only-routes-remain",\n "manual-approval-pending"/' "$unsupported_blocker" +expect_failure "unsupported XCM production evidence blocker" "unsupported XCM production evidence blocker" run_audit "$unsupported_blocker" "$routes" "$gaps" + +duplicate_blocker="$tmp_dir/duplicate-blocker.json" +cp "$blocked" "$duplicate_blocker" +perl -0pi -e 's/"discovery-only-routes-remain"/"discovery-only-routes-remain",\n "discovery-only-routes-remain"/' "$duplicate_blocker" +expect_failure "duplicate XCM production evidence blocker" "duplicate XCM production evidence blocker" run_audit "$duplicate_blocker" "$routes" "$gaps" + +ready_with_gaps="$tmp_dir/ready-with-gaps.json" +write_ready_manifest "$ready_with_gaps" +perl -0pi -e 's/"discoveryOnlyRouteCount": 0/"discoveryOnlyRouteCount": 1/' "$ready_with_gaps" +expect_failure "ready evidence with discovery-only routes" "ready evidence cannot have discovery-only routes remaining" run_audit "$ready_with_gaps" "$routes" "$gaps" + +ready_with_blocker="$tmp_dir/ready-with-blocker.json" +cp "$ready" "$ready_with_blocker" +perl -0pi -e 's/"blockers": \[\]/"blockers": ["e2e-transfer-evidence-missing"]/' "$ready_with_blocker" +expect_failure "ready evidence carries blockers" "blockers must be empty when XCM production evidence is ready" run_audit "$ready_with_blocker" "$routes" "$empty_gaps" --require-ready + +ready_missing_route="$tmp_dir/ready-missing-route.json" +cp "$ready" "$ready_missing_route" +perl -0pi -e 's/,\n \{\n "originChainId": "destination".*?\n \}\n \]/\n \]/s' "$ready_missing_route" +expect_failure "ready evidence without all required routes" "ready evidence missing E2E transfer evidence for route destination -> origin DOT" run_audit "$ready_missing_route" "$routes" "$empty_gaps" --require-ready + +ready_bad_hash="$tmp_dir/ready-bad-hash.json" +cp "$ready" "$ready_bad_hash" +perl -0pi -e 's/0x0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef/0x1234/' "$ready_bad_hash" +expect_failure "ready evidence malformed extrinsic hash" "extrinsicHash must be a 0x-prefixed 32-byte hash" run_audit "$ready_bad_hash" "$routes" "$empty_gaps" --require-ready + +ready_placeholder_hash="$tmp_dir/ready-placeholder-hash.json" +cp "$ready" "$ready_placeholder_hash" +perl -0pi -e 's/0x0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef/0x1111111111111111111111111111111111111111111111111111111111111111/' "$ready_placeholder_hash" +expect_failure "ready evidence placeholder extrinsic hash" "extrinsicHash must not be a placeholder extrinsic hash" run_audit "$ready_placeholder_hash" "$routes" "$empty_gaps" --require-ready + +ready_bad_amount="$tmp_dir/ready-bad-amount.json" +cp "$ready" "$ready_bad_amount" +perl -0pi -e 's/"amount": "1"/"amount": "0"/' "$ready_bad_amount" +expect_failure "ready evidence zero amount" "amount must be greater than zero" run_audit "$ready_bad_amount" "$routes" "$empty_gaps" --require-ready + +ready_bad_environment="$tmp_dir/ready-bad-environment.json" +cp "$ready" "$ready_bad_environment" +perl -0pi -e 's/"environment": "mainnet"/"environment": "staging"/' "$ready_bad_environment" +expect_failure "ready evidence bad environment" "environment must be mainnet or testnet" run_audit "$ready_bad_environment" "$routes" "$empty_gaps" --require-ready + +ready_testnet_environment="$tmp_dir/ready-testnet-environment.json" +cp "$ready" "$ready_testnet_environment" +perl -0pi -e 's/"environment": "mainnet"/"environment": "testnet"/' "$ready_testnet_environment" +expect_failure "ready evidence testnet environment" "environment must be mainnet when XCM production evidence is ready" run_audit "$ready_testnet_environment" "$routes" "$empty_gaps" --require-ready + +ready_placeholder_sender="$tmp_dir/ready-placeholder-sender.json" +cp "$ready" "$ready_placeholder_sender" +perl -0pi -e 's/"sender": "sender-origin"/"sender": "TODO_sender_public_address"/' "$ready_placeholder_sender" +expect_failure "ready evidence placeholder sender" "sender must not be a placeholder public address" run_audit "$ready_placeholder_sender" "$routes" "$empty_gaps" --require-ready + +ready_placeholder_recipient="$tmp_dir/ready-placeholder-recipient.json" +cp "$ready" "$ready_placeholder_recipient" +perl -0pi -e 's/"recipient": "recipient-destination"/"recipient": "TODO_recipient_public_address"/' "$ready_placeholder_recipient" +expect_failure "ready evidence placeholder recipient" "recipient must not be a placeholder public address" run_audit "$ready_placeholder_recipient" "$routes" "$empty_gaps" --require-ready + +ready_same_parties="$tmp_dir/ready-same-parties.json" +cp "$ready" "$ready_same_parties" +perl -0pi -e 's/"recipient": "recipient-destination"/"recipient": "sender-origin"/' "$ready_same_parties" +expect_failure "ready evidence same sender and recipient" "sender and recipient must differ" run_audit "$ready_same_parties" "$routes" "$empty_gaps" --require-ready + +ready_placeholder_operator="$tmp_dir/ready-placeholder-operator.json" +cp "$ready" "$ready_placeholder_operator" +perl -0pi -e 's/"operator": "release"/"operator": "TODO_operator_or_runbook_id"/' "$ready_placeholder_operator" +expect_failure "ready evidence placeholder operator" "operator must not be a placeholder operator" run_audit "$ready_placeholder_operator" "$routes" "$empty_gaps" --require-ready + +ready_unsupported_evidence_field="$tmp_dir/ready-unsupported-evidence-field.json" +cp "$ready" "$ready_unsupported_evidence_field" +perl -0pi -e 's/"operator": "release"/"operator": "release",\n "receiptUrl": "https:\/\/example.invalid\/evidence"/' "$ready_unsupported_evidence_field" +expect_failure "unsupported XCM production evidence record field" "unsupported XCM production evidence[0] field" run_audit "$ready_unsupported_evidence_field" "$routes" "$empty_gaps" --require-ready + +ready_unknown_route="$tmp_dir/ready-unknown-route.json" +cp "$ready" "$ready_unknown_route" +perl -0pi -e 's/"destinationChainId": "destination"/"destinationChainId": "unknown"/' "$ready_unknown_route" +expect_failure "ready evidence for untracked route" "route is not declared in required route manifest" run_audit "$ready_unknown_route" "$routes" "$empty_gaps" --require-ready + +ready_duplicate_hash="$tmp_dir/ready-duplicate-hash.json" +cp "$ready" "$ready_duplicate_hash" +perl -0pi -e 's/0xfedcba9876543210fedcba9876543210fedcba9876543210fedcba9876543210/0x0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef/' "$ready_duplicate_hash" +expect_failure "ready evidence duplicate extrinsic hash" "duplicate E2E transfer extrinsicHash" run_audit "$ready_duplicate_hash" "$routes" "$empty_gaps" --require-ready + +ready_future_timestamp="$tmp_dir/ready-future-timestamp.json" +cp "$ready" "$ready_future_timestamp" +perl -0pi -e 's/2026-06-26T00:00:00Z/2999-01-01T00:00:00Z/g' "$ready_future_timestamp" +expect_failure "ready evidence future timestamp" "timestamp must not be in the future" run_audit "$ready_future_timestamp" "$routes" "$empty_gaps" --require-ready + +ready_secret_key="$tmp_dir/ready-secret-key.json" +cp "$ready" "$ready_secret_key" +perl -0pi -e 's/"operator": "release"/"operator": "release",\n "privateKey": "0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"/' "$ready_secret_key" +expect_failure "secret-like XCM production evidence key" "must not be included in public XCM production evidence" run_audit "$ready_secret_key" "$routes" "$empty_gaps" --require-ready + +blocked_stale_counts="$tmp_dir/blocked-stale-counts.json" +cp "$blocked" "$blocked_stale_counts" +perl -0pi -e 's/"requiredExecutableRouteCount": 2/"requiredExecutableRouteCount": 1/' "$blocked_stale_counts" +expect_failure "stale production evidence counts" "currentState.requiredExecutableRouteCount must match required route manifest count 2" run_audit "$blocked_stale_counts" "$routes" "$gaps" + +blocked_stale_discovery="$tmp_dir/blocked-stale-discovery.json" +cp "$ready" "$blocked_stale_discovery" +perl -0pi -e 's/"status": "ready"/"status": "blocked"/' "$blocked_stale_discovery" +perl -0pi -e 's/"releaseEnabled": true/"releaseEnabled": false/' "$blocked_stale_discovery" +perl -0pi -e 's/"blockers": \[\]/"blockers": ["discovery-only-routes-remain"]/' "$blocked_stale_discovery" +expect_failure "blocked evidence stale discovery blocker" "blocked evidence has stale blocker discovery-only-routes-remain" run_audit "$blocked_stale_discovery" "$routes" "$empty_gaps" + +echo "[xcm-production-evidence-test] all assertions passed" diff --git a/scripts/test-xcm-production-evidence-template.sh b/scripts/test-xcm-production-evidence-template.sh new file mode 100755 index 0000000000..c8390baa3a --- /dev/null +++ b/scripts/test-xcm-production-evidence-template.sh @@ -0,0 +1,135 @@ +#!/usr/bin/env bash +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +GENERATOR="$SCRIPT_DIR/generate-xcm-production-evidence-template.sh" + +fail() { + echo "[xcm-evidence-template-test][error] $*" >&2 + exit 1 +} + +write_routes() { + local file="$1" + cat >"$file" <<'ROUTES' +# origin destination asset +origin destination DOT +destination origin xcKSM +ROUTES +} + +expect_failure() { + local name="$1" + local expected="$2" + shift 2 + local output + + set +e + output="$("$@" 2>&1)" + local status=$? + set -e + + if [[ "$status" -eq 0 ]]; then + echo "$output" >&2 + fail "$name unexpectedly passed" + fi + + if [[ "$output" != *"$expected"* ]]; then + echo "$output" >&2 + fail "$name did not report expected text: $expected" + fi +} + +tmp_dir="$(mktemp -d)" +trap 'rm -rf "$tmp_dir"' EXIT + +routes="$tmp_dir/routes.tsv" +stdout_template="$tmp_dir/template-stdout.json" +output_template="$tmp_dir/template-output.json" +write_routes "$routes" + +bash "$GENERATOR" --required-route-file "$routes" >"$stdout_template" +bash "$GENERATOR" --required-route-file "$routes" --output "$output_template" +cmp "$stdout_template" "$output_template" >/dev/null || + fail "output file matches stdout" + +node - "$stdout_template" "$routes" <<'NODE' +const assert = require('node:assert/strict'); +const fs = require('node:fs'); + +const [file, routesFile] = process.argv.slice(2); +const template = JSON.parse(fs.readFileSync(file, 'utf8')); + +assert.equal(template.schemaVersion, 1); +assert.equal(template.scope, 'android-xcm-production-evidence-template'); +assert.equal(template.sourceRequiredRouteFile, routesFile); +assert.equal(template.requiredRouteCount, 2); +assert.deepEqual(template.requiredEvidenceFields, [ + 'originChainId', + 'destinationChainId', + 'assetSymbol', + 'extrinsicHash', + 'sender', + 'recipient', + 'amount', + 'timestamp', + 'environment', + 'operator' +]); +assert.equal(template.evidence.length, 2); +assert.deepEqual(template.evidence[0], { + originChainId: 'origin', + destinationChainId: 'destination', + assetSymbol: 'DOT', + extrinsicHash: 'TODO_0x_prefixed_32_byte_hash', + sender: 'TODO_sender_public_address', + recipient: 'TODO_recipient_public_address', + amount: 'TODO_positive_decimal_amount', + timestamp: 'TODO_YYYY-MM-DDTHH:MM:SSZ', + environment: 'mainnet', + operator: 'TODO_operator_or_runbook_id' +}); +assert.equal(template.evidence[1].assetSymbol, 'KSM'); + +function assertNoSecretLikeKeys(value, path = '$') { + if (!value || typeof value !== 'object') { + return; + } + if (Array.isArray(value)) { + value.forEach((item, index) => assertNoSecretLikeKeys(item, `${path}[${index}]`)); + return; + } + for (const [key, nested] of Object.entries(value)) { + assert.doesNotMatch(key, /(private[-_]?key|mnemonic|seed|secret|password|authorization|credential|clientDataJSON)/iu, `${path}.${key}`); + assertNoSecretLikeKeys(nested, `${path}.${key}`); + } +} + +assertNoSecretLikeKeys(template); +NODE + +expect_failure "missing required route file" "required route file missing" \ + bash "$GENERATOR" --required-route-file "$tmp_dir/missing.tsv" + +malformed="$tmp_dir/malformed.tsv" +printf 'origin destination DOT extra\n' >"$malformed" +expect_failure "malformed required route file" "Invalid required route file line" \ + bash "$GENERATOR" --required-route-file "$malformed" + +empty="$tmp_dir/empty.tsv" +printf '# no routes\n' >"$empty" +expect_failure "empty required route file" "required route file must contain at least one route" \ + bash "$GENERATOR" --required-route-file "$empty" + +duplicate="$tmp_dir/duplicate.tsv" +cat >"$duplicate" <<'ROUTES' +origin destination DOT +origin destination xcDOT +ROUTES +expect_failure "duplicate required route" "duplicate required route" \ + bash "$GENERATOR" --required-route-file "$duplicate" + +expect_failure "unknown argument" "Unknown argument" \ + bash "$GENERATOR" --bogus + +echo "[xcm-evidence-template-test] all assertions passed" diff --git a/scripts/test-xcm-registry-metadata-audit.sh b/scripts/test-xcm-registry-metadata-audit.sh new file mode 100755 index 0000000000..95bb13c7c7 --- /dev/null +++ b/scripts/test-xcm-registry-metadata-audit.sh @@ -0,0 +1,343 @@ +#!/usr/bin/env bash +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +AUDIT_SCRIPT="$SCRIPT_DIR/audit-xcm-registry-metadata.sh" + +fail() { + echo "[xcm-registry-test][error] $*" >&2 + exit 1 +} + +write_registry() { + local file="$1" + local destination_fragment="$2" + + cat >"$file" <}))" }, + "feeAssetLocation": { "parents": 1, "interior": "Here" }, + "feeAssetItem": 0, + "weightLimit": { "type": "Limited", "refTime": "1000000000", "proofSize": "65536" }, + "destinationFee": { "mode": "Estimated", "assetSymbol": "DOT" } + } +JSON +} + +bridge_execution_json() { + cat <<'JSON' +, + "bridgeParachainId": "expected", + "execution": { + "palletName": "PolkadotXcm", + "callName": "limitedReserveTransferAssets", + "transferType": "limitedReserveTransferAssets", + "destinationLocation": { "parents": 1, "interior": "X1(Parachain(2000))" }, + "assetLocation": { "parents": 1, "interior": "X2(Parachain(1000), GeneralKey(dot))" }, + "beneficiaryLocation": { "parents": 0, "interior": "X1(AccountId32({network: Any, id: }))" }, + "feeAssetLocation": { "parents": 1, "interior": "Here" }, + "feeAssetItem": 0, + "weightLimit": { "type": "Limited", "refTime": "1000000000", "proofSize": "65536" }, + "destinationFee": { "mode": "Estimated", "assetSymbol": "DOT" }, + "bridge": { + "parachainId": "actual", + "feeAssetLocation": { "parents": 1, "interior": "Here" }, + "feeAssetItem": 0 + } + } +JSON +} + +xtokens_execution_json() { + cat <<'JSON' +, + "execution": { + "palletName": "XTokens", + "callName": "transferMultiasset", + "transferType": "xTokensTransferMultiasset", + "argumentShape": "xTokensTransferMultiasset", + "destinationLocation": { "parents": 1, "interior": "X1(Parachain(2000))" }, + "assetLocation": { "parents": 1, "interior": "X2(Parachain(1000), GeneralKey(dot))" }, + "beneficiaryLocation": { "parents": 0, "interior": "X1(AccountId32({network: Any, id: }))" }, + "feeAssetLocation": { "parents": 1, "interior": "Here" }, + "feeAssetItem": 0, + "weightLimit": { "type": "Limited", "refTime": "1000000000", "proofSize": "65536" }, + "destinationFee": { "mode": "Estimated", "assetSymbol": "DOT" } + } +JSON +} + +run_audit() { + local file="$1" + shift + bash "$AUDIT_SCRIPT" --registry "$file" "$@" +} + +expect_failure() { + local name="$1" + local file="$2" + local expected="$3" + shift 3 + local output + + set +e + output="$(run_audit "$file" "$@" 2>&1)" + local status=$? + set -e + + if [[ "$status" -eq 0 ]]; then + echo "$output" >&2 + fail "$name unexpectedly passed" + fi + + if [[ "$output" != *"$expected"* ]]; then + echo "$output" >&2 + fail "$name did not report expected text: $expected" + fi +} + +tmp_dir="$(mktemp -d)" +trap 'rm -rf "$tmp_dir"' EXIT + +passing="$tmp_dir/passing.json" +write_registry "$passing" "$(execution_json)" +run_audit "$passing" --require-executable >/dev/null +run_audit "$passing" --require-route origin destination DOT >/dev/null +xtokens_passing="$tmp_dir/xtokens-passing.json" +write_registry "$xtokens_passing" "$(xtokens_execution_json)" +run_audit "$xtokens_passing" --require-executable >/dev/null +run_audit "$xtokens_passing" --require-route origin destination DOT >/dev/null +required_route_file="$tmp_dir/required-routes.tsv" +cat >"$required_route_file" <<'ROUTES' +# origin destination asset +origin destination DOT +ROUTES +run_audit "$passing" --require-route-file "$required_route_file" >/dev/null + +missing_required_route="$tmp_dir/missing-required-route.json" +write_registry "$missing_required_route" "$(execution_json)" +expect_failure \ + "missing required executable route" \ + "$missing_required_route" \ + "Required executable XCM route missing: origin -> other DOT" \ + --require-route origin other DOT +expect_failure \ + "missing required executable route from file" \ + "$missing_required_route" \ + "Required executable XCM route missing: origin -> other DOT" \ + --require-route-file <(printf 'origin other DOT\n') +missing_required_route_file="$tmp_dir/missing-required-routes.tsv" +expect_failure \ + "missing required route file" \ + "$missing_required_route" \ + "Required route file missing" \ + --require-route-file "$missing_required_route_file" +bad_required_route_file="$tmp_dir/bad-required-routes.tsv" +cat >"$bad_required_route_file" <<'ROUTES' +origin destination DOT extra-column +ROUTES +expect_failure \ + "malformed required route file" \ + "$missing_required_route" \ + "Invalid required route file line 1" \ + --require-route-file "$bad_required_route_file" + +missing_execution="$tmp_dir/missing-execution.json" +write_registry "$missing_execution" "" +run_audit "$missing_execution" >/dev/null +gap_report="$tmp_dir/xcm-gap-report.json" +run_audit "$missing_execution" --write-gap-report "$gap_report" >/dev/null +required_gap_file="$tmp_dir/required-gaps.tsv" +cat >"$required_gap_file" <<'GAPS' +# origin destination assets reason bridge +origin destination DOT cross-parachain-native - +GAPS +run_audit "$missing_execution" --require-gap-file "$required_gap_file" >/dev/null +node - "$gap_report" <<'NODE' +const fs = require('fs'); +const report = JSON.parse(fs.readFileSync(process.argv[2], 'utf8')); + +function assert(condition, message) { + if (!condition) { + throw new Error(message); + } +} + +assert(report.schemaVersion === 1, 'schemaVersion must be 1'); +assert(report.summary.remainingDiscoveryOnlyDestinations === 1, 'remaining destination count must be reported'); +assert(report.summary.executableDestinations === 0, 'executable destination count must be reported'); +assert(Array.isArray(report.missingExecutableDestinations), 'missing destination list must be an array'); +assert(report.missingExecutableDestinations.length === 1, 'missing destination list must contain one route'); + +const route = report.missingExecutableDestinations[0]; +assert(route.originChainId === 'origin', 'origin chain id must be reported'); +assert(route.originName === 'Origin', 'origin name must be reported'); +assert(route.destinationChainId === 'destination', 'destination chain id must be reported'); +assert(route.destinationName === null, 'unknown destination name must be null'); +assert(route.reason === 'missingExecutionSpec', 'missing route reason must be explicit'); +assert(route.bridgeParachainId === null, 'missing bridge parachain id must be null'); +assert(route.assetSymbols.length === 1 && route.assetSymbols[0] === 'DOT', 'asset symbols must be normalized'); +NODE +missing_required_gap_file="$tmp_dir/missing-required-gaps.tsv" +expect_failure \ + "missing required gap file" \ + "$missing_execution" \ + "Required gap file missing" \ + --require-gap-file "$missing_required_gap_file" +bad_required_gap_file="$tmp_dir/bad-required-gaps.tsv" +cat >"$bad_required_gap_file" <<'GAPS' +origin destination DOT +GAPS +expect_failure \ + "malformed required gap file" \ + "$missing_execution" \ + "Invalid required gap file line 1" \ + --require-gap-file "$bad_required_gap_file" +empty_gap_file="$tmp_dir/empty-required-gaps.tsv" +printf '# no gaps tracked\n' >"$empty_gap_file" +expect_failure \ + "untracked discovery-only gap" \ + "$missing_execution" \ + "Untracked discovery-only XCM gap: origin -> destination DOT" \ + --require-gap-file "$empty_gap_file" +stale_gap_file="$tmp_dir/stale-required-gaps.tsv" +printf 'origin other DOT cross-parachain-native -\n' >"$stale_gap_file" +expect_failure \ + "stale discovery-only gap" \ + "$missing_execution" \ + "Required discovery-only XCM gap is stale or executable: origin -> other DOT" \ + --require-gap-file "$stale_gap_file" +wrong_gap_reason_file="$tmp_dir/wrong-reason-required-gaps.tsv" +printf 'origin destination DOT bridge-sora -\n' >"$wrong_gap_reason_file" +expect_failure \ + "wrong discovery-only gap reason" \ + "$missing_execution" \ + "Required discovery-only XCM gap reason mismatch" \ + --require-gap-file "$wrong_gap_reason_file" +wrong_gap_bridge_file="$tmp_dir/wrong-bridge-required-gaps.tsv" +printf 'origin destination DOT cross-parachain-native unexpected-bridge\n' >"$wrong_gap_bridge_file" +expect_failure \ + "wrong discovery-only gap bridge" \ + "$missing_execution" \ + "Required discovery-only XCM gap bridge mismatch" \ + --require-gap-file "$wrong_gap_bridge_file" +expect_failure "missing executable route" "$missing_execution" "No executable XCM route metadata found" --require-executable +expect_failure \ + "required route without execution" \ + "$missing_execution" \ + "Required executable XCM route missing: origin -> destination DOT" \ + --require-route origin destination DOT + +blocked_gap_parent="$tmp_dir/blocked-gap-parent" +printf locked >"$blocked_gap_parent" +expect_failure \ + "unwritable gap report" \ + "$missing_execution" \ + "Failed to write XCM gap report" \ + --write-gap-report "$blocked_gap_parent/report.json" + +all_routes_required="$tmp_dir/all-routes-required.json" +write_registry "$all_routes_required" "" +expect_failure "missing all-route execution" "$all_routes_required" "executable route metadata is required" --require-all-routes-executable + +bad_argument_shape="$tmp_dir/bad-argument-shape.json" +write_registry "$bad_argument_shape" "$(execution_json)" +perl -0pi -e 's/"transferType": "limitedReserveTransferAssets"/"transferType": "limitedReserveTransferAssets",\n "argumentShape": "operatorAlias"/' "$bad_argument_shape" +expect_failure "unsupported argument shape" "$bad_argument_shape" "argumentShape is unsupported" --require-executable + +bad_polkadot_call_shape="$tmp_dir/bad-polkadot-call-shape.json" +write_registry "$bad_polkadot_call_shape" "$(execution_json)" +perl -0pi -e 's/"callName": "limitedReserveTransferAssets"/"callName": "transferMultiasset"/' "$bad_polkadot_call_shape" +expect_failure "mismatched PolkadotXcm shape" "$bad_polkadot_call_shape" "PolkadotXcm transfer-assets argumentShape callName must match transferType" --require-executable + +bad_xtokens_shape="$tmp_dir/bad-xtokens-shape.json" +write_registry "$bad_xtokens_shape" "$(execution_json)" +perl -0pi -e 's/"callName": "limitedReserveTransferAssets"/"callName": "transferMultiasset"/' "$bad_xtokens_shape" +perl -0pi -e 's/"transferType": "limitedReserveTransferAssets"/"transferType": "xTokensTransferMultiasset",\n "argumentShape": "xTokensTransferMultiasset"/' "$bad_xtokens_shape" +expect_failure "mismatched XTokens shape" "$bad_xtokens_shape" "XTokens transferMultiasset argumentShape requires palletName XTokens" --require-executable + +bad_weight="$tmp_dir/bad-weight.json" +write_registry "$bad_weight" "$(execution_json | sed 's/, "proofSize": "65536"//')" +bad_weight_gap_report="$tmp_dir/bad-weight-gap-report.json" +expect_failure "bad limited weight" "$bad_weight" "limited weight requires refTime and proofSize" --require-executable +expect_failure "bad limited weight with gap report" "$bad_weight" "limited weight requires refTime and proofSize" --write-gap-report "$bad_weight_gap_report" --require-executable +[[ ! -e "$bad_weight_gap_report" ]] || fail "validation failure unexpectedly wrote a gap report" + +bad_unlimited="$tmp_dir/bad-unlimited.json" +write_registry "$bad_unlimited" "$(execution_json | sed 's/"type": "Limited"/"type": "Unlimited"/')" +expect_failure "bad unlimited weight" "$bad_unlimited" "unlimited weight must not include refTime or proofSize" --require-executable + +bad_destination_location="$tmp_dir/bad-destination-location.json" +write_registry "$bad_destination_location" "$(execution_json)" +perl -0pi -e 's/"destinationLocation": \{ "parents": 1, "interior": "X1\(Parachain\(2000\)\)" \},//' "$bad_destination_location" +expect_failure "missing destination location" "$bad_destination_location" "destinationLocation is required" --require-executable + +bad_fixed_fee="$tmp_dir/bad-fixed-fee.json" +write_registry "$bad_fixed_fee" "$(execution_json | sed 's/"mode": "Estimated"/"mode": "Fixed"/')" +expect_failure "bad fixed fee" "$bad_fixed_fee" "fixed destination fee requires amount" --require-executable + +bad_estimated_fee="$tmp_dir/bad-estimated-fee.json" +write_registry "$bad_estimated_fee" "$(execution_json | sed 's/"assetSymbol": "DOT"/"assetSymbol": "DOT", "amount": "1"/')" +expect_failure "bad estimated fee amount" "$bad_estimated_fee" "amount is only valid for fixed destination fees" --require-executable + +bad_bridge="$tmp_dir/bad-bridge.json" +write_registry "$bad_bridge" "$(bridge_execution_json)" +expect_failure "bridge mismatch" "$bad_bridge" "bridge.parachainId must match destination.bridgeParachainId" --require-executable + +bad_min_amount="$tmp_dir/bad-min-amount.json" +write_registry "$bad_min_amount" "$(execution_json)" +perl -0pi -e 's/"minAmount": "1"/"minAmount": "-1"/' "$bad_min_amount" +expect_failure "negative min amount" "$bad_min_amount" "minAmount must be a non-negative integer string" --require-executable + +bad_symbol="$tmp_dir/bad-symbol.json" +write_registry "$bad_symbol" "$(execution_json)" +perl -0pi -e 's/"symbol": "DOT", "minAmount"/"symbol": " ", "minAmount"/' "$bad_symbol" +expect_failure "blank route symbol" "$bad_symbol" "symbol must not be blank" --require-executable + +bad_account_placeholder="$tmp_dir/bad-account-placeholder.json" +write_registry "$bad_account_placeholder" "$(execution_json)" +perl -0pi -e 's/id: /id: 0x1234/' "$bad_account_placeholder" +expect_failure "missing account placeholder" "$bad_account_placeholder" "AccountId32 must include recipient placeholder" --require-executable + +bad_junction_count="$tmp_dir/bad-junction-count.json" +write_registry "$bad_junction_count" "$(execution_json)" +perl -0pi -e 's/X2\(Parachain\(1000\), GeneralKey\(dot\)\)/X2(Parachain(1000))/' "$bad_junction_count" +expect_failure "bad junction count" "$bad_junction_count" "declares X2 but contains 1 junctions" --require-executable + +bad_unsupported_junction="$tmp_dir/bad-unsupported-junction.json" +write_registry "$bad_unsupported_junction" "$(execution_json)" +perl -0pi -e 's/GeneralKey\(dot\)/Unsupported(1)/' "$bad_unsupported_junction" +expect_failure "unsupported junction" "$bad_unsupported_junction" "has unsupported junction Unsupported" --require-executable + +echo "[xcm-registry-test] all tests passed" diff --git a/scripts/validate-local.sh b/scripts/validate-local.sh old mode 100644 new mode 100755 index 3f4de604ed..f7882e8f81 --- a/scripts/validate-local.sh +++ b/scripts/validate-local.sh @@ -38,7 +38,7 @@ ensure_java() { ensure_android_sdk() { if [[ -z "${ANDROID_SDK_ROOT:-}" ]]; then # Try common locations - local candidates=("$HOME/Library/Android/sdk" "$HOME/Android/Sdk") + local candidates=("$HOME/Library/Android/sdk" "$HOME/Android/Sdk" "/opt/homebrew/share/android-commandlinetools" "/usr/local/share/android-commandlinetools") for d in "${candidates[@]}"; do if [[ -d "$d" ]]; then export ANDROID_SDK_ROOT="$d"; break; fi done @@ -48,15 +48,23 @@ ensure_android_sdk() { warn "Docs: https://developer.android.com/studio#command-tools" return 1 fi + if [[ -z "${ANDROID_HOME:-}" ]]; then + export ANDROID_HOME="$ANDROID_SDK_ROOT" + fi log "Using ANDROID_SDK_ROOT=$ANDROID_SDK_ROOT" } find_sdkmanager() { if command -v sdkmanager >/dev/null 2>&1; then echo "$(command -v sdkmanager)"; return; fi + local sdk_root="${ANDROID_SDK_ROOT:-}" + if [[ -z "$sdk_root" ]]; then + echo "" + return + fi local paths=( - "$ANDROID_SDK_ROOT/cmdline-tools/latest/bin/sdkmanager" - "$ANDROID_SDK_ROOT/cmdline-tools/bin/sdkmanager" - "$ANDROID_SDK_ROOT/tools/bin/sdkmanager" + "$sdk_root/cmdline-tools/latest/bin/sdkmanager" + "$sdk_root/cmdline-tools/bin/sdkmanager" + "$sdk_root/tools/bin/sdkmanager" ) for p in "${paths[@]}"; do [[ -x "$p" ]] && { echo "$p"; return; } @@ -77,6 +85,28 @@ prepare_android_packages() { "$sm" --install "platforms;android-${REQUIRED_API}" "build-tools;${REQUIRED_BUILD_TOOLS}" "platform-tools" "ndk;${REQUIRED_NDK}" } +ensure_fearless_utils() { + export FORCE_LOCAL_UTILS="${FORCE_LOCAL_UTILS:-true}" + export FEARLESS_UTILS_LIBRARY_ONLY="${FEARLESS_UTILS_LIBRARY_ONLY:-true}" + ./scripts/ensure-fearless-utils.sh +} + +check_iroha_mobile_sdk_release_assets() { + log "Checking Iroha mobile SDK release asset contract..." + bash ./scripts/check-iroha-mobile-sdk-release-assets.sh --self-test + if [[ -n "${IROHA_MOBILE_SDK_RELEASE_TAG:-}" ]]; then + bash ./scripts/check-iroha-mobile-sdk-release-assets.sh --download --tag "$IROHA_MOBILE_SDK_RELEASE_TAG" + else + warn "IROHA_MOBILE_SDK_RELEASE_TAG is not set; skipping real release asset validation." + fi +} + +audit_xcm_registry_metadata() { + log "Checking XCM registry metadata contract..." + bash ./scripts/test-xcm-registry-metadata-audit.sh + bash ./scripts/audit-xcm-registry-metadata.sh +} + run_gradle_tasks() { if [[ ! -x "./gradlew" ]]; then err "Gradle wrapper not found. Run from repo root."; return 1 @@ -95,6 +125,14 @@ run_gradle_tasks() { } main() { + ./scripts/audit-public-artifacts.sh + bash ./scripts/test-public-dependency-upstream-delta-export.sh + bash ./scripts/export-public-dependency-upstream-delta.sh --output build/reports/public-dependency-upstream-delta + bash ./scripts/test-todo-debt-audit.sh + bash ./scripts/audit-todo-debt.sh + audit_xcm_registry_metadata + check_iroha_mobile_sdk_release_assets + ensure_fearless_utils ensure_java || exit 1 ensure_android_sdk || warn "SDK not fully configured; continuing if tasks do not require it." prepare_android_packages || warn "Could not ensure SDK packages; unit tests may still run." diff --git a/scripts/xcm-discovery-only-routes.tsv b/scripts/xcm-discovery-only-routes.tsv new file mode 100644 index 0000000000..63237785d1 --- /dev/null +++ b/scripts/xcm-discovery-only-routes.tsv @@ -0,0 +1,49 @@ +# origin_chain_id destination_chain_id asset_symbols reason bridge_parachain_id +# +# reason is one of: +# - bridge-sora: route depends on SORA bridge execution metadata and bridge fee semantics. +# - non-native-asset: route carries non-native or parachain asset metadata that must be route-reviewed. +# - cross-parachain-native: route carries relay-native assets through non-relay origins and needs route review. +# +# Keep this manifest in sync with: +# scripts/audit-xcm-registry-metadata.sh --write-gap-report build/reports/xcm-registry-gap-report.json --require-gap-file scripts/xcm-discovery-only-routes.tsv + +# SORA bridge routes. +91b171bb158e2d3848fa23a9f1c25182fb8e20313b2c1eb49219da7a70ce90c3 7e4e32d0feafd4f9c9414b0be86373f9a1efa904809b683453a9af6856d38ad5 DOT bridge-sora e92d165ad41e41e215d09713788173aecfdbe34d3bed29409d33a2ef03980738 +b0a8d493285c2df73290dfb7e61f870f17b41801197a149ca93654499ea3dafe 7e4e32d0feafd4f9c9414b0be86373f9a1efa904809b683453a9af6856d38ad5 KSM bridge-sora 6d8d9f145c2177fa83512492cdd80a71e29f22473f4a8943a6292149ac319fb9 +fc41b9bd8ef8fe53d58c7ea67c794c7ec9a73daf05e6d54b14ff6342c99ba64c 7e4e32d0feafd4f9c9414b0be86373f9a1efa904809b683453a9af6856d38ad5 ACA bridge-sora e92d165ad41e41e215d09713788173aecfdbe34d3bed29409d33a2ef03980738 +9eb76c5184c4ab8679d2d5d819fdf90b9c001403e9e17da2e14b6d8aec4029c6 7e4e32d0feafd4f9c9414b0be86373f9a1efa904809b683453a9af6856d38ad5 ASTR bridge-sora e92d165ad41e41e215d09713788173aecfdbe34d3bed29409d33a2ef03980738 +6408de7737c59c238890533af25896a2c20608d8b380bb01029acb392781063e 3266816be9fa51b32cfea58d3e33ca77246bc9618595a4300e44c8856a8d8a17 ROC bridge-sora 8685a8d3e57fa8024b91b8ead6cc97acf953889c6fb0a355602826a1e2db198f + +# Non-native and parachain asset routes. +48239ef607d7928874027a43a67689209727dfb3d3dc5e5b03a39bdc2eda771a 401a1f9dca3da46f5c4091016c8a2f26dcea05865116b286f60f668207d1474b RMRK non-native-asset - +48239ef607d7928874027a43a67689209727dfb3d3dc5e5b03a39bdc2eda771a baf5aabe40646d11f0ee8abbdc64f4a4b7674925cba08e4a05ff9ebed6e2126b RMRK,USDT non-native-asset - +48239ef607d7928874027a43a67689209727dfb3d3dc5e5b03a39bdc2eda771a 9f28c6a68e0fc9646eff64935684f6eeeece527e37bbe1f213d22caa1d9d6bed RMRK,USDT non-native-asset - +68d56f15f85d3136970ec16946040bc1752654e906147f7e43e9d539d7c3de2f fe58ea77779b7abda7da4ec526d14db9b1e9cd40a217c34892af80a9b332b76d USDT non-native-asset - +68d56f15f85d3136970ec16946040bc1752654e906147f7e43e9d539d7c3de2f e61a41c53f5dcd0beb09df93b34402aada44cb05117b71059cce40a2723a4e97 USDT non-native-asset - +fc41b9bd8ef8fe53d58c7ea67c794c7ec9a73daf05e6d54b14ff6342c99ba64c fe58ea77779b7abda7da4ec526d14db9b1e9cd40a217c34892af80a9b332b76d ACA,AUSD,GLMR non-native-asset - +fc41b9bd8ef8fe53d58c7ea67c794c7ec9a73daf05e6d54b14ff6342c99ba64c e61a41c53f5dcd0beb09df93b34402aada44cb05117b71059cce40a2723a4e97 ACA,LCDOT non-native-asset - +baf5aabe40646d11f0ee8abbdc64f4a4b7674925cba08e4a05ff9ebed6e2126b 48239ef607d7928874027a43a67689209727dfb3d3dc5e5b03a39bdc2eda771a RMRK,USDT non-native-asset - +baf5aabe40646d11f0ee8abbdc64f4a4b7674925cba08e4a05ff9ebed6e2126b 9f28c6a68e0fc9646eff64935684f6eeeece527e37bbe1f213d22caa1d9d6bed AUSD,BNC,KAR,KSM non-native-asset - +baf5aabe40646d11f0ee8abbdc64f4a4b7674925cba08e4a05ff9ebed6e2126b 401a1f9dca3da46f5c4091016c8a2f26dcea05865116b286f60f668207d1474b AUSD,KAR,KSM,MOVR non-native-asset - +401a1f9dca3da46f5c4091016c8a2f26dcea05865116b286f60f668207d1474b 9f28c6a68e0fc9646eff64935684f6eeeece527e37bbe1f213d22caa1d9d6bed KSM,MOVR non-native-asset - +401a1f9dca3da46f5c4091016c8a2f26dcea05865116b286f60f668207d1474b baf5aabe40646d11f0ee8abbdc64f4a4b7674925cba08e4a05ff9ebed6e2126b AUSD non-native-asset - +401a1f9dca3da46f5c4091016c8a2f26dcea05865116b286f60f668207d1474b 48239ef607d7928874027a43a67689209727dfb3d3dc5e5b03a39bdc2eda771a RMRK non-native-asset - +9f28c6a68e0fc9646eff64935684f6eeeece527e37bbe1f213d22caa1d9d6bed 48239ef607d7928874027a43a67689209727dfb3d3dc5e5b03a39bdc2eda771a USDT non-native-asset - +9f28c6a68e0fc9646eff64935684f6eeeece527e37bbe1f213d22caa1d9d6bed baf5aabe40646d11f0ee8abbdc64f4a4b7674925cba08e4a05ff9ebed6e2126b AUSD,BNC,KSM non-native-asset - +9f28c6a68e0fc9646eff64935684f6eeeece527e37bbe1f213d22caa1d9d6bed 401a1f9dca3da46f5c4091016c8a2f26dcea05865116b286f60f668207d1474b BNC,KSM,MOVR non-native-asset - +e61a41c53f5dcd0beb09df93b34402aada44cb05117b71059cce40a2723a4e97 fc41b9bd8ef8fe53d58c7ea67c794c7ec9a73daf05e6d54b14ff6342c99ba64c ACA,LCDOT,LDOT non-native-asset - +e61a41c53f5dcd0beb09df93b34402aada44cb05117b71059cce40a2723a4e97 fe58ea77779b7abda7da4ec526d14db9b1e9cd40a217c34892af80a9b332b76d GLMR non-native-asset - +e61a41c53f5dcd0beb09df93b34402aada44cb05117b71059cce40a2723a4e97 68d56f15f85d3136970ec16946040bc1752654e906147f7e43e9d539d7c3de2f USDT non-native-asset - +fe58ea77779b7abda7da4ec526d14db9b1e9cd40a217c34892af80a9b332b76d fc41b9bd8ef8fe53d58c7ea67c794c7ec9a73daf05e6d54b14ff6342c99ba64c ACA,AUSD,GLMR non-native-asset - +fe58ea77779b7abda7da4ec526d14db9b1e9cd40a217c34892af80a9b332b76d e61a41c53f5dcd0beb09df93b34402aada44cb05117b71059cce40a2723a4e97 GLMR non-native-asset - +fe58ea77779b7abda7da4ec526d14db9b1e9cd40a217c34892af80a9b332b76d 68d56f15f85d3136970ec16946040bc1752654e906147f7e43e9d539d7c3de2f USDT non-native-asset - +3266816be9fa51b32cfea58d3e33ca77246bc9618595a4300e44c8856a8d8a17 6408de7737c59c238890533af25896a2c20608d8b380bb01029acb392781063e ROC non-native-asset - +7e4e32d0feafd4f9c9414b0be86373f9a1efa904809b683453a9af6856d38ad5 fc41b9bd8ef8fe53d58c7ea67c794c7ec9a73daf05e6d54b14ff6342c99ba64c ACA non-native-asset - +7e4e32d0feafd4f9c9414b0be86373f9a1efa904809b683453a9af6856d38ad5 6bd89e052d67a45bb60a9a23e8581053d5e0d619f15cb9865946937e690c42d6 LLD,LLM,XOR non-native-asset - +7e4e32d0feafd4f9c9414b0be86373f9a1efa904809b683453a9af6856d38ad5 9eb76c5184c4ab8679d2d5d819fdf90b9c001403e9e17da2e14b6d8aec4029c6 ASTR non-native-asset - +6bd89e052d67a45bb60a9a23e8581053d5e0d619f15cb9865946937e690c42d6 7e4e32d0feafd4f9c9414b0be86373f9a1efa904809b683453a9af6856d38ad5 LLD,LLM,XOR non-native-asset - + +# Relay-native assets through SORA/non-relay origins. +7e4e32d0feafd4f9c9414b0be86373f9a1efa904809b683453a9af6856d38ad5 b0a8d493285c2df73290dfb7e61f870f17b41801197a149ca93654499ea3dafe KSM cross-parachain-native - +7e4e32d0feafd4f9c9414b0be86373f9a1efa904809b683453a9af6856d38ad5 91b171bb158e2d3848fa23a9f1c25182fb8e20313b2c1eb49219da7a70ce90c3 DOT cross-parachain-native - diff --git a/scripts/xcm-production-evidence.json b/scripts/xcm-production-evidence.json new file mode 100644 index 0000000000..024f74b970 --- /dev/null +++ b/scripts/xcm-production-evidence.json @@ -0,0 +1,43 @@ +{ + "schemaVersion": 1, + "scope": "android-xcm-production-readiness", + "status": "blocked", + "releaseEnabled": false, + "lastReviewed": "2026-06-26", + "currentState": { + "requiredExecutableRouteCount": 15, + "discoveryOnlyRouteCount": 34 + }, + "blockers": [ + "e2e-transfer-evidence-missing", + "all-routes-executable-gate-not-green", + "discovery-only-routes-remain" + ], + "routeManifests": { + "requiredExecutableRoutes": "scripts/xcm-required-routes.tsv", + "discoveryOnlyRoutes": "scripts/xcm-discovery-only-routes.tsv", + "gapReport": "build/reports/xcm-registry-gap-report.json" + }, + "readyVerificationCommands": [ + "bash ./scripts/test-xcm-production-evidence-template.sh", + "bash ./scripts/test-xcm-production-evidence-audit.sh", + "bash ./scripts/audit-xcm-production-evidence.sh --require-ready", + "bash ./scripts/test-xcm-registry-metadata-audit.sh", + "bash ./scripts/audit-xcm-registry-metadata.sh --require-executable --require-all-routes-executable --require-route-file scripts/xcm-required-routes.tsv --require-gap-file scripts/xcm-discovery-only-routes.tsv", + "./gradlew :public-shared-features-xcm:testDebugUnitTest --tests 'jp.co.soramitsu.xcm.XcmServiceTest' --tests 'jp.co.soramitsu.xcm.domain.XcmEntitiesFetcherTest' --tests 'jp.co.soramitsu.xcm.SubstrateXcmTransferEngineTest'", + "./gradlew :runtime:testDebugUnitTest --tests 'jp.co.soramitsu.runtime.multiNetwork.chain.remote.XcmLocalChainsRegistryTest'" + ], + "requiredEvidenceFields": [ + "originChainId", + "destinationChainId", + "assetSymbol", + "extrinsicHash", + "sender", + "recipient", + "amount", + "timestamp", + "environment", + "operator" + ], + "evidence": [] +} diff --git a/scripts/xcm-required-routes.tsv b/scripts/xcm-required-routes.tsv new file mode 100644 index 0000000000..ff2b34383a --- /dev/null +++ b/scripts/xcm-required-routes.tsv @@ -0,0 +1,20 @@ +# origin_chain_id destination_chain_id asset_symbol + +# Relay native DOT/KSM from relay chains to system/parachain destinations. +91b171bb158e2d3848fa23a9f1c25182fb8e20313b2c1eb49219da7a70ce90c3 68d56f15f85d3136970ec16946040bc1752654e906147f7e43e9d539d7c3de2f DOT +91b171bb158e2d3848fa23a9f1c25182fb8e20313b2c1eb49219da7a70ce90c3 fe58ea77779b7abda7da4ec526d14db9b1e9cd40a217c34892af80a9b332b76d DOT +91b171bb158e2d3848fa23a9f1c25182fb8e20313b2c1eb49219da7a70ce90c3 fc41b9bd8ef8fe53d58c7ea67c794c7ec9a73daf05e6d54b14ff6342c99ba64c DOT +91b171bb158e2d3848fa23a9f1c25182fb8e20313b2c1eb49219da7a70ce90c3 e61a41c53f5dcd0beb09df93b34402aada44cb05117b71059cce40a2723a4e97 DOT +b0a8d493285c2df73290dfb7e61f870f17b41801197a149ca93654499ea3dafe 48239ef607d7928874027a43a67689209727dfb3d3dc5e5b03a39bdc2eda771a KSM +b0a8d493285c2df73290dfb7e61f870f17b41801197a149ca93654499ea3dafe 401a1f9dca3da46f5c4091016c8a2f26dcea05865116b286f60f668207d1474b KSM +b0a8d493285c2df73290dfb7e61f870f17b41801197a149ca93654499ea3dafe baf5aabe40646d11f0ee8abbdc64f4a4b7674925cba08e4a05ff9ebed6e2126b KSM + +# Relay native DOT/KSM returning from parachains to relay chains. +68d56f15f85d3136970ec16946040bc1752654e906147f7e43e9d539d7c3de2f 91b171bb158e2d3848fa23a9f1c25182fb8e20313b2c1eb49219da7a70ce90c3 DOT +fc41b9bd8ef8fe53d58c7ea67c794c7ec9a73daf05e6d54b14ff6342c99ba64c 91b171bb158e2d3848fa23a9f1c25182fb8e20313b2c1eb49219da7a70ce90c3 DOT +e61a41c53f5dcd0beb09df93b34402aada44cb05117b71059cce40a2723a4e97 91b171bb158e2d3848fa23a9f1c25182fb8e20313b2c1eb49219da7a70ce90c3 DOT +fe58ea77779b7abda7da4ec526d14db9b1e9cd40a217c34892af80a9b332b76d 91b171bb158e2d3848fa23a9f1c25182fb8e20313b2c1eb49219da7a70ce90c3 DOT +48239ef607d7928874027a43a67689209727dfb3d3dc5e5b03a39bdc2eda771a b0a8d493285c2df73290dfb7e61f870f17b41801197a149ca93654499ea3dafe KSM +baf5aabe40646d11f0ee8abbdc64f4a4b7674925cba08e4a05ff9ebed6e2126b b0a8d493285c2df73290dfb7e61f870f17b41801197a149ca93654499ea3dafe KSM +401a1f9dca3da46f5c4091016c8a2f26dcea05865116b286f60f668207d1474b b0a8d493285c2df73290dfb7e61f870f17b41801197a149ca93654499ea3dafe KSM +9f28c6a68e0fc9646eff64935684f6eeeece527e37bbe1f213d22caa1d9d6bed b0a8d493285c2df73290dfb7e61f870f17b41801197a149ca93654499ea3dafe KSM diff --git a/settings.gradle b/settings.gradle index 5c315ab3b2..9e00bc30fa 100644 --- a/settings.gradle +++ b/settings.gradle @@ -116,29 +116,50 @@ include ':feature-nft-impl' include ':feature-liquiditypools-api' include ':feature-liquiditypools-impl' +include ':public-android-foundation' +include ':public-ui-core' +include ':public-xnetworking' +include ':public-shared-features-core' +include ':public-shared-features-xcm' +include ':public-shared-features-backup' + // Prefer a local fearless-utils-Android checkout when available. def localUtilsPath = System.getenv("FEARLESS_UTILS_PATH") if (localUtilsPath == null || localUtilsPath.trim().isEmpty()) { - localUtilsPath = new File(rootDir, "../fearless-utils-Android").canonicalPath + def localUtilsCandidates = [ + new File(rootDir, "fearless-utils-Android"), + new File(rootDir, "../fearless-utils-Android") + ] + localUtilsPath = (localUtilsCandidates.find { it.exists() } ?: localUtilsCandidates.last()).canonicalPath } def localUtilsDir = new File(localUtilsPath) -// Enable Git source dependency fallback by setting -PUSE_REMOTE_UTILS=true or USE_REMOTE_UTILS=true +// Enable the experimental Git source dependency fallback by setting -PUSE_REMOTE_UTILS=true or USE_REMOTE_UTILS=true. +// Public CI uses an explicit pinned checkout instead because Gradle sourceControl does not resolve every published version. def useRemoteUtils = ( - providers.gradleProperty("USE_REMOTE_UTILS").orNull?.toBoolean() ?: false -) || (System.getenv("USE_REMOTE_UTILS") == "true") + providers.gradleProperty("USE_REMOTE_UTILS").orNull?.toBoolean() + ?: System.getenv("USE_REMOTE_UTILS")?.toBoolean() + ?: false +) def forceLocalUtils = ( providers.gradleProperty("FORCE_LOCAL_UTILS").orNull?.toBoolean() ?: false ) || (System.getenv("FORCE_LOCAL_UTILS") == "true") def gradleMajorVersion = gradle.gradleVersion.tokenize('.').first().toInteger() -def localUtilsSupported = gradleMajorVersion < 9 +// The compatibility shims above keep the pinned fearless-utils checkout usable +// through Gradle 9. Future Gradle majors must opt in with FORCE_LOCAL_UTILS until +// the included build is verified again. +def localUtilsSupported = gradleMajorVersion < 10 if (localUtilsDir.exists() && (localUtilsSupported || forceLocalUtils)) { println("Including local fearless-utils from: ${localUtilsDir}") - includeBuild(localUtilsDir) + includeBuild(localUtilsDir) { + dependencySubstitution { + substitute module("jp.co.soramitsu.fearless-utils:fearless-utils") using project(":fearless-utils") + } + } } else if (localUtilsDir.exists() && !forceLocalUtils && !localUtilsSupported) { - println("Skipping local fearless-utils because Gradle ${gradle.gradleVersion} is incompatible with its build scripts. Set FORCE_LOCAL_UTILS=true to override.") + println("Skipping local fearless-utils because Gradle ${gradle.gradleVersion} is not verified with its build scripts. Set FORCE_LOCAL_UTILS=true to override.") if (useRemoteUtils) { println("Using remote fearless-utils from GitHub via sourceControl") sourceControl { diff --git a/test-shared/src/main/java/jp/co/soramitsu/testshared/CreateTestSocket.kt b/test-shared/src/main/java/jp/co/soramitsu/testshared/CreateTestSocket.kt index 66a920f71b..32445ac116 100644 --- a/test-shared/src/main/java/jp/co/soramitsu/testshared/CreateTestSocket.kt +++ b/test-shared/src/main/java/jp/co/soramitsu/testshared/CreateTestSocket.kt @@ -2,8 +2,8 @@ package jp.co.soramitsu.testshared import com.google.gson.Gson import com.neovisionaries.ws.client.WebSocketFactory -import jp.co.soramitsu.shared_utils.wsrpc.SocketService -import jp.co.soramitsu.shared_utils.wsrpc.recovery.Reconnector -import jp.co.soramitsu.shared_utils.wsrpc.request.CoroutinesRequestExecutor +import jp.co.soramitsu.fearless_utils.wsrpc.SocketService +import jp.co.soramitsu.fearless_utils.wsrpc.recovery.Reconnector +import jp.co.soramitsu.fearless_utils.wsrpc.request.CoroutinesRequestExecutor fun createTestSocket() = SocketService(Gson(), NoOpLogger, WebSocketFactory(), Reconnector(), CoroutinesRequestExecutor()) diff --git a/test-shared/src/main/java/jp/co/soramitsu/testshared/LoggerHelpers.kt b/test-shared/src/main/java/jp/co/soramitsu/testshared/LoggerHelpers.kt index 9a33e0c594..463be1a8b8 100644 --- a/test-shared/src/main/java/jp/co/soramitsu/testshared/LoggerHelpers.kt +++ b/test-shared/src/main/java/jp/co/soramitsu/testshared/LoggerHelpers.kt @@ -1,6 +1,6 @@ package jp.co.soramitsu.testshared -import jp.co.soramitsu.shared_utils.wsrpc.logging.Logger +import jp.co.soramitsu.fearless_utils.wsrpc.logging.Logger object StdoutLogger : Logger { override fun log(message: String?) {