diff --git a/.github/workflows/build-android.yml b/.github/workflows/build-android.yml index 5bd8df3b6..a61902b16 100644 --- a/.github/workflows/build-android.yml +++ b/.github/workflows/build-android.yml @@ -22,10 +22,10 @@ jobs: build: runs-on: ubuntu-latest container: - # Same Cake-Labs prebuilt image used for Linux. Bundles Flutter 3.32.0, + # Same Cake-Labs prebuilt image used for Linux. Bundles Flutter 3.41.9, # Android NDK r28, Go 1.24.1, Android SDK, JDK 17, gradle, and the # native build deps (boost, openssl, libsodium, libzmq, unbound, icu). - image: ghcr.io/cake-tech/cake_wallet:debian13-flutter3.32.0-ndkr28-go1.24.1-ruststablenightly + image: ghcr.io/cake-tech/cake_wallet:debian13-flutter3.41.9-ndkr28-go1.24.1-ruststablenightly env: BRANCH_NAME: ${{ github.head_ref || github.ref_name }} APP_ANDROID_TYPE: cakewallet @@ -86,8 +86,12 @@ jobs: echo "monero_c TAG: $MONERO_C_TAG" mkdir -p "scripts/monero_c/release/$MONERO_C_TAG" pushd "scripts/monero_c/release/$MONERO_C_TAG" - wget -q https://github.com/MrCyjaneK/monero_c/releases/download/v0.18.4.6-RC1/release-bundle.zip + wget -q https://github.com/MrCyjaneK/monero_c/releases/download/v0.18.4.6-RC2/release-bundle.zip unzip -q release-bundle.zip + # RC2 nests everything under an inner v0.18.4.6-*/ dir; strip it. + shopt -s nullglob + for inner in v0.18.4.6-*/; do mv "$inner"* . && rmdir "$inner"; done + shopt -u nullglob rm release-bundle.zip ls popd diff --git a/.github/workflows/build-ios-sim.yml b/.github/workflows/build-ios-sim.yml index 8ab88501e..5e231b184 100644 --- a/.github/workflows/build-ios-sim.yml +++ b/.github/workflows/build-ios-sim.yml @@ -45,21 +45,21 @@ jobs: # Self-hosted Mac runner persists Flutter between runs. Only install if # it's not already on PATH or version doesn't match. - - name: Check Flutter 3.32.0 + - name: Check Flutter 3.41.9 id: flutter_check run: | - if command -v flutter >/dev/null && flutter --version 2>/dev/null | grep -q "3\.32\.0"; then + if command -v flutter >/dev/null && flutter --version 2>/dev/null | grep -q "3\.41\.9"; then echo "installed=true" >> "$GITHUB_OUTPUT" flutter --version else echo "installed=false" >> "$GITHUB_OUTPUT" fi - - name: Install Flutter 3.32.0 (if missing) + - name: Install Flutter 3.41.9 (if missing) if: steps.flutter_check.outputs.installed != 'true' uses: subosito/flutter-action@v2 with: - flutter-version: '3.32.0' + flutter-version: '3.41.9' channel: stable cache: true @@ -179,8 +179,12 @@ jobs: MONERO_C_TAG=$(cd scripts/monero_c && git describe --tags) mkdir -p "scripts/monero_c/release/$MONERO_C_TAG" pushd "scripts/monero_c/release/$MONERO_C_TAG" - curl -fsSL -O https://github.com/MrCyjaneK/monero_c/releases/download/v0.18.4.6-RC1/release-bundle.zip + curl -fsSL -O https://github.com/MrCyjaneK/monero_c/releases/download/v0.18.4.6-RC2/release-bundle.zip unzip -q release-bundle.zip + # RC2 nests everything under an inner v0.18.4.6-*/ dir; strip it. + shopt -s nullglob + for inner in v0.18.4.6-*/; do mv "$inner"* . && rmdir "$inner"; done + shopt -u nullglob rm release-bundle.zip echo "=== bundle contents (one level) ===" ls diff --git a/.github/workflows/build-ios-testflight.yml b/.github/workflows/build-ios-testflight.yml index c195f8d7d..87a06a8e6 100644 --- a/.github/workflows/build-ios-testflight.yml +++ b/.github/workflows/build-ios-testflight.yml @@ -47,21 +47,21 @@ jobs: with: fetch-depth: 1 - - name: Check Flutter 3.32.0 + - name: Check Flutter 3.41.9 id: flutter_check run: | - if command -v flutter >/dev/null && flutter --version 2>/dev/null | grep -q "3\.32\.0"; then + if command -v flutter >/dev/null && flutter --version 2>/dev/null | grep -q "3\.41\.9"; then echo "installed=true" >> "$GITHUB_OUTPUT" flutter --version else echo "installed=false" >> "$GITHUB_OUTPUT" fi - - name: Install Flutter 3.32.0 (if missing) + - name: Install Flutter 3.41.9 (if missing) if: steps.flutter_check.outputs.installed != 'true' uses: subosito/flutter-action@v2 with: - flutter-version: '3.32.0' + flutter-version: '3.41.9' channel: stable cache: true @@ -198,8 +198,12 @@ jobs: MONERO_C_TAG=$(cd scripts/monero_c && git describe --tags) mkdir -p "scripts/monero_c/release/$MONERO_C_TAG" pushd "scripts/monero_c/release/$MONERO_C_TAG" - curl -fsSL -O https://github.com/MrCyjaneK/monero_c/releases/download/v0.18.4.6-RC1/release-bundle.zip + curl -fsSL -O https://github.com/MrCyjaneK/monero_c/releases/download/v0.18.4.6-RC2/release-bundle.zip unzip -q release-bundle.zip + # RC2 nests everything under an inner v0.18.4.6-*/ dir; strip it. + shopt -s nullglob + for inner in v0.18.4.6-*/; do mv "$inner"* . && rmdir "$inner"; done + shopt -u nullglob rm release-bundle.zip popd diff --git a/.github/workflows/build-linux.yml b/.github/workflows/build-linux.yml index f7c5bfc24..e67547b35 100644 --- a/.github/workflows/build-linux.yml +++ b/.github/workflows/build-linux.yml @@ -40,11 +40,11 @@ jobs: # carry the `ubuntu-latest` label, so this works on either platform. runs-on: ubuntu-latest container: - # Cake Labs' prebuilt build image with Flutter 3.32.0, Go 1.24.1, NDK r28, + # Cake Labs' prebuilt build image with Flutter 3.41.9, Go 1.24.1, NDK r28, # Rust nightly, and all the C++ build deps (boost, openssl, libsodium, # libzmq, unbound, icu) preinstalled. Saves us from doing the simplybs # source-bootstrap dance that ate 9GB on jw-laptop. - image: ghcr.io/cake-tech/cake_wallet:debian13-flutter3.32.0-ndkr28-go1.24.1-ruststablenightly + image: ghcr.io/cake-tech/cake_wallet:debian13-flutter3.41.9-ndkr28-go1.24.1-ruststablenightly env: BRANCH_NAME: ${{ github.head_ref || github.ref_name }} # cake_wallet build profile name, retained from upstream — internal @@ -122,8 +122,12 @@ jobs: echo "monero_c TAG: $MONERO_C_TAG" mkdir -p "scripts/monero_c/release/$MONERO_C_TAG" pushd "scripts/monero_c/release/$MONERO_C_TAG" - wget -q https://github.com/MrCyjaneK/monero_c/releases/download/v0.18.4.6-RC1/release-bundle.zip + wget -q https://github.com/MrCyjaneK/monero_c/releases/download/v0.18.4.6-RC2/release-bundle.zip unzip -q release-bundle.zip + # RC2 nests everything under an inner v0.18.4.6-*/ dir; strip it. + shopt -s nullglob + for inner in v0.18.4.6-*/; do mv "$inner"* . && rmdir "$inner"; done + shopt -u nullglob rm release-bundle.zip ls x86_64-linux-gnu/ popd @@ -205,7 +209,7 @@ jobs: awk -F"'" '/^const (trocador[A-Za-z]*) = / { split($1, a, " "); v = (length($2) ? "set (" length($2) " chars)" : "EMPTY"); print a[2] ": " v }' lib/.secrets.g.dart # lengths only; values never reach the log # Initialize Flutter SDK once before parallel-ish work happens. flutter - # precache for Flutter 3.32.0 hits a 404 on flutter_gpu.zip (asset not + # precache for Flutter 3.41.9 hits a 404 on flutter_gpu.zip (asset not # actually published at the path Flutter constructs); the `|| true` # absorbs that — desktop Linux build doesn't need flutter_gpu. - name: Initialize Flutter SDK diff --git a/.github/workflows/build-macos.yml b/.github/workflows/build-macos.yml index 0520d9c1e..c88e24842 100644 --- a/.github/workflows/build-macos.yml +++ b/.github/workflows/build-macos.yml @@ -99,21 +99,21 @@ jobs: echo " developer warning even with a valid Developer ID signature." fi - - name: Check Flutter 3.32.0 + - name: Check Flutter 3.41.9 id: flutter_check run: | - if command -v flutter >/dev/null && flutter --version 2>/dev/null | grep -q "3\.32\.0"; then + if command -v flutter >/dev/null && flutter --version 2>/dev/null | grep -q "3\.41\.9"; then echo "installed=true" >> "$GITHUB_OUTPUT" flutter --version else echo "installed=false" >> "$GITHUB_OUTPUT" fi - - name: Install Flutter 3.32.0 (if missing) + - name: Install Flutter 3.41.9 (if missing) if: steps.flutter_check.outputs.installed != 'true' uses: subosito/flutter-action@v2 with: - flutter-version: '3.32.0' + flutter-version: '3.41.9' channel: stable cache: true @@ -187,8 +187,12 @@ jobs: mkdir -p "scripts/monero_c/release/$MONERO_C_TAG" pushd "scripts/monero_c/release/$MONERO_C_TAG" if [ ! -d aarch64-apple-darwin ]; then - curl -fsSL -O https://github.com/MrCyjaneK/monero_c/releases/download/v0.18.4.6-RC1/release-bundle.zip + curl -fsSL -O https://github.com/MrCyjaneK/monero_c/releases/download/v0.18.4.6-RC2/release-bundle.zip unzip -q release-bundle.zip + # RC2 nests everything under an inner v0.18.4.6-*/ dir; strip it. + shopt -s nullglob + for inner in v0.18.4.6-*/; do mv "$inner"* . && rmdir "$inner"; done + shopt -u nullglob rm release-bundle.zip fi ls -la aarch64-apple-darwin x86_64-apple-darwin diff --git a/.github/workflows/build-windows.yml b/.github/workflows/build-windows.yml index 477c527d6..fd1bbfc7b 100644 --- a/.github/workflows/build-windows.yml +++ b/.github/workflows/build-windows.yml @@ -176,28 +176,28 @@ jobs: ' # ---- Flutter, pinned ------------------------------------------------- - # Every other platform builds on 3.32.0: Linux and Android inside the - # cake_wallet debian13-flutter3.32.0 image, iOS by installing it + # Every other platform builds on 3.41.9: Linux and Android inside the + # cake_wallet debian13-flutter3.41.9 image, iOS by installing it # explicitly. Windows was the one that took whatever the runner happened # to have, and on 2026-09-19 that was 3.38.5 / Dart 3.10.4. Dart 3.10 # removed the _macros package, so `flutter pub get` could not solve # hive_generator against build_resolvers and the build died at exit 69 # long before it compiled anything. A build machine's drifting toolchain # must not decide which dependencies resolve. - - name: Require Flutter 3.32.0 + - name: Require Flutter 3.41.9 run: | set -e ver="$(flutter --version 2>/dev/null | head -1 || true)" echo "runner has: ${ver:-}" case "$ver" in - *3.32.0*) exit 0 ;; + *3.41.9*) exit 0 ;; esac cat >&2 <<'MSG' - Refusing to build: this runner's Flutter is not the pinned 3.32.0. + Refusing to build: this runner's Flutter is not the pinned 3.41.9. Every other platform is pinned, Linux and Android by the - cake_wallet debian13-flutter3.32.0 image and iOS by installing it, + cake_wallet debian13-flutter3.41.9 image and iOS by installing it, so a drifted Windows SDK breaks this platform alone and does it during dependency resolution rather than at compile time. @@ -289,8 +289,12 @@ jobs: echo "monero_c TAG: $MONERO_C_TAG" mkdir -p "scripts/monero_c/release/$MONERO_C_TAG" pushd "scripts/monero_c/release/$MONERO_C_TAG" - curl -fsSL -O https://github.com/MrCyjaneK/monero_c/releases/download/v0.18.4.6-RC1/release-bundle.zip + curl -fsSL -O https://github.com/MrCyjaneK/monero_c/releases/download/v0.18.4.6-RC2/release-bundle.zip unzip -q release-bundle.zip + # RC2 nests everything under an inner v0.18.4.6-*/ dir; strip it. + shopt -s nullglob + for inner in v0.18.4.6-*/; do mv "$inner"* . && rmdir "$inner"; done + shopt -u nullglob rm release-bundle.zip echo "=== bundle contents (top level) ===" ls diff --git a/.github/workflows/compare_overrides.yml b/.github/workflows/compare_overrides.yml new file mode 100644 index 000000000..a305d1c33 --- /dev/null +++ b/.github/workflows/compare_overrides.yml @@ -0,0 +1,55 @@ +name: Check for dependency changes + +on: + pull_request: + types: [opened, synchronize, reopened] + +permissions: + contents: read + pull-requests: write + +jobs: + compare-overrides: + runs-on: puzl-ubuntu-latest + + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + ref: ${{ github.event.pull_request.head.sha }} + + - uses: actions/setup-go@v5 + with: + go-version: stable + + - name: Compare pubspec overrides + id: compare + env: + BASE_REF: ${{ github.event.pull_request.base.ref }} + run: | + git fetch origin "$BASE_REF" + base_sha=$(git rev-parse "origin/$BASE_REF") + head_sha="${{ github.event.pull_request.head.sha }}" + + ./scripts/compare_overrides.sh "$base_sha" "$head_sha" > /tmp/compare_output.txt + + if [[ ! -s /tmp/compare_output.txt ]]; then + echo "has_output=false" >> "$GITHUB_OUTPUT" + exit 0 + fi + + { + echo '### changes in dependencies' + echo + echo '```' + cat /tmp/compare_output.txt + echo '```' + } > /tmp/comment_body.md + + echo "has_output=true" >> "$GITHUB_OUTPUT" + + - uses: marocchino/sticky-pull-request-comment@v2 + if: steps.compare.outputs.has_output == 'true' + with: + header: pubspec-overrides-compare + path: /tmp/comment_body.md diff --git a/.github/workflows/funds_integration_tests.yml b/.github/workflows/funds_integration_tests.yml new file mode 100644 index 000000000..bfa02a269 --- /dev/null +++ b/.github/workflows/funds_integration_tests.yml @@ -0,0 +1,64 @@ +name: Funds Integration Tests + +permissions: + contents: read + packages: write + +# This test is never triggered automatically as it moves real funds +on: + workflow_dispatch: + inputs: + flows: + description: "Which flows to run" + required: true + type: choice + options: + - all + - send + - swap + default: all + chains: + description: "Chains the send suites run on. auto discovers every funded chain, or a comma separated list of wallet type names" + required: true + type: string + default: auto + swap_from: + description: "Wallet type the swap deposits from. Empty takes the first funded chain" + required: false + type: string + default: "" + swap_to: + description: "Currency the swap receives, for example eth or btc. Empty leaves the swap page's own pairing" + required: false + type: string + default: "" + spend: + description: "Actually broadcast and create trades. Off runs the dry runs only" + required: true + type: boolean + default: false + record_screen: + description: "Record the emulator screen, uploaded GPG encrypted" + required: true + type: boolean + default: true + +concurrency: + group: funds-integration-tests + cancel-in-progress: false + +jobs: + funds-tests: + uses: ./.github/workflows/reusable-integration-test.yml + with: + environment: funds + ref: ${{ github.sha }} + suite_dir: integration_test/funds_suites + test_tier: all + write_funds_secrets: true + record_screen: ${{ inputs.record_screen }} + timeout_minutes: 120 + slack_notify: true + extra_dart_defines: "FLOWS=${{ inputs.flows }};CHAINS=${{ inputs.chains }};SPEND=${{ inputs.spend }};SWAP_FROM=${{ inputs.swap_from }};SWAP_RECEIVE=${{ inputs.swap_to }}" + secrets: inherit + diff --git a/.github/workflows/integration_tests.yml b/.github/workflows/integration_tests.yml new file mode 100644 index 000000000..2d6799885 --- /dev/null +++ b/.github/workflows/integration_tests.yml @@ -0,0 +1,39 @@ +name: Integration Tests + +permissions: + contents: read + packages: write + +on: + workflow_dispatch: + +concurrency: + group: integration-tests-${{ github.event.pull_request.number || github.ref }} + cancel-in-progress: true + +jobs: + check-suites: + if: github.event_name == 'workflow_dispatch' || github.event.pull_request.head.repo.fork == false + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + with: + ref: ${{ github.event.pull_request.head.sha || github.sha }} + sparse-checkout: integration_test/suites + + - name: Check the branch has the test suites + run: | + if [[ ! -d integration_test/suites/tier0 ]]; then + echo "::error::This branch has no integration test suites yet, sync it with dev to run them" + exit 1 + fi + + android-integration-tests: + needs: check-suites + uses: ./.github/workflows/reusable-integration-test.yml + with: + ref: ${{ github.event.pull_request.head.sha || github.sha }} + suite_dir: integration_test/suites + test_tier: tier0 + slack_notify: true + secrets: inherit diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml new file mode 100644 index 000000000..da2859286 --- /dev/null +++ b/.github/workflows/lint.yml @@ -0,0 +1,115 @@ +name: Lint + +on: [pull_request] + +defaults: + run: + shell: bash +jobs: +# lint: +# runs-on: [Linux, amd64, android] +# container: +# image: ghcr.io/cake-tech/cake_wallet:debian13-flutter3.41.9-ndkr28-go1.24.1-ruststablenightly +# env: +# STORE_PASS: test@cake_wallet +# KEY_PASS: test@cake_wallet +# MONEROC_CACHE_DIR_ROOT: /opt/generic_cache +# BRANCH_NAME: ${{ github.head_ref || github.ref_name }} +# ANDROID_AVD_HOME: /root/.android/avd +# volumes: +# - /opt/cw_cache_android/root/.cache:/root/.cache +# - /opt/cw_cache_android/root/.android/avd/:/root/.android/avd +# - /opt/cw_cache_android/root/.ccache:/root/.ccache +# - /opt/cw_cache_android/root/.pub-cache/:/root/.pub-cache +# - /opt/cw_cache_android/root/.gradle/:/root/.gradle +# - /opt/cw_cache_android/root/.android/:/root/.android +# - /opt/cw_cache_android/root/go/pkg:/root/go/pkg +# - /opt/cw_cache_android/opt/generic_cache:/opt/generic_cache +# - /dev/kvm:/dev/kvm +# - /var/run/docker.sock:/var/run/docker.sock +# strategy: +# matrix: +# api-level: [ 29 ] +# +# steps: +# - name: Fix github actions messing up $HOME... +# run: "echo HOME=/root | sudo tee -a $GITHUB_ENV" +# - uses: actions/checkout@v4 +# with: +# ref: ${{ inputs.ref }} +# +# - name: configure git +# run: | +# git config --global --add safe.directory '*' +# git config --global user.email "ci@cakewallet.com" +# git config --global user.name "CakeWallet CI" +# +# - name: Fetch prebuilt Torch +# run: | +# set -x -e +# pushd scripts +# wget https://github.com/MrCyjaneK/torch_dart/releases/download/v1.0.17/torch_dart-v1.0.17.tar.gz -O torch_dart.tar.gz +# rm -rf torch_dart +# mkdir torch_dart +# pushd torch_dart +# tar -xzf ../torch_dart.tar.gz +# popd +# rm ./torch_dart.tar.gz +# popd +# +# - name: Fetch prebuilt Reown +# run: | +# set -x -e +# pushd scripts +# # cleaning +# rm -rf reown_flutter +# rm -f reown_flutter.tar.gz +# +# wget https://github.com/cake-tech/reown_flutter/releases/download/v0.0.4/reown_flutter-v0.0.4.tar.gz -O reown_flutter.tar.gz +# mkdir reown_flutter +# pushd reown_flutter +# tar -xzf ../reown_flutter.tar.gz +# popd +# rm ./reown_flutter.tar.gz +# popd +# +# - name: Build Bitbox Flutter +# run: | +# set -x -e +# pushd scripts +# ./build_bitbox_flutter.sh +# popd +# +# - name: Prepare Zcash +# run: | +# set -x -e +# scripts/prepare_zcash.sh +# +# - name: Execute Build and Setup Commands +# run: | +# pushd scripts/android +# source ./app_env.sh cakewallet +# export GITHUB_HUH=yeah +# ./app_config.sh +# popd +# +# - name: Install Flutter dependencies +# run: | +# flutter pub get +# +# - name: Build generated code +# run: | +# flutter --version +# flutter clean +# rm -rf .dart_tool +# rm pubspec.lock +# flutter pub get +# ./model_generator.sh async +# +# - name: Generate localization +# run: | +# dart run tool/generate_localization.dart +# +# - name: Lint +# run: | +# ABORT_ON_CHANGE=yeah scripts/lint.sh \ No newline at end of file diff --git a/.github/workflows/reusable-integration-test.yml b/.github/workflows/reusable-integration-test.yml new file mode 100644 index 000000000..9c4c24603 --- /dev/null +++ b/.github/workflows/reusable-integration-test.yml @@ -0,0 +1,589 @@ +name: Reusable Integration Test Logic + +permissions: + contents: read + packages: write + +on: + workflow_call: + inputs: + ref: + required: true + type: string + suite_dir: + required: false + type: string + default: "integration_test/suites" + test_tier: + required: false + type: string + default: "tier0" + extra_dart_defines: + required: false + type: string + default: "" + environment: + required: false + type: string + default: "" + record_screen: + required: false + type: boolean + default: false + write_funds_secrets: + required: false + type: boolean + default: false + timeout_minutes: + required: false + type: number + default: 240 + slack_notify: + required: false + type: boolean + default: false + extract_only: + required: false + type: boolean + default: false + +defaults: + run: + shell: bash + +jobs: + integration-test: + runs-on: ubuntu-latest + environment: ${{ inputs.environment }} + timeout-minutes: ${{ inputs.timeout_minutes }} + env: + STORE_PASS: test@cake_wallet + KEY_PASS: test@cake_wallet + BRANCH_NAME: ${{ github.head_ref || github.ref_name }} + + steps: + - uses: actions/checkout@v4 + with: + ref: ${{ inputs.ref }} + - name: Authenticate git for package clones + run: | + git config --global --unset-all credential.helper || true + git config --global url."https://x-access-token:${{ github.token }}@github.com/".insteadOf "https://github.com/" + - name: Free disk space + run: | + sudo rm -rf /usr/share/dotnet /opt/ghc /usr/local/.ghcup /usr/local/lib/node_modules /opt/hostedtoolcache/CodeQL + sudo rm -rf /usr/share/swift /usr/local/share/powershell /usr/share/miniconda /opt/az /usr/local/graalvm + sudo docker image prune -af > /dev/null + df -h / + - name: Enable KVM + run: | + echo 'KERNEL=="kvm", GROUP="kvm", MODE="0666", OPTIONS+="static_node=kvm"' | sudo tee /etc/udev/rules.d/99-kvm4all.rules + sudo udevadm control --reload-rules + sudo udevadm trigger --name-match=kvm + sudo chmod 666 /dev/kvm || true + + if [[ ! -c /dev/kvm ]]; then + echo "::error::/dev/kvm is not a character device, this runner has no usable KVM" + exit 1 + fi + + - name: Install protobuf compiler + run: | + sudo apt-get update + sudo apt-get install -y protobuf-compiler + + - uses: actions/setup-java@v4 + with: + distribution: temurin + java-version: "17" + + - name: Restore flutter sdk + id: flutter-cache + uses: actions/cache/restore@v4 + with: + path: ~/flutter + key: flutter-sdk-3.41.9-${{ runner.os }} + + - name: Install Flutter + run: | + if [[ ! -d "$HOME/flutter/bin" ]]; then + git clone --branch 3.41.9 --depth 1 https://github.com/flutter/flutter.git "$HOME/flutter" + fi + echo "$HOME/flutter/bin" >> $GITHUB_PATH + export PATH="$PATH:$HOME/flutter/bin" + flutter --version + + - name: Save flutter sdk + if: steps.flutter-cache.outputs.cache-hit != 'true' + continue-on-error: true + uses: actions/cache/save@v4 + with: + path: ~/flutter + key: flutter-sdk-3.41.9-${{ runner.os }} + + - name: Setup gradle + uses: gradle/actions/setup-gradle@v3 + with: + cache-read-only: false + + - name: Restore pub packages + id: pub-cache + uses: actions/cache/restore@v4 + with: + path: ~/.pub-cache + key: pub-cache-${{ runner.os }}-${{ hashFiles('pubspec_base.yaml', 'cw_*/pubspec.yaml') }} + restore-keys: pub-cache-${{ runner.os }}- + + - name: Docker sock permissions + run: sudo chmod 666 /var/run/docker.sock || true + + - name: Log in to GitHub Container Registry + uses: docker/login-action@v3 + with: + registry: ghcr.io + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + - name: Add secrets + run: | + touch cw_bitcoin/lib/.secrets.g.dart + touch cw_evm/lib/.secrets.g.dart + touch cw_solana/lib/.secrets.g.dart + touch cw_core/lib/.secrets.g.dart + touch cw_nano/lib/.secrets.g.dart + touch cw_tron/lib/.secrets.g.dart + + echo "const polygonScanApiKey = '${{ secrets.POLYGON_SCAN_API_KEY }}';" >> cw_evm/lib/.secrets.g.dart + echo "const etherScanApiKey = '${{ secrets.ETHER_SCAN_API_KEY }}';" >> cw_evm/lib/.secrets.g.dart + echo "const moralisApiKey = '${{ secrets.MORALIS_API_KEY }}';" >> cw_evm/lib/.secrets.g.dart + echo "const nowNodesApiKey = '${{ secrets.EVM_NOWNODES_API_KEY }}';" >> cw_evm/lib/.secrets.g.dart + echo "const alchemyApiKey = '${{ secrets.ALCHEMY_API_KEY }}';" >> cw_evm/lib/.secrets.g.dart + echo "const blinkApiKey = '${{ secrets.BLINK_API_KEY }}';" >> cw_evm/lib/.secrets.g.dart + echo "const ankrApiKey = '${{ secrets.ANKR_API_KEY }}';" >> cw_solana/lib/.secrets.g.dart + echo "const chainStackApiKey = '${{ secrets.CHAIN_STACK_API_KEY }}';" >> cw_solana/lib/.secrets.g.dart + echo "const moralisApiKey = '${{ secrets.MORALIS_API_KEY }}';" >> cw_solana/lib/.secrets.g.dart + + echo "const nano2ApiKey = '${{ secrets.NANO2_API_KEY }}';" >> cw_nano/lib/.secrets.g.dart + echo "const nanoNowNodesApiKey = '${{ secrets.NANO_NOW_NODES_API_KEY }}';" >> cw_nano/lib/.secrets.g.dart + + echo "const tronGridApiKey = '${{ secrets.TRON_GRID_API_KEY }}';" >> cw_tron/lib/.secrets.g.dart + echo "const tronNowNodesApiKey = '${{ secrets.TRON_NOW_NODES_API_KEY }}';" >> cw_tron/lib/.secrets.g.dart + + echo "const breezApiKey = '${{ secrets.BREEZ_API_KEY }}';" >> cw_bitcoin/lib/.secrets.g.dart + + echo "${{ secrets.MAIN_SECRETS_FILE }}" | base64 --decode > lib/.secrets.g.dart + - name: Add funded wallet secrets + if: ${{ inputs.write_funds_secrets }} + run: | + echo "${{ secrets.FUNDS_SECRETS_FILE }}" | base64 --decode > integration_test/core/funded_wallets.dart + + - name: Build dependencies utilizing ghcr cache + if: ${{ !inputs.extract_only }} + run: | + env \ + CW_DOCKER_REGISTRY=ghcr.io/cake-tech/cake_wallet \ + CW_DOCKER_USE_CLOUD=true \ + ./scripts/android/docker/build.sh + + - name: Extract native dependencies + if: ${{ inputs.extract_only }} + run: | + env \ + CW_DOCKER_REGISTRY=ghcr.io/cake-tech/cake_wallet \ + CW_DOCKER_USE_CLOUD=true \ + ./scripts/android/docker/build.sh --extract + + - name: Reclaim the space the dependency images took + run: | + docker system prune -af --volumes > /dev/null + df -h / + + - name: Execute Build and Setup Commands + run: | + pushd scripts/android + source ./app_env.sh cakewallet + ./app_config.sh + popd + + - name: Install Flutter dependencies + run: | + flutter pub get + + - name: Save pub packages + if: steps.pub-cache.outputs.cache-hit != 'true' + continue-on-error: true + uses: actions/cache/save@v4 + with: + path: ~/.pub-cache + key: pub-cache-${{ runner.os }}-${{ hashFiles('pubspec_base.yaml', 'cw_*/pubspec.yaml') }} + + - name: Build generated code + run: | + flutter --version + ./model_generator.sh + + - name: Generate KeyStore + run: | + keytool -genkey -v -keystore android/app/key.jks -keyalg RSA -keysize 2048 -validity 10000 -alias testKey -noprompt -dname "CN=CakeWallet, OU=CakeWallet, O=CakeWallet, L=Florida, S=America, C=USA" -storepass $STORE_PASS -keypass $KEY_PASS + + - name: Generate key properties + run: | + dart run tool/generate_android_key_properties.dart keyAlias=testKey storeFile=key.jks storePassword=$STORE_PASS keyPassword=$KEY_PASS + + - name: Generate localization + run: | + dart run tool/generate_localization.dart + + - name: Compile graphics + run: | + ./compile_graphics.sh + + - name: Compile assets/images graphics + run: | + while read -r dir; do + dart run vector_graphics_compiler --input-dir "$dir" --out-dir "$dir" > /dev/null & + done < <(find assets/images -type d) + wait + + - name: Rename app + run: | + sanitized_branch_name=${BRANCH_NAME#origin/} # Remove 'origin/' prefix if it exists + sanitized_branch_name=${sanitized_branch_name:0:16} # Take only the first 16 characters + sanitized_branch_name=$(echo "$sanitized_branch_name" | tr '[:upper:]' '[:lower:]') # Convert to lowercase + sanitized_branch_name=$(echo "$sanitized_branch_name" | sed 's/[^a-z0-9]//g') # Remove all special characters + + echo -e "id=com.cakewallet.test_${sanitized_branch_name}\nname=${BRANCH_NAME}" > android/app.properties + + - name: Prime test build + env: + SUITE_DIR_INPUT: ${{ inputs.suite_dir }} + TEST_TIER_INPUT: ${{ inputs.test_tier }} + run: | + search_dir="$SUITE_DIR_INPUT" + if [[ "$TEST_TIER_INPUT" != "all" ]]; then + search_dir="$search_dir/$TEST_TIER_INPUT" + fi + first_suite=$(find "$search_dir" -name "*_test.dart" -type f | sort | head -1) + echo "Priming build with $first_suite" + flutter build apk --debug --target-platform android-x64 --target="$first_suite" --dart-define=CI_BUILD=true + + - name: Cap the gradle heap for the test phase + run: | + sed -i 's/^org.gradle.jvmargs=.*/org.gradle.jvmargs=-Xmx3072M/' android/gradle.properties + grep '^org.gradle.jvmargs' android/gradle.properties + pkill -f GradleDaemon || echo "No gradle daemon to stop" + free -h || true + df -h / || true + + - name: Integration tests on Android Emulator + uses: reactivecircus/android-emulator-runner@v2 + env: + RECORD_SCREEN: ${{ inputs.record_screen }} + ENCRYPT_LOGS: ${{ inputs.write_funds_secrets }} + SUITE_DIR_INPUT: ${{ inputs.suite_dir }} + EXTRA_DART_DEFINES_INPUT: ${{ inputs.extra_dart_defines }} + with: + api-level: 31 + target: google_apis + arch: x86_64 + profile: pixel_5 + force-avd-creation: false + emulator-options: -no-window -gpu swiftshader_indirect -noaudio -no-boot-anim -camera-back none -read-only -memory 3072 -cores 4 -no-snapshot-save + disable-animations: false + working-directory: ${{ github.workspace }} + script: bash scripts/ci/run_integration_tests.sh ${{ inputs.test_tier }} + + - name: Upload test logs + if: always() + continue-on-error: true + uses: actions/upload-artifact@v4 + with: + name: integration-test-logs + path: | + ${{ github.workspace }}/${{ inputs.test_tier }}_run.log + ${{ github.workspace }}/${{ inputs.test_tier }}_run.log.gpg + ${{ github.workspace }}/${{ inputs.test_tier }}_logcat.txt + ${{ github.workspace }}/${{ inputs.test_tier }}_logcat.txt.gpg + ${{ github.workspace }}/recordings/*.gpg + + - name: Integration tests on Android Emulator (tier1, non-blocking) + if: ${{ inputs.test_tier == 'tier0' }} + continue-on-error: true + uses: reactivecircus/android-emulator-runner@v2 + env: + RECORD_SCREEN: ${{ inputs.record_screen }} + ENCRYPT_LOGS: ${{ inputs.write_funds_secrets }} + SUITE_DIR_INPUT: ${{ inputs.suite_dir }} + EXTRA_DART_DEFINES_INPUT: ${{ inputs.extra_dart_defines }} + with: + api-level: 31 + target: google_apis + arch: x86_64 + profile: pixel_5 + force-avd-creation: false + emulator-options: -no-window -gpu swiftshader_indirect -noaudio -no-boot-anim -camera-back none -read-only -memory 3072 -cores 4 -no-snapshot-save + disable-animations: false + working-directory: ${{ github.workspace }} + script: bash scripts/ci/run_integration_tests.sh tier1 + + - name: Upload tier1 logs + if: ${{ always() && inputs.test_tier == 'tier0' }} + continue-on-error: true + uses: actions/upload-artifact@v4 + with: + name: integration-test-logs-tier1 + path: | + ${{ github.workspace }}/tier1_run.log + ${{ github.workspace }}/tier1_run.log.gpg + ${{ github.workspace }}/tier1_logcat.txt + ${{ github.workspace }}/tier1_logcat.txt.gpg + + # Superseded runs are cancelled on purpose, reporting those would be noise + - name: Report the run to slack + if: ${{ !cancelled() && inputs.slack_notify }} + continue-on-error: true + env: + SLACK_TOKEN: ${{ secrets.SLACK_TESTS_TOKEN }} + SLACK_CHANNEL: ${{ secrets.SLACK_TESTS_CHANNEL }} + BRANCH: ${{ github.head_ref || github.ref_name }} + PR_NUMBER: ${{ github.event.pull_request.number }} + PR_TITLE: ${{ github.event.pull_request.title }} + COMMIT: ${{ github.event.pull_request.head.sha || github.sha }} + ACTOR: ${{ github.actor }} + REPO_URL: ${{ github.server_url }}/${{ github.repository }} + RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }} + DART_DEFINES: ${{ inputs.extra_dart_defines }} + run: | + if [[ -z "$SLACK_TOKEN" || -z "$SLACK_CHANNEL" ]]; then + echo "::notice::No slack token or channel configured, skipping the report" + exit 0 + fi + + fmt() { + printf '%dm %02ds' $(( $1 / 60 )) $(( $1 % 60 )) + } + + field() { + awk -F= -v key="$2" '$1 == key { print $2; exit }' "$1" + } + + tier_line() { + local file="$1" label="$2" note="$3" + + if [[ ! -f "$file" ]]; then + printf '*%s*\n%s' "$label" "$note" + return + fi + + printf '*%s*\n%s/%s passed in %s' "$label" \ + "$(field "$file" passed)" "$(field "$file" total)" \ + "$(fmt "$(field "$file" duration)")" + } + + # For scenarios where the suite never ran because of driver issues, + # we can't call it a failing test, so we just log it + failure_lines() { + local file="$1" label="$2" + + [[ -f "$file" ]] || return 0 + + while IFS= read -r entry; do + [[ -n "$entry" ]] || continue + + local rest="${entry#*|}" + local name="${entry%%|*}" + local duration="${rest%%|*}" + local reason="${rest#*|}" + + if [[ "$reason" == "void" ]]; then + printf '\n• `%s` (%s) _%s, driver never attached, the suite did not run_' \ + "$name" "$(fmt "$duration")" "$label" + else + printf '\n• `%s` (%s) _%s_' "$name" "$(fmt "$duration")" "$label" + fi + done < <(sed -n 's/^fail=//p' "$file") + } + + # Adding the results for the funds suites to the report too, so we know if we need to topup + chain_lines() { + local status_wanted="$1" icon="$2" file + local seen="" + + for file in *_summary.txt; do + [[ -f "$file" ]] || continue + + while IFS= read -r entry; do + [[ -n "$entry" ]] || continue + + local chain="${entry%%|*}" + local rest="${entry#*|}" + local status="${rest%%|*}" + local detail="${rest#*|}" + + [[ "$status" == "$status_wanted" ]] || continue + [[ "$seen" == *"[$chain]"* ]] && continue + seen="$seen[$chain]" + + printf '\n%s *%s* _%s_' "$icon" "$chain" "$detail" + done < <(sed -n 's/^chain=//p' "$file") + done + } + + pass_names() { + local file="$1" label="$2" + + [[ -f "$file" ]] || return 0 + + printf '*%s* (%s)' "$label" "$(field "$file" passed)" + + while IFS= read -r entry; do + [[ -n "$entry" ]] || continue + printf '\n• %s _%s_' "${entry%%|*}" "$(fmt "${entry##*|}")" + done < <(sed -n 's/^pass=//p' "$file") + } + + tier0_failed=1 + tier1_failed=0 + total_passed=0 + + if [[ -f all_summary.txt ]]; then + tier0_failed=$(field all_summary.txt failed) + else + [[ -f tier0_summary.txt ]] && tier0_failed=$(field tier0_summary.txt failed) + [[ -f tier1_summary.txt ]] && tier1_failed=$(field tier1_summary.txt failed) + fi + + for file in tier0_summary.txt tier1_summary.txt; do + [[ -f "$file" ]] || continue + total_passed=$(( total_passed + $(field "$file" passed) )) + done + + if (( tier0_failed > 0 )); then + headline=":x: Integration tests failed" + elif (( tier1_failed > 0 )); then + headline=":warning: Gate passed, tier1 has failures" + else + headline=":white_check_mark: Integration tests passed" + fi + + if (( tier0_failed > 0 )); then + tier1_note="_skipped, tier0 failed_" + else + tier1_note="_did not run_" + fi + + where="<$REPO_URL/tree/$BRANCH|$BRANCH>" + if [[ -n "$PR_NUMBER" ]]; then + where="$where · <$REPO_URL/pull/$PR_NUMBER|#$PR_NUMBER ${PR_TITLE:-}>" + fi + + failures=$(printf '%s%s%s' \ + "$(failure_lines tier0_summary.txt tier0)" \ + "$(failure_lines tier1_summary.txt tier1)" \ + "$(failure_lines all_summary.txt suites)") + + if [[ -f all_summary.txt ]]; then + primary=$(tier_line all_summary.txt suites '') + secondary="" + else + primary=$(tier_line tier0_summary.txt tier0 '_did not run_') + secondary=$(tier_line tier1_summary.txt tier1 "$tier1_note") + fi + + chains=$(printf '%s%s%s%s' \ + "$(chain_lines ok ':white_check_mark:')" \ + "$(chain_lines empty ':moneybag:')" \ + "$(chain_lines waiting ':hourglass:')" \ + "$(chain_lines failed ':x:')") + + needs_funding=$(chain_lines empty ':moneybag:') + too_slow=$(chain_lines waiting ':hourglass:') + + blocks=$(jq -n \ + --arg headline "$headline" \ + --arg where "$where" \ + --arg primary "$primary" \ + --arg secondary "$secondary" \ + --arg failures "$failures" \ + --arg chains "$chains" \ + --arg funding "$needs_funding" \ + --arg slow "$too_slow" \ + --arg defines "$(printf '%s' "$DART_DEFINES" | tr ';' ' ')" \ + --arg commit "${COMMIT:0:8}" \ + --arg actor "$ACTOR" \ + --arg repo "$REPO_URL" \ + --arg run "$RUN_URL" \ + '[ + { type: "header", text: { type: "plain_text", text: $headline, emoji: true } }, + { type: "section", text: { type: "mrkdwn", text: $where } }, + { type: "section", fields: ( + [ { type: "mrkdwn", text: $primary } ] + + (if $secondary == "" then [] else + [ { type: "mrkdwn", text: $secondary } ] end) + ) } + ] + + (if $failures == "" then [] else + [ { type: "section", text: { type: "mrkdwn", + text: ("*Did not pass*" + $failures) } } ] + end) + + (if $chains == "" then [] else + [ { type: "divider" }, + { type: "section", text: { type: "mrkdwn", + text: ("*Chains*" + $chains) } } ] + end) + + (if $funding == "" then [] else + [ { type: "context", elements: [ { type: "mrkdwn", + text: ":moneybag: The chains above could not cover a send. Top those wallets up and re run the funds workflow, nothing there needs debugging :)." } ] } ] + end) + + (if $slow == "" then [] else + [ { type: "context", elements: [ { type: "mrkdwn", + text: ":hourglass: Still rescanning when the clock ran out, so nothing was proven either way and the run is not red for them." } ] } ] + end) + + [ + { type: "context", elements: [ + { type: "mrkdwn", + text: ("<" + $repo + "/commit/" + $commit + "|" + $commit + "> · " + $actor + + (if $defines == "" then "" else " · `" + $defines + "`" end)) } + ] }, + { type: "actions", elements: [ + { type: "button", url: $run, + text: { type: "plain_text", text: "View run", emoji: false } } + ] } + ]') + + response=$(curl -sS -X POST https://slack.com/api/chat.postMessage \ + -H "Authorization: Bearer $SLACK_TOKEN" \ + -H "Content-type: application/json; charset=utf-8" \ + --data "$(jq -n --arg channel "$SLACK_CHANNEL" --arg text "$headline" \ + --argjson blocks "$blocks" \ + '{channel: $channel, text: $text, blocks: $blocks}')") + + if [[ "$(jq -r '.ok' <<< "$response")" != "true" ]]; then + error=$(jq -r '.error // "unknown"' <<< "$response") + needed=$(jq -r '.needed // empty' <<< "$response") + + if [[ -n "$needed" ]]; then + echo "::warning::Slack rejected the report: $error, it wants the $needed scope" + else + echo "::warning::Slack rejected the report: $error" + fi + + exit 0 + fi + + thread_ts=$(jq -r '.ts // empty' <<< "$response") + + if [[ -z "$thread_ts" ]] || (( total_passed == 0 )); then + exit 0 + fi + + # So that the channel is not clutter (and uglyyyy), we put everything that passed in the thread + # and the short version is the one that shows up in the channel + passed=$(printf '%s\n\n%s' \ + "$(pass_names tier0_summary.txt tier0)" "$(pass_names tier1_summary.txt tier1)") + + curl -sS -X POST https://slack.com/api/chat.postMessage \ + -H "Authorization: Bearer $SLACK_TOKEN" \ + -H "Content-type: application/json; charset=utf-8" \ + --data "$(jq -n --arg channel "$SLACK_CHANNEL" --arg ts "$thread_ts" --arg text "$passed" \ + '{channel: $channel, thread_ts: $ts, text: $text}')" > /dev/null diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index b02f89369..395e62d8f 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -22,8 +22,8 @@ jobs: test: runs-on: ubuntu-latest container: - # Same prebuilt image as the build workflows (bundles Flutter 3.32.0). - image: ghcr.io/cake-tech/cake_wallet:debian13-flutter3.32.0-ndkr28-go1.24.1-ruststablenightly + # Same prebuilt image as the build workflows (bundles Flutter 3.41.9). + image: ghcr.io/cake-tech/cake_wallet:debian13-flutter3.41.9-ndkr28-go1.24.1-ruststablenightly steps: - name: Fix Actions HOME (writeable for ~/.cache) diff --git a/.gitignore b/.gitignore index 6fa4f20f8..f50916a8e 100644 --- a/.gitignore +++ b/.gitignore @@ -157,6 +157,7 @@ lib/zcash/zcash.dart # scaffold first. PNGs remain build artifacts. # ios/Runner/Assets.xcassets/AppIcon.appiconset/Contents.json ios/Runner/Assets.xcassets/AppIcon.appiconset/*.png +ios/AppIcon.icon/* ios/Runner/Info.plist android/app/src/main/res/mipmap-* @@ -170,7 +171,6 @@ android/app/src/main/res/drawable/ic_launcher.png assets/new-ui/**/*.svg.vec /pubspec.yaml -/pubspec.lock /android/app.properties /android/app/src/main/AndroidManifest.xml @@ -192,6 +192,7 @@ macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_512.png macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_1024.png macos/Runner/Configs/AppInfo.xcconfig macos/Runner.xcodeproj/project.pbxproj +macos/AppIcon.icon/* integration_test/playground.dart @@ -247,3 +248,7 @@ __pycache__/ # Internal engineering notes (not for the public repo) docs/internal-notes.md +*.salive +android/.kotlin +scripts/.cache +pubspec.lock diff --git a/Dockerfile b/Dockerfile index 773ac6822..78e119877 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,4 +1,4 @@ -# docker buildx build --push --pull --platform linux/amd64,linux/arm64 . -f Dockerfile -t ghcr.io/cake-tech/cake_wallet:debian13-flutter3.32.0-ndkr28-go1.24.1-ruststablenightly +# docker buildx build --push --pull --platform linux/amd64,linux/arm64 . -f Dockerfile -t ghcr.io/cake-tech/cake_wallet:debian13-flutter3.41.9-ndkr28-go1.24.1-ruststablenightly # Heavily inspired by cirrusci images # https://github.com/cirruslabs/docker-images-android/blob/master/sdk/tools/Dockerfile @@ -15,7 +15,7 @@ LABEL org.opencontainers.image.source=https://github.com/cake-tech/cake_wallet ENV GOLANG_VERSION=1.24.1 # Pin Flutter version to latest known-working version -ENV FLUTTER_VERSION=3.32.0 +ENV FLUTTER_VERSION=3.41.9 # Pin Android Studio, platform, and build tools versions to latest known-working version # Comes from https://developer.android.com/studio/#command-tools @@ -52,7 +52,7 @@ RUN set -o xtrace \ # for x86 emulators libatk-bridge2.0-0 libgdk-pixbuf-xlib-2.0-0 libgtk-3-0 libnspr4 libnss3-dev libsqlite3-dev libxtst6 libxss1 lftp sqlite3 xxd \ # Linux desktop dependencies - clang cmake libgtk-3-dev ninja-build pkg-config libsecret-1-0 libsecret-1-dev gir1.2-secret-1 \ + clang lld cmake libgtk-3-dev ninja-build pkg-config libsecret-1-0 libsecret-1-dev gir1.2-secret-1 \ # monero_c dependencies autoconf automake build-essential ccache gperf libtool llvm \ # extra stuff for KVM diff --git a/analysis_options.yaml b/analysis_options.yaml index 9440621cf..d686123b2 100644 --- a/analysis_options.yaml +++ b/analysis_options.yaml @@ -1,4 +1,3 @@ -include: package:lints/recommended.yaml analyzer: @@ -8,6 +7,7 @@ analyzer: lib/**.g.dart, cw_core/lib/**.g.dart, cw_monero/lib/**.g.dart, + cw_mweb/lib/**.g.dart, lib/generated/*.dart, cw_monero/ios/External/**, cw_shared_external/**, @@ -17,73 +17,124 @@ analyzer: strict-casts: true strict-raw-types: true +plugins: + cw_custom_lints: + path: cw_custom_lints + linter: rules: - cancel_subscriptions - always_declare_return_types - prefer_final_fields - - empty_catches + - always_put_control_body_on_new_line + - always_put_required_named_parameters_first + - always_use_package_imports + - annotate_overrides + - annotate_redeclares + - avoid_empty_else + - avoid_function_literals_in_foreach_calls + - avoid_init_to_null + - avoid_multiple_declarations_per_line + - avoid_positional_boolean_parameters + - avoid_relative_lib_imports + - avoid_renaming_method_parameters + - avoid_return_types_on_setters + - avoid_returning_null_for_void + - avoid_slow_async_io + - avoid_types_as_parameter_names + - avoid_types_on_closure_parameters + - avoid_unnecessary_containers + - avoid_unused_constructor_parameters + - avoid_void_async - await_only_futures - - valid_regexps - - unrelated_type_equality_checks + - camel_case_extensions + - camel_case_types + - cast_nullable_to_non_nullable + - collection_methods_unrelated_type + - constant_identifier_names + - curly_braces_in_flow_control_structures + - directives_ordering + - empty_constructor_bodies + - empty_statements + - eol_at_end_of_file + - flutter_style_todos - hash_and_equals + - implicit_reopen + - library_private_types_in_public_api + - non_constant_identifier_names + - only_throw_errors + - package_names + - prefer_asserts_with_message + - prefer_conditional_assignment + - prefer_const_constructors + - prefer_const_constructors_in_immutables + - prefer_const_declarations + - prefer_constructors_over_static_methods + - prefer_contains + - prefer_double_quotes + - prefer_expression_function_bodies + - prefer_final_in_for_each + - prefer_function_declarations_over_variables + - prefer_if_null_operators + - prefer_initializing_formals + - prefer_inlined_adds + - prefer_int_literals + - prefer_is_empty + - prefer_is_not_empty + - prefer_is_not_operator + - prefer_iterable_wheretype + - prefer_null_aware_operators + - prefer_spread_collections + - prefer_typing_uninitialized_variables + - recursive_getters + - require_trailing_commas + - sized_box_for_whitespace + - sort_child_properties_last + - sort_constructors_first + - sort_unnamed_constructors_first - test_types_in_equals - - throw_in_finally - - control_flow_in_finally + - unawaited_futures + - unnecessary_async + - unnecessary_await_in_return + - unnecessary_const + - unnecessary_constructor_name + - unnecessary_lambdas + - unnecessary_late + - unnecessary_new + - unnecessary_null_aware_assignments + - unnecessary_null_checks + - unnecessary_null_in_if_null_operators + - unnecessary_nullable_for_final_variable_declarations + - unnecessary_overrides + - unnecessary_parenthesis + - unnecessary_string_escapes + - unnecessary_string_interpolations + - unnecessary_this + - unnecessary_to_list_in_spreads + - unnecessary_underscores + - unreachable_from_main + - unrelated_type_equality_checks + - use_build_context_synchronously + - use_colored_box + - use_full_hex_values_for_flutter_colors + - use_function_type_syntax_for_parameters + - use_is_even_rather_than_modulo + - use_named_constants + - use_null_aware_elements + - use_raw_strings + - use_rethrow_when_possible + - use_string_buffers + - use_super_parameters + - use_truncating_division + - valid_regexps + - void_checks + - prefer_final_locals + - prefer_interpolation_to_compose_strings + - null_closures + - no_wildcard_variable_uses + + formatter: page_width: 100 - - -# analyzer: -# strong-mode: -# implicit-casts: false -# implicit-dynamic: false -# exclude: [build/**, lib/generated/*.dart, lib/**.g.dart, cw_monero/ios/External/**, cw_shared_external/**, shared_external/**] - -# linter: -# rules: -# - always_declare_return_types -# - annotate_overrides -# - avoid_empty_else -# - avoid_init_to_null -# - avoid_return_types_on_setters -# - await_only_futures -# - camel_case_types -# - cancel_subscriptions -# - close_sinks -# - comment_references -# - constant_identifier_names -# - control_flow_in_finally -# - empty_catches -# - empty_constructor_bodies -# - empty_statements -# - hash_and_equals -# - invariant_booleans -# - iterable_contains_unrelated_type -# - library_names -# - library_prefixes -# - list_remove_unrelated_type -# - literal_only_boolean_expressions -# - non_constant_identifier_names -# - one_member_abstracts -# - only_throw_errors -# - overridden_fields -# - package_api_docs -# - package_names -# - package_prefixed_library_names -# - parameter_assignments -# - prefer_final_fields -# - prefer_final_locals -# - prefer_is_not_empty -# - slash_for_doc_comments -# - sort_constructors_first -# - sort_unnamed_constructors_first -# - test_types_in_equals -# - throw_in_finally -# - type_init_formals -# - unawaited_futures -# - unnecessary_getters_setters -# - unrelated_type_equality_checks -# - valid_regexps diff --git a/android/app/build.gradle b/android/app/build.gradle index cedc2e4ba..e33406e8a 100644 --- a/android/app/build.gradle +++ b/android/app/build.gradle @@ -60,7 +60,7 @@ android { defaultConfig { applicationId appProperties['id'] - minSdkVersion 24 + minSdkVersion 26 targetSdkVersion 36 versionCode flutterVersionCode.toInteger() versionName flutterVersionName diff --git a/android/app/src/main/AndroidManifestBase.xml b/android/app/src/main/AndroidManifestBase.xml index ded83fe53..82cb759ae 100644 --- a/android/app/src/main/AndroidManifestBase.xml +++ b/android/app/src/main/AndroidManifestBase.xml @@ -90,7 +90,9 @@ + + @@ -115,6 +117,9 @@ + + + @@ -128,6 +133,14 @@ + + + + + + + + + + + + + + + + + diff --git a/android/gradle.properties b/android/gradle.properties index 4f09ae9b4..0b88d38ca 100644 --- a/android/gradle.properties +++ b/android/gradle.properties @@ -7,4 +7,5 @@ org.gradle.workers.max=2 android.enableR8=true android.useAndroidX=true android.enableJetifier=true + android.ndk.suppressMinSdkVersionError=21 diff --git a/android/gradle/wrapper/gradle-wrapper.properties b/android/gradle/wrapper/gradle-wrapper.properties index 0cc4e42b9..c494406ec 100644 --- a/android/gradle/wrapper/gradle-wrapper.properties +++ b/android/gradle/wrapper/gradle-wrapper.properties @@ -3,4 +3,4 @@ distributionBase=GRADLE_USER_HOME distributionPath=wrapper/dists zipStoreBase=GRADLE_USER_HOME zipStorePath=wrapper/dists -distributionUrl=https\://services.gradle.org/distributions/gradle-8.10.2-all.zip +distributionUrl=https\://services.gradle.org/distributions/gradle-8.14.4-all.zip diff --git a/android/settings.gradle b/android/settings.gradle index 468f9b55f..b6764328b 100644 --- a/android/settings.gradle +++ b/android/settings.gradle @@ -18,8 +18,8 @@ pluginManagement { plugins { id "dev.flutter.flutter-plugin-loader" version "1.0.0" - id "com.android.application" version "8.7.1" apply false - id "org.jetbrains.kotlin.android" version "2.0.21" apply false + id "com.android.application" version "8.9.1" apply false + id "org.jetbrains.kotlin.android" version "2.3.20" apply false id "com.google.gms.google-services" version "4.3.8" apply false } diff --git a/assets/arbitrum_node_list.yml b/assets/arbitrum_node_list.yml index 6bde495c3..1ff983751 100644 --- a/assets/arbitrum_node_list.yml +++ b/assets/arbitrum_node_list.yml @@ -1,8 +1,10 @@ - + label: "PublicNode" uri: arbitrum-one-rpc.publicnode.com - is_default: true + isDefault: true useSSL: true isEnabledForAutoSwitching: true - + label: "dRPC" uri: arbitrum.drpc.org - useSSL: true \ No newline at end of file + useSSL: true diff --git a/assets/banano_node_list.yml b/assets/banano_node_list.yml index a7178963f..be7e25a51 100644 --- a/assets/banano_node_list.yml +++ b/assets/banano_node_list.yml @@ -2,5 +2,5 @@ uri: kaliumapi.appditto.com path: /api useSSL: true - is_default: true + isDefault: true isEnabledForAutoSwitching: true \ No newline at end of file diff --git a/assets/base_node_list.yml b/assets/base_node_list.yml index b1baef838..680a6eb3d 100644 --- a/assets/base_node_list.yml +++ b/assets/base_node_list.yml @@ -1,11 +1,14 @@ - + label: "LlamaNodes" uri: base.llamarpc.com useSSL: true - + label: "PublicNode" uri: base-rpc.publicnode.com - is_default: true + isDefault: true useSSL: true isEnabledForAutoSwitching: true - + label: "1RPC" uri: 1rpc.io/base - useSSL: true \ No newline at end of file + useSSL: true diff --git a/assets/bitcoin_cash_electrum_server_list.yml b/assets/bitcoin_cash_electrum_server_list.yml index 6c3ead0fc..67cd5fa27 100644 --- a/assets/bitcoin_cash_electrum_server_list.yml +++ b/assets/bitcoin_cash_electrum_server_list.yml @@ -1,9 +1,11 @@ - + label: "Stack Wallet" uri: bitcoincash.stackwallet.com:50002 - is_default: true + isDefault: true useSSL: true isEnabledForAutoSwitching: true - + label: "Aftrek" uri: bch.aftrek.org:50002 useSSL: true isEnabledForAutoSwitching: true diff --git a/assets/bitcoin_electrum_server_list.yml b/assets/bitcoin_electrum_server_list.yml index 37e038cf4..01436ee37 100644 --- a/assets/bitcoin_electrum_server_list.yml +++ b/assets/bitcoin_electrum_server_list.yml @@ -1,21 +1,27 @@ - uri: electrum.blockstream.info:50002 - useSSL: true isDefault: true - isEnabledForAutoSwitching: true -- - uri: btc-electrum.cakewallet.com:50002 useSSL: true - isDefault: false isEnabledForAutoSwitching: true - - uri: electrs.cakewallet.com:50001 + label: "Cake Wallet Electrum" + uri: btc-electrum.cakewallet.com:50002 + isOfficial: true + useSSL: true + isEnabledForAutoSwitching: true - + label: "Cake Wallet Electrs" + uri: electrs.cakewallet.com:50001 + isOfficial: true +- + label: "Seth For Privacy" uri: fulcrum.sethforprivacy.com:50002 useSSL: true isEnabledForAutoSwitching: true - + label: "Cake Wallet (Legacy)" uri: electrum.cakewallet.com:50002 + isOfficial: true useSSL: true isEnabledForAutoSwitching: true - @@ -30,3 +36,8 @@ uri: fortress.qtornado.com:443 useSSL: true isEnabledForAutoSwitching: true +- + label: "Cake Wallet (Tor)" + uri: cakebtc5pihzt3byjclxfumiwvtxj72auarshrsvmaatmwmr7dj6zyid.onion:50001 + isOfficial: true + useSSL: false diff --git a/assets/bsc_node_list.yml b/assets/bsc_node_list.yml index 20e0cdcca..4403b6b01 100644 --- a/assets/bsc_node_list.yml +++ b/assets/bsc_node_list.yml @@ -3,26 +3,33 @@ useSSL: true isDefault: true isEnabledForAutoSwitching: true + label: "BNBChain" - uri: bsc-dataseed2.bnbchain.org useSSL: true isEnabledForAutoSwitching: true + label: "BNBChain #2" - uri: bsc-dataseed.nariox.org useSSL: true isEnabledForAutoSwitching: true + label: "Nariox" - uri: bsc-dataseed.defibit.io useSSL: true isEnabledForAutoSwitching: true + label: "DeFibit" - uri: bsc-dataseed.ninicoin.io useSSL: true isEnabledForAutoSwitching: true + label: "NiniCoin" - uri: bsc.nodereal.io useSSL: true isEnabledForAutoSwitching: true + label: "NodeReal" - uri: bsc-dataseed-public.bnbchain.org useSSL: true + label: "BNBChain Public" \ No newline at end of file diff --git a/assets/decred_node_list.yml b/assets/decred_node_list.yml index 9f5938e8c..7121867e0 100644 --- a/assets/decred_node_list.yml +++ b/assets/decred_node_list.yml @@ -1,8 +1,10 @@ - uri: default-spv-nodes - is_default: true + isDefault: true isEnabledForAutoSwitching: true + label: "Decred" - uri: dcrd.sethforprivacy.com:9108 useSSL: true - isEnabledForAutoSwitching: true \ No newline at end of file + isEnabledForAutoSwitching: true + label: "Seth For Privacy" \ No newline at end of file diff --git a/assets/dogecoin_electrum_server_list.yml b/assets/dogecoin_electrum_server_list.yml index e9af2929d..80271c85f 100644 --- a/assets/dogecoin_electrum_server_list.yml +++ b/assets/dogecoin_electrum_server_list.yml @@ -1,13 +1,16 @@ - + label: "Stack Wallet" uri: dogecoin.stackwallet.com:50022 - is_default: true + isDefault: true useSSL: true isEnabledForAutoSwitching: true - + label: "Aftrek" uri: doge.aftrek.org:50002 useSSL: true isEnabledForAutoSwitching: true - + label: "Cipig" uri: electrum1.cipig.net:20060 useSSL: true isEnabledForAutoSwitching: true diff --git a/assets/ethereum_server_list.yml b/assets/ethereum_server_list.yml index 0ba098fc5..08c2f4ae5 100644 --- a/assets/ethereum_server_list.yml +++ b/assets/ethereum_server_list.yml @@ -3,18 +3,21 @@ useSSL: true isDefault: true isEnabledForAutoSwitching: true + label: "PublicNode" - uri: eth.llamarpc.com useSSL: true + label: "LlamaNodes" - uri: rpc.flashbots.net useSSL: true + label: "Flashbots" - uri: eth-mainnet.public.blastapi.io useSSL: true + label: "Blast" - uri: eth.nownodes.io useSSL: true isEnabledForAutoSwitching: true -- - uri: ethereum.publicnode.com \ No newline at end of file + label: "NOWNodes" \ No newline at end of file diff --git a/assets/fonts/Lato-Bold.ttf b/assets/fonts/Lato-Bold.ttf deleted file mode 100644 index 59c48434c..000000000 Binary files a/assets/fonts/Lato-Bold.ttf and /dev/null differ diff --git a/assets/fonts/Lato-Medium.ttf b/assets/fonts/Lato-Medium.ttf deleted file mode 100644 index a208b9912..000000000 Binary files a/assets/fonts/Lato-Medium.ttf and /dev/null differ diff --git a/assets/fonts/Lato-Regular.ttf b/assets/fonts/Lato-Regular.ttf deleted file mode 100644 index f01f55892..000000000 Binary files a/assets/fonts/Lato-Regular.ttf and /dev/null differ diff --git a/assets/fonts/Lato-Semibold.ttf b/assets/fonts/Lato-Semibold.ttf deleted file mode 100644 index 0b95c6430..000000000 Binary files a/assets/fonts/Lato-Semibold.ttf and /dev/null differ diff --git a/assets/haven_node_list.yml b/assets/haven_node_list.yml index 27d9ee611..2dfa644b7 100644 --- a/assets/haven_node_list.yml +++ b/assets/haven_node_list.yml @@ -1,4 +1,4 @@ - uri: nodes.havenprotocol.org:443 useSSL: true - is_default: true \ No newline at end of file + isDefault: true \ No newline at end of file diff --git a/assets/images/hardware_wallet/device_ledger_nano_gen_5.svg b/assets/images/hardware_wallet/device_ledger_nano_gen_5.svg deleted file mode 100644 index 9d2fc8bbd..000000000 --- a/assets/images/hardware_wallet/device_ledger_nano_gen_5.svg +++ /dev/null @@ -1,3 +0,0 @@ - - - diff --git a/assets/images/hardware_wallet/device_trezor_safe_5.svg b/assets/images/hardware_wallet/device_trezor_safe_5.svg deleted file mode 100644 index 14c0329be..000000000 --- a/assets/images/hardware_wallet/device_trezor_safe_5.svg +++ /dev/null @@ -1,3 +0,0 @@ - - - diff --git a/assets/images/hardware_wallet/ledger_flex.png b/assets/images/hardware_wallet/ledger_flex.png deleted file mode 100644 index fa39f241f..000000000 Binary files a/assets/images/hardware_wallet/ledger_flex.png and /dev/null differ diff --git a/assets/images/hardware_wallet/ledger_nano_s.png b/assets/images/hardware_wallet/ledger_nano_s.png deleted file mode 100644 index 02777aeb6..000000000 Binary files a/assets/images/hardware_wallet/ledger_nano_s.png and /dev/null differ diff --git a/assets/images/hardware_wallet/ledger_stax.png b/assets/images/hardware_wallet/ledger_stax.png deleted file mode 100644 index 06c9c848e..000000000 Binary files a/assets/images/hardware_wallet/ledger_stax.png and /dev/null differ diff --git a/assets/images/ios_icons/monero_ios_icons/Contents.json b/assets/images/ios_icons/monero_ios_icons/Contents.json deleted file mode 100644 index d0d98aa17..000000000 --- a/assets/images/ios_icons/monero_ios_icons/Contents.json +++ /dev/null @@ -1 +0,0 @@ -{"images":[{"size":"20x20","idiom":"iphone","filename":"Icon-App-20x20@2x.png","scale":"2x"},{"size":"20x20","idiom":"iphone","filename":"Icon-App-20x20@3x.png","scale":"3x"},{"size":"29x29","idiom":"iphone","filename":"Icon-App-29x29@1x.png","scale":"1x"},{"size":"29x29","idiom":"iphone","filename":"Icon-App-29x29@2x.png","scale":"2x"},{"size":"29x29","idiom":"iphone","filename":"Icon-App-29x29@3x.png","scale":"3x"},{"size":"40x40","idiom":"iphone","filename":"Icon-App-40x40@2x.png","scale":"2x"},{"size":"40x40","idiom":"iphone","filename":"Icon-App-40x40@3x.png","scale":"3x"},{"size":"57x57","idiom":"iphone","filename":"Icon-App-57x57@1x.png","scale":"1x"},{"size":"57x57","idiom":"iphone","filename":"Icon-App-57x57@2x.png","scale":"2x"},{"size":"60x60","idiom":"iphone","filename":"Icon-App-60x60@2x.png","scale":"2x"},{"size":"60x60","idiom":"iphone","filename":"Icon-App-60x60@3x.png","scale":"3x"},{"size":"20x20","idiom":"ipad","filename":"Icon-App-20x20@1x.png","scale":"1x"},{"size":"20x20","idiom":"ipad","filename":"Icon-App-20x20@2x.png","scale":"2x"},{"size":"29x29","idiom":"ipad","filename":"Icon-App-29x29@1x.png","scale":"1x"},{"size":"29x29","idiom":"ipad","filename":"Icon-App-29x29@2x.png","scale":"2x"},{"size":"40x40","idiom":"ipad","filename":"Icon-App-40x40@1x.png","scale":"1x"},{"size":"40x40","idiom":"ipad","filename":"Icon-App-40x40@2x.png","scale":"2x"},{"size":"50x50","idiom":"ipad","filename":"Icon-App-50x50@1x.png","scale":"1x"},{"size":"50x50","idiom":"ipad","filename":"Icon-App-50x50@2x.png","scale":"2x"},{"size":"72x72","idiom":"ipad","filename":"Icon-App-72x72@1x.png","scale":"1x"},{"size":"72x72","idiom":"ipad","filename":"Icon-App-72x72@2x.png","scale":"2x"},{"size":"76x76","idiom":"ipad","filename":"Icon-App-76x76@1x.png","scale":"1x"},{"size":"76x76","idiom":"ipad","filename":"Icon-App-76x76@2x.png","scale":"2x"},{"size":"83.5x83.5","idiom":"ipad","filename":"Icon-App-83.5x83.5@2x.png","scale":"2x"},{"size":"1024x1024","idiom":"ios-marketing","filename":"Icon-App-1024x1024@1x.png","scale":"1x"}],"info":{"version":1,"author":"xcode"}} \ No newline at end of file diff --git a/assets/images/ios_icons/monero_ios_icons/Icon-App-1024x1024@1x.png b/assets/images/ios_icons/monero_ios_icons/Icon-App-1024x1024@1x.png deleted file mode 100644 index 22b43da1a..000000000 Binary files a/assets/images/ios_icons/monero_ios_icons/Icon-App-1024x1024@1x.png and /dev/null differ diff --git a/assets/images/ios_icons/monero_ios_icons/Icon-App-20x20@1x.png b/assets/images/ios_icons/monero_ios_icons/Icon-App-20x20@1x.png deleted file mode 100644 index 55f006709..000000000 Binary files a/assets/images/ios_icons/monero_ios_icons/Icon-App-20x20@1x.png and /dev/null differ diff --git a/assets/images/ios_icons/monero_ios_icons/Icon-App-20x20@2x.png b/assets/images/ios_icons/monero_ios_icons/Icon-App-20x20@2x.png deleted file mode 100644 index 9ac20f5d6..000000000 Binary files a/assets/images/ios_icons/monero_ios_icons/Icon-App-20x20@2x.png and /dev/null differ diff --git a/assets/images/ios_icons/monero_ios_icons/Icon-App-20x20@3x.png b/assets/images/ios_icons/monero_ios_icons/Icon-App-20x20@3x.png deleted file mode 100644 index d6a44407e..000000000 Binary files a/assets/images/ios_icons/monero_ios_icons/Icon-App-20x20@3x.png and /dev/null differ diff --git a/assets/images/ios_icons/monero_ios_icons/Icon-App-29x29@1x.png b/assets/images/ios_icons/monero_ios_icons/Icon-App-29x29@1x.png deleted file mode 100644 index eae48aa12..000000000 Binary files a/assets/images/ios_icons/monero_ios_icons/Icon-App-29x29@1x.png and /dev/null differ diff --git a/assets/images/ios_icons/monero_ios_icons/Icon-App-29x29@2x.png b/assets/images/ios_icons/monero_ios_icons/Icon-App-29x29@2x.png deleted file mode 100644 index ffa4c0e80..000000000 Binary files a/assets/images/ios_icons/monero_ios_icons/Icon-App-29x29@2x.png and /dev/null differ diff --git a/assets/images/ios_icons/monero_ios_icons/Icon-App-29x29@3x.png b/assets/images/ios_icons/monero_ios_icons/Icon-App-29x29@3x.png deleted file mode 100644 index 3f8c0acc0..000000000 Binary files a/assets/images/ios_icons/monero_ios_icons/Icon-App-29x29@3x.png and /dev/null differ diff --git a/assets/images/ios_icons/monero_ios_icons/Icon-App-40x40@1x.png b/assets/images/ios_icons/monero_ios_icons/Icon-App-40x40@1x.png deleted file mode 100644 index 9ac20f5d6..000000000 Binary files a/assets/images/ios_icons/monero_ios_icons/Icon-App-40x40@1x.png and /dev/null differ diff --git a/assets/images/ios_icons/monero_ios_icons/Icon-App-40x40@2x.png b/assets/images/ios_icons/monero_ios_icons/Icon-App-40x40@2x.png deleted file mode 100644 index 14644910d..000000000 Binary files a/assets/images/ios_icons/monero_ios_icons/Icon-App-40x40@2x.png and /dev/null differ diff --git a/assets/images/ios_icons/monero_ios_icons/Icon-App-40x40@3x.png b/assets/images/ios_icons/monero_ios_icons/Icon-App-40x40@3x.png deleted file mode 100644 index 359371094..000000000 Binary files a/assets/images/ios_icons/monero_ios_icons/Icon-App-40x40@3x.png and /dev/null differ diff --git a/assets/images/ios_icons/monero_ios_icons/Icon-App-50x50@1x.png b/assets/images/ios_icons/monero_ios_icons/Icon-App-50x50@1x.png deleted file mode 100644 index 95ffad635..000000000 Binary files a/assets/images/ios_icons/monero_ios_icons/Icon-App-50x50@1x.png and /dev/null differ diff --git a/assets/images/ios_icons/monero_ios_icons/Icon-App-50x50@2x.png b/assets/images/ios_icons/monero_ios_icons/Icon-App-50x50@2x.png deleted file mode 100644 index f5270fda9..000000000 Binary files a/assets/images/ios_icons/monero_ios_icons/Icon-App-50x50@2x.png and /dev/null differ diff --git a/assets/images/ios_icons/monero_ios_icons/Icon-App-57x57@1x.png b/assets/images/ios_icons/monero_ios_icons/Icon-App-57x57@1x.png deleted file mode 100644 index 2c1b9be5b..000000000 Binary files a/assets/images/ios_icons/monero_ios_icons/Icon-App-57x57@1x.png and /dev/null differ diff --git a/assets/images/ios_icons/monero_ios_icons/Icon-App-57x57@2x.png b/assets/images/ios_icons/monero_ios_icons/Icon-App-57x57@2x.png deleted file mode 100644 index d71092f8e..000000000 Binary files a/assets/images/ios_icons/monero_ios_icons/Icon-App-57x57@2x.png and /dev/null differ diff --git a/assets/images/ios_icons/monero_ios_icons/Icon-App-60x60@2x.png b/assets/images/ios_icons/monero_ios_icons/Icon-App-60x60@2x.png deleted file mode 100644 index 359371094..000000000 Binary files a/assets/images/ios_icons/monero_ios_icons/Icon-App-60x60@2x.png and /dev/null differ diff --git a/assets/images/ios_icons/monero_ios_icons/Icon-App-60x60@3x.png b/assets/images/ios_icons/monero_ios_icons/Icon-App-60x60@3x.png deleted file mode 100644 index 300ae990b..000000000 Binary files a/assets/images/ios_icons/monero_ios_icons/Icon-App-60x60@3x.png and /dev/null differ diff --git a/assets/images/ios_icons/monero_ios_icons/Icon-App-72x72@1x.png b/assets/images/ios_icons/monero_ios_icons/Icon-App-72x72@1x.png deleted file mode 100644 index bf884a35c..000000000 Binary files a/assets/images/ios_icons/monero_ios_icons/Icon-App-72x72@1x.png and /dev/null differ diff --git a/assets/images/ios_icons/monero_ios_icons/Icon-App-72x72@2x.png b/assets/images/ios_icons/monero_ios_icons/Icon-App-72x72@2x.png deleted file mode 100644 index a66916e64..000000000 Binary files a/assets/images/ios_icons/monero_ios_icons/Icon-App-72x72@2x.png and /dev/null differ diff --git a/assets/images/ios_icons/monero_ios_icons/Icon-App-76x76@1x.png b/assets/images/ios_icons/monero_ios_icons/Icon-App-76x76@1x.png deleted file mode 100644 index 8a4813d1e..000000000 Binary files a/assets/images/ios_icons/monero_ios_icons/Icon-App-76x76@1x.png and /dev/null differ diff --git a/assets/images/ios_icons/monero_ios_icons/Icon-App-76x76@2x.png b/assets/images/ios_icons/monero_ios_icons/Icon-App-76x76@2x.png deleted file mode 100644 index 577a8af9f..000000000 Binary files a/assets/images/ios_icons/monero_ios_icons/Icon-App-76x76@2x.png and /dev/null differ diff --git a/assets/images/ios_icons/monero_ios_icons/Icon-App-83.5x83.5@2x.png b/assets/images/ios_icons/monero_ios_icons/Icon-App-83.5x83.5@2x.png deleted file mode 100644 index 77597a4cf..000000000 Binary files a/assets/images/ios_icons/monero_ios_icons/Icon-App-83.5x83.5@2x.png and /dev/null differ diff --git a/assets/images/ios_icons/monerocom-app.icon/Assets/monero-tinted.svg b/assets/images/ios_icons/monerocom-app.icon/Assets/monero-tinted.svg new file mode 100644 index 000000000..d2e7f9064 --- /dev/null +++ b/assets/images/ios_icons/monerocom-app.icon/Assets/monero-tinted.svg @@ -0,0 +1,25 @@ + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/assets/images/ios_icons/monerocom-app.icon/Assets/monerocom-icon-app-dark.svg b/assets/images/ios_icons/monerocom-app.icon/Assets/monerocom-icon-app-dark.svg new file mode 100644 index 000000000..3f2a6834e --- /dev/null +++ b/assets/images/ios_icons/monerocom-app.icon/Assets/monerocom-icon-app-dark.svg @@ -0,0 +1,51 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/assets/images/ios_icons/monerocom-app.icon/Assets/monerocom-icon-app.svg b/assets/images/ios_icons/monerocom-app.icon/Assets/monerocom-icon-app.svg new file mode 100644 index 000000000..0abff0181 --- /dev/null +++ b/assets/images/ios_icons/monerocom-app.icon/Assets/monerocom-icon-app.svg @@ -0,0 +1,25 @@ + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/assets/images/ios_icons/monerocom-app.icon/icon.json b/assets/images/ios_icons/monerocom-app.icon/icon.json new file mode 100644 index 000000000..c115ef005 --- /dev/null +++ b/assets/images/ios_icons/monerocom-app.icon/icon.json @@ -0,0 +1,52 @@ +{ + "fill" : { + "linear-gradient" : [ + "display-p3:1.00000,1.00000,1.00000,1.00000", + "srgb:0.93725,0.93725,0.97255,1.00000" + ], + "orientation" : { + "start" : { + "x" : 0.5, + "y" : 0 + }, + "stop" : { + "x" : 0.5, + "y" : 0.7 + } + } + }, + "groups" : [ + { + "layers" : [ + { + "glass" : false, + "image-name-specializations" : [ + { + "value" : "monerocom-icon-app.svg" + }, + { + "appearance" : "dark", + "value" : "monerocom-icon-app-dark.svg" + }, + { + "appearance" : "tinted", + "value" : "monero-tinted.svg" + } + ], + "name" : "monerocom-icon-app-dark" + } + ], + "shadow" : { + "kind" : "neutral", + "opacity" : 0.5 + }, + "translucency" : { + "enabled" : true, + "value" : 0.5 + } + } + ], + "supported-platforms" : { + "squares" : "shared" + } +} \ No newline at end of file diff --git a/assets/images/monerocom_logo.svg b/assets/images/monerocom_logo.svg new file mode 100644 index 000000000..76c8be604 --- /dev/null +++ b/assets/images/monerocom_logo.svg @@ -0,0 +1,25 @@ + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/assets/images/radar.png b/assets/images/radar.png new file mode 100644 index 000000000..386511418 Binary files /dev/null and b/assets/images/radar.png differ diff --git a/assets/images/robinhood_chain_QR.svg b/assets/images/robinhood_chain_QR.svg new file mode 100644 index 000000000..a8ee2b6f9 --- /dev/null +++ b/assets/images/robinhood_chain_QR.svg @@ -0,0 +1,20 @@ + + + + + + + + + + + + + + + + + + + + diff --git a/assets/images/robinhood_chain_mono.svg b/assets/images/robinhood_chain_mono.svg new file mode 100644 index 000000000..6c34a94ec --- /dev/null +++ b/assets/images/robinhood_chain_mono.svg @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/assets/images/skrill.svg b/assets/images/skrill.svg index b264b57eb..542c00c12 100644 --- a/assets/images/skrill.svg +++ b/assets/images/skrill.svg @@ -1,9 +1,9 @@ - + - + diff --git a/assets/images/stocks/amd.png b/assets/images/stocks/amd.png new file mode 100644 index 000000000..d46e2abbe Binary files /dev/null and b/assets/images/stocks/amd.png differ diff --git a/assets/images/stocks/asml.png b/assets/images/stocks/asml.png new file mode 100644 index 000000000..654ec8288 Binary files /dev/null and b/assets/images/stocks/asml.png differ diff --git a/assets/images/stocks/baba.png b/assets/images/stocks/baba.png new file mode 100644 index 000000000..33830abaa Binary files /dev/null and b/assets/images/stocks/baba.png differ diff --git a/assets/images/stocks/cost.png b/assets/images/stocks/cost.png new file mode 100644 index 000000000..412f340ab Binary files /dev/null and b/assets/images/stocks/cost.png differ diff --git a/assets/images/stocks/ddog.png b/assets/images/stocks/ddog.png new file mode 100644 index 000000000..12751fb03 Binary files /dev/null and b/assets/images/stocks/ddog.png differ diff --git a/assets/images/stocks/dell.png b/assets/images/stocks/dell.png new file mode 100644 index 000000000..19572f468 Binary files /dev/null and b/assets/images/stocks/dell.png differ diff --git a/assets/images/stocks/f.png b/assets/images/stocks/f.png new file mode 100644 index 000000000..e928f6a66 Binary files /dev/null and b/assets/images/stocks/f.png differ diff --git a/assets/images/stocks/intu.png b/assets/images/stocks/intu.png new file mode 100644 index 000000000..9c5d3a524 Binary files /dev/null and b/assets/images/stocks/intu.png differ diff --git a/assets/images/stocks/lulu.png b/assets/images/stocks/lulu.png new file mode 100644 index 000000000..22195d62b Binary files /dev/null and b/assets/images/stocks/lulu.png differ diff --git a/assets/images/stocks/mu.png b/assets/images/stocks/mu.png new file mode 100644 index 000000000..012df2660 Binary files /dev/null and b/assets/images/stocks/mu.png differ diff --git a/assets/images/stocks/nu.png b/assets/images/stocks/nu.png new file mode 100644 index 000000000..7cbc3d496 Binary files /dev/null and b/assets/images/stocks/nu.png differ diff --git a/assets/images/stocks/qcom.png b/assets/images/stocks/qcom.png new file mode 100644 index 000000000..41fbe6340 Binary files /dev/null and b/assets/images/stocks/qcom.png differ diff --git a/assets/images/stocks/rivn.png b/assets/images/stocks/rivn.png new file mode 100644 index 000000000..f692f9cf6 Binary files /dev/null and b/assets/images/stocks/rivn.png differ diff --git a/assets/images/stocks/shop.png b/assets/images/stocks/shop.png new file mode 100644 index 000000000..a1d3d81b7 Binary files /dev/null and b/assets/images/stocks/shop.png differ diff --git a/assets/images/stocks/sofi.png b/assets/images/stocks/sofi.png new file mode 100644 index 000000000..80894a1ff Binary files /dev/null and b/assets/images/stocks/sofi.png differ diff --git a/assets/images/stocks/tsm.png b/assets/images/stocks/tsm.png new file mode 100644 index 000000000..ca531cf80 Binary files /dev/null and b/assets/images/stocks/tsm.png differ diff --git a/assets/images/stocks/ttwo.png b/assets/images/stocks/ttwo.png new file mode 100644 index 000000000..9683ab1bb Binary files /dev/null and b/assets/images/stocks/ttwo.png differ diff --git a/assets/images/stocks/ups.png b/assets/images/stocks/ups.png new file mode 100644 index 000000000..188668f22 Binary files /dev/null and b/assets/images/stocks/ups.png differ diff --git a/assets/images/stocks/wday.png b/assets/images/stocks/wday.png new file mode 100644 index 000000000..64eaf939c Binary files /dev/null and b/assets/images/stocks/wday.png differ diff --git a/assets/images/tbb_icon.png b/assets/images/tbb_icon.png new file mode 100644 index 000000000..744b9e3e1 Binary files /dev/null and b/assets/images/tbb_icon.png differ diff --git a/assets/images/usdg_icon.png b/assets/images/usdg_icon.png new file mode 100644 index 000000000..ef6d8b8d6 Binary files /dev/null and b/assets/images/usdg_icon.png differ diff --git a/assets/images/xmr-og.webp b/assets/images/xmr-og.webp new file mode 100644 index 000000000..94eda5211 Binary files /dev/null and b/assets/images/xmr-og.webp differ diff --git a/assets/lightning_deposit_help.svg b/assets/lightning_deposit_help.svg deleted file mode 100644 index 5cecc0bab..000000000 --- a/assets/lightning_deposit_help.svg +++ /dev/null @@ -1,99 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/assets/litecoin_electrum_server_list.yml b/assets/litecoin_electrum_server_list.yml index f9c0efd0c..bbcfc9dd4 100644 --- a/assets/litecoin_electrum_server_list.yml +++ b/assets/litecoin_electrum_server_list.yml @@ -1,24 +1,30 @@ - + label: "Bysh" uri: electrum-ltc.bysh.me:50002 - useSSL: true isDefault: true - isEnabledForAutoSwitching: true -- - uri: ltc-electrum.cakewallet.com:50002 useSSL: true - isDefault: false isEnabledForAutoSwitching: true - + label: "Cake Wallet" + uri: ltc-electrum.cakewallet.com:50002 + isOfficial: true + useSSL: true + isEnabledForAutoSwitching: true +- + label: "Stack Wallet" uri: litecoin.stackwallet.com:20063 useSSL: true isEnabledForAutoSwitching: true - + label: "Fiat Faucet" uri: lightweight.fiatfaucet.com:50002 useSSL: true - + label: "Xurious" uri: electrum.ltc.xurious.com:50002 useSSL: true - + label: "Electrum-LTC" uri: backup.electrum-ltc.org:443 useSSL: true isEnabledForAutoSwitching: true @@ -26,3 +32,8 @@ uri: ltc.rentonisk.com:50002 useSSL: true isEnabledForAutoSwitching: true +- + label: "Cake Wallet (Tor)" + uri: cakeltcnzbdxhk3dhlzxjnnylxbfhhbx2njegaluyh35t7dca35vcnqd.onion:50001 + isOfficial: true + useSSL: false diff --git a/assets/nano_node_list.yml b/assets/nano_node_list.yml index aa1d8e3c9..1f153a290 100644 --- a/assets/nano_node_list.yml +++ b/assets/nano_node_list.yml @@ -1,34 +1,37 @@ +- + uri: rpc.nano.to + isDefault: true + useSSL: true + isEnabledForAutoSwitching: true + label: "Nano.to" - uri: nano.nownodes.io useSSL: true isEnabledForAutoSwitching: true -- - uri: rpc.nano.to - is_default: true - useSSL: true - isEnabledForAutoSwitching: true + label: "NOWNodes" - uri: node.nautilus.io path: /api useSSL: true isEnabledForAutoSwitching: true + label: "Nautilus" - uri: app.natrium.io path: /api useSSL: true + label: "Natrium" - uri: rainstorm.city path: /api useSSL: true + label: "Rainstorm City" - uri: node.somenano.com path: /proxy useSSL: true + label: "SomeNano" - uri: nanoslo.0x.no path: /proxy useSSL: true -- - uri: www.bitrequest.app - port: 8020 - useSSL: true \ No newline at end of file + label: "0x.no" \ No newline at end of file diff --git a/assets/nano_pow_node_list.yml b/assets/nano_pow_node_list.yml index ec1b10ff9..4b5c1a8be 100644 --- a/assets/nano_pow_node_list.yml +++ b/assets/nano_pow_node_list.yml @@ -1,7 +1,7 @@ - uri: rpc.nano.to useSSL: true - is_default: true + isDefault: true isEnabledForAutoSwitching: true - uri: workers.perish.co diff --git a/assets/new-ui/changelog/text/changelog_ar.json b/assets/new-ui/changelog/text/changelog_ar.json index 371b74305..9f045aab1 100644 --- a/assets/new-ui/changelog/text/changelog_ar.json +++ b/assets/new-ui/changelog/text/changelog_ar.json @@ -1 +1 @@ -[{"title":"تحسينات الأداء","description":"تحسين أداء التطبيق وسرعته"},{"title":"أضف جسر USDT","description":"قم بتوصيل USDT الخاص بك من سلسلة إلى أخرى"},{"title":"تحسينات المحافظ","description":"تحسين واجهة مستخدم المحفظة ووظائفها"},{"title":"إصلاحات الشوائب","description":"سحق المزيد من الأخطاء، لجعل تجربتك سلسة"}] \ No newline at end of file +[{"title":"تحسينات تريزور","description":"يمكنك الآن إدخال عبارة المرور الخاصة بك على جهاز Trezor نفسه"},{"title":"تحسينات الشراء/البيع","description":"شراء وبيع العملات المشفرة الخاصة بك بشكل أسهل وأكثر سلاسة"},{"title":"تحسينات مونيرو","description":"يستمر تقدم المزامنة في التحرك أثناء تسخين محفظتك، حتى تعرف دائمًا مكانها"},{"title":"إصلاحات الشوائب","description":"مزامنة جهات الاتصال، واختيار العقدة، وتسجيل الدخول إلى Cake Pay، والمزيد، تم سحقها جميعًا"}] \ No newline at end of file diff --git a/assets/new-ui/changelog/text/changelog_bg.json b/assets/new-ui/changelog/text/changelog_bg.json index 5119062ed..33f26dcfb 100644 --- a/assets/new-ui/changelog/text/changelog_bg.json +++ b/assets/new-ui/changelog/text/changelog_bg.json @@ -1 +1 @@ -[{"title":"Подобрения в производителността","description":"Подобрете производителността и скоростта на приложението"},{"title":"Добавете USDT мост","description":"Прехвърлете вашия USDT от една верига към друга"},{"title":"Подобрения в портфейлите","description":"Подобрете потребителския интерфейс и функционалността на портфейлите"},{"title":"Поправки на грешки","description":"Скуош повече грешки, за да направи вашето изживяване гладко"}] \ No newline at end of file +[{"title":"Подобрения на Trezor","description":"сега можете да въведете вашата парола на самото устройство Trezor"},{"title":"Подобрения за покупка/продаване","description":"Купувайте и продавайте вашата крипто по-лесно и по-гладко"},{"title":"Подобрения на Monero","description":"Прогресът на синхронизирането продължава да се движи, докато портфейлът ви загрява, така че винаги да знаете къде се намира"},{"title":"Поправки на грешки","description":"Синхронизиране на контакти, избор на възел, влизане в Cake Pay и други, всички смачкани"}] \ No newline at end of file diff --git a/assets/new-ui/changelog/text/changelog_cs.json b/assets/new-ui/changelog/text/changelog_cs.json index 779ffd3f0..f199b9f9a 100644 --- a/assets/new-ui/changelog/text/changelog_cs.json +++ b/assets/new-ui/changelog/text/changelog_cs.json @@ -1 +1 @@ -[{"title":"Vylepšení výkonu","description":"Zlepšete výkon a rychlost aplikace"},{"title":"Přidejte přemostění USDT","description":"Přemostění USDT z jednoho řetězce do druhého"},{"title":"Vylepšení peněženek","description":"Vylepšete uživatelské rozhraní a funkčnost peněženky"},{"title":"Opravy chyb","description":"Squash více chyb, aby vaše zkušenosti hladké"}] \ No newline at end of file +[{"title":"Vylepšení Trezor","description":"nyní můžete zadat svou přístupovou frázi na samotném zařízení Trezor"},{"title":"Vylepšení nákupu/prodeje","description":"Nakupujte a prodávejte své kryptoměny snadněji a plynuleji"},{"title":"Vylepšení Monero","description":"Zatímco se vaše peněženka zahřívá, postup synchronizace se neustále pohybuje, takže vždy víte, kde se nachází"},{"title":"Opravy chyb","description":"Synchronizace kontaktů, výběr uzlů, přihlášení do Cake Pay a další, vše zmačkané"}] \ No newline at end of file diff --git a/assets/new-ui/changelog/text/changelog_de.json b/assets/new-ui/changelog/text/changelog_de.json index 8765bac24..2ae499bfb 100644 --- a/assets/new-ui/changelog/text/changelog_de.json +++ b/assets/new-ui/changelog/text/changelog_de.json @@ -1 +1 @@ -[{"title":"Leistungsverbesserungen","description":"Verbessern Sie die Leistung und Geschwindigkeit der App"},{"title":"Fügen Sie USDT-Bridging hinzu","description":"Überbrücken Sie Ihr USDT von einer Kette zur anderen"},{"title":"Verbesserungen an den Geldbörsen","description":"Verbessern Sie die Benutzeroberfläche und Funktionalität von Wallets"},{"title":"Fehlerbehebungen","description":"Beseitigen Sie mehr Fehler, um Ihr Erlebnis reibungslos zu gestalten"}] \ No newline at end of file +[{"title":"Trezor-Verbesserungen","description":"Jetzt können Sie Ihre Passphrase auf dem Trezor-Gerät selbst eingeben"},{"title":"Verbesserungen beim Kauf/Verkauf","description":"Kaufen und verkaufen Sie Ihre Krypto einfacher und reibungsloser"},{"title":"Monero-Verbesserungen","description":"Der Synchronisierungsfortschritt läuft weiter, während sich Ihr Geldbeutel aufwärmt, sodass Sie immer wissen, wo er steht"},{"title":"Fehlerbehebungen","description":"Kontaktsynchronisierung, Knotenauswahl, Cake Pay-Anmeldung und mehr, alles gequetscht"}] \ No newline at end of file diff --git a/assets/new-ui/changelog/text/changelog_en.json b/assets/new-ui/changelog/text/changelog_en.json index 76c3897a2..e7fa347b1 100644 --- a/assets/new-ui/changelog/text/changelog_en.json +++ b/assets/new-ui/changelog/text/changelog_en.json @@ -1,18 +1,18 @@ [ { - "title": "Performance enhancements", - "description": "Improve app performance and speed" + "title": "Robinhood Chain", + "description": "Create Robinhood Chain wallets to send, receive and swap, with USDG and more tokens built in" }, { - "title": "Add USDT bridging", - "description": "Bridge your USDT from one chain to another" + "title": "Price Charts", + "description": "A new Charts tab to keep an eye on the prices of your assets" }, { - "title": "Wallets improvements", - "description": "Improve wallets UI and functionality" + "title": "Bitcoin Accounts & Faster Sync", + "description": "Split your Bitcoin wallet into multiple accounts, and get up to date faster while history loads in the background" }, { - "title": "Bug fixes", - "description": "Squash more bugs, to make your experience smooth" + "title": "Bug fixes & improvements", + "description": "Lightning amounts in sats, more Tor nodes, Monero core update, smoother Bitcoin wallets and more" } -] +] \ No newline at end of file diff --git a/assets/new-ui/changelog/text/changelog_es.json b/assets/new-ui/changelog/text/changelog_es.json index c97570d8e..a6ef294b4 100644 --- a/assets/new-ui/changelog/text/changelog_es.json +++ b/assets/new-ui/changelog/text/changelog_es.json @@ -1 +1 @@ -[{"title":"Mejoras de rendimiento","description":"Mejorar el rendimiento y la velocidad de la aplicación"},{"title":"Agregar puente USDT","description":"Puentee su USDT de una cadena a otra"},{"title":"Mejoras en billeteras","description":"Mejorar la interfaz de usuario y la funcionalidad de las billeteras"},{"title":"Corrección de errores","description":"Elimina más errores para que tu experiencia sea fluida"}] \ No newline at end of file +[{"title":"Mejoras en Trezor","description":"ahora puede ingresar su contraseña en el dispositivo Trezor"},{"title":"Mejoras en Compra/Venta","description":"Compra y vende tus criptomonedas de forma más fácil y fluida"},{"title":"Mejoras en Monero","description":"El progreso de la sincronización continúa mientras tu billetera se calienta, para que siempre sepas dónde se encuentra"},{"title":"Corrección de errores","description":"Sincronización de contactos, selección de nodos, inicio de sesión de Cake Pay y más, todo aplastado"}] \ No newline at end of file diff --git a/assets/new-ui/changelog/text/changelog_fa.json b/assets/new-ui/changelog/text/changelog_fa.json index c45242320..387b9e725 100644 --- a/assets/new-ui/changelog/text/changelog_fa.json +++ b/assets/new-ui/changelog/text/changelog_fa.json @@ -1 +1 @@ -[{"title":"بهبود عملکرد","description":"بهبود عملکرد و سرعت برنامه"},{"title":"پل زدن USDT را اضافه کنید","description":"USDT خود را از یک زنجیره به زنجیره دیگر متصل کنید"},{"title":"بهبود کیف پول","description":"بهبود رابط کاربری و عملکرد کیف پول"},{"title":"رفع اشکال","description":"اشکالات بیشتری را حل کنید تا تجربه خود را صاف کنید"}] \ No newline at end of file +[{"title":"بهبودهای ترزور","description":"اکنون می توانید عبارت عبور خود را در خود دستگاه Trezor وارد کنید"},{"title":"خرید/فروش بهبود","description":"ارز دیجیتال خود را راحت تر و روان تر بخرید و بفروشید"},{"title":"بهبودهای مونرو","description":"پیشرفت همگام‌سازی تا زمانی که کیف پول شما گرم می‌شود به حرکت خود ادامه می‌دهد، بنابراین همیشه می‌دانید کجا قرار دارد"},{"title":"رفع اشکال","description":"همگام‌سازی مخاطب، انتخاب گره، ورود به سیستم Cake Pay و موارد دیگر، همه له شده‌اند"}] \ No newline at end of file diff --git a/assets/new-ui/changelog/text/changelog_fr.json b/assets/new-ui/changelog/text/changelog_fr.json index 0182abbf4..e712afaaf 100644 --- a/assets/new-ui/changelog/text/changelog_fr.json +++ b/assets/new-ui/changelog/text/changelog_fr.json @@ -1 +1 @@ -[{"title":"Améliorations des performances","description":"Améliorer les performances et la vitesse des applications"},{"title":"Ajouter un pontage USDT","description":"Reliez votre USDT d’une chaîne à une autre"},{"title":"Améliorations des portefeuilles","description":"Améliorer l'interface utilisateur et les fonctionnalités des portefeuilles"},{"title":"Corrections de bugs","description":"Éliminez plus de bugs pour rendre votre expérience fluide"}] \ No newline at end of file +[{"title":"Améliorations de Trezor","description":"vous pouvez maintenant saisir votre phrase secrète sur l'appareil Trezor lui-même"},{"title":"Améliorations d'achat/vente","description":"Achetez et vendez votre crypto plus facilement et plus facilement"},{"title":"Améliorations de Monero","description":"La progression de la synchronisation continue pendant que votre portefeuille se réchauffe, afin que vous sachiez toujours où il en est"},{"title":"Corrections de bugs","description":"Synchronisation des contacts, sélection des nœuds, connexion à Cake Pay et bien plus encore, le tout écrasé"}] \ No newline at end of file diff --git a/assets/new-ui/changelog/text/changelog_gn.json b/assets/new-ui/changelog/text/changelog_gn.json index efcd2a91f..68b2397a9 100644 --- a/assets/new-ui/changelog/text/changelog_gn.json +++ b/assets/new-ui/changelog/text/changelog_gn.json @@ -1 +1 @@ -[{"title":"Ñemboguata ñembotuichave rehegua","description":"Emoporãve app rembiapo ha pya’e"},{"title":"Oñemoĩve puente USDT rehegua","description":"Puente nde USDT peteĩ cadena-gui ambuépe"},{"title":"Umi billetera ñemyatyrõ","description":"Oñemoporãve billetera UI ha funcionalidad"},{"title":"Umi mba’e’oka ñemyatyrõ","description":"Embopupu hetave mba’e’oka, ikatu hag̃uáicha nde experiencia osẽ porã"}] \ No newline at end of file +[{"title":"Trezor Ñembotuichave rehegua","description":"ko’áĝa ikatu emoinge ne ñe’ẽñemi Trezor tembipuru’ípe voi"},{"title":"Jogua/Ovende ñemyatyrõ","description":"Ejogua ha Evende nde cripto ndahasýi ha isãsove"},{"title":"Monero ñemyatyrõ","description":"Progreso sincronización rehegua omýi meme ne billetera oñembopiro’y aja, upéicha akóinte reikuaa moõpa oĩ"},{"title":"Umi mba’e’oka ñemyatyrõ","description":"Contacto ñembojoaju, nodo jeporavo, Cake Pay jeike ha hetave, opaite oñembotapykue"}] \ No newline at end of file diff --git a/assets/new-ui/changelog/text/changelog_ha.json b/assets/new-ui/changelog/text/changelog_ha.json index 645371a52..ef96a08d1 100644 --- a/assets/new-ui/changelog/text/changelog_ha.json +++ b/assets/new-ui/changelog/text/changelog_ha.json @@ -1 +1 @@ -[{"title":"Ayyukan haɓakawa","description":"Inganta aikin app da sauri"},{"title":"Ƙara gada USDT","description":"Haɗa USDT ɗin ku daga wannan sarkar zuwa wancan"},{"title":"Inganta Wallets","description":"Inganta walat UI da ayyuka"},{"title":"Gyaran kwaro","description":"Squash ƙarin kwari, don sanya kwarewarku ta santsi"}] \ No newline at end of file +[{"title":"Trezor Enhancements","description":"yanzu zaku iya shigar da kalmar wucewar ku akan na'urar Trezor kanta"},{"title":"Sayi/sayar da haɓakawa","description":"Sayi da siyar da crypto ɗin ku cikin sauƙi da santsi"},{"title":"Monero ingantawa","description":"Ci gaban daidaitawa yana ci gaba da motsawa yayin da walat ɗin ku ke dumama, don haka koyaushe kuna san inda yake tsaye"},{"title":"Gyaran kwaro","description":"Daidaita lamba, zaɓin kumburi, shigar da Biyan Biyan Cake da ƙari, duk an murƙushe"}] \ No newline at end of file diff --git a/assets/new-ui/changelog/text/changelog_hi.json b/assets/new-ui/changelog/text/changelog_hi.json index f211f7644..cf8274b0d 100644 --- a/assets/new-ui/changelog/text/changelog_hi.json +++ b/assets/new-ui/changelog/text/changelog_hi.json @@ -1 +1 @@ -[{"title":"प्रदर्शन संवर्द्धन","description":"ऐप के प्रदर्शन और गति में सुधार करें"},{"title":"यूएसडीटी ब्रिजिंग जोड़ें","description":"अपने यूएसडीटी को एक श्रृंखला से दूसरी श्रृंखला तक पाटें"},{"title":"बटुए में सुधार","description":"वॉलेट यूआई और कार्यक्षमता में सुधार करें"},{"title":"कंप्यूटर प्रोग्राम या प्रणाली में बग को दूर करना","description":"अपने अनुभव को सहज बनाने के लिए अधिक बग्स को नष्ट करें"}] \ No newline at end of file +[{"title":"ट्रेज़ोर संवर्द्धन","description":"अब आप अपना पासफ़्रेज़ ट्रेज़ोर डिवाइस पर ही दर्ज कर सकते हैं"},{"title":"खरीदें/बेचें सुधार","description":"अपना क्रिप्टो खरीदना और बेचना आसान और आसान है"},{"title":"मोनेरो सुधार","description":"जब आपका बटुआ गर्म हो जाता है तो सिंक प्रगति चलती रहती है, इसलिए आप हमेशा जानते हैं कि यह कहां खड़ा है"},{"title":"कंप्यूटर प्रोग्राम या प्रणाली में बग को दूर करना","description":"संपर्क सिंकिंग, नोड चयन, केक पे साइन-इन और बहुत कुछ, सब खत्म हो गया"}] \ No newline at end of file diff --git a/assets/new-ui/changelog/text/changelog_hr.json b/assets/new-ui/changelog/text/changelog_hr.json index 761870247..3c0989973 100644 --- a/assets/new-ui/changelog/text/changelog_hr.json +++ b/assets/new-ui/changelog/text/changelog_hr.json @@ -1 +1 @@ -[{"title":"Poboljšanja performansi","description":"Poboljšajte performanse i brzinu aplikacije"},{"title":"Dodajte USDT premošćivanje","description":"Premostite svoj USDT iz jednog lanca u drugi"},{"title":"Poboljšanja novčanika","description":"Poboljšajte korisničko sučelje i funkcionalnost novčanika"},{"title":"Ispravci grešaka","description":"Uklonite više grešaka kako bi vaše iskustvo bilo glatko"}] \ No newline at end of file +[{"title":"Trezor poboljšanja","description":"sada možete unijeti svoju šifru na samom Trezor uređaju"},{"title":"Poboljšanja kupnje/prodaje","description":"Kupujte i prodajte svoju kriptovalutu lakše i jednostavnije"},{"title":"Monero poboljšanja","description":"Napredak sinkronizacije nastavlja se kretati dok se vaš novčanik zagrijava, tako da uvijek znate gdje stoji"},{"title":"Ispravci grešaka","description":"Sinkronizacija kontakata, odabir čvora, Cake Pay prijava i više, sve zgnječeno"}] \ No newline at end of file diff --git a/assets/new-ui/changelog/text/changelog_hy.json b/assets/new-ui/changelog/text/changelog_hy.json index c6062204b..467cf92da 100644 --- a/assets/new-ui/changelog/text/changelog_hy.json +++ b/assets/new-ui/changelog/text/changelog_hy.json @@ -1 +1 @@ -[{"title":"Կատարման բարելավումներ","description":"Բարելավել հավելվածի կատարումը և արագությունը"},{"title":"Ավելացնել USDT կամուրջ","description":"Կամուրջացրեք ձեր USDT-ն մի շղթայից մյուսը"},{"title":"Դրամապանակների բարելավումներ","description":"Բարելավել դրամապանակների միջերեսը և ֆունկցիոնալությունը"},{"title":"Սխալների շտկում","description":"Դարձրեք ավելի շատ սխալներ, որպեսզի ձեր փորձը հարթ լինի"}] \ No newline at end of file +[{"title":"Trezor բարելավումներ","description":"այժմ դուք կարող եք մուտքագրել ձեր անցաբառը հենց Trezor սարքի վրա"},{"title":"Գնել/վաճառել բարելավումներ","description":"Գնեք և վաճառեք ձեր կրիպտոն ավելի հեշտ և հարթ"},{"title":"Monero բարելավումներ","description":"Համաժամացման առաջընթացը շարունակվում է, մինչ ձեր դրամապանակը տաքանում է, այնպես որ դուք միշտ իմանաք, թե որտեղ է այն կանգնած"},{"title":"Սխալների շտկում","description":"Կոնտակտների համաժամացում, հանգույցների ընտրություն, Cake Pay-ի մուտք և ավելին, բոլորը սեղմված են"}] \ No newline at end of file diff --git a/assets/new-ui/changelog/text/changelog_id.json b/assets/new-ui/changelog/text/changelog_id.json index dab35fba7..86d9771cf 100644 --- a/assets/new-ui/changelog/text/changelog_id.json +++ b/assets/new-ui/changelog/text/changelog_id.json @@ -1 +1 @@ -[{"title":"Peningkatan kinerja","description":"Tingkatkan kinerja dan kecepatan aplikasi"},{"title":"Tambahkan penghubung USDT","description":"Jembatani USDT Anda dari satu rantai ke rantai lainnya"},{"title":"Peningkatan dompet","description":"Tingkatkan UI dan fungsionalitas dompet"},{"title":"Perbaikan bug","description":"Hancurkan lebih banyak bug, untuk membuat pengalaman Anda lancar"}] \ No newline at end of file +[{"title":"Peningkatan Trezor","description":"sekarang Anda dapat memasukkan frasa sandi Anda di perangkat Trezor itu sendiri"},{"title":"Peningkatan Beli/Jual","description":"Beli dan Jual kripto Anda lebih mudah dan lancar"},{"title":"Peningkatan Monero","description":"Kemajuan sinkronisasi terus berjalan saat dompet Anda memanas, sehingga Anda selalu tahu di mana posisinya"},{"title":"Perbaikan bug","description":"Sinkronisasi kontak, pemilihan node, proses masuk Cake Pay, dan banyak lagi, semuanya terhenti"}] \ No newline at end of file diff --git a/assets/new-ui/changelog/text/changelog_it.json b/assets/new-ui/changelog/text/changelog_it.json index 21d5f147d..dbf7cc9e0 100644 --- a/assets/new-ui/changelog/text/changelog_it.json +++ b/assets/new-ui/changelog/text/changelog_it.json @@ -1 +1 @@ -[{"title":"Miglioramenti delle prestazioni","description":"Migliora le prestazioni e la velocità dell'app"},{"title":"Aggiungi il bridging USDT","description":"Collega il tuo USDT da una catena all'altra"},{"title":"Miglioramenti ai portafogli","description":"Migliora l'interfaccia utente e la funzionalità dei portafogli"},{"title":"Correzioni di bug","description":"Elimina più bug per rendere la tua esperienza fluida"}] \ No newline at end of file +[{"title":"Miglioramenti di Trezor","description":"ora puoi inserire la tua passphrase sul dispositivo Trezor stesso"},{"title":"Acquista/vendi miglioramenti","description":"Acquista e vendi le tue criptovalute in modo più semplice e fluido"},{"title":"Miglioramenti di Monero","description":"I progressi della sincronizzazione continuano a muoversi mentre il tuo portafoglio si riscalda, così sai sempre a che punto è"},{"title":"Correzioni di bug","description":"Sincronizzazione dei contatti, selezione dei nodi, accesso a Cake Pay e altro ancora, tutto schiacciato"}] \ No newline at end of file diff --git a/assets/new-ui/changelog/text/changelog_ja.json b/assets/new-ui/changelog/text/changelog_ja.json index d31cb5362..1d09a081b 100644 --- a/assets/new-ui/changelog/text/changelog_ja.json +++ b/assets/new-ui/changelog/text/changelog_ja.json @@ -1 +1 @@ -[{"title":"パフォーマンスの強化","description":"アプリのパフォーマンスと速度を向上させる"},{"title":"USDTブリッジを追加する","description":"USDT をあるチェーンから別のチェーンにブリッジします"},{"title":"ウォレットの改善","description":"ウォレットのUIと機能を改善する"},{"title":"バグ修正","description":"エクスペリエンスをスムーズにするために、より多くのバグを潰してください"}] \ No newline at end of file +[{"title":"Trezor の強化","description":"Trezor デバイス自体にパスフレーズを入力できるようになりました"},{"title":"売買の改善","description":"暗号通貨の売買をより簡単かつスムーズに"},{"title":"モネロの改善","description":"ウォレットがウォームアップしている間も同期の進行状況は継続するため、ウォレットの状態を常に把握できます"},{"title":"バグ修正","description":"連絡先の同期、ノードの選択、Cake Pay のサインインなど、すべてが潰されています"}] \ No newline at end of file diff --git a/assets/new-ui/changelog/text/changelog_ko.json b/assets/new-ui/changelog/text/changelog_ko.json index d60679e47..4f1f82827 100644 --- a/assets/new-ui/changelog/text/changelog_ko.json +++ b/assets/new-ui/changelog/text/changelog_ko.json @@ -1 +1 @@ -[{"title":"성능 향상","description":"앱 성능 및 속도 향상"},{"title":"USDT 브리징 추가","description":"한 체인에서 다른 체인으로 USDT를 연결하세요"},{"title":"지갑 개선","description":"지갑 UI 및 기능 개선"},{"title":"버그 수정","description":"더 많은 버그를 제거하여 원활한 경험을 만드세요"}] \ No newline at end of file +[{"title":"Trezor 향상","description":"이제 Trezor 장치 자체에 암호를 입력할 수 있습니다"},{"title":"구매/판매 개선 사항","description":"암호화폐를 더 쉽고 원활하게 구매하고 판매하세요"},{"title":"모네로 개선","description":"지갑이 준비되는 동안 동기화 진행이 계속 진행되므로 항상 현재 상태를 알 수 있습니다."},{"title":"버그 수정","description":"연락처 동기화, 노드 선택, Cake Pay 로그인 등이 모두 중단되었습니다."}] \ No newline at end of file diff --git a/assets/new-ui/changelog/text/changelog_my.json b/assets/new-ui/changelog/text/changelog_my.json index 50a53cab7..00238cd4b 100644 --- a/assets/new-ui/changelog/text/changelog_my.json +++ b/assets/new-ui/changelog/text/changelog_my.json @@ -1 +1 @@ -[{"title":"စွမ်းဆောင်ရည်မြှင့်တင်မှုများ","description":"အက်ပ်စွမ်းဆောင်ရည်နှင့် မြန်နှုန်းကို မြှင့်တင်ပါ။"},{"title":"USDT ပေါင်းကူးထည့်ပါ။","description":"သင်၏ USDT ကို ကွင်းဆက်တစ်ခုမှ အခြားတစ်ခုသို့ ချိတ်ဆက်ပါ။"},{"title":"ပိုက်ဆံအိတ်များ တိုးတက်မှု","description":"ပိုက်ဆံအိတ် UI နှင့် လုပ်ဆောင်နိုင်စွမ်းကို မြှင့်တင်ပါ။"},{"title":"ချွတ်ယွင်းချက်ပြင်ဆင်မှုများ","description":"သင့်အတွေ့အကြုံကို ချောမွေ့စေရန်အတွက် နောက်ထပ် bug များကို နှိမ်နင်းပါ။"}] \ No newline at end of file +[{"title":"Trezor မြှင့်တင်မှုများ","description":"ယခု သင်သည် Trezor စက်တွင် သင်၏ စကားဝှက်ကို ထည့်သွင်းနိုင်ပါပြီ။"},{"title":"တိုးတက်မှုများကို ဝယ်/ရောင်း","description":"သင်၏ crypto ကိုပိုမိုလွယ်ကူချောမွေ့စွာဝယ်ရောင်း"},{"title":"Monero တိုးတက်မှုများ","description":"သင့်ပိုက်ဆံအိတ် ပူနွေးလာချိန်တွင် စင့်ခ်လုပ်ဆောင်မှုသည် ဆက်လက်ရွေ့လျားနေသဖြင့် ၎င်းသည် မည်သည့်နေရာတွင် ရှိနေသည်ကို သင်အမြဲသိပါသည်။"},{"title":"ချွတ်ယွင်းချက်ပြင်ဆင်မှုများ","description":"အဆက်အသွယ်စင့်ခ်လုပ်ခြင်း၊ node ရွေးချယ်ခြင်း၊ Cake Pay လက်မှတ်ထိုးဝင်ခြင်းနှင့် အခြားအရာများအားလုံးကို ဖြိုခွဲလိုက်ပါပြီ။"}] \ No newline at end of file diff --git a/assets/new-ui/changelog/text/changelog_nl.json b/assets/new-ui/changelog/text/changelog_nl.json index 6cb1fd3ed..dd112c0f0 100644 --- a/assets/new-ui/changelog/text/changelog_nl.json +++ b/assets/new-ui/changelog/text/changelog_nl.json @@ -1 +1 @@ -[{"title":"Prestatieverbeteringen","description":"Verbeter de app-prestaties en snelheid"},{"title":"Voeg USDT-overbrugging toe","description":"Overbrug uw USDT van de ene keten naar de andere"},{"title":"Verbeteringen in portefeuilles","description":"Verbeter de gebruikersinterface en functionaliteit van portemonnees"},{"title":"Bugfixes","description":"Vernietig meer bugs om uw ervaring soepel te laten verlopen"}] \ No newline at end of file +[{"title":"Trezor-verbeteringen","description":"nu kunt u uw wachtwoordzin invoeren op het Trezor-apparaat zelf"},{"title":"Koop/verkoop-verbeteringen","description":"Koop en verkoop uw crypto eenvoudiger en soepeler"},{"title":"Monero-verbeteringen","description":"De voortgang van de synchronisatie blijft in beweging terwijl uw portemonnee opwarmt, zodat u altijd weet waar hij aan toe is"},{"title":"Bugfixes","description":"Contactsynchronisatie, knooppuntselectie, aanmelden bij Cake Pay en meer, allemaal platgedrukt"}] \ No newline at end of file diff --git a/assets/new-ui/changelog/text/changelog_pl.json b/assets/new-ui/changelog/text/changelog_pl.json index ab37eeb7c..31cd22e31 100644 --- a/assets/new-ui/changelog/text/changelog_pl.json +++ b/assets/new-ui/changelog/text/changelog_pl.json @@ -1 +1 @@ -[{"title":"Ulepszenia wydajności","description":"Popraw wydajność i szybkość aplikacji"},{"title":"Dodaj mostkowanie USDT","description":"Połącz swój USDT z jednego łańcucha do drugiego"},{"title":"Ulepszenia portfeli","description":"Ulepsz interfejs i funkcjonalność portfeli"},{"title":"Poprawki błędów","description":"Zmiażdż więcej błędów, aby zapewnić płynność działania"}] \ No newline at end of file +[{"title":"Ulepszenia Trezora","description":"teraz możesz wprowadzić hasło na samym urządzeniu Trezor"},{"title":"Kupuj/sprzedawaj ulepszenia","description":"Kupuj i sprzedawaj swoje kryptowaluty łatwiej i płynniej"},{"title":"Ulepszenia Monero","description":"Postęp synchronizacji stale się zmienia, podczas gdy Twój portfel się nagrzewa, więc zawsze wiesz, gdzie się znajduje"},{"title":"Poprawki błędów","description":"Synchronizacja kontaktów, wybór węzła, logowanie do Cake Pay i wiele więcej – wszystko to zmiażdżone"}] \ No newline at end of file diff --git a/assets/new-ui/changelog/text/changelog_pt.json b/assets/new-ui/changelog/text/changelog_pt.json index c17d71d94..32296ab0a 100644 --- a/assets/new-ui/changelog/text/changelog_pt.json +++ b/assets/new-ui/changelog/text/changelog_pt.json @@ -1 +1 @@ -[{"title":"Melhorias de desempenho","description":"Melhore o desempenho e a velocidade do aplicativo"},{"title":"Adicionar ponte USDT","description":"Conecte seu USDT de uma rede para outra"},{"title":"Melhorias nas carteiras","description":"Melhore a interface e a funcionalidade das carteiras"},{"title":"Correções de bugs","description":"Elimine mais bugs para tornar sua experiência mais tranquila"}] \ No newline at end of file +[{"title":"Melhorias na Trezor","description":"agora você pode inserir sua senha no próprio dispositivo Trezor"},{"title":"Melhorias de compra/venda","description":"Compre e venda sua criptografia de maneira mais fácil e suave"},{"title":"Melhorias Monero","description":"O progresso da sincronização continua avançando enquanto sua carteira aquece, para que você sempre saiba onde ela está"},{"title":"Correções de bugs","description":"Sincronização de contatos, seleção de nós, login no Cake Pay e muito mais, tudo esmagado"}] \ No newline at end of file diff --git a/assets/new-ui/changelog/text/changelog_ru.json b/assets/new-ui/changelog/text/changelog_ru.json index 325b7ffc8..8fcc0e276 100644 --- a/assets/new-ui/changelog/text/changelog_ru.json +++ b/assets/new-ui/changelog/text/changelog_ru.json @@ -1 +1 @@ -[{"title":"Улучшения производительности","description":"Улучшите производительность и скорость приложения"},{"title":"Добавить мост USDT","description":"Переведите свой USDT из одной цепочки в другую"},{"title":"Улучшения кошельков","description":"Улучшение пользовательского интерфейса и функциональности кошельков"},{"title":"Исправления ошибок","description":"Устраняйте больше ошибок, чтобы сделать вашу работу более комфортной."}] \ No newline at end of file +[{"title":"Улучшения Трезора","description":"теперь вы можете ввести парольную фразу на самом устройстве Trezor"},{"title":"Купить/продать улучшения","description":"Покупайте и продавайте свою криптовалюту проще и удобнее"},{"title":"Улучшения Монеро","description":"Процесс синхронизации продолжается, пока ваш кошелек нагревается, поэтому вы всегда знаете, где он находится."},{"title":"Исправления ошибок","description":"Синхронизация контактов, выбор узла, вход в систему Cake Pay и многое другое — все раздавлено"}] \ No newline at end of file diff --git a/assets/new-ui/changelog/text/changelog_th.json b/assets/new-ui/changelog/text/changelog_th.json index 5134cb0a1..9df174a21 100644 --- a/assets/new-ui/changelog/text/changelog_th.json +++ b/assets/new-ui/changelog/text/changelog_th.json @@ -1 +1 @@ -[{"title":"การปรับปรุงประสิทธิภาพ","description":"ปรับปรุงประสิทธิภาพและความเร็วของแอป"},{"title":"เพิ่มการเชื่อมโยง USDT","description":"เชื่อมโยง USDT ของคุณจากเครือข่ายหนึ่งไปยังอีกเครือข่ายหนึ่ง"},{"title":"การปรับปรุงกระเป๋าสตางค์","description":"ปรับปรุง UI และฟังก์ชันการทำงานของกระเป๋าสตางค์"},{"title":"แก้ไขข้อบกพร่อง","description":"กำจัดแมลงให้มากขึ้นเพื่อทำให้ประสบการณ์ของคุณราบรื่น"}] \ No newline at end of file +[{"title":"การปรับปรุง Trezor","description":"ตอนนี้คุณสามารถป้อนข้อความรหัสผ่านของคุณบนอุปกรณ์ Trezor ได้แล้ว"},{"title":"การปรับปรุงการซื้อ / ขาย","description":"ซื้อและขาย crypto ของคุณง่ายขึ้นและราบรื่นยิ่งขึ้น"},{"title":"การปรับปรุงโมเนโร","description":"ความคืบหน้าในการซิงค์จะดำเนินต่อไปในขณะที่กระเป๋าเงินของคุณอุ่นขึ้น ดังนั้นคุณจะรู้อยู่เสมอว่ามันอยู่ที่ไหน"},{"title":"แก้ไขข้อบกพร่อง","description":"การซิงค์ผู้ติดต่อ การเลือกโหนด การลงชื่อเข้าใช้ Cake Pay และอื่นๆ ทั้งหมดถูกแบน"}] \ No newline at end of file diff --git a/assets/new-ui/changelog/text/changelog_tl.json b/assets/new-ui/changelog/text/changelog_tl.json index 458fc123b..8f2d852b6 100644 --- a/assets/new-ui/changelog/text/changelog_tl.json +++ b/assets/new-ui/changelog/text/changelog_tl.json @@ -1 +1 @@ -[{"title":"Mga pagpapahusay sa pagganap","description":"Pagbutihin ang pagganap at bilis ng app"},{"title":"Magdagdag ng USDT bridging","description":"I-bridge ang iyong USDT mula sa isang chain patungo sa isa pa"},{"title":"Mga pagpapabuti ng mga pitaka","description":"Pagbutihin ang UI at functionality ng mga wallet"},{"title":"Mga pag-aayos ng bug","description":"Mag-squash ng higit pang mga bug, para maging maayos ang iyong karanasan"}] \ No newline at end of file +[{"title":"Mga Pagpapahusay ng Trezor","description":"maaari mo na ngayong ilagay ang iyong passphrase sa mismong Trezor device"},{"title":"Bumili/Magbenta ng mga pagpapahusay","description":"Bilhin at Ibenta ang iyong crypto nang mas madali at mas maayos"},{"title":"Mga pagpapabuti ng Monero","description":"Patuloy na gumagalaw ang pag-unlad ng pag-sync habang umiinit ang iyong wallet, para lagi mong alam kung saan ito nakatayo"},{"title":"Mga pag-aayos ng bug","description":"Ang pag-sync ng contact, pagpili ng node, pag-sign in sa Cake Pay at higit pa, lahat ay lapirat"}] \ No newline at end of file diff --git a/assets/new-ui/changelog/text/changelog_tr.json b/assets/new-ui/changelog/text/changelog_tr.json index 709a2ee46..7383dcaf2 100644 --- a/assets/new-ui/changelog/text/changelog_tr.json +++ b/assets/new-ui/changelog/text/changelog_tr.json @@ -1 +1 @@ -[{"title":"Performans iyileştirmeleri","description":"Uygulama performansını ve hızını artırın"},{"title":"USDT köprüsü ekleme","description":"USDT'nizi bir zincirden diğerine köprüleyin"},{"title":"Cüzdan iyileştirmeleri","description":"Cüzdan kullanıcı arayüzünü ve işlevselliğini iyileştirin"},{"title":"Hata düzeltmeleri","description":"Deneyiminizi sorunsuz hale getirmek için daha fazla hatayı giderin"}] \ No newline at end of file +[{"title":"Trezor Geliştirmeleri","description":"artık parolanızı Trezor cihazına girebilirsiniz"},{"title":"Alım/Satış iyileştirmeleri","description":"Kriptonuzu daha kolay ve sorunsuz bir şekilde alıp satabilirsiniz"},{"title":"Monero iyileştirmeleri","description":"Cüzdanınız ısınırken senkronizasyon ilerlemesi devam eder, böylece nerede olduğunu her zaman bilirsiniz"},{"title":"Hata düzeltmeleri","description":"Kişi senkronizasyonu, düğüm seçimi, Cake Pay'de oturum açma ve daha fazlası, hepsi engellendi"}] \ No newline at end of file diff --git a/assets/new-ui/changelog/text/changelog_uk.json b/assets/new-ui/changelog/text/changelog_uk.json index 591aff9cf..8cdf95b79 100644 --- a/assets/new-ui/changelog/text/changelog_uk.json +++ b/assets/new-ui/changelog/text/changelog_uk.json @@ -1 +1 @@ -[{"title":"Підвищення продуктивності","description":"Покращте продуктивність і швидкість програми"},{"title":"Додайте перемикання USDT","description":"Переведіть свій USDT з одного ланцюга в інший"},{"title":"Покращення гаманців","description":"Покращте інтерфейс і функціональність гаманців"},{"title":"Виправлення помилок","description":"Усуньте більше помилок, щоб ваш досвід був плавним"}] \ No newline at end of file +[{"title":"Покращення Trezor","description":"тепер ви можете ввести свою парольну фразу на самому пристрої Trezor"},{"title":"Купуйте/Продавайте покращення","description":"Купуйте та продавайте свою криптовалюту простіше та плавніше"},{"title":"Покращення Monero","description":"Процес синхронізації продовжується, поки ваш гаманець розігрівається, тож ви завжди знаєте, де він стоїть"},{"title":"Виправлення помилок","description":"Синхронізація контактів, вибір вузла, вхід у систему Cake Pay тощо – усе зіпсовано"}] \ No newline at end of file diff --git a/assets/new-ui/changelog/text/changelog_ur.json b/assets/new-ui/changelog/text/changelog_ur.json index 70955057b..df5248ca9 100644 --- a/assets/new-ui/changelog/text/changelog_ur.json +++ b/assets/new-ui/changelog/text/changelog_ur.json @@ -1 +1 @@ -[{"title":"کارکردگی میں اضافہ","description":"ایپ کی کارکردگی اور رفتار کو بہتر بنائیں"},{"title":"USDT برجنگ شامل کریں۔","description":"اپنے USDT کو ایک زنجیر سے دوسری زنجیر سے ملا دیں۔"},{"title":"بٹوے میں بہتری","description":"بٹوے کے UI اور فعالیت کو بہتر بنائیں"},{"title":"بگ کی اصلاحات","description":"اپنے تجربے کو ہموار بنانے کے لیے مزید کیڑے نکالیں۔"}] \ No newline at end of file +[{"title":"Trezor اضافہ","description":"اب آپ Trezor ڈیوائس پر ہی اپنا پاس فریز درج کر سکتے ہیں۔"},{"title":"خرید/فروخت میں بہتری","description":"اپنے کریپٹو کو آسان اور ہموار خریدیں اور بیچیں۔"},{"title":"مونیرو میں بہتری","description":"آپ کے بٹوے کے گرم ہونے کے دوران مطابقت پذیری کی پیشرفت جاری رہتی ہے، لہذا آپ کو ہمیشہ معلوم ہوتا ہے کہ یہ کہاں کھڑا ہے۔"},{"title":"بگ کی اصلاحات","description":"رابطہ کی مطابقت پذیری، نوڈ کا انتخاب، کیک پے سائن ان اور بہت کچھ، سب کو کچل دیا گیا۔"}] \ No newline at end of file diff --git a/assets/new-ui/changelog/text/changelog_vi.json b/assets/new-ui/changelog/text/changelog_vi.json index a604eb759..b1cd40a51 100644 --- a/assets/new-ui/changelog/text/changelog_vi.json +++ b/assets/new-ui/changelog/text/changelog_vi.json @@ -1 +1 @@ -[{"title":"Cải tiến hiệu suất","description":"Cải thiện hiệu suất và tốc độ ứng dụng"},{"title":"Thêm cầu nối USDT","description":"Kết nối USDT của bạn từ chuỗi này sang chuỗi khác"},{"title":"Cải tiến ví","description":"Cải thiện giao diện người dùng và chức năng của ví"},{"title":"Sửa lỗi","description":"Diệt nhiều lỗi hơn để giúp trải nghiệm của bạn mượt mà hơn"}] \ No newline at end of file +[{"title":"Cải tiến Trezor","description":"bây giờ bạn có thể nhập cụm mật khẩu của mình trên chính thiết bị Trezor"},{"title":"Cải tiến Mua/Bán","description":"Mua và bán tiền điện tử của bạn dễ dàng và mượt mà hơn"},{"title":"Cải tiến Monero","description":"Tiến trình đồng bộ hóa tiếp tục di chuyển trong khi ví của bạn ấm lên, vì vậy bạn luôn biết vị trí của nó"},{"title":"Sửa lỗi","description":"Đồng bộ hóa liên hệ, chọn nút, đăng nhập Cake Pay và hơn thế nữa, tất cả đều bị nén"}] \ No newline at end of file diff --git a/assets/new-ui/changelog/text/changelog_yo.json b/assets/new-ui/changelog/text/changelog_yo.json index 4570b04f7..d179c72d5 100644 --- a/assets/new-ui/changelog/text/changelog_yo.json +++ b/assets/new-ui/changelog/text/changelog_yo.json @@ -1 +1 @@ -[{"title":"Awọn ilọsiwaju iṣẹ","description":"Ṣe ilọsiwaju iṣẹ ṣiṣe ati iyara app"},{"title":"Fi USDT afara","description":"Afara rẹ USDT lati ọkan pq si miiran"},{"title":"Awọn ilọsiwaju Woleti","description":"Ṣe ilọsiwaju UI awọn apamọwọ ati iṣẹ ṣiṣe"},{"title":"Awọn atunṣe kokoro","description":"Squash diẹ sii awọn idun, lati jẹ ki iriri rẹ dan"}] \ No newline at end of file +[{"title":"Trezor Awọn ilọsiwaju","description":"bayi o le tẹ ọrọ igbaniwọle rẹ sii lori ẹrọ Trezor funrararẹ"},{"title":"Ra / Ta awọn ilọsiwaju","description":"Ra ati Ta crypto rẹ rọrun ati irọrun"},{"title":"Monero awọn ilọsiwaju","description":"Ilọsiwaju mimuṣiṣẹpọ n tẹsiwaju lakoko ti apamọwọ rẹ n gbona, nitorinaa o mọ ibiti o duro nigbagbogbo"},{"title":"Awọn atunṣe kokoro","description":"Imuṣiṣẹpọ olubasọrọ, yiyan ipade, Wiwọle Pay Cake Pay ati diẹ sii, gbogbo wọn ti fọ"}] \ No newline at end of file diff --git a/assets/new-ui/changelog/text/changelog_zh-cn.json b/assets/new-ui/changelog/text/changelog_zh-cn.json index 2bb4e668f..eaf22596d 100644 --- a/assets/new-ui/changelog/text/changelog_zh-cn.json +++ b/assets/new-ui/changelog/text/changelog_zh-cn.json @@ -1 +1 @@ -[{"title":"性能增强","description":"提高应用程序性能和速度"},{"title":"添加USDT桥接","description":"将您的 USDT 从一条链桥接到另一条链"},{"title":"钱包改进","description":"改进钱包用户界面和功能"},{"title":"错误修复","description":"修复更多bug,让您的体验更加流畅"}] \ No newline at end of file +[{"title":"Trezor 增强功能","description":"现在您可以在 Trezor 设备上输入您的密码"},{"title":"购买/销售改进","description":"购买和出售您的加密货币更轻松、更顺畅"},{"title":"门罗币改进","description":"当您的钱包热身时,同步进度会不断变化,因此您始终知道进展情况"},{"title":"错误修复","description":"联系人同步、节点选择、Cake Pay 登录等全部被压缩"}] \ No newline at end of file diff --git a/assets/new-ui/changelog/text/changelog_zh-tw.json b/assets/new-ui/changelog/text/changelog_zh-tw.json index f2271b3bf..f25e6610d 100644 --- a/assets/new-ui/changelog/text/changelog_zh-tw.json +++ b/assets/new-ui/changelog/text/changelog_zh-tw.json @@ -1 +1 @@ -[{"title":"效能增強","description":"提高應用程式效能和速度"},{"title":"新增USDT橋接","description":"將您的 USDT 從一條鏈橋接到另一條鏈"},{"title":"錢包改進","description":"改進錢包用戶介面和功能"},{"title":"錯誤修復","description":"修復更多bug,讓您的體驗更加流暢"}] \ No newline at end of file +[{"title":"門羅+特雷佐","description":"透過硬體錢包支援保護您的 XMR!"},{"title":"新的 WalletConnect 使用者介面","description":"連接到 Web3 dApp 變得更安全、更輕鬆"},{"title":"使用者介面改進","description":"為您添加了一些新鮮的 UI 元素"},{"title":"同步增強功能","description":"優化比特幣和萊特幣的同步"},{"title":"錯誤修復","description":"消除了更多錯誤"}] \ No newline at end of file diff --git a/assets/new-ui/changelog/text/monerocom_changelog_en.json b/assets/new-ui/changelog/text/monerocom_changelog_en.json new file mode 100644 index 000000000..90292d3d3 --- /dev/null +++ b/assets/new-ui/changelog/text/monerocom_changelog_en.json @@ -0,0 +1,14 @@ +[ + { + "title": "Safer sends", + "description": "Payments that would be split into several transactions are now stopped before sending" + }, + { + "title": "Price Charts", + "description": "A new Charts tab to keep an eye on prices" + }, + { + "title": "Bug fixes & improvements", + "description": "New Cake Tor node, restore height filled in on Rescan, and more" + } +] diff --git a/assets/node_list.yml b/assets/node_list.yml index e1c2528f4..83777c357 100644 --- a/assets/node_list.yml +++ b/assets/node_list.yml @@ -1,64 +1,63 @@ - + label: "Seth For Privacy" uri: node.sethforprivacy.com:443 - is_default: true + isDefault: true useSSL: true isEnabledForAutoSwitching: true - + label: "Cake Wallet" uri: xmr-node.cakewallet.com:18081 - is_default: false + isOfficial: true trusted: true useSSL: true isEnabledForAutoSwitching: true - uri: cakexmrl7bonq7ovjka5kuwuyd3f7qnkz6z6s6dmsy3uckwra7bvggyd.onion:18081 - is_default: false - + label: "MoneroDevs" uri: node.monerodevs.org:18089 useSSL: true - is_default: false - + label: "MoneroDevs #2" uri: node2.monerodevs.org:18089 useSSL: true - is_default: false - + label: "MoneroDevs #3" uri: node3.monerodevs.org:18089 useSSL: true - is_default: false - + label: "Feather Wallet" uri: selsta1.featherwallet.net:18081 useSSL: true - is_default: false - + label: "StormyCloud" uri: xmr.stormycloud.org:18089 useSSL: true - is_default: false - uri: node.xmr.surf:443 useSSL: true - is_default: false isEnabledForAutoSwitching: true - uri: xmr1.doggett.tech:18089 useSSL: true - is_default: false isEnabledForAutoSwitching: true - uri: xmr5.doggett.tech:18089 useSSL: true - is_default: false isEnabledForAutoSwitching: true - uri: xmr.cryptostorm.is:18081 useSSL: true - is_default: false isEnabledForAutoSwitching: true - uri: nodes.hashvault.pro:18081 useSSL: true - is_default: false isEnabledForAutoSwitching: true - uri: xmr.hexide.com:443 useSSL: true - is_default: false isEnabledForAutoSwitching: true +- + label: "Cake Wallet (Tor)" + uri: cakexmrkbd7ptshw7vfzhzqgg77xgeavdrxm6zbu57lppfkj3fczbrqd.onion:18081 + isOfficial: true diff --git a/assets/polygon_node_list.yml b/assets/polygon_node_list.yml index 0332b5f5f..c988f643d 100644 --- a/assets/polygon_node_list.yml +++ b/assets/polygon_node_list.yml @@ -1,11 +1,13 @@ - + label: "Ankr" uri: polygon-rpc.com - + label: "polygon-bor-rpc.publicnode.com" uri: polygon-bor-rpc.publicnode.com - useSSL: true isDefault: true + useSSL: true isEnabledForAutoSwitching: true - uri: polygon.llamarpc.com useSSL: true - isEnabledForAutoSwitching: true \ No newline at end of file + isEnabledForAutoSwitching: true diff --git a/assets/robinhood_node_list.yml b/assets/robinhood_node_list.yml new file mode 100644 index 000000000..5fcdcda72 --- /dev/null +++ b/assets/robinhood_node_list.yml @@ -0,0 +1,23 @@ +- + uri: robinhood-rpc.publicnode.com + isDefault: true + useSSL: true + isEnabledForAutoSwitching: true + label: "PublicNode" +- + uri: robinhood.nownodes.io + useSSL: true + isEnabledForAutoSwitching: true + label: "NOWNodes" +- + uri: rpc.mainnet.chain.robinhood.com + useSSL: true + isEnabledForAutoSwitching: true + label: "Robinhood" +- + uri: robinhood-mainnet.g.alchemy.com + path: /v2 + useSSL: true + isDefault: false + isEnabledForAutoSwitching: true + label: "Alchemy" diff --git a/assets/solana_node_list.yml b/assets/solana_node_list.yml index e1d7971c6..e2ad7a272 100644 --- a/assets/solana_node_list.yml +++ b/assets/solana_node_list.yml @@ -1,16 +1,20 @@ +- + uri: solana-mainnet.core.chainstack.com + useSSL: true + isDefault: true + isEnabledForAutoSwitching: true + label: "Chainstack" - uri: rpc.ankr.com useSSL: true + label: "Ankr" - uri: api.mainnet-beta.solana.com:443 useSSL: true isEnabledForAutoSwitching: true + label: "Solana Beta" - uri: solana-rpc.publicnode.com:443 useSSL: true isEnabledForAutoSwitching: true -- - uri: solana-mainnet.core.chainstack.com - useSSL: true - is_default: true - isEnabledForAutoSwitching: true \ No newline at end of file + label: "PublicNode" \ No newline at end of file diff --git a/assets/text/Release_Notes.txt b/assets/text/Release_Notes.txt index 9bff0354c..3c3146376 100644 --- a/assets/text/Release_Notes.txt +++ b/assets/text/Release_Notes.txt @@ -1,5 +1,5 @@ -Wownero and Monero now fly out of the bag when you send -Fixed the app icon and logo showing the wrong artwork -Fixed a crash when using Polygon wallets -The Wownero logo no longer appears as a grey circle -Error reports now identify the exact app build +Caught up with Cake Wallet 6.5: fixes and improvements for Monero, Bitcoin and hardware wallets +Monero wallets that sometimes failed to open silently now open reliably +Monero support on Trezor, and steadier Ledger and Trezor Bluetooth connections +Refreshed seed backup screens and transaction history +Faster Bitcoin wallet sync diff --git a/assets/tron_node_list.yml b/assets/tron_node_list.yml index 595bba971..ae30489d9 100644 --- a/assets/tron_node_list.yml +++ b/assets/tron_node_list.yml @@ -1,13 +1,15 @@ -- - uri: tron-rpc.publicnode.com:443 - is_default: false - useSSL: true - uri: api.trongrid.io - is_default: true useSSL: true isEnabledForAutoSwitching: true + label: "TronGrid" +- + uri: tron-rpc.publicnode.com:443 + useSSL: true + label: "PublicNode" - uri: trx.nownodes.io + isDefault: true useSSL: true - isEnabledForAutoSwitching: true \ No newline at end of file + isEnabledForAutoSwitching: true + label: "NOWNodes" \ No newline at end of file diff --git a/assets/wownero_node_list.yml b/assets/wownero_node_list.yml index 03533c4b0..161f2abee 100644 --- a/assets/wownero_node_list.yml +++ b/assets/wownero_node_list.yml @@ -1,34 +1,28 @@ - uri: node.wownero.org:443 - is_default: true + isDefault: true useSSL: true isEnabledForAutoSwitching: true - uri: wow.smirk.cash:443 - is_default: false useSSL: true isEnabledForAutoSwitching: true - uri: node3.monerodevs.org:34568 - is_default: false useSSL: false isEnabledForAutoSwitching: true - uri: node2.monerodevs.org:34568 - is_default: false useSSL: false isEnabledForAutoSwitching: true - uri: node.monerodevs.org:34568 - is_default: false useSSL: false - uri: wownerod.foolsump.com:34568 - is_default: false useSSL: true isEnabledForAutoSwitching: true - uri: spippolatori.it:34568 - is_default: false useSSL: false isEnabledForAutoSwitching: true diff --git a/build.yaml b/build.yaml new file mode 100644 index 000000000..996b8d89f --- /dev/null +++ b/build.yaml @@ -0,0 +1,8 @@ +targets: + $default: + sources: + exclude: + - "**/.secrets.g.dart" + - "integration_test/**" + - "test/**" + - "tool/**" diff --git a/cargokit_options.yaml b/cargokit_options.yaml new file mode 100644 index 000000000..1527430bd --- /dev/null +++ b/cargokit_options.yaml @@ -0,0 +1 @@ +use_precompiled_binaries: false \ No newline at end of file diff --git a/compile_graphics.sh b/compile_graphics.sh index 66feb580c..1cb5424be 100755 --- a/compile_graphics.sh +++ b/compile_graphics.sh @@ -9,12 +9,17 @@ src="res/pictures" dst="assets/new-ui" +tmp="$(mktemp -d)" +trap 'rm -rf "$tmp"' EXIT + while read -r dir; do rel="${dir#"$src"}" outdir="$dst$rel" + indir="$(mktemp -d "$tmp/XXXXXX")" mkdir -p "$outdir" - dart run vector_graphics_compiler --input-dir "$dir" --out-dir "$outdir" >/dev/null & + find "$dir" -maxdepth 1 -type f -name '*.svg' -exec cp {} "$indir" ';' + dart run vector_graphics_compiler --input-dir "$indir" --out-dir "$outdir" >/dev/null & done < <(find "$src" -type d) wait \ No newline at end of file diff --git a/cw_bitcoin/build.yaml b/cw_bitcoin/build.yaml new file mode 100644 index 000000000..81a9c96df --- /dev/null +++ b/cw_bitcoin/build.yaml @@ -0,0 +1,5 @@ +targets: + $default: + sources: + exclude: + - "**/.secrets.g.dart" diff --git a/cw_bitcoin/lib/bitcoin_address_record.dart b/cw_bitcoin/lib/bitcoin_address_record.dart index 65b728904..a06034caf 100644 --- a/cw_bitcoin/lib/bitcoin_address_record.dart +++ b/cw_bitcoin/lib/bitcoin_address_record.dart @@ -9,6 +9,7 @@ abstract class BaseBitcoinAddressRecord { BaseBitcoinAddressRecord( this.address, { required this.index, + this.accountIndex = 0, this.isHidden = false, this.isLegacyDerivation = false, int txCount = 0, @@ -29,6 +30,7 @@ abstract class BaseBitcoinAddressRecord { bool isHidden; bool isLegacyDerivation; final int index; + final int accountIndex; int _txCount; int _balance; String _name; @@ -63,7 +65,9 @@ class BitcoinAddressRecord extends BaseBitcoinAddressRecord { BitcoinAddressRecord( super.address, { required super.index, + super.accountIndex = 0, super.isHidden = false, + this.isHiddenChecked = false, super.isLegacyDerivation = false, super.txCount = 0, super.balance = 0, @@ -105,7 +109,9 @@ class BitcoinAddressRecord extends BaseBitcoinAddressRecord { return BitcoinAddressRecord( decoded['address'] as String, index: decoded['index'] as int, + accountIndex: decoded['accountIndex'] as int? ?? 0, isHidden: decoded['isHidden'] as bool? ?? false, + isHiddenChecked: decoded['isHiddenChecked'] as bool? ?? false, isLegacyDerivation: parsedIsLegacy, isUsed: decoded['isUsed'] as bool? ?? false, txCount: decoded['txCount'] as int? ?? 0, @@ -117,6 +123,10 @@ class BitcoinAddressRecord extends BaseBitcoinAddressRecord { ); } + /// Whether [isHidden] has been checked against the wallet's keys. Records created from the + /// wallet's own keys are correct by construction; older ones are checked once on wallet open. + bool isHiddenChecked; + String? scriptHash; static int _purposeForType(BitcoinAddressType type) { @@ -162,7 +172,8 @@ class BitcoinAddressRecord extends BaseBitcoinAddressRecord { final coinType = _coinTypeForNetwork(); final purpose = _purposeForType(type); - final accountPath = isLegacyDerivation ? electrum_path : "m/$purpose'/$coinType'/0'"; + final accountPath = + isLegacyDerivation ? electrum_path : "m/$purpose'/$coinType'/$accountIndex'"; final chain = isHidden ? 1 : 0; return "$accountPath/$chain/$index"; @@ -172,7 +183,9 @@ class BitcoinAddressRecord extends BaseBitcoinAddressRecord { String toJSON() => json.encode({ 'address': address, 'index': index, + 'accountIndex': accountIndex, 'isHidden': isHidden, + 'isHiddenChecked': isHiddenChecked, 'isLegacyDerivation': isLegacyDerivation, 'isUsed': isUsed, 'txCount': txCount, @@ -187,6 +200,7 @@ class BitcoinSilentPaymentAddressRecord extends BaseBitcoinAddressRecord { BitcoinSilentPaymentAddressRecord( super.address, { required super.index, + super.accountIndex = 0, super.isHidden = false, super.txCount = 0, super.balance = 0, @@ -205,6 +219,7 @@ class BitcoinSilentPaymentAddressRecord extends BaseBitcoinAddressRecord { return BitcoinSilentPaymentAddressRecord( decoded['address'] as String, index: decoded['index'] as int, + accountIndex: decoded['accountIndex'] as int? ?? 0, isHidden: decoded['isHidden'] as bool? ?? false, isUsed: decoded['isUsed'] as bool? ?? false, txCount: decoded['txCount'] as int? ?? 0, @@ -233,6 +248,7 @@ class BitcoinSilentPaymentAddressRecord extends BaseBitcoinAddressRecord { String toJSON() => json.encode({ 'address': address, 'index': index, + 'accountIndex': accountIndex, 'isHidden': isHidden, 'isUsed': isUsed, 'txCount': txCount, diff --git a/cw_bitcoin/lib/bitcoin_wallet.dart b/cw_bitcoin/lib/bitcoin_wallet.dart index 688cce8fc..132227f27 100644 --- a/cw_bitcoin/lib/bitcoin_wallet.dart +++ b/cw_bitcoin/lib/bitcoin_wallet.dart @@ -7,16 +7,20 @@ import 'package:cw_bitcoin/.secrets.g.dart' as secrets; import 'package:cw_bitcoin/address_from_output.dart'; import 'package:cw_bitcoin/bitcoin_address_record.dart'; import 'package:cw_bitcoin/bitcoin_mnemonic.dart'; +import "package:cw_bitcoin/bitcoin_receive_page_option.dart"; import 'package:cw_bitcoin/bitcoin_transaction_credentials.dart'; import 'package:cw_bitcoin/bitcoin_wallet_addresses.dart'; import 'package:cw_bitcoin/electrum_balance.dart'; import 'package:cw_bitcoin/electrum_derivations.dart'; import 'package:cw_bitcoin/electrum_transaction_info.dart'; import 'package:cw_bitcoin/electrum_wallet.dart'; +import 'package:cw_bitcoin/electrum_wallet_addresses.dart'; import 'package:cw_bitcoin/electrum_wallet_snapshot.dart'; +import 'package:cw_bitcoin/locktime.dart'; import 'package:cw_bitcoin/hardware/bitcoin_hardware_wallet_service.dart'; import 'package:cw_bitcoin/lightning/lightning_wallet.dart'; import 'package:cw_bitcoin/hardware/bitcoin_ledger_service.dart'; +import 'package:cw_bitcoin/output_ordering.dart'; import 'package:cw_bitcoin/payjoin/manager.dart'; import 'package:cw_bitcoin/payjoin/storage.dart'; import 'package:cw_bitcoin/pending_bitcoin_transaction.dart'; @@ -24,12 +28,14 @@ import 'package:cw_bitcoin/psbt/signer.dart'; import 'package:cw_bitcoin/psbt/transaction_builder.dart'; import 'package:cw_bitcoin/psbt/v0_deserialize.dart'; import 'package:cw_bitcoin/psbt/v0_finalizer.dart'; +import 'package:cw_core/amount/money.dart'; import 'package:cw_core/crypto_currency.dart'; import 'package:cw_core/encryption_file_utils.dart'; import 'package:cw_core/output_info.dart'; -import 'package:cw_core/parse_fixed.dart'; import 'package:cw_core/payjoin_session.dart'; import 'package:cw_core/pending_transaction.dart'; +import 'package:cw_core/sync_status.dart'; +import "package:cw_core/receive_page_option.dart"; import 'package:cw_core/unspent_coin_type.dart'; import 'package:cw_core/unspent_coins_info.dart'; import 'package:cw_core/utils/print_verbose.dart'; @@ -123,8 +129,10 @@ abstract class BitcoinWalletBase extends ElectrumWallet with Store { initialChangeAddressIndex: initialChangeAddressIndex, initialSilentAddresses: initialSilentAddresses, initialSilentAddressIndex: initialSilentAddressIndex, - mainHdByType: mainHdByType, - sideHdByType: sideHdByType, + mainHdByTypeAndAccount: mainHdByTypeAndAccount, + sideHdByTypeAndAccount: sideHdByTypeAndAccount, + accountIndexes: [currentAccountIndex], + currentAccountIndex: currentAccountIndex, legacyMainHd: mainHd, legacySideHd: sideHd, network: networkParam ?? network, @@ -152,9 +160,11 @@ abstract class BitcoinWalletBase extends ElectrumWallet with Store { lnurlDomain: "cake.cash", cachedAddress: cachedLightningAddress, ); + walletAddresses.lightningWallet = lightningWallet; walletAddresses.setLightningAddress(walletInfo.name); } } else { + lightningWallet?.close(); lightningWallet = null; } }); @@ -345,15 +355,21 @@ abstract class BitcoinWalletBase extends ElectrumWallet with Store { return balance; } - final lBalance = await lightningWallet!.getBalance(); + try { + final lBalance = await lightningWallet!.getBalance(); - this.balance[CryptoCurrency.btcln] = - ElectrumBalance(confirmed: lBalance.toInt(), unconfirmed: 0, frozen: 0); + this.balance[CryptoCurrency.btcln] = ElectrumBalance( + confirmed: lBalance, + unconfirmed: Money.zero(CryptoCurrency.btcln), + frozen: Money.zero(CryptoCurrency.btcln)); + } catch (e) { + printV("Error fetching lightning balance: $e"); + } return ElectrumBalance( confirmed: balance.confirmed, unconfirmed: balance.unconfirmed, - frozen: balance.frozen.toInt(), + frozen: balance.frozen ?? Money.zero(currency), ); } @@ -408,12 +424,18 @@ abstract class BitcoinWalletBase extends ElectrumWallet with Store { late final PayjoinManager payjoinManager; + @override + bool get hasPayjoinSupport => keys.privateKey.isNotEmpty; + + @override + bool get hasLightningSupport => seed != null && LightningWallet.isAvailable; + bool get isPayjoinAvailable => unspentCoinsInfo.values .where((element) => element.walletId == id && element.isSending && !element.isFrozen) .isNotEmpty; Future buildPsbt({ - required List outputs, + required List outputs, required List cwOutputs, required BigInt fee, required BasedUtxoNetwork network, @@ -430,24 +452,65 @@ abstract class BitcoinWalletBase extends ElectrumWallet with Store { final rawTx = await electrumClient.getTransactionHex(hash: utxo.utxo.txHash); final publicKeyAndDerivationPath = publicKeys[utxo.ownerDetails.address.pubKeyHash()]!; - psbtReadyInputs.add(PSBTReadyUtxoWithAddress( - utxo: utxo.utxo, - rawTx: rawTx, - ownerDetails: utxo.ownerDetails, - ownerDerivationPath: publicKeyAndDerivationPath.derivationPath, - ownerMasterFingerprint: masterFingerprint, - ownerPublicKey: publicKeyAndDerivationPath.publicKey, - )); + psbtReadyInputs.add( + PSBTReadyUtxoWithAddress( + utxo: utxo.utxo, + rawTx: rawTx, + ownerDetails: utxo.ownerDetails, + ownerDerivationPath: publicKeyAndDerivationPath.derivationPath, + ownerMasterFingerprint: masterFingerprint, + ownerPublicKey: publicKeyAndDerivationPath.publicKey, + ), + ); } + final psbtReadyOutputs = outputs.map((o) { + final cwOutput = cwOutputs + .where( + (e) => [e.address, e.extractedAddress] + .map((e) => e?.toLowerCase()) + .contains(o.address.toAddress().toLowerCase()), + ) + .firstOrNull; + + if (o.isChange && publicKeys.containsKey(o.address.pubKeyHash())) { + final changeKey = publicKeys[o.address.pubKeyHash()]!; + return PSBTReadyBitcoinOutput( + address: o.address, + value: o.value, + isSilentPayment: o.isSilentPayment, + isChange: o.isChange, + changeMasterFingerprint: masterFingerprint, + changeDerivationPath: changeKey.derivationPath, + changePublicKey: changeKey.publicKey, + outputInfo: cwOutput, + ); + } + return PSBTReadyBitcoinOutput( + address: o.address, + value: o.value, + isSilentPayment: o.isSilentPayment, + isChange: o.isChange, + outputInfo: cwOutput, + ); + }).toList(); + + final locktime = antiFeeSnipingLocktime( + chainTip: await getCurrentChainTip(), + synced: syncStatus is SyncedSyncStatus, + ); + return PSBTTransactionBuild( - inputs: psbtReadyInputs, outputs: outputs, enableRBF: enableRBF, cwOutputs: cwOutputs) - .psbt; + inputs: psbtReadyInputs, + outputs: psbtReadyOutputs, + enableRBF: enableRBF, + locktime: locktime, + ).psbt; } @override Future buildHardwareWalletTransaction({ - required List outputs, + required List outputs, required BigInt fee, required BasedUtxoNetwork network, required List utxos, @@ -461,8 +524,10 @@ abstract class BitcoinWalletBase extends ElectrumWallet with Store { final masterFingerprint = await (hardwareWalletService as BitcoinHardwareWalletService).getMasterFingerprint(); + final orderedOutputs = orderOutputs(outputs, outputOrdering); + final psbt = await buildPsbt( - outputs: outputs, + outputs: orderedOutputs, fee: fee, network: network, utxos: utxos, @@ -472,7 +537,8 @@ abstract class BitcoinWalletBase extends ElectrumWallet with Store { memo: memo, enableRBF: enableRBF, inputOrdering: inputOrdering, - outputOrdering: outputOrdering, + // Already applied above; don't reorder again. + outputOrdering: BitcoinOrdering.none, ); final psbtStr = base64Encode(psbt.serialize()); @@ -495,19 +561,19 @@ abstract class BitcoinWalletBase extends ElectrumWallet with Store { final isLNCompatible = await lightningWallet?.isCompatible(lnAddr); if ((credentials.coinTypeToSpendFrom == UnspentCoinType.lightning && lightningWallet != null) || isLNCompatible == true) { - BigInt amount; + Money amount; if (credentials.outputs.first.sendAll) { amount = await lightningWallet!.getBalance(); } else { - amount = parseFixed( - credentials.outputs.first.cryptoAmount?.isNotEmpty == true - ? credentials.outputs.first.cryptoAmount! - : "0", - 8); + amount = credentials.outputs.first.cryptoAmount; } - return lightningWallet!.createTransaction(lnAddr, amount > BigInt.zero ? amount : null, - credentials.priority, credentials.outputs.first.sendAll); + return lightningWallet!.createTransaction( + lnAddr, + amount.amount > BigInt.zero ? amount.amount : null, + credentials.priority, + credentials.outputs.first.sendAll, + ); } final tx = (await super.createTransaction(credentials)) as PendingBitcoinTransaction; @@ -526,7 +592,7 @@ abstract class BitcoinWalletBase extends ElectrumWallet with Store { )) .toList(), cwOutputs: credentials.outputs, - fee: BigInt.from(tx.fee), + fee: tx.fee.amount, network: network, memo: credentials.outputs.first.memo, outputOrdering: BitcoinOrdering.none, @@ -545,17 +611,18 @@ abstract class BitcoinWalletBase extends ElectrumWallet with Store { tx.commitOverride = () async { final sender = await payjoinManager.initSender(payjoinUri!, originalPsbt, int.parse(tx.feeRate)); - payjoinManager.spawnNewSender( - sender: sender, pjUrl: payjoinUri, amount: BigInt.from(tx.amount)); + payjoinManager.spawnNewSender(sender: sender, pjUrl: payjoinUri, amount: tx.amount.amount); }; return tx; } - List getUtxoWithPrivateKeys() => unspentCoins - .where((e) => (e.isSending && !e.isFrozen)) - .map((unspent) => UtxoWithPrivateKey.fromUnspent(unspent, this)) - .toList(); + List getUtxoWithPrivateKeys({bool confirmedOnly = false}) => + unspentCoinsForCurrentAccount + .where( + (e) => e.isSending && !e.isFrozen && (!confirmedOnly || (e.confirmations ?? 0) > 0)) + .map((unspent) => UtxoWithPrivateKey.fromUnspent(unspent, this)) + .toList(); Future commitPsbt(String finalizedPsbt) { final psbt = PsbtV2()..deserializeV0(base64.decode(finalizedPsbt)); @@ -565,9 +632,10 @@ abstract class BitcoinWalletBase extends ElectrumWallet with Store { return PendingBitcoinTransaction( btcTx, type, + accountIndex: currentAccountIndex, electrumClient: electrumClient, - amount: 0, - fee: 0, + amount: Money.zero(currency), + fee: Money.zero(currency), feeRate: "", network: network, hasChange: true, @@ -623,9 +691,10 @@ abstract class BitcoinWalletBase extends ElectrumWallet with Store { return PendingBitcoinTransaction( btcTx, type, + accountIndex: currentAccountIndex, electrumClient: electrumClient, - amount: 0, - fee: 0, + amount: Money.zero(currency), + fee: Money.zero(currency), feeRate: "", network: network, hasChange: true, @@ -653,4 +722,29 @@ abstract class BitcoinWalletBase extends ElectrumWallet with Store { return super.signMessage(message, address: address); } + + @override + bool receiveOptionAvailable(ReceivePageOption option) { + final isExtraAccount = currentAccountIndex > 0; + + if (option == BitcoinReceivePageOption.lightning) { + // Lightning is tied to the primary account only + return !isExtraAccount && hasLightningSupport && isLightningInitialized; + } + + if (option == BitcoinReceivePageOption.silent_payments) { + // Silent payments are tied to the primary account only + return !isExtraAccount && hasSilentPaymentsScanning; + } + + // Restrict extra Bitcoin accounts (accountIndex > 0) to the allowed address types + if (isExtraAccount && option is BitcoinReceivePageOption) { + final addressType = option.toType(); + if (!EXTRA_ACCOUNT_ADDRESS_TYPES.contains(addressType)) { + return false; + } + } + + return true; + } } diff --git a/cw_bitcoin/lib/bitcoin_wallet_addresses.dart b/cw_bitcoin/lib/bitcoin_wallet_addresses.dart index 471e09498..534abf8b4 100644 --- a/cw_bitcoin/lib/bitcoin_wallet_addresses.dart +++ b/cw_bitcoin/lib/bitcoin_wallet_addresses.dart @@ -23,8 +23,10 @@ class BitcoinWalletAddresses = BitcoinWalletAddressesBase with _$BitcoinWalletAd abstract class BitcoinWalletAddressesBase extends ElectrumWalletAddresses with Store { BitcoinWalletAddressesBase( WalletInfo walletInfo, { - required super.mainHdByType, - required super.sideHdByType, + required super.mainHdByTypeAndAccount, + required super.sideHdByTypeAndAccount, + required super.accountIndexes, + required super.currentAccountIndex, required super.legacySideHd, required super.legacyMainHd, required super.network, @@ -124,7 +126,7 @@ abstract class BitcoinWalletAddressesBase extends ElectrumWalletAddresses with S Future getPaymentRequestUri(String amount) async { if (addressPageType is LightningAddressType && lightningWallet != null) { - final amountSats = amount.isNotEmpty ? parseFixed(amount, 8) : null; + final amountSats = amount.isNotEmpty ? tryParseFixed(amount, 8) : null; final lnUrl = getLnurlOfLightningAddress(address); if (amountSats == null) { return LightningPaymentRequest(address: address, lnURL: lnUrl, amount: amount); @@ -145,7 +147,11 @@ abstract class BitcoinWalletAddressesBase extends ElectrumWalletAddresses with S try { return super .allAddresses - .firstWhere((e) => e.type == SegwitAddresType.p2wpkh && !e.isHidden && !e.isUsed) + .firstWhere((e) => + e.accountIndex == currentAccountIndex && + e.type == SegwitAddresType.p2wpkh && + !e.isHidden && + !e.isUsed) .address; } catch (_) { return super.addressForBuy; @@ -154,11 +160,21 @@ abstract class BitcoinWalletAddressesBase extends ElectrumWalletAddresses with S @override String get addressForExchange { - if (addressPageType == LightningAddressType.p2l) { - final addresses = receiveAddresses - .where((element) => element.type == SegwitAddresType.p2wpkh && !element.isUsed); - return addresses.first.address; + final current = getFreshAddress(); + final availableReceiveAddresses = receiveAddresses.where((element) => + !element.isUsed && !element.isHidden && !hiddenAddresses.contains(element.address)); + + final bool isSilentPaymentsPage = addressPageType == SilentPaymentsAddresType.p2sp; + final bool isLightningPage = addressPageType == LightningAddressType.p2l; + + if (isSilentPaymentsPage || isLightningPage) { + final segwit = availableReceiveAddresses + .where((e) => e.type == SegwitAddresType.p2wpkh) + .map((e) => e.address) + .toList(); + + return segwit.isNotEmpty ? segwit.first : current; } - return address; + return current; } } diff --git a/cw_bitcoin/lib/bitcoin_wallet_keys.dart b/cw_bitcoin/lib/bitcoin_wallet_keys.dart index 9a9bdcba4..abc2d8fa2 100644 --- a/cw_bitcoin/lib/bitcoin_wallet_keys.dart +++ b/cw_bitcoin/lib/bitcoin_wallet_keys.dart @@ -1,12 +1,23 @@ class BitcoinWalletKeys { - const BitcoinWalletKeys( - {required this.wif, required this.privateKey, required this.publicKey, required this.xpub}); + const BitcoinWalletKeys({ + required this.wif, + required this.privateKey, + required this.publicKey, + required this.xpub, + this.masterFingerprint = '', + }); final String wif; final String privateKey; final String publicKey; final String xpub; + final String masterFingerprint; - Map toJson() => - {'wif': wif, 'privateKey': privateKey, 'publicKey': publicKey, 'xpub': xpub}; + Map toJson() => { + 'wif': wif, + 'privateKey': privateKey, + 'publicKey': publicKey, + 'xpub': xpub, + 'masterFingerprint': masterFingerprint, + }; } diff --git a/cw_bitcoin/lib/bitcoin_wallet_service.dart b/cw_bitcoin/lib/bitcoin_wallet_service.dart index 858adc424..836b006d2 100644 --- a/cw_bitcoin/lib/bitcoin_wallet_service.dart +++ b/cw_bitcoin/lib/bitcoin_wallet_service.dart @@ -8,7 +8,6 @@ import 'package:cw_core/encryption_file_utils.dart'; import 'package:cw_core/payjoin_session.dart'; import 'package:cw_core/unspent_coins_info.dart'; import 'package:cw_core/utils/zpub.dart'; -import 'package:cw_core/wallet_base.dart'; import 'package:cw_core/wallet_service.dart'; import 'package:cw_bitcoin/bitcoin_wallet.dart'; import 'package:cw_core/pathForWallet.dart'; @@ -133,31 +132,6 @@ class BitcoinWalletService extends WalletService< } } - @override - Future rename(String currentName, String password, String newName) async { - final currentWalletInfo = await WalletInfo.get(currentName, getType()); - if (currentWalletInfo == null) { - throw Exception('Wallet not found'); - } - final currentWallet = await BitcoinWalletBase.open( - password: password, - name: currentName, - walletInfo: currentWalletInfo, - unspentCoinsInfo: unspentCoinsInfoSource, - payjoinBox: payjoinSessionSource, - encryptionFileUtils: encryptionFileUtilsFor(isDirect), - ); - - await currentWallet.renameWalletFiles(newName); - await saveBackup(newName); - - final newWalletInfo = currentWalletInfo; - newWalletInfo.id = WalletBase.idFor(newName, getType()); - newWalletInfo.name = newName; - - await newWalletInfo.save(); - } - @override Future restoreFromHardwareWallet(BitcoinRestoreWalletFromHardware credentials, {bool? isTestnet}) async { diff --git a/cw_bitcoin/lib/coin_selection.dart b/cw_bitcoin/lib/coin_selection.dart new file mode 100644 index 000000000..4db86e1c2 --- /dev/null +++ b/cw_bitcoin/lib/coin_selection.dart @@ -0,0 +1,88 @@ +import 'dart:math'; + +int effectiveValue(int value, int inputCost) => value - inputCost; + +class SelectedCoins { + final List indices; + final bool hasChange; + const SelectedCoins(this.indices, this.hasChange); +} + +SelectedCoins? branchAndBound(List effValues, int target, int costOfChange, + {int maxTries = 100000}) { + final n = effValues.length; + final order = List.generate(n, (i) => i) + ..sort((a, b) => effValues[b].compareTo(effValues[a])); + final sorted = [for (final i in order) effValues[i]]; + final suffix = List.filled(n + 1, 0); + for (var i = n - 1; i >= 0; i--) suffix[i] = suffix[i + 1] + sorted[i]; + final upper = target + costOfChange; + List? best; + final picked = []; + var tries = 0; + void dfs(int i, int sum) { + if (best != null || tries++ >= maxTries) return; + if (sum > upper) return; + if (sum >= target) { best = List.of(picked); return; } + if (i >= n || sum + suffix[i] < target) return; + picked.add(order[i]); dfs(i + 1, sum + sorted[i]); picked.removeLast(); + dfs(i + 1, sum); + } + dfs(0, 0); + return best == null ? null : SelectedCoins(best!, false); +} + +SelectedCoins? singleRandomDraw(List effValues, int target, int minChange, Random rng) { + final order = List.generate(effValues.length, (i) => i)..shuffle(rng); + final picked = []; var sum = 0; + for (final i in order) { + picked.add(i); sum += effValues[i]; + if (sum >= target + minChange) return SelectedCoins(picked, true); + } + return null; +} + +/// Branch-and-bound over effective values (value minus the cost of spending the +/// input at the current fee rate). [inputCosts] is parallel to [values] so each +/// input pays for its own script type's size. A match means the excess over +/// [target] stays within [window], so the remainder can be absorbed into the fee +/// instead of creating a change output. Returns null when no such subset exists. +SelectedCoins? changelessMatch({ + required List values, + required int target, + required List inputCosts, + required int window, + int maxTries = 100000, +}) { + assert(values.length == inputCosts.length); + final keep = []; + final eff = []; + for (var i = 0; i < values.length; i++) { + final e = effectiveValue(values[i], inputCosts[i]); + if (e > 0) { + keep.add(i); + eff.add(e); + } + } + final match = branchAndBound(eff, target, window, maxTries: maxTries); + if (match == null) return null; + return SelectedCoins([for (final i in match.indices) keep[i]], false); +} + +class InsufficientFundsException implements Exception { const InsufficientFundsException(); } + +SelectedCoins selectCoins({required List values, required int target, + required int inputCost, required int costOfChange, required int minChange, Random? rng}) { + final keep = []; final eff = []; + for (var i = 0; i < values.length; i++) { + final e = effectiveValue(values[i], inputCost); + if (e > 0) { keep.add(i); eff.add(e); } + } + SelectedCoins? map(SelectedCoins? s) => + s == null ? null : SelectedCoins([for (final i in s.indices) keep[i]], s.hasChange); + final bnb = map(branchAndBound(eff, target, costOfChange)); + if (bnb != null) return bnb; + final srd = map(singleRandomDraw(eff, target, minChange, rng ?? Random.secure())); + if (srd != null) return srd; + throw const InsufficientFundsException(); +} diff --git a/cw_bitcoin/lib/electrum.dart b/cw_bitcoin/lib/electrum.dart index 195e9425f..b3bc6e843 100644 --- a/cw_bitcoin/lib/electrum.dart +++ b/cw_bitcoin/lib/electrum.dart @@ -173,6 +173,7 @@ class ElectrumClient { if (isJSONStringCorrect(unterminatedString)) { final response = json.decode(unterminatedString); + _handleResponse(response); // unterminatedString = null; unterminatedString = ''; } @@ -340,6 +341,80 @@ class ElectrumClient { return historyMap; } + Future>>> getBatchUnspent( + List scriptHashes, { + int timeout = 10000, + }) async { + final paramsList = scriptHashes.map((h) => [h]).toList(growable: false); + + final batchResults = await callBatchWithTimeout( + method: 'blockchain.scripthash.listunspent', + paramsList: paramsList, + timeout: timeout, + ); + + final unspentMap = >>{}; + + for (int i = 0; i < scriptHashes.length; i++) { + final sh = scriptHashes[i]; + + if (i >= batchResults.length) { + unspentMap[sh] = const []; + continue; + } + + final result = batchResults[i]; + + if (result is List) { + unspentMap[sh] = result + .whereType>() + .map((m) => m.map((k, v) => MapEntry(k.toString(), v))) + .cast>() + .toList(); + } else { + unspentMap[sh] = const []; + } + } + + return unspentMap; + } + + Future>> getBatchBalance( + List scriptHashes, { + int timeout = 10000, + }) async { + final paramsList = scriptHashes.map((h) => [h]).toList(growable: false); + + final batchResults = await callBatchWithTimeout( + method: 'blockchain.scripthash.get_balance', + paramsList: paramsList, + timeout: timeout, + ); + + final balanceMap = >{}; + + for (int i = 0; i < scriptHashes.length; i++) { + final sh = scriptHashes[i]; + + if (i >= batchResults.length) { + balanceMap[sh] = {}; + continue; + } + + final result = batchResults[i]; + + if (result is Map) { + balanceMap[sh] = result; + } else if (result is Map) { + balanceMap[sh] = Map.from(result); + } else { + balanceMap[sh] = {}; + } + } + + return balanceMap; + } + Future>> getBatchTransactionVerbose( List hashes, { int timeout = 10000, @@ -504,14 +579,29 @@ class ElectrumClient { return []; }); + // Floor at 0 so unavailable/-1 estimates never become negative rates; + // cap at 2000 sat/vB per CW-1597. + static const int _maxFeeRate = 2000; + + static int _sanitizeFeeRate(double feeRate) { + final rate = (stringDoubleToBitcoinAmount(feeRate.toString()) / 1000).round(); + if (rate < 0) { + return 0; + } + if (rate > _maxFeeRate) { + return _maxFeeRate; + } + return rate; + } + Future> feeRates({BasedUtxoNetwork? network}) async { try { - final topDoubleString = await estimatefee(p: 1); - final middleDoubleString = await estimatefee(p: 5); - final bottomDoubleString = await estimatefee(p: 10); - final top = (stringDoubleToBitcoinAmount(topDoubleString.toString()) / 1000).round(); - final middle = (stringDoubleToBitcoinAmount(middleDoubleString.toString()) / 1000).round(); - final bottom = (stringDoubleToBitcoinAmount(bottomDoubleString.toString()) / 1000).round(); + final topDouble = await estimatefee(p: 1); + final middleDouble = await estimatefee(p: 5); + final bottomDouble = await estimatefee(p: 10); + final top = _sanitizeFeeRate(topDouble); + final middle = _sanitizeFeeRate(middleDouble); + final bottom = _sanitizeFeeRate(bottomDouble); return [bottom, middle, top]; } catch (_) { diff --git a/cw_bitcoin/lib/electrum_balance.dart b/cw_bitcoin/lib/electrum_balance.dart index 11a19ce15..e8a39420c 100644 --- a/cw_bitcoin/lib/electrum_balance.dart +++ b/cw_bitcoin/lib/electrum_balance.dart @@ -1,51 +1,90 @@ import 'dart:convert'; +import 'package:cw_core/amount/money.dart'; import 'package:cw_core/balance.dart'; +import "package:cw_core/crypto_currency.dart"; +import 'package:cw_core/currency.dart'; class ElectrumBalance extends Balance { + + factory ElectrumBalance.zero(CryptoCurrency currency) => ElectrumBalance( + confirmed: Money.zero(currency), + unconfirmed: Money.zero(currency), + frozen: Money.zero(currency), + ); + ElectrumBalance({ required this.confirmed, required this.unconfirmed, - required int frozen, - this.secondConfirmed = 0, - this.secondUnconfirmed = 0, - }) : this._frozen = frozen, - super.fromInt(confirmed, unconfirmed, - secondAvailable: secondConfirmed, secondAdditional: secondUnconfirmed, frozen: frozen); + required this.frozen, + this.secondConfirmed, + this.secondUnconfirmed, + }) : super(confirmed, unconfirmed, + secondAvailable: secondConfirmed, secondUnavailable: secondUnconfirmed); - static ElectrumBalance? fromJSON(String? jsonSource) { + Money confirmed; + Money unconfirmed; + Money? secondConfirmed; + Money? secondUnconfirmed; + + @override + Money get available => (confirmed + unconfirmed) - frozen; + + @override + Money get unavailable => unconfirmed; + + @override + Money frozen; + + ElectrumBalance copy() => ElectrumBalance( + confirmed: confirmed, + unconfirmed: unconfirmed, + frozen: frozen, + secondConfirmed: secondConfirmed, + secondUnconfirmed: secondUnconfirmed, + ); + + static ElectrumBalance? fromJSON(String? jsonSource, Currency currency) { if (jsonSource == null) return null; final decoded = json.decode(jsonSource) as Map; return ElectrumBalance( - confirmed: decoded['confirmed'] as int? ?? 0, - unconfirmed: decoded['unconfirmed'] as int? ?? 0, - frozen: decoded['frozen'] as int? ?? 0, - secondConfirmed: decoded['secondConfirmed'] as int? ?? 0, - secondUnconfirmed: decoded['secondUnconfirmed'] as int? ?? 0, + confirmed: Money.fromInt(decoded['confirmed'] as int? ?? 0, currency), + unconfirmed: Money.fromInt(decoded['unconfirmed'] as int? ?? 0, currency), + frozen: Money.fromInt(decoded['frozen'] as int? ?? 0, currency), + secondConfirmed: Money.fromInt(decoded['secondConfirmed'] as int? ?? 0, currency), + secondUnconfirmed: Money.fromInt(decoded['secondUnconfirmed'] as int? ?? 0, currency), ); } - int confirmed; - int unconfirmed; - final int _frozen; - - @override - BigInt get frozen => BigInt.from(_frozen); - - int secondConfirmed = 0; - int secondUnconfirmed = 0; - - @override - BigInt get fullAvailableBalance => - BigInt.from((confirmed + unconfirmed) + secondConfirmed - _frozen); - String toJSON() => json.encode({ - 'confirmed': confirmed, - 'unconfirmed': unconfirmed, - 'frozen': _frozen, - 'secondConfirmed': secondConfirmed, - 'secondUnconfirmed': secondUnconfirmed, + 'confirmed': confirmed.amount.toInt(), + 'unconfirmed': unavailable.amount.toInt(), + 'frozen': frozen.amount.toInt(), + 'secondConfirmed': secondAvailable?.amount.toInt() ?? 0, + 'secondUnconfirmed': secondUnavailable?.amount.toInt() ?? 0, }); + + // Value equality so instances can be compared/deduped (e.g. by MobX reactions via MapEquality) + @override + bool operator ==(Object other) => + identical(this, other) || + (other is ElectrumBalance && + other.confirmed == confirmed && + other.unconfirmed == unconfirmed && + other.frozen == frozen && + other.secondConfirmed == secondConfirmed && + other.secondUnconfirmed == secondUnconfirmed); + + @override + int get hashCode => Object.hash( + confirmed, + unconfirmed, + frozen, + secondConfirmed, + secondUnconfirmed, + ); } + + diff --git a/cw_bitcoin/lib/electrum_transaction_info.dart b/cw_bitcoin/lib/electrum_transaction_info.dart index da36c8c07..17360e5e7 100644 --- a/cw_bitcoin/lib/electrum_transaction_info.dart +++ b/cw_bitcoin/lib/electrum_transaction_info.dart @@ -5,10 +5,11 @@ import 'package:cw_bitcoin/address_from_output.dart'; import 'package:cw_bitcoin/bitcoin_address_record.dart'; import 'package:cw_bitcoin/bitcoin_amount_format.dart'; import 'package:cw_bitcoin/bitcoin_unspent.dart'; +import 'package:cw_core/amount/money.dart'; +import 'package:cw_core/crypto_currency.dart'; import 'package:cw_core/currency_for_wallet_type.dart'; import 'package:cw_core/transaction_direction.dart'; import 'package:cw_core/transaction_info.dart'; -import 'package:cw_core/format_amount.dart'; import 'package:cw_core/wallet_type.dart'; import 'package:hex/hex.dart'; @@ -17,12 +18,13 @@ class ElectrumTransactionBundle { {required this.ins, required this.confirmations, this.time}); final BtcTransaction originalTransaction; - final List ins; + final List ins; final int? time; final int confirmations; } class ElectrumTransactionInfo extends TransactionInfo { + int? accountIndex; List? unspents; bool isReceivedSilentPayment; bool isHogEx; @@ -31,8 +33,8 @@ class ElectrumTransactionInfo extends TransactionInfo { this.type, { required String id, int? height, - required int amount, - int? fee, + required Money amount, + Money? fee, List? inputAddresses, List? outputAddresses, required TransactionDirection direction, @@ -40,6 +42,7 @@ class ElectrumTransactionInfo extends TransactionInfo { bool isReplaced = false, required DateTime date, required int confirmations, + this.accountIndex, String? to, this.unspents, this.isReceivedSilentPayment = false, @@ -106,9 +109,9 @@ class ElectrumTransactionInfo extends TransactionInfo { height: height, isPending: false, isReplaced: false, - fee: fee, + fee: Money.fromInt(fee, walletTypeToCryptoCurrency(type)), direction: direction, - amount: amount, + amount: Money.fromInt(amount, walletTypeToCryptoCurrency(type)), date: date, confirmations: confirmations); } @@ -126,9 +129,14 @@ class ElectrumTransactionInfo extends TransactionInfo { List inputAddresses = []; List outputAddresses = []; + var hasMissingInputTx = false; for (var i = 0; i < bundle.originalTransaction.inputs.length; i++) { final input = bundle.originalTransaction.inputs[i]; - final inputTransaction = bundle.ins[i]; + final inputTransaction = i < bundle.ins.length ? bundle.ins[i] : null; + if (inputTransaction == null || input.txIndex >= inputTransaction.outputs.length) { + hasMissingInputTx = true; + continue; + } final outTransaction = inputTransaction.outputs[input.txIndex]; inputAmount += outTransaction.amount.toInt(); if (addresses.contains(addressFromOutputScript(outTransaction.scriptPubKey, network))) { @@ -182,9 +190,13 @@ class ElectrumTransactionInfo extends TransactionInfo { final b = tx.outputs.first.scriptPubKey.toBytes(); return b.length == 34 && b[0] == 88 && b[1] == 32; }; - final isHogEx = isHogExTx(bundle.originalTransaction) && isHogExTx(bundle.ins.first); + final firstInput = bundle.ins.isNotEmpty ? bundle.ins.first : null; + final isHogEx = + firstInput != null && isHogExTx(bundle.originalTransaction) && isHogExTx(firstInput); - final fee = inputAmount - totalOutAmount; + final fee = hasMissingInputTx ? null : inputAmount - totalOutAmount; + final walletCurrency = walletTypeToCryptoCurrency(type); + final feeMoney = fee != null ? Money.fromInt(fee, walletCurrency) : null; return ElectrumTransactionInfo(type, id: bundle.originalTransaction.txId(), height: height, @@ -192,11 +204,12 @@ class ElectrumTransactionInfo extends TransactionInfo { isReplaced: false, inputAddresses: inputAddresses, outputAddresses: outputAddresses, - fee: fee, + fee: feeMoney, direction: direction, - amount: amount, + amount: Money.fromInt(amount, walletCurrency), date: date, isHogEx: isHogEx, + additionalInfo: {'hasMissingInputTx': hasMissingInputTx}, confirmations: bundle.confirmations); } @@ -204,18 +217,23 @@ class ElectrumTransactionInfo extends TransactionInfo { final inputAddresses = data['inputAddresses'] as List? ?? []; final outputAddresses = data['outputAddresses'] as List? ?? []; final unspents = data['unspents'] as List? ?? []; + final additionalInfo = data['additionalInfo'] as Map?; + final isLightning = (additionalInfo?['isLightning'] as bool?) == true; + + final currency = isLightning ? CryptoCurrency.btcln : walletTypeToCryptoCurrency(type); return ElectrumTransactionInfo( type, id: data['id'] as String, height: data['height'] as int?, - amount: data['amount'] as int, - fee: data['fee'] as int, + amount: Money.fromInt(data['amount'] as int, currency), + fee: data['fee'] != null ? Money.fromInt(data['fee'] as int, currency) : null, direction: parseTransactionDirectionFromInt(data['direction'] as int), date: DateTime.fromMillisecondsSinceEpoch(data['date'] as int), isPending: data['isPending'] as bool, isReplaced: data['isReplaced'] as bool? ?? false, confirmations: data['confirmations'] as int, + accountIndex: data['accountIndex'] as int?, inputAddresses: inputAddresses.isEmpty ? [] : inputAddresses.map((e) => e.toString()).toList(), outputAddresses: @@ -226,29 +244,12 @@ class ElectrumTransactionInfo extends TransactionInfo { BitcoinSilentPaymentsUnspent.fromJSON(null, unspent as Map)) .toList(), isReceivedSilentPayment: data['isReceivedSilentPayment'] as bool? ?? false, - additionalInfo: data['additionalInfo'] as Map?, + additionalInfo: additionalInfo, ); } final WalletType type; - String? _fiatAmount; - - @override - String amountFormatted() => - '${walletTypeToCryptoCurrency(type).formatAmount(BigInt.from(amount))} ${walletTypeToCryptoCurrency(type).title}'; - - @override - String? feeFormatted() => fee != null - ? '${walletTypeToCryptoCurrency(type).formatAmount(BigInt.from(fee!))} ${walletTypeToCryptoCurrency(type).title}' - : ''; - - @override - String fiatAmount() => _fiatAmount ?? ''; - - @override - void changeFiatAmount(String amount) => _fiatAmount = formatAmount(amount); - ElectrumTransactionInfo updated(ElectrumTransactionInfo info) { return ElectrumTransactionInfo(info.type, id: id, @@ -262,6 +263,7 @@ class ElectrumTransactionInfo extends TransactionInfo { inputAddresses: inputAddresses, outputAddresses: outputAddresses, confirmations: info.confirmations, + accountIndex: accountIndex, additionalInfo: additionalInfo); } @@ -269,13 +271,14 @@ class ElectrumTransactionInfo extends TransactionInfo { final m = {}; m['id'] = id; m['height'] = height; - m['amount'] = amount; + m['amount'] = amount.amount.toInt(); m['direction'] = direction.index; m['date'] = date.millisecondsSinceEpoch; m['isPending'] = isPending; m['isReplaced'] = isReplaced; m['confirmations'] = confirmations; - m['fee'] = fee; + m['accountIndex'] = accountIndex; + m['fee'] = fee?.amount.toInt(); m['to'] = to; m['unspents'] = unspents?.map((e) => e.toJson()).toList() ?? []; m['inputAddresses'] = inputAddresses; @@ -286,6 +289,6 @@ class ElectrumTransactionInfo extends TransactionInfo { } String toString() { - return 'ElectrumTransactionInfo(id: $id, height: $height, amount: $amount, fee: $fee, direction: $direction, date: $date, isPending: $isPending, isReplaced: $isReplaced, confirmations: $confirmations, to: $to, unspent: $unspents, inputAddresses: $inputAddresses, outputAddresses: $outputAddresses, additionalInfo: $additionalInfo)'; + return 'ElectrumTransactionInfo(id: $id, height: $height, amount: $amount, fee: $fee, direction: $direction, date: $date, isPending: $isPending, isReplaced: $isReplaced, confirmations: $confirmations, accountIndex: $accountIndex, to: $to, unspent: $unspents, inputAddresses: $inputAddresses, outputAddresses: $outputAddresses, additionalInfo: $additionalInfo)'; } } diff --git a/cw_bitcoin/lib/electrum_wallet.dart b/cw_bitcoin/lib/electrum_wallet.dart index fd320a692..4b73d52d8 100644 --- a/cw_bitcoin/lib/electrum_wallet.dart +++ b/cw_bitcoin/lib/electrum_wallet.dart @@ -1,14 +1,18 @@ import 'dart:async'; import 'dart:convert'; -import 'dart:io'; import 'dart:isolate'; +import 'dart:math' show Random; import 'package:bitcoin_base/bitcoin_base.dart'; +import 'package:cw_bitcoin/lightning/lightning_addres_type.dart'; +import 'package:cw_bitcoin/lightning/lightning_wallet.dart'; +import 'package:cw_bitcoin/locktime.dart'; import 'package:cw_core/hardware/hardware_wallet_service.dart'; import 'package:cw_core/root_dir.dart'; import 'package:cw_core/utils/proxy_wrapper.dart'; import 'package:cw_core/utils/print_verbose.dart'; import 'package:cw_bitcoin/bitcoin_wallet.dart'; +import 'package:cw_bitcoin/coin_selection.dart'; import 'package:cw_bitcoin/litecoin_wallet.dart'; import 'package:shared_preferences/shared_preferences.dart'; import 'package:blockchain_utils/blockchain_utils.dart'; @@ -28,12 +32,12 @@ import 'package:cw_bitcoin/electrum_wallet_addresses.dart'; import 'package:cw_bitcoin/exceptions.dart'; import 'package:cw_bitcoin/pending_bitcoin_transaction.dart'; import 'package:cw_bitcoin/utils.dart'; +import 'package:cw_core/amount/money.dart'; import 'package:cw_core/crypto_currency.dart'; import 'package:cw_core/encryption_file_utils.dart'; import 'package:cw_core/get_height_by_date.dart'; import 'package:cw_core/node.dart'; import 'package:cw_core/output_info.dart'; -import 'package:cw_core/pathForWallet.dart'; import 'package:cw_core/pending_transaction.dart'; import 'package:cw_core/sync_status.dart'; import 'package:cw_core/transaction_direction.dart'; @@ -76,7 +80,7 @@ abstract class ElectrumWalletBase ElectrumBalance? initialBalance, CryptoCurrency? currency, bool? alwaysScan, - this.useLightning = true, + bool useLightning = true, }) : _masterHD = getMasterHD(seedBytes, network, walletInfo.hardwareWalletType), accountHD = getAccountHDWallet( currency, network, seedBytes, xpub, derivationInfo, walletInfo.hardwareWalletType), @@ -89,23 +93,14 @@ abstract class ElectrumWalletBase _scripthashesUpdateSubject = {}, this.alwaysScan = alwaysScan, silentPaymentsScanningActive = alwaysScan ?? false, - balance = ObservableMap.of(currency != null - ? { - currency: initialBalance ?? - ElectrumBalance( - confirmed: 0, - unconfirmed: 0, - frozen: 0, - ) - } - : {}), + balance = ObservableMap.of( + currency != null ? {currency: initialBalance ?? ElectrumBalance.zero(currency)} : {}), this.unspentCoinsInfo = unspentCoinsInfo, this.isTestnet = !network.isMainnet, this._mnemonic = mnemonic, + this.electrumClient = electrumClient ?? electrum.ElectrumClient(), + _useLightning = useLightning, super(walletInfo, derivationInfo) { - this.electrumClient = electrumClient ?? electrum.ElectrumClient(); - this.walletInfo = walletInfo; - this.derivationInfo = derivationInfo; transactionHistory = ElectrumTransactionHistory( walletInfo: walletInfo, password: password, @@ -113,41 +108,9 @@ abstract class ElectrumWalletBase ); reaction((_) => syncStatus, _syncStatusReaction); - sharedPrefs.complete(SharedPreferences.getInstance()); - - final supportedTypes = supportedAddressTypes(walletInfo.type); - mainHdByType = {}; - sideHdByType = {}; - - final isElectrumDerivation = derivationInfo.derivationType == DerivationType.electrum; - - final canDeriveFromSeed = _masterHD != null && currency != null; - - if (isElectrumDerivation) { - // Electrum derivation does not follow BIP44/49/84 etc. standards - for (final type in supportedTypes) { - mainHdByType[type] = mainHd; // accountHD.child(0) - sideHdByType[type] = sideHd; // accountHD.child(1) - } - } else if (canDeriveFromSeed) { - final coinType = _coinTypeFor(currency); - final accountIndex = _parseAccountIndex(derivationInfo.derivationPath); - - for (final type in supportedTypes) { - final purpose = _purposeForType(type); - final accountPath = "m/$purpose'/$coinType'/$accountIndex'"; - - mainHdByType[type] = _masterHD!.derivePath("$accountPath/0") as Bip32Slip10Secp256k1; - sideHdByType[type] = _masterHD!.derivePath("$accountPath/1") as Bip32Slip10Secp256k1; - } - } else { - // View-only wallet (xpub only) - for (final type in supportedTypes) { - mainHdByType[type] = mainHd; - sideHdByType[type] = sideHd; - } - } + // Probably not needed as init will be called right after creating the wallet. + _prepareHdForAccount(currentAccountIndex, currency); } int _purposeForType(BitcoinAddressType type) { @@ -196,8 +159,7 @@ abstract class ElectrumWalletBase final coinType = _coinTypeFor(currency); final purpose = _purposeForType(record.type); - final accountIndex = _parseAccountIndex(derivationInfo.derivationPath); - return "m/$purpose'/$coinType'/$accountIndex'"; + return "m/$purpose'/$coinType'/${record.accountIndex}'"; } List supportedAddressTypes(WalletType type) { @@ -259,6 +221,29 @@ abstract class ElectrumWalletBase static int estimatedTransactionSize(int inputsCount, int outputsCounts) => inputsCount * 68 + outputsCounts * 34 + 10; + // vbytes an input of the given script type adds to a transaction. The generic + // estimate above assumes P2WPKH (68); legacy and taproot inputs differ enough + // to break changeless-match arithmetic if not accounted for. + static int estimatedInputSize(BitcoinAddressType type) { + if (type == P2pkhAddressType.p2pkh) return 148; + if (type == P2shAddressType.p2wpkhInP2sh) return 91; + if (type == SegwitAddresType.p2tr) return 58; + if (type == SegwitAddresType.p2wsh) return 105; + if (type == SilentPaymentsAddresType.p2sp) return 58; // spent via taproot + return 68; + } + + // vbytes an output of the given script type adds to a transaction. Silent + // payment outputs are delivered as taproot. + static int estimatedOutputSize(BitcoinAddressType type) { + if (type == P2pkhAddressType.p2pkh) return 34; + if (type == P2shAddressType.p2wpkhInP2sh) return 32; + if (type == SegwitAddresType.p2tr) return 43; + if (type == SegwitAddresType.p2wsh) return 43; + if (type == SilentPaymentsAddresType.p2sp) return 43; + return 31; + } + // Parses the account index from a BIP-44/49/84/86 derivation path. // e.g. "m/84'/0'/1'" → 1. Returns 0 for unrecognised formats. static int _parseAccountIndex(String? derivationPath) { @@ -290,28 +275,36 @@ abstract class ElectrumWalletBase static const int addressHistoryChunkSize = 150; static const int transactionChunkSize = 150; - static const int inputTransactionChunkSize = 150; static const int discoveryHistoryChunkSize = 20; - static const int transactionBatchTimeoutMs = 15000; - static const int batchTestTimeoutMs = 4000; static const int batchTestHashesCount = 2; - static const bool useBatchForHistory = true; + static const int maxProbAccounts = 3; + + final Map> _appliedCoinKeysByAddress = {}; + @observable bool? alwaysScan; + @computed + bool get useLightning => _useLightning && LightningWallet.isAvailable; + + set useLightning(bool val) => _useLightning = val && LightningWallet.isAvailable; + @observable - bool useLightning; + bool _useLightning; + + @observable + ObservableMap accountBalances = ObservableMap(); final Bip32Slip10Secp256k1? _masterHD; final Bip32Slip10Secp256k1 accountHD; final String? _mnemonic; - late final Map mainHdByType; - late final Map sideHdByType; + final mainHdByTypeAndAccount = >{}; + final sideHdByTypeAndAccount = >{}; Bip32Slip10Secp256k1 get mainHd => accountHD.childKey(Bip32KeyIndex(0)); @@ -340,6 +333,145 @@ abstract class ElectrumWalletBase @observable SyncStatus syncStatus; + bool _hasCompleteUnspentSet = false; + bool _isSyncing = false; + + Future loadCurrentAccount() async { + final accounts = await walletInfo.getAccounts(); + final current = accounts.firstWhere( + (account) => account.accountIndex == currentAccountIndex, + orElse: () => accounts.first, + ); + + return current; + } + + Future> loadAccountIndexes() async { + if (!hasAccountsSupport) return [0]; + + final accounts = await walletInfo.getAccounts(); + return accounts.map((account) => account.accountIndex).toList(); + } + + void _prepareHdForAccount(int accountIndex, CryptoCurrency? currency) { + // For the first account, we support all address types. For extra accounts, we only support SegwitAddresType.p2wpkh. + final supportedTypes = + accountIndex == 0 ? supportedAddressTypes(walletInfo.type) : EXTRA_ACCOUNT_ADDRESS_TYPES; + final isElectrumDerivation = derivationInfo.derivationType == DerivationType.electrum; + final canDeriveFromSeed = _masterHD != null && currency != null; + + final mainMap = mainHdByTypeAndAccount[accountIndex] ??= {}; + final sideMap = sideHdByTypeAndAccount[accountIndex] ??= {}; + + if (canDeriveFromSeed && !isElectrumDerivation) { + final coinType = _coinTypeFor(currency); + + for (final type in supportedTypes) { + final purpose = _purposeForType(type); + final accountPath = "m/$purpose'/$coinType'/$accountIndex'"; + + mainMap[type] = _masterHD!.derivePath("$accountPath/0") as Bip32Slip10Secp256k1; + sideMap[type] = _masterHD!.derivePath("$accountPath/1") as Bip32Slip10Secp256k1; + } + } else { + // case for Electrum derivation or when we don't have seed but only xpub (view only wallet) + for (final type in supportedTypes) { + mainMap[type] = mainHd; // accountHD.child(0) + sideMap[type] = sideHd; // accountHD.child(1) + } + } + } + + ElectrumBalance balanceForAccount(int accountIndex) => + accountBalances[accountIndex] ?? ElectrumBalance.zero(currency); + + void _updateAccountBalancesFromUnspents() { + if (!_hasCompleteUnspentSet) return; + + final newBalances = {}; + + for (final coin in unspentCoins) { + final accountIndex = coin.bitcoinAddressRecord.accountIndex; + final current = newBalances[accountIndex] ?? ElectrumBalance.zero(currency); + final amount = Money.fromInt(coin.value, currency); + + if (coin.isFrozen) { + current.frozen += amount; + } + + if (coin.confirmations != null && coin.confirmations! > 0) { + current.confirmed += amount; + } else { + current.unconfirmed += amount; + } + + newBalances[accountIndex] = current; + } + + for (final accountIndex in walletAddresses.accountIndexes) { + newBalances.putIfAbsent(accountIndex, () => ElectrumBalance.zero(currency)); + } + + accountBalances = ObservableMap.of(newBalances); + _updateCurrentAccountBalance(); + } + + void _updateCurrentAccountBalance({int? accountIndex}) { + if (type != WalletType.bitcoin) return; + + final targetAccountIndex = accountIndex ?? currentAccountIndex; + final newBalance = accountBalances[targetAccountIndex]; + + if (newBalance == null) { + // If the account balance is not found, set it to zero for the current currency. + balance[currency] = ElectrumBalance.zero(currency); + return; + } + + balance[currency] = newBalance.copy(); + } + + Map> get addressesSetByAccount { + final result = >{}; + for (final addr in walletAddresses.allAddresses) { + if (addr.type == SegwitAddresType.mweb) continue; + (result[addr.accountIndex] ??= {}).add(addr.address); + } + return result; + } + + int? _accountForTransaction( + ElectrumTransactionBundle bundle, + Map> addressesByAccount, + ) { + if (type != WalletType.bitcoin) return null; + + int? accountForAddress(String address) { + for (final entry in addressesByAccount.entries) { + if (entry.value.contains(address)) return entry.key; + } + return null; + } + + for (var i = 0; i < bundle.originalTransaction.inputs.length; i++) { + final inputTransaction = bundle.ins[i]; + if (inputTransaction == null) continue; + + final outTransaction = + inputTransaction.outputs[bundle.originalTransaction.inputs[i].txIndex]; + final account = + accountForAddress(addressFromOutputScript(outTransaction.scriptPubKey, network)); + if (account != null) return account; + } + + for (final out in bundle.originalTransaction.outputs) { + final account = accountForAddress(addressFromOutputScript(out.scriptPubKey, network)); + if (account != null) return account; + } + + return null; + } + Set get addressesSet => walletAddresses.allAddresses .where((element) => element.type != SegwitAddresType.mweb) .map((addr) => addr.address) @@ -358,6 +490,77 @@ abstract class ElectrumWalletBase String get xpub => accountHD.publicKey.toExtended; + int get currentAccountIndex => walletInfo.currentAccountIndex; + + @action + Future setCurrentAccount(int accountIndex) async { + + final isNewAccount = !walletAddresses.accountIndexes.contains(accountIndex); + if (isNewAccount) { + walletAddresses.accountIndexes.add(accountIndex); + + accountBalances[accountIndex] = ElectrumBalance.zero(currency); + } + + await walletInfo.setSelectedAccount(accountIndex); + + _prepareHdForAccount(accountIndex, currency); + + walletAddresses.currentAccountIndex = accountIndex; + + final currentPageType = walletAddresses.addressPageType; + await walletAddresses.setAddressType(currentPageType); + + // For extra accounts, we only prepare SegwitAddresType.p2wpkh type. + if (isNewAccount) { + await walletAddresses.prepareAccountAddresses( + accountIndex, + types: accountIndex == 0 ? BITCOIN_ADDRESS_TYPES : EXTRA_ACCOUNT_ADDRESS_TYPES, + ); + await walletAddresses.updateAddressesInBox(); + } + + walletAddresses.updateReceiveAddresses(); + walletAddresses.updateChangeAddresses(); + + _updateCurrentAccountBalance(accountIndex: accountIndex); + + unawaited(() async { + try { + if (isNewAccount && !_isSyncing) { + final histories = {}; + + final typesToFetch = accountIndex == 0 ? BITCOIN_ADDRESS_TYPES : EXTRA_ACCOUNT_ADDRESS_TYPES; + // Explicit for clarity — fetchTransactionsForAddressTypeBatch already skips types + // with no addresses for this account, so this doesn't change RPC calls, just intent. + + for (final addressType in typesToFetch) { + if (shouldUseBatchFetching) { + await fetchTransactionsForAddressTypeBatch(histories, addressType, + accountIndex: accountIndex); + } else { + await fetchTransactionsForAddressType(histories, addressType, + accountIndex: accountIndex); + } + } + + await resolveQueuedTransactionDetails(); + } + + await updateAllUnspents(); + } catch (e) { + printV('setCurrentAccount balance update failed: $e'); + } + }()); + } + + bool get shouldUseBatchFetching => useBatchForHistory && _isBatchSupported == true; + + bool get isInitialBitcoinAccountsSync => + hasAccountsSupport && + walletInfo.isMultiAccountsEnabled == true && + (walletInfo.accountDiscoveryLimit ?? 0) < maxProbAccounts; + @override String? get seed => _mnemonic; @@ -373,8 +576,19 @@ abstract class ElectrumWalletBase @override bool isTestnet; + @override bool get hasSilentPaymentsScanning => type == WalletType.bitcoin && keys.privateKey.isNotEmpty; + @override + bool get hasCoinControl => true; + + @override + bool get hasAccountsSupport => + type == WalletType.bitcoin && + isSoftwareWallet && + _masterHD != null && // xpub / watch-only: can't derive other (hardened) accounts + derivationInfo.derivationType != DerivationType.electrum; + @observable bool nodeSupportsSilentPayments = true; @observable @@ -383,6 +597,9 @@ abstract class ElectrumWalletBase bool _isTryingToConnect = false; bool? _isBatchSupported; DateTime? _syncBenchmarkStartTime; + DateTime? _syncStartedAt; + + static const Duration maxSyncDuration = Duration(minutes: 2); Completer sharedPrefs = Completer(); @@ -414,7 +631,7 @@ abstract class ElectrumWalletBase _isolate?.then((value) => value.kill(priority: Isolate.immediate)); if (electrumClient.isConnected) { - syncStatus = SyncedSyncStatus(); + if (!_isSyncing) syncStatus = SyncedSyncStatus(); } else { syncStatus = NotConnectedSyncStatus(); } @@ -440,13 +657,24 @@ abstract class ElectrumWalletBase return currentChainTip ?? 0; } + /// Anti-fee-sniping locktime (current tip, exact-tip), LE-encoded for + /// `BitcoinTransactionBuilder`. + Future> _antiFeeSnipingLocktime() async { + return locktimeToBytes(antiFeeSnipingLocktime( + chainTip: await getCurrentChainTip(), + synced: syncStatus is SyncedSyncStatus, + )); + } + @override BitcoinWalletKeys get keys { String? wif; String? privateKey; String? publicKey; - final hd = mainHdByType[SegwitAddresType.p2wpkh] ?? mainHd; + // `keys` is wallet-level metadata + final hd = + mainHdByTypeAndAccount[0]?[SegwitAddresType.p2wpkh] ?? mainHd; try { wif = WifEncoder.encode(hd.privateKey.raw, netVer: network.wifNetVer); @@ -463,6 +691,7 @@ abstract class ElectrumWalletBase privateKey: privateKey ?? '', publicKey: publicKey ?? '', xpub: xpub, + masterFingerprint: _masterHD?.fingerPrint.toHex() ?? '', ); } @@ -478,6 +707,12 @@ abstract class ElectrumWalletBase bool _isTransactionUpdating; Future? _isolate; + final Map> _missingHistoryQueue = {}; + + Future? _resolveMissingHistoryFuture; + final Map _unresolvedAccountRetryCount = {}; + static const int _maxUnresolvedAccountRetries = 3; + void Function(FlutterErrorDetails)? _onError; Timer? _autoSaveTimer; StreamSubscription? _receiveStream; @@ -485,9 +720,16 @@ abstract class ElectrumWalletBase static const int _autoSaveInterval = 1; Future init() async { - await walletAddresses.init(); + final accountIndexes = await loadAccountIndexes(); + + for (final accountIndex in accountIndexes) { + _prepareHdForAccount(accountIndex, currency); + } + + await walletAddresses.init(accountIndexes: accountIndexes); await transactionHistory.init(); await cleanUpDuplicateUnspentCoins(); + _seedAccountBalancesFromAddressRecords(); await save(); _autoSaveTimer = @@ -586,13 +828,13 @@ abstract class ElectrumWalletBase : newUnspents[0].value; if (existingTxInfo.direction == TransactionDirection.incoming) { - existingTxInfo.amount += newAmount; + existingTxInfo.amount += Money.fromInt(newAmount, currency); } // Updates existing TX transactionHistory.addOne(existingTxInfo); // Update balance record - balance[currency]!.confirmed += newAmount; + balance[currency]!.confirmed += Money.fromInt(newAmount, currency); } } else { // else: First time seeing this TX after scanning @@ -654,14 +896,32 @@ abstract class ElectrumWalletBase DateTime? _lastSilentPaymentsScan; static const Duration _silentPaymentsScanDelay = Duration(minutes: 1); + Iterable get unspentCoinsForCurrentAccount { + if (type != WalletType.bitcoin) { + return unspentCoins; + } + + return unspentCoins + .where((coin) => coin.bitcoinAddressRecord.accountIndex == currentAccountIndex); + } + @action @override Future startSync() async { - try { - if (syncStatus is SyncronizingSyncStatus) { + if (_isSyncing) { + final startedAt = _syncStartedAt; + if (startedAt != null && DateTime.now().difference(startedAt) > maxSyncDuration) { + printV('startSync: previous sync appears stuck, allowing a new one'); + _isSyncing = false; + } else { + printV('startSync: already syncing, skipping'); return; } + } + _isSyncing = true; + _syncStartedAt = DateTime.now(); + try { if (_syncBenchmarkStartTime == null) { _syncBenchmarkStartTime = DateTime.now(); printV('[ELECTRUM_WALLET SYNC] Starting: ${_syncBenchmarkStartTime!}'); @@ -699,23 +959,17 @@ abstract class ElectrumWalletBase } await subscribeForUpdates(); - await _checkIfBatchSupported(); + await checkIfBatchSupported(); await updateTransactions(); await updateAllUnspents(); await updateBalance(); - await updateFeeRates(); - - _updateFeeRateTimer ??= - Timer.periodic(const Duration(minutes: 1), (timer) async => await updateFeeRates()); if (alwaysScan == true) { setSilentPaymentsScanning(true); + } else if (syncStatus is LostConnectionSyncStatus) { + _syncBenchmarkStartTime = null; } else { - if (syncStatus is LostConnectionSyncStatus) { - return; - } - final syncEnd = DateTime.now(); final totalMs = _syncBenchmarkStartTime != null ? syncEnd.difference(_syncBenchmarkStartTime!).inMilliseconds @@ -726,6 +980,13 @@ abstract class ElectrumWalletBase _syncBenchmarkStartTime = null; syncStatus = SyncedSyncStatus(); } + + await updateFeeRates(); + + _updateFeeRateTimer ??= + Timer.periodic(const Duration(minutes: 1), (timer) async => await updateFeeRates()); + + unawaited(resolveQueuedTransactionDetails()); } catch (e, stacktrace) { final syncEnd = DateTime.now(); final totalMs = _syncBenchmarkStartTime != null @@ -738,9 +999,14 @@ abstract class ElectrumWalletBase _syncBenchmarkStartTime = null; syncStatus = FailedSyncStatus(); + } finally { + _isSyncing = false; } } + static bool _isValidFeeRates(List feeRates) => + feeRates.length == 3 && feeRates.every((rate) => rate > 0); + @action Future updateFeeRates() async { if (await checkIfMempoolAPIIsEnabled() && type == WalletType.bitcoin) { @@ -767,7 +1033,7 @@ abstract class ElectrumWalletBase } final feeRates = await electrumClient.feeRates(network: network); - if (feeRates != [0, 0, 0]) { + if (_isValidFeeRates(feeRates)) { _feeRates = feeRates; } else if (isTestnet) { _feeRates = [1, 1, 1]; @@ -788,9 +1054,10 @@ abstract class ElectrumWalletBase if (server.toLowerCase().contains('electrs')) { node!.isElectrs = true; - if (node!.isInBox) { - node!.save(); - } + // TODO figure out why condition was needed + // if (node!.isInBox) { + node!.save(); + // } return node!.isElectrs!; } } @@ -853,15 +1120,28 @@ abstract class ElectrumWalletBase } } - int get networkDustAmount => 546; + BigInt get networkDustAmount => BigInt.from(546); - bool _isBelowDust(int amount) => amount <= networkDustAmount && network != BitcoinNetwork.testnet; + bool _isBelowDust(BigInt amount) => + amount <= networkDustAmount && network != BitcoinNetwork.testnet; + + // Random draw priority per outpoint. Assigned lazily with a secure RNG and kept + // until the next createTransaction call, so the recursive estimate passes of one + // transaction build all see the same input order (reshuffling between passes made + // fee estimation unstable). Cleared per transaction so each send is a fresh draw. + final Random _coinSelectionRng = Random.secure(); + final Map _coinSelectionOrder = {}; + + int _coinSelectionPriority(BitcoinUnspent utx) => _coinSelectionOrder.putIfAbsent( + '${utx.hash}:${utx.vout}', () => _coinSelectionRng.nextInt(1 << 32)); UtxoDetails _createUTXOS({ required bool sendAll, required bool paysToSilentPayment, int credentialsAmount = 0, int? inputsCount, + int feeRate = 0, + int? outputsVBytes, UnspentCoinType coinTypeToSpendFrom = UnspentCoinType.any, }) { List utxos = []; @@ -873,7 +1153,7 @@ abstract class ElectrumWalletBase bool spendsUnconfirmedTX = false; int leftAmount = credentialsAmount; - var availableInputs = unspentCoins.where((utx) { + var availableInputs = unspentCoinsForCurrentAccount.where((utx) { if (!utx.isSending || utx.isFrozen) { return false; } @@ -890,8 +1170,52 @@ abstract class ElectrumWalletBase }).toList(); final unconfirmedCoins = availableInputs.where((utx) => utx.confirmations == 0).toList(); - // sort the unconfirmed coins so that mweb coins are last: - availableInputs.sort((a, b) => a.bitcoinAddressRecord.type == SegwitAddresType.mweb ? 1 : -1); + // Single Random Draw: order the pool by each coin's random priority so selection is + // non-deterministic, removing the predictable address/scan order (a fingerprint). + // The priority is stable across the repeated calls of one transaction build. + // MWEB coins are kept last afterwards. + availableInputs.sort((a, b) { + final byPriority = _coinSelectionPriority(a).compareTo(_coinSelectionPriority(b)); + if (byPriority != 0) return byPriority; + return '${a.hash}:${a.vout}'.compareTo('${b.hash}:${b.vout}'); + }); + availableInputs = [ + ...availableInputs.where((u) => u.bitcoinAddressRecord.type != SegwitAddresType.mweb), + ...availableInputs.where((u) => u.bitcoinAddressRecord.type == SegwitAddresType.mweb), + ]; + + // Branch-and-bound: prefer an input set whose excess over the amount plus its own fee + // stays below dust, so the caller drops the change output and the send is changeless. + // When no such set exists, the shuffled pool below acts as a single random draw. + final canTryChangeless = !sendAll && + inputsCount == null && + credentialsAmount > 0 && + feeRate > 0 && + outputsVBytes != null && + outputsVBytes > 0 && + !availableInputs.any((u) => u.bitcoinAddressRecord.type == SegwitAddresType.mweb); + if (canTryChangeless) { + final match = changelessMatch( + values: [for (final u in availableInputs) u.value], + // estimatedTransactionSize(0, 0) is the fixed tx overhead (version, + // counters, locktime); the outputs' own vbytes come pre-computed per type. + target: credentialsAmount + (estimatedTransactionSize(0, 0) + outputsVBytes!) * feeRate, + inputCosts: [ + for (final u in availableInputs) + estimatedInputSize(u.bitcoinAddressRecord.type) * feeRate + ], + window: networkDustAmount.toInt(), + ); + if (match != null) { + final chosen = match.indices.toSet(); + availableInputs = [ + for (final i in match.indices) availableInputs[i], + for (var i = 0; i < availableInputs.length; i++) + if (!chosen.contains(i)) availableInputs[i], + ]; + inputsCount = match.indices.length; + } + } for (int i = 0; i < availableInputs.length; i++) { final utx = availableInputs[i]; @@ -910,7 +1234,7 @@ abstract class ElectrumWalletBase final address = RegexUtils.addressTypeFromStr(utx.address, network); ECPrivate? privkey; bool? isSilentPayment = false; - final hd = _hdFor(record: utx.bitcoinAddressRecord); + final hd = _hdForAddressRecord(record: utx.bitcoinAddressRecord); if (utx.bitcoinAddressRecord is BitcoinSilentPaymentAddressRecord) { final unspentAddress = utx.bitcoinAddressRecord as BitcoinSilentPaymentAddressRecord; @@ -1013,14 +1337,14 @@ abstract class ElectrumWalletBase vinOutpoints: utxoDetails.vinOutpoints, ); - if (fee == 0) { + if (fee <= 0) { throw BitcoinTransactionNoFeeException(); } // Here, when sending all, the output amount equals to the input value - fee to fully spend every input on the transaction and have no amount left for change - int amount = utxoDetails.allInputsAmount - fee; + final amount = BigInt.from(utxoDetails.allInputsAmount - fee); - if (amount <= 0) { + if (amount <= BigInt.zero) { throw BitcoinTransactionWrongBalanceException(amount: utxoDetails.allInputsAmount + fee); } @@ -1032,7 +1356,7 @@ abstract class ElectrumWalletBase if (outputs.length == 1) { outputs[0] = BitcoinOutput( address: outputs.last.address, - value: BigInt.from(amount), + value: amount, isSilentPayment: hasSilentPayment, ); } @@ -1041,8 +1365,8 @@ abstract class ElectrumWalletBase utxos: utxoDetails.utxos, inputPrivKeyInfos: utxoDetails.inputPrivKeyInfos, publicKeys: utxoDetails.publicKeys, - fee: fee, - amount: amount, + fee: Money.fromInt(fee, currency), + amount: Money(amount, currency), isSendAll: true, hasChange: false, memo: memo, @@ -1052,7 +1376,7 @@ abstract class ElectrumWalletBase } Future estimateTxForAmount( - int credentialsAmount, + Money credentialsAmount, List outputs, List updatedOutputs, int feeRate, { @@ -1063,7 +1387,7 @@ abstract class ElectrumWalletBase UnspentCoinType coinTypeToSpendFrom = UnspentCoinType.any, }) async { // Attempting to send less than the dust limit - if (_isBelowDust(credentialsAmount)) { + if (_isBelowDust(credentialsAmount.amount)) { throw BitcoinTransactionNoDustException(); } @@ -1107,10 +1431,25 @@ abstract class ElectrumWalletBase } } + // Per-type output sizes for the changeless target. MWEB outputs follow a + // different size model entirely, so they disable the changeless path (null). + int? outputsVBytes = 0; + for (final out in outputs) { + final type = + out.isSilentPayment == true ? SilentPaymentsAddresType.p2sp : _getScriptType(out.address); + if (type == SegwitAddresType.mweb) { + outputsVBytes = null; + break; + } + outputsVBytes = outputsVBytes! + estimatedOutputSize(type); + } + final utxoDetails = _createUTXOS( sendAll: false, - credentialsAmount: credentialsAmount, + credentialsAmount: credentialsAmount.amount.toInt(), inputsCount: inputsCount, + feeRate: feeRate, + outputsVBytes: outputsVBytes, paysToSilentPayment: hasSilentPayment, coinTypeToSpendFrom: coinTypeToSpendFrom, ); @@ -1120,8 +1459,8 @@ abstract class ElectrumWalletBase utxoDetails.utxos.length == utxoDetails.availableInputs.length - utxoDetails.unconfirmedCoins.length; - // How much is being spent - how much is being sent - int amountLeftForChangeAndFee = utxoDetails.allInputsAmount - credentialsAmount; + final amountLeftForChangeAndFee = + utxoDetails.allInputsAmount - credentialsAmount.amount.toInt(); if (amountLeftForChangeAndFee <= 0) { if (!spendingAllCoins) { @@ -1162,8 +1501,11 @@ abstract class ElectrumWalletBase final changeDerivationPath = "${_hardenedDerivationPath(changeBaseDerivationPath)}" "/${changeAddress.isHidden ? "1" : "0"}" "/${changeAddress.index}"; + final changeHd = _hdForAddressRecord(record: changeAddress).childKey(Bip32KeyIndex(changeAddress.index)); + final changePubKeyHex = changeHd.publicKey.toHex(); + utxoDetails.publicKeys[address.pubKeyHash()] = - PublicKeyWithDerivationPath('', changeDerivationPath); + PublicKeyWithDerivationPath(changePubKeyHex, changeDerivationPath); // calcFee updates the silent payment outputs to calculate the tx size accounting // for taproot addresses, but if more inputs are needed to make up for fees, @@ -1194,13 +1536,13 @@ abstract class ElectrumWalletBase )); } - if (fee == 0) { + if (fee <= 0) { throw BitcoinTransactionNoFeeException(); } - int amount = credentialsAmount; + var amount = credentialsAmount; final lastOutput = updatedOutputs.last; - final amountLeftForChange = amountLeftForChangeAndFee - fee; + final amountLeftForChange = BigInt.from(amountLeftForChangeAndFee - fee); if (_isBelowDust(amountLeftForChange)) { // If has change that is lower than dust, will end up with tx rejected by network rules @@ -1211,7 +1553,7 @@ abstract class ElectrumWalletBase // If the computed change is negative or below dust: // - negative: try a no-change tx (recalculate fee without change) // - non-negative but dust: drop change and add remainder to fee - if (amountLeftForChange < 0) { + if (amountLeftForChange < BigInt.zero) { final tempNoChange = outputs.map((o) => o).toList(); final feeNoChange = await calcFee( utxos: utxoDetails.utxos, @@ -1222,7 +1564,8 @@ abstract class ElectrumWalletBase inputPrivKeyInfos: utxoDetails.inputPrivKeyInfos, vinOutpoints: utxoDetails.vinOutpoints, ); - final leftover = utxoDetails.allInputsAmount - credentialsAmount - feeNoChange; + final leftover = + utxoDetails.allInputsAmount - credentialsAmount.amount.toInt() - feeNoChange; if (leftover >= 0) { final finalFee = feeNoChange + leftover; // absorb tiny remainder @@ -1230,7 +1573,7 @@ abstract class ElectrumWalletBase utxos: utxoDetails.utxos, inputPrivKeyInfos: utxoDetails.inputPrivKeyInfos, publicKeys: utxoDetails.publicKeys, - fee: finalFee, + fee: Money.fromInt(finalFee, currency), amount: amount, hasChange: false, isSendAll: spendingAllCoins, @@ -1259,13 +1602,13 @@ abstract class ElectrumWalletBase // if the amount left for change is less than dust, but not less than 0 // then add it to the fees - fee += amountLeftForChange; + fee += amountLeftForChange.toInt(); return EstimatedTxResult( utxos: utxoDetails.utxos, inputPrivKeyInfos: utxoDetails.inputPrivKeyInfos, publicKeys: utxoDetails.publicKeys, - fee: fee, + fee: Money.fromInt(fee, currency), amount: amount, hasChange: false, isSendAll: spendingAllCoins, @@ -1277,13 +1620,13 @@ abstract class ElectrumWalletBase // Here, lastOutput already is change, return the amount left without the fee to the user's address. updatedOutputs[updatedOutputs.length - 1] = BitcoinOutput( address: lastOutput.address, - value: BigInt.from(amountLeftForChange), + value: amountLeftForChange, isSilentPayment: lastOutput.isSilentPayment, isChange: true, ); outputs[outputs.length - 1] = BitcoinOutput( address: lastOutput.address, - value: BigInt.from(amountLeftForChange), + value: amountLeftForChange, isSilentPayment: lastOutput.isSilentPayment, isChange: true, ); @@ -1292,7 +1635,7 @@ abstract class ElectrumWalletBase utxos: utxoDetails.utxos, inputPrivKeyInfos: utxoDetails.inputPrivKeyInfos, publicKeys: utxoDetails.publicKeys, - fee: fee, + fee: Money.fromInt(fee, currency), amount: amount, hasChange: true, isSendAll: spendingAllCoins, @@ -1303,7 +1646,7 @@ abstract class ElectrumWalletBase } } - Future _maxSpendableNoChangeAmount({ + Future _maxSpendableNoChangeAmount({ required BitcoinOutput initialOutput, required int feeRate, String? memo, @@ -1335,7 +1678,7 @@ abstract class ElectrumWalletBase ); final maxSpendable = utxoDetailsAll.allInputsAmount - feeNoChange; - return maxSpendable > 0 ? maxSpendable : 0; + return Money.fromInt(maxSpendable > 0 ? maxSpendable : 0, currency); } Future calcFee({ @@ -1372,6 +1715,10 @@ abstract class ElectrumWalletBase @override Future createTransaction(Object credentials) async { try { + // New transaction, new random draw: drop the previous input ordering so this + // build gets fresh priorities, then keep them fixed for all estimate passes. + _coinSelectionOrder.clear(); + // start by updating unspent coins await updateAllUnspents(); @@ -1382,13 +1729,13 @@ abstract class ElectrumWalletBase final memo = transactionCredentials.outputs.first.memo; final coinTypeToSpendFrom = transactionCredentials.coinTypeToSpendFrom; - int credentialsAmount = 0; - bool hasSilentPayment = false; + var credentialsAmount = Money.zero(currency); + var hasSilentPayment = false; for (final out in transactionCredentials.outputs) { - final outputAmount = out.formattedCryptoAmount!; + final outputAmount = out.cryptoAmount; - if (!sendAll && _isBelowDust(outputAmount)) { + if (!sendAll && _isBelowDust(outputAmount.amount)) { throw BitcoinTransactionNoDustException(); } @@ -1412,13 +1759,13 @@ abstract class ElectrumWalletBase // The value will be changed after estimating the Tx size and deducting the fee from the total to be sent outputs.add(BitcoinOutput( address: address, - value: BigInt.from(0), + value: BigInt.zero, isSilentPayment: isSilentPayment, )); } else { outputs.add(BitcoinOutput( address: address, - value: BigInt.from(outputAmount), + value: outputAmount.amount, isSilentPayment: isSilentPayment, )); } @@ -1471,10 +1818,12 @@ abstract class ElectrumWalletBase utxos: estimatedTx.utxos, outputs: updatedOutputs, publicKeys: estimatedTx.publicKeys, - fee: BigInt.from(estimatedTx.fee), + fee: estimatedTx.fee.amount, network: network, memo: estimatedTx.memo, - outputOrdering: BitcoinOrdering.none, + // Shuffle so the change output isn't placed deterministically last + // (privacy fingerprint). Applied by orderOutputs in the builder. + outputOrdering: BitcoinOrdering.shuffle, enableRBF: true, cwOutputs: transactionCredentials.outputs, ); @@ -1482,6 +1831,7 @@ abstract class ElectrumWalletBase return PendingBitcoinTransaction( transaction, type, + accountIndex: currentAccountIndex, electrumClient: electrumClient, amount: estimatedTx.amount, fee: estimatedTx.fee, @@ -1489,35 +1839,43 @@ abstract class ElectrumWalletBase network: network, hasChange: estimatedTx.hasChange, isSendAll: estimatedTx.isSendAll, - hasTaprootInputs: false, // ToDo: (Konsti) Support Taproot, + hasTaprootInputs: false, + // ToDo: (Konsti) Support Taproot, isViewOnly: false, )..addListener((transaction) async { transactionHistory.addOne(transaction); - await updateBalance(); await updateAllUnspents(); + await updateBalance(); }); } + final locktime = await _antiFeeSnipingLocktime(); + BasedBitcoinTransacationBuilder txb; if (network is BitcoinCashNetwork) { txb = ForkedTransactionBuilder( utxos: estimatedTx.utxos, outputs: updatedOutputs, - fee: BigInt.from(estimatedTx.fee), + fee: estimatedTx.fee.amount, network: network, memo: estimatedTx.memo, - outputOrdering: BitcoinOrdering.none, + // Shuffle so the change output isn't placed deterministically last + // (privacy fingerprint). Change is found by isChange, not position. + inputOrdering: BitcoinOrdering.shuffle, + outputOrdering: BitcoinOrdering.shuffle, enableRBF: !estimatedTx.spendsUnconfirmedTX, ); } else { txb = BitcoinTransactionBuilder( utxos: estimatedTx.utxos, outputs: updatedOutputs, - fee: BigInt.from(estimatedTx.fee), + fee: estimatedTx.fee.amount, network: network, memo: estimatedTx.memo, - outputOrdering: BitcoinOrdering.none, + inputOrdering: BitcoinOrdering.shuffle, + outputOrdering: BitcoinOrdering.shuffle, enableRBF: !estimatedTx.spendsUnconfirmedTX, + locktime: locktime, ); } @@ -1565,6 +1923,7 @@ abstract class ElectrumWalletBase return PendingBitcoinTransaction( transaction, type, + accountIndex: currentAccountIndex, electrumClient: electrumClient, amount: estimatedTx.amount, fee: estimatedTx.fee, @@ -1574,6 +1933,7 @@ abstract class ElectrumWalletBase isSendAll: estimatedTx.isSendAll, hasTaprootInputs: hasTaprootInputs, utxos: estimatedTx.utxos, + derivedOutputs: updatedOutputs, publicKeys: estimatedTx.publicKeys, isViewOnly: keys.privateKey.isEmpty, )..addListener((transaction) async { @@ -1589,8 +1949,8 @@ abstract class ElectrumWalletBase unspentCoins .removeWhere((utxo) => estimatedTx.utxos.any((e) => e.utxo.txHash == utxo.hash)); - await updateBalance(); await updateAllUnspents(); + await updateBalance(); }); } catch (e) { throw e; @@ -1600,7 +1960,7 @@ abstract class ElectrumWalletBase HardwareWalletService? hardwareWalletService; Future buildHardwareWalletTransaction({ - required List outputs, + required List outputs, required BigInt fee, required BasedUtxoNetwork network, required List utxos, @@ -1675,7 +2035,7 @@ abstract class ElectrumWalletBase if (amount != null) { int totalValue = 0; - for (final input in unspentCoins) { + for (final input in unspentCoinsForCurrentAccount) { if (totalValue >= amount) { break; } @@ -1688,7 +2048,7 @@ abstract class ElectrumWalletBase if (totalValue < amount) return 0; } else { - for (final input in unspentCoins) { + for (final input in unspentCoinsForCurrentAccount) { if (input.isSending) { inputsCount += 1; } @@ -1713,28 +2073,6 @@ abstract class ElectrumWalletBase await transactionHistory.save(); } - @override - Future renameWalletFiles(String newWalletName) async { - final currentWalletPath = await pathForWallet(name: walletInfo.name, type: type); - final currentWalletFile = File(currentWalletPath); - - final currentDirPath = await pathForWalletDir(name: walletInfo.name, type: type); - final currentTransactionsFile = File('$currentDirPath/$transactionsHistoryFileName'); - - // Copies current wallet files into new wallet name's dir and files - if (currentWalletFile.existsSync()) { - final newWalletPath = await pathForWallet(name: newWalletName, type: type); - await currentWalletFile.copy(newWalletPath); - } - if (currentTransactionsFile.existsSync()) { - final newDirPath = await pathForWalletDir(name: newWalletName, type: type); - await currentTransactionsFile.copy('$newDirPath/$transactionsHistoryFileName'); - } - - // Delete old name's dir and files - await Directory(currentDirPath).delete(recursive: true); - } - @override Future changePassword(String password) async { _password = password; @@ -1753,6 +2091,8 @@ abstract class ElectrumWalletBase @override Future close({bool shouldCleanup = false}) async { + _missingHistoryQueue.clear(); + _unresolvedAccountRetryCount.clear(); try { await _receiveStream?.cancel(); await electrumClient.close(); @@ -1764,6 +2104,7 @@ abstract class ElectrumWalletBase @action Future updateAllUnspents() async { + List updatedUnspentCoins = []; final previousUnspentCoins = List.from(unspentCoins.where((utxo) => @@ -1780,18 +2121,22 @@ abstract class ElectrumWalletBase } // Set the balance of all non-silent payment and non-mweb addresses to 0 before updating - walletAddresses.allAddresses - .where((element) => element.type != SegwitAddresType.mweb) - .forEach((addr) { - if (addr is! BitcoinSilentPaymentAddressRecord) addr.balance = 0; - }); - final addressFutures = walletAddresses.allAddresses + final targetAddresses = walletAddresses.allAddresses .where((element) => element.type != SegwitAddresType.mweb) - .map((address) => fetchUnspent(address)) .toList(); - final results = await Future.wait(addressFutures); + for (final addr in targetAddresses) { + if (addr is! BitcoinSilentPaymentAddressRecord) { + addr.balance = 0; + _appliedCoinKeysByAddress[addr.address]?.clear(); + } + } + + final results = shouldUseBatchFetching + ? await _fetchUnspentsBatch(targetAddresses) + : await _fetchUnspentsRegular(targetAddresses); + final failedCount = results.where((result) => result == null).length; if (failedCount == 0) { @@ -1799,6 +2144,7 @@ abstract class ElectrumWalletBase updatedUnspentCoins.addAll(result!); } unspentCoins = updatedUnspentCoins; + _hasCompleteUnspentSet = true; } else { if (updatedUnspentCoins.isEmpty) { unspentCoins = handleFailedUtxoFetch( @@ -1820,9 +2166,81 @@ abstract class ElectrumWalletBase } await updateCoins(unspentCoins); + _updateAccountBalancesFromUnspents(); await _refreshUnspentCoinsInfo(); } + Future?>> _fetchUnspentsRegular( + List addresses, + ) async { + final addressFutures = addresses.map((address) => fetchUnspent(address)).toList(); + return Future.wait(addressFutures); + } + + Future?>> _fetchUnspentsBatch( + List addresses, + ) async { + final byScriptHash = { + for (final address in addresses) address.getScriptHash(network): address, + }; + + final scriptHashes = byScriptHash.keys.toList(); + + try { + final failedScriptHashes = {}; + final unspentByScriptHash = + await _processChunksToMap>>( + items: scriptHashes, + chunkSize: addressHistoryChunkSize, + processChunk: _getListUnspentBatch, + onChunkError: (chunk, _) => failedScriptHashes.addAll(chunk), + ); + + final txHashes = {}; + final coinsByScriptHash = >{}; + + for (final entry in unspentByScriptHash.entries) { + final addressRecord = byScriptHash[entry.key]; + if (addressRecord == null) continue; + + final coins = []; + + for (final unspent in entry.value) { + final coin = BitcoinUnspent.fromJSON(addressRecord, unspent); + coin.isChange = addressRecord.isHidden; + coins.add(coin); + txHashes.add(coin.hash); + } + + coinsByScriptHash[entry.key] = coins; + } + + final txInfoByHash = await fetchTransactionInfoBatch( + hashes: txHashes.toList(), + retryOnFailure: true, + retryDelay: const Duration(seconds: 1), + ); + + for (final coins in coinsByScriptHash.values) { + for (final coin in coins) { + final tx = txInfoByHash[coin.hash]; + coin.confirmations = tx?.confirmations; + coin.isPegOut = tx?.isHogEx; + } + } + + return addresses.map((address) { + final scriptHash = address.getScriptHash(network); + if (failedScriptHashes.contains(scriptHash)) return null; + if (!unspentByScriptHash.containsKey(scriptHash)) return null; + return coinsByScriptHash[scriptHash] ?? []; + }).toList(); + } catch (e) { + printV('fetchUnspentsBatch failed: $e'); + return List?>.filled(addresses.length, null); + } + } + List handleFailedUtxoFetch({ required int failedCount, required List previousUnspentCoins, @@ -1874,8 +2292,15 @@ abstract class ElectrumWalletBase coin.isSending = coinInfo.isSending; coin.note = coinInfo.note; - if (coin.bitcoinAddressRecord is! BitcoinSilentPaymentAddressRecord) - coin.bitcoinAddressRecord.balance += coinInfo.value; + if (coin.bitcoinAddressRecord is! BitcoinSilentPaymentAddressRecord) { + final addr = coin.bitcoinAddressRecord.address; + final coinKey = '${coin.hash}:${coin.vout}'; + final appliedKeys = _appliedCoinKeysByAddress.putIfAbsent(addr, () => {}); + + if (appliedKeys.add(coinKey)) { + coin.bitcoinAddressRecord.balance += coinInfo.value; + } + } } else { addCoinInfo(coin); } @@ -1915,8 +2340,12 @@ abstract class ElectrumWalletBase @action Future addCoinInfo(BitcoinUnspent coin) async { // Check if the coin is already in the unspentCoinsInfo for the wallet - final existingCoinInfo = unspentCoinsInfo.values - .firstWhereOrNull((element) => element.walletId == walletInfo.id && element == coin); + final existingCoinInfo = unspentCoinsInfo.values.firstWhereOrNull( + (element) => + element.walletId == walletInfo.id && + element.hash == coin.hash && + element.vout == coin.vout, + ); if (existingCoinInfo == null) { final newInfo = UnspentCoinsInfo( @@ -1943,6 +2372,7 @@ abstract class ElectrumWalletBase unspentCoinsInfo.values.where((record) => record.walletId == id); for (final element in currentWalletUnspentCoins) { + if (element.isFrozen) continue; if (RegexUtils.addressTypeFromStr(element.address, network) is MwebAddress) continue; final existUnspentCoins = unspentCoins.where((coin) => element == coin); @@ -1978,6 +2408,41 @@ abstract class ElectrumWalletBase if (duplicateKeys.isNotEmpty) await unspentCoinsInfo.deleteAll(duplicateKeys); } + void _seedAccountBalancesFromAddressRecords() { + if (type != WalletType.bitcoin) return; + if (accountBalances.isNotEmpty) return; + + final newBalances = {}; + + for (final addr in walletAddresses.allAddresses) { + if (addr.type == SegwitAddresType.mweb) continue; + final current = newBalances[addr.accountIndex] ?? ElectrumBalance.zero(currency); + current.confirmed += Money.fromInt(addr.balance, currency); + newBalances[addr.accountIndex] = current; + } + + for (final accountIndex in walletAddresses.accountIndexes) { + newBalances.putIfAbsent(accountIndex, () => ElectrumBalance.zero(currency)); + } + + // balance[currency] came from the wallet file and is authoritative for the + // selected account — prefer it if the address records look stale. + final persisted = balance[currency]; + final seeded = newBalances[currentAccountIndex]; + if (persisted != null && seeded != null) { + final seededIsZero = seeded.confirmed == Money.zero(currency) && + seeded.unconfirmed == Money.zero(currency); + final persistedIsNonZero = persisted.confirmed != Money.zero(currency) || + persisted.unconfirmed != Money.zero(currency); + if (seededIsZero && persistedIsNonZero) { + newBalances[currentAccountIndex] = persisted; + } + } + + accountBalances = ObservableMap.of(newBalances); + _updateCurrentAccountBalance(); + } + int transactionVSize(String transactionHex) => BtcTransaction.fromRaw(transactionHex).getVSize(); Future canReplaceByFee(ElectrumTransactionInfo tx) async { @@ -2006,7 +2471,8 @@ abstract class ElectrumWalletBase throw Exception("Receiver output not found."); } - final availableInputs = unspentCoins.where((utxo) => utxo.isSending && !utxo.isFrozen).toList(); + final availableInputs = + unspentCoinsForCurrentAccount.where((utxo) => utxo.isSending && !utxo.isFrozen).toList(); int totalBalance = availableInputs.fold( 0, (previousValue, element) => previousValue + element.value.toInt()); @@ -2014,17 +2480,20 @@ abstract class ElectrumWalletBase for (int i = 0; i < bundle.originalTransaction.inputs.length; i++) { final input = bundle.originalTransaction.inputs[i]; final inputTransaction = bundle.ins[i]; + if (inputTransaction == null) { + throw Exception("Missing input transaction for fee calculation"); + } final vout = input.txIndex; final outTransaction = inputTransaction.outputs[vout]; allInputsAmount += outTransaction.amount.toInt(); } - int totalOutAmount = bundle.originalTransaction.outputs + final totalOutAmount = bundle.originalTransaction.outputs .fold(0, (previousValue, element) => previousValue + element.amount.toInt()); var currentFee = allInputsAmount - totalOutAmount; - int remainingFee = (newFee - currentFee > 0) ? newFee - currentFee : newFee; - return totalBalance - receiverAmount - remainingFee >= networkDustAmount; + final remainingFee = (newFee - currentFee > 0) ? newFee - currentFee : newFee; + return totalBalance - receiverAmount - remainingFee >= networkDustAmount.toInt(); } Future replaceByFee(String hash, int newFee) async { @@ -2042,6 +2511,9 @@ abstract class ElectrumWalletBase for (var i = 0; i < bundle.originalTransaction.inputs.length; i++) { final input = bundle.originalTransaction.inputs[i]; final inputTransaction = bundle.ins[i]; + if (inputTransaction == null) { + throw Exception("Missing input transaction for replace-by-fee"); + } final vout = input.txIndex; final outTransaction = inputTransaction.outputs[vout]; final address = addressFromOutputScript(outTransaction.scriptPubKey, network); @@ -2051,7 +2523,7 @@ abstract class ElectrumWalletBase walletAddresses.allAddresses.firstWhere((element) => element.address == address); final btcAddress = RegexUtils.addressTypeFromStr(addressRecord.address, network); - final hd = _hdFor(record: addressRecord); + final hd = _hdForAddressRecord(record: addressRecord); final privkey = generateECPrivate(hd: hd, index: addressRecord.index, network: network); @@ -2096,14 +2568,14 @@ abstract class ElectrumWalletBase int totalOutAmount = outputs.fold(0, (previousValue, output) => previousValue + output.value.toInt()); int currentFee = allInputsAmount - totalOutAmount; - int remainingFee = newFee - currentFee; + var remainingFee = BigInt.from(newFee - currentFee); - if (remainingFee <= 0) { + if (remainingFee <= BigInt.zero) { throw Exception("New fee must be higher than the current fee."); } // Deduct fee from change outputs first, if possible - if (remainingFee > 0) { + if (remainingFee > BigInt.zero) { final changeAddresses = walletAddresses.allAddresses.where((element) => element.isHidden); for (int i = outputs.length - 1; i >= 0; i--) { final output = outputs[i]; @@ -2111,31 +2583,32 @@ abstract class ElectrumWalletBase .any((element) => element.address == output.address.toAddress(network)); if (isChange) { - int outputAmount = output.value.toInt(); + final outputAmount = output.value; if (outputAmount > networkDustAmount) { - int deduction = (outputAmount - networkDustAmount >= remainingFee) + final deduction = (outputAmount - networkDustAmount >= remainingFee) ? remainingFee : outputAmount - networkDustAmount; - outputs[i] = BitcoinOutput( - address: output.address, value: BigInt.from(outputAmount - deduction)); + outputs[i] = BitcoinOutput(address: output.address, value: outputAmount - deduction); remainingFee -= deduction; - if (remainingFee <= 0) break; + if (remainingFee <= BigInt.zero) break; } } } } - // If still not enough, add UTXOs until the fee is covered - if (remainingFee > 0) { - final unusedUtxos = unspentCoins + // If still not enough, add UTXOs until the fee is covered, drawing them at + // random instead of in the predictable wallet scan order (address, then age). + if (remainingFee > BigInt.zero) { + final unusedUtxos = unspentCoinsForCurrentAccount .where((utxo) => utxo.isSending && !utxo.isFrozen && utxo.confirmations! > 0) - .toList(); + .toList() + ..shuffle(Random.secure()); for (final utxo in unusedUtxos) { final address = RegexUtils.addressTypeFromStr(utxo.address, network); - final hd = _hdFor(record: utxo.bitcoinAddressRecord); + final hd = _hdForAddressRecord(record: utxo.bitcoinAddressRecord); final privkey = generateECPrivate( hd: hd, @@ -2155,52 +2628,51 @@ abstract class ElectrumWalletBase )); allInputsAmount += utxo.value; - remainingFee -= utxo.value; + remainingFee -= BigInt.from(utxo.value); - if (remainingFee < 0) { + if (remainingFee < BigInt.zero) { final changeOutput = outputs.firstWhereOrNull((output) => walletAddresses.allAddresses .any((addr) => addr.address == output.address.toAddress(network))); if (changeOutput != null) { - final newValue = changeOutput.value.toInt() + (-remainingFee); + final newValue = changeOutput.value + (-remainingFee); outputs[outputs.indexOf(changeOutput)] = - BitcoinOutput(address: changeOutput.address, value: BigInt.from(newValue)); + BitcoinOutput(address: changeOutput.address, value: newValue); } else { final changeAddress = await walletAddresses.getChangeAddress(); outputs.add(BitcoinOutput( address: RegexUtils.addressTypeFromStr(changeAddress.address, network), - value: BigInt.from(-remainingFee))); + value: -remainingFee)); } - remainingFee = 0; + remainingFee = BigInt.zero; break; } - if (remainingFee <= 0) break; + if (remainingFee <= BigInt.zero) break; } } // Deduct from the receiver's output if remaining fee is still greater than 0 - if (remainingFee > 0) { + if (remainingFee > BigInt.zero) { for (int i = 0; i < outputs.length; i++) { final output = outputs[i]; - int outputAmount = output.value.toInt(); + final outputAmount = output.value; if (outputAmount > networkDustAmount) { - int deduction = (outputAmount - networkDustAmount >= remainingFee) + final deduction = (outputAmount - networkDustAmount >= remainingFee) ? remainingFee : outputAmount - networkDustAmount; - outputs[i] = BitcoinOutput( - address: output.address, value: BigInt.from(outputAmount - deduction)); + outputs[i] = BitcoinOutput(address: output.address, value: outputAmount - deduction); remainingFee -= deduction; - if (remainingFee <= 0) break; + if (remainingFee <= BigInt.zero) break; } } } // Final check if the remaining fee couldn't be deducted - if (remainingFee > 0) { + if (remainingFee > BigInt.zero) { throw Exception("Not enough funds to cover the fee."); } @@ -2223,8 +2695,10 @@ abstract class ElectrumWalletBase fee: BigInt.from(newFee), network: network, memo: memo, - outputOrdering: BitcoinOrdering.none, + inputOrdering: BitcoinOrdering.shuffle, + outputOrdering: BitcoinOrdering.shuffle, enableRBF: true, + locktime: await _antiFeeSnipingLocktime(), ); final transaction = txb.buildTransaction((txDigest, utxo, publicKey, sighash) { @@ -2244,25 +2718,26 @@ abstract class ElectrumWalletBase return PendingBitcoinTransaction( transaction, type, + accountIndex: transactionHistory.transactions[hash]?.accountIndex ?? currentAccountIndex, electrumClient: electrumClient, - amount: sendingAmount, - fee: newFee, + amount: Money.fromInt(sendingAmount, currency), + fee: Money.fromInt(newFee, currency), network: network, hasChange: changeOutputs.isNotEmpty, feeRate: newFee.toString(), isViewOnly: keys.privateKey.isEmpty, )..addListener((transaction) async { - transactionHistory.transactions.values.forEach((tx) { - if (tx.id == hash) { - tx.isReplaced = true; - tx.isPending = false; - transactionHistory.addOne(tx); - } - }); - transactionHistory.addOne(transaction); - await updateBalance(); - await updateAllUnspents(); + transactionHistory.transactions.values.forEach((tx) { + if (tx.id == hash) { + tx.isReplaced = true; + tx.isPending = false; + transactionHistory.addOne(tx); + } }); + transactionHistory.addOne(transaction); + await updateAllUnspents(); + await updateBalance(); + }); } catch (e) { throw e; } @@ -2328,20 +2803,24 @@ abstract class ElectrumWalletBase } final original = BtcTransaction.fromRaw(transactionHex); - final ins = []; + final ins = []; for (final vin in original.inputs) { - final verboseTransaction = await electrumClient.getTransactionVerbose(hash: vin.txId); + try { + final verboseTransaction = await electrumClient.getTransactionVerbose(hash: vin.txId); - final String inputTransactionHex; + final String inputTransactionHex; - if (verboseTransaction.isEmpty) { - inputTransactionHex = await electrumClient.getTransactionHex(hash: hash); - } else { - inputTransactionHex = verboseTransaction['hex'] as String; + if (verboseTransaction.isEmpty) { + inputTransactionHex = await electrumClient.getTransactionHex(hash: vin.txId); + } else { + inputTransactionHex = verboseTransaction['hex'] as String; + } + + ins.add(inputTransactionHex.isEmpty ? null : BtcTransaction.fromRaw(inputTransactionHex)); + } catch (_) { + ins.add(null); } - - ins.add(BtcTransaction.fromRaw(inputTransactionHex)); } return ElectrumTransactionBundle( @@ -2355,13 +2834,19 @@ abstract class ElectrumWalletBase Future fetchTransactionInfo( {required String hash, int? height, bool? retryOnFailure}) async { try { - return ElectrumTransactionInfo.fromElectrumBundle( - await getTransactionExpanded(hash: hash, height: height), + final bundle = await getTransactionExpanded(hash: hash, height: height); + final addressesByAccount = addressesSetByAccount; + final owner = _accountForTransaction(bundle, addressesByAccount); + + final info = ElectrumTransactionInfo.fromElectrumBundle( + bundle, walletInfo.type, network, - addresses: addressesSet, + addresses: owner != null ? addressesByAccount[owner]! : addressesSet, height: height, ); + info.accountIndex = owner; + return info; } catch (e) { if (e is FormatException && retryOnFailure == true) { await Future.delayed(const Duration(seconds: 2)); @@ -2380,29 +2865,38 @@ abstract class ElectrumWalletBase Future> fetchTransactions() async { try { final Map historiesWithDetails = {}; - final shouldUseBatchForHistory = useBatchForHistory && _isBatchSupported == true; - printV('[BATCH_TEST] Fetching transactions with batch: $shouldUseBatchForHistory'); + printV("[BATCH_TEST] Fetching transactions with batch: $shouldUseBatchFetching"); if (type == WalletType.bitcoin) { - await Future.wait(BITCOIN_ADDRESS_TYPES.map((type) => shouldUseBatchForHistory + if (isInitialBitcoinAccountsSync) { + await fetchBitcoinTransactionsForInitialRestore(historiesWithDetails); + } else { + await Future.wait(BITCOIN_ADDRESS_TYPES.map((type) => shouldUseBatchFetching + ? fetchTransactionsForAddressTypeBatch(historiesWithDetails, type) + : fetchTransactionsForAddressType(historiesWithDetails, type))); + } + } else if (type == WalletType.bitcoinCash) { + await Future.wait(BITCOIN_CASH_ADDRESS_TYPES.map((type) => shouldUseBatchFetching ? fetchTransactionsForAddressTypeBatch(historiesWithDetails, type) : fetchTransactionsForAddressType(historiesWithDetails, type))); - } else if (type == WalletType.bitcoinCash) { - await Future.wait(BITCOIN_CASH_ADDRESS_TYPES - .map((type) => fetchTransactionsForAddressType(historiesWithDetails, type))); } else if (type == WalletType.litecoin) { - await Future.wait(LITECOIN_ADDRESS_TYPES - .where((type) => type != SegwitAddresType.mweb) - .map((type) => fetchTransactionsForAddressType(historiesWithDetails, type))); + await Future.wait(LITECOIN_ADDRESS_TYPES.where((type) => type != SegwitAddresType.mweb).map( + (type) => shouldUseBatchFetching + ? fetchTransactionsForAddressTypeBatch(historiesWithDetails, type) + : fetchTransactionsForAddressType(historiesWithDetails, type))); } else if (type == WalletType.dogecoin) { - await Future.wait(DOGECOIN_ADDRESS_TYPES - .map((type) => fetchTransactionsForAddressType(historiesWithDetails, type))); + await Future.wait(DOGECOIN_ADDRESS_TYPES.map((type) => shouldUseBatchFetching + ? fetchTransactionsForAddressTypeBatch(historiesWithDetails, type) + : fetchTransactionsForAddressType(historiesWithDetails, type))); } + printV('[QUICK_SYNC] deferred ${_missingHistoryQueue.length} transactions to background'); + transactionHistory.transactions.values.forEach((tx) async { - final isPendingSilentPaymentUtxo = - (tx.isPending || tx.confirmations == 0) && historiesWithDetails[tx.id] == null; + final isPendingSilentPaymentUtxo = (tx.isPending || tx.confirmations == 0) && + historiesWithDetails[tx.id] == null && + !_missingHistoryQueue.containsKey(tx.id); if (isPendingSilentPaymentUtxo) { final info = @@ -2425,217 +2919,180 @@ abstract class ElectrumWalletBase } Future fetchTransactionsForAddressType( - Map historiesWithDetails, - BitcoinAddressType type, - ) async { - final addressesByType = walletAddresses.allAddresses.where((addr) => addr.type == type); - final hiddenAddresses = addressesByType.where((addr) => addr.isHidden == true); - final receiveAddresses = addressesByType.where((addr) => addr.isHidden == false); - walletAddresses.hiddenAddresses.addAll(hiddenAddresses.map((e) => e.address)); + Map historiesWithDetails, + BitcoinAddressType type, { + int? accountIndex, + }) async { + final addressesByType = walletAddresses.allAddresses + .where((addr) => addr.type == type) + .where((addr) => accountIndex == null || addr.accountIndex == accountIndex) + .toList(); + + + final branchCache = >{}; + List branchFor(BitcoinAddressRecord record) => + branchCache.putIfAbsent( + '${record.accountIndex}:${record.isHidden}:${record.isLegacyDerivation}', + () => getAddressBranchByType( + type: type, + hidden: record.isHidden, + legacy: record.isLegacyDerivation, + accountIndex: record.accountIndex, + ), + ); + + walletAddresses.hiddenAddresses + .addAll(addressesByType.where((addr) => addr.isHidden).map((e) => e.address)); await walletAddresses.saveAddressesInBox(); + await Future.wait(addressesByType.map((addressRecord) async { - final history = await _fetchAddressHistory(addressRecord, await getCurrentChainTip()); + final result = await _fetchAddressHistory(addressRecord, await getCurrentChainTip()); - if (history.isNotEmpty) { - addressRecord.txCount = history.length; - historiesWithDetails.addAll(history); + if (result.hasHistory) { + historiesWithDetails.addAll(result.transactions); - final matchedAddresses = addressRecord.isHidden ? hiddenAddresses : receiveAddresses; - final isUsedAddressUnderGap = matchedAddresses.toList().indexOf(addressRecord) >= + final matchedAddresses = branchFor(addressRecord); + final isUsedAddressAboveGap = matchedAddresses.indexOf(addressRecord) >= matchedAddresses.length - (addressRecord.isHidden ? ElectrumWalletAddressesBase.defaultChangeAddressesCount : ElectrumWalletAddressesBase.defaultReceiveAddressesCount); - if (isUsedAddressUnderGap) { - final prevLength = walletAddresses.allAddresses.length; + if (isUsedAddressAboveGap) { // Discover new addresses for the same address type until the gap limit is respected await walletAddresses.discoverAddresses( - matchedAddresses.toList(), + matchedAddresses, addressRecord.isHidden, - (address) async { + (address) async { await subscribeForUpdates(); return _fetchAddressHistory(address, await getCurrentChainTip()) - .then((history) => history.isNotEmpty ? address.address : null); + .then((result) => result.hasHistory ? address.address : null); }, type: type, isLegacyDerivation: addressRecord.isLegacyDerivation, + accountIndex: addressRecord.accountIndex, ); - final newLength = walletAddresses.allAddresses.length; - - if (newLength > prevLength) { - await fetchTransactionsForAddressType(historiesWithDetails, type); - } } } + }),); + } + + Future _fetchAddressHistory( + BitcoinAddressRecord addressRecord, int? currentHeight) => + _fetchAddressHistories([addressRecord], currentHeight, addressHistoryChunkSize); + + Future _fetchAddressHistories( + List addressRecords, + int? currentHeight, + int historyChunkSize) async { + final scan = await _scanAddressHistories(addressRecords, historyChunkSize); + + final split = await _markUsedAndSplitHistory(scan, addressRecords, currentHeight); + + _enqueueMissingHistory(split.missingHistoryItems); + + return split; + } + + void _enqueueMissingHistory(List> missingHistoryItems) { + for (final item in missingHistoryItems) { + final txid = item['tx_hash'] as String?; + if (txid == null || txid.isEmpty) continue; + _missingHistoryQueue.putIfAbsent(txid, () => item); + } + } + + Future resolveQueuedTransactionDetails() { + final inProgress = _resolveMissingHistoryFuture; + if (inProgress != null) return inProgress; + + if (_missingHistoryQueue.isEmpty) return Future.value(); + + final future = _drainMissingHistoryQueue(); + _resolveMissingHistoryFuture = future; + return future.whenComplete(() => _resolveMissingHistoryFuture = null); + } + + Future _drainMissingHistoryQueue() async { + final watch = Stopwatch()..start(); + var resolvedCount = 0; + try { + printV('[QUICK_SYNC] resolving ${_missingHistoryQueue.length} deferred transactions'); + + while (_missingHistoryQueue.isNotEmpty) { + final queued = _missingHistoryQueue.values.toList(growable: false); + _missingHistoryQueue.clear(); + final resolved = await _resolveTransactions(queued, addressHistoryChunkSize); + resolvedCount += resolved.length; + } + } finally { + printV( + '[QUICK_SYNC] resolved $resolvedCount deferred transactions in ${watch.elapsedMilliseconds} ms'); + } + } + + Future _scanAddressHistories( + List addressRecords, int historyChunkSize) async { + final historyByScriptHash = >>{}; + var hadFailures = false; + + if (shouldUseBatchFetching) { + final scriptHashes = addressRecords.map((a) => a.getScriptHash(network)).toList(); + + historyByScriptHash.addAll( + await _processChunksToMap>>( + items: scriptHashes, + chunkSize: historyChunkSize, + processChunk: _getHistoryBatch, + onChunkError: (chunk, error) { + hadFailures = true; + printV('address history scan failed for ${chunk.length} scripthashes: $error'); + }, + ), + ); + + return AddressHistoryScan( + historyByScriptHash: historyByScriptHash, + hadFailures: hadFailures, + ); + } + + await Future.wait(addressRecords.map((addressRecord) async { + final scriptHash = addressRecord.getScriptHash(network); + + try { + historyByScriptHash[scriptHash] = await electrumClient.getHistory(scriptHash); + } catch (e) { + hadFailures = true; + printV('address history scan failed for ${addressRecord.address}: $e'); + } })); - } - Future> _fetchAddressHistory( - BitcoinAddressRecord addressRecord, int? currentHeight) async { - String txid = ""; - - try { - final Map historiesWithDetails = {}; - - final history = await electrumClient.getHistory(addressRecord.getScriptHash(network)); - - if (history.isNotEmpty) { - addressRecord.setAsUsed(); - walletAddresses.clearLockIfMatches(addressRecord.type, addressRecord.address); - - if (this is BitcoinWallet) { - //removes transactions no longer returned by the api, presumed replaced/invalid. - transactionHistory.transactions.removeWhere( - (hash, tx) => - tx.outputAddresses != null && - tx.outputAddresses!.contains(addressRecord.address) && - !history.any((newTransaction) => newTransaction['tx_hash'] == hash), - ); - } - - await Future.wait(history.map((transaction) async { - txid = transaction['tx_hash'] as String; - final height = transaction['height'] as int; - final storedTx = transactionHistory.transactions[txid]; - - if (storedTx != null) { - if (height > 0) { - storedTx.height = height; - // the tx's block itself is the first confirmation so add 1 - if ((currentHeight ?? 0) > 0) { - storedTx.confirmations = currentHeight! - height + 1; - } - storedTx.isPending = storedTx.confirmations == 0; - } - - historiesWithDetails[txid] = storedTx; - } else { - final tx = await fetchTransactionInfo(hash: txid, height: height, retryOnFailure: true); - - if (tx != null) { - historiesWithDetails[txid] = tx; - - // Got a new transaction fetched, add it to the transaction history - // instead of waiting all to finish, and next time it will be faster - - if (this is LitecoinWallet) { - // if we have a peg out transaction with the same value - // that matches this received transaction, mark it as being from a peg out: - for (final tx2 in transactionHistory.transactions.values) { - final heightDiff = ((tx2.height ?? 0) - (tx.height ?? 0)).abs(); - // this isn't a perfect matching algorithm since we don't have the right input/output information from these transaction models (the addresses are in different formats), but this should be more than good enough for now as it's extremely unlikely a user receives the EXACT same amount from 2 different sources and one of them is a peg out and the other isn't WITHIN 5 blocks of each other - if (tx2.additionalInfo["isPegOut"] == true && - tx2.amount == tx.amount && - heightDiff <= 5) { - tx.additionalInfo["fromPegOut"] = true; - } - } - } - transactionHistory.addOne(tx); - await transactionHistory.save(); - } - } - - return Future.value(null); - })); - } - - return historiesWithDetails; - } catch (e, stacktrace) { - _onError?.call(FlutterErrorDetails( - exception: "$txid - $e", - stack: stacktrace, - library: this.runtimeType.toString(), - )); - return {}; - } - } - - Future fetchTransactionsForAddressTypeBatch( - Map historiesWithDetails, BitcoinAddressType type) async { - final addressesByType = - walletAddresses.allAddresses.where((addr) => addr.type == type).toList(); - final hiddenAddresses = addressesByType.where((addr) => addr.isHidden).toList(); - final receiveAddresses = addressesByType.where((addr) => !addr.isHidden).toList(); - walletAddresses.hiddenAddresses.addAll(hiddenAddresses.map((e) => e.address)); - await walletAddresses.saveAddressesInBox(); - - final tip = await getCurrentChainTip(); - - final addressHistory = - await _processChunksToMap( - items: addressesByType, - chunkSize: addressHistoryChunkSize, - processChunk: (chunk) => _fetchBatchAddressHistory(chunk, tip, addressHistoryChunkSize), + return AddressHistoryScan( + historyByScriptHash: historyByScriptHash, + hadFailures: hadFailures, ); - - if (addressHistory.isNotEmpty) historiesWithDetails.addAll(addressHistory); - - for (final addressRecord in addressesByType) { - final matchedAddresses = addressRecord.isHidden ? hiddenAddresses : receiveAddresses; - - final isUsedAddressUnderGap = matchedAddresses.indexOf(addressRecord) >= - matchedAddresses.length - ElectrumWalletAddressesBase.gap; - - if (isUsedAddressUnderGap && addressRecord.isUsed) { - final prevLength = walletAddresses.allAddresses.length; - - await walletAddresses.discoverAddressesBatch( - matchedAddresses, - addressRecord.isHidden, - (newAddresses) async { - await _fetchBatchAddressHistory( - newAddresses, - tip, - discoveryHistoryChunkSize, - ); - - return newAddresses - .where((addressRecord) => addressRecord.isUsed) - .map((addressRecord) => addressRecord.address) - .toSet(); - }, - type: type, - ); - - final newLength = walletAddresses.allAddresses.length; - - if (newLength > prevLength) { - await fetchTransactionsForAddressTypeBatch(historiesWithDetails, type); - return; - } - } - } } - Future> _fetchBatchAddressHistory( - List addressRecords, int? currentHeight, int historyChunkSize) async { + Future _markUsedAndSplitHistory(AddressHistoryScan scan, + List addressRecords, int? currentHeight) async { + String lastTxId = ''; bool didUpdateHistory = false; try { - final Map historiesWithDetails = {}; + final Map transactions = {}; + final missingHistoryItems = >[]; + var hasHistory = false; - // List of script hashes for the given address records - final scriptHashes = addressRecords.map((a) => a.getScriptHash(network)).toList(); - - final historyByScriptHash = - await _processChunksToMap>>( - items: scriptHashes, chunkSize: historyChunkSize, processChunk: _getHistoryBatch); - - // Map scriptHash -> addressRecord final byScriptHash = {}; for (final a in addressRecords) { byScriptHash[a.getScriptHash(network)] = a; } - // Split into already-known txs vs missing txs - final missingHistoryItems = >[]; - - for (final entry in historyByScriptHash.entries) { + for (final entry in scan.historyByScriptHash.entries) { final sh = entry.key; final addressRecord = byScriptHash[sh]; if (addressRecord == null) continue; @@ -2643,7 +3100,9 @@ abstract class ElectrumWalletBase final history = entry.value; if (history.isEmpty) continue; + hasHistory = true; addressRecord.setAsUsed(); + addressRecord.txCount = history.length; walletAddresses.clearLockIfMatches(addressRecord.type, addressRecord.address); //removes transactions no longer returned by the api, presumed replaced/invalid. @@ -2659,7 +3118,6 @@ abstract class ElectrumWalletBase } } - // For each transaction in the history, check if we already have it in our transaction history. If we do, update its details if necessary. If we don't, add it to the list of missing history items to fetch later. for (final item in history) { final txid = item['tx_hash'] as String?; final height = item['height'] as int? ?? 0; @@ -2668,7 +3126,19 @@ abstract class ElectrumWalletBase lastTxId = txid; final storedTx = transactionHistory.transactions[txid]; - if (storedTx != null) { + // If we've already tried resolving this tx's account owner + // several times and it still can't be determined, stop treating it + // as "missing" every scan - otherwise we'd refetch its full raw tx + // (and all its inputs' raw txs) forever with no chance of ever + // converging. + final hasExhaustedAccountRetries = + (_unresolvedAccountRetryCount[txid] ?? 0) >= _maxUnresolvedAccountRetries; + final needsAccount = type == WalletType.bitcoin && + storedTx != null && + storedTx.accountIndex == null && + !hasExhaustedAccountRetries; + + if (storedTx != null && !needsAccount) { if (height > 0) { final oldHeight = storedTx.height; final oldConfs = storedTx.confirmations; @@ -2676,6 +3146,7 @@ abstract class ElectrumWalletBase storedTx.height = height; + // the tx's block itself is the first confirmation so add 1 if ((currentHeight ?? 0) > 0) { storedTx.confirmations = currentHeight! - height + 1; } @@ -2690,7 +3161,7 @@ abstract class ElectrumWalletBase } } - historiesWithDetails[txid] = storedTx; + transactions[txid] = storedTx; } else { missingHistoryItems.add({ 'tx_hash': txid, @@ -2702,12 +3173,86 @@ abstract class ElectrumWalletBase } } - // Batch fetch missing tx verbose details - if (missingHistoryItems.isEmpty) { - if (didUpdateHistory) await transactionHistory.save(); - return historiesWithDetails; + if (didUpdateHistory) { + await transactionHistory.save(); } + return AddressHistoryResult( + transactions: transactions, + missingHistoryItems: missingHistoryItems, + hasHistory: hasHistory, + hadFailures: scan.hadFailures, + ); + } catch (e, stacktrace) { + final prefix = lastTxId.isNotEmpty ? '$lastTxId - ' : ''; + _onError?.call(FlutterErrorDetails( + exception: '$prefix$e', + stack: stacktrace, + library: runtimeType.toString(), + )); + return AddressHistoryResult( + transactions: {}, + missingHistoryItems: const [], + hasHistory: false, + hadFailures: true, + ); + } + } + + // Tracks how many times we've resolved a tx and still couldn't determine + // its owning account. Used by `_markUsedAndSplitHistory` to stop + // requeueing a tx forever when its account can never be determined. + void _trackAccountResolution(ElectrumTransactionInfo tx) { + if (type != WalletType.bitcoin) return; + + if (tx.accountIndex == null) { + _unresolvedAccountRetryCount.update(tx.id, (count) => count + 1, ifAbsent: () => 1); + } else { + _unresolvedAccountRetryCount.remove(tx.id); + } + } + + Future> _resolveTransactions( + List> missingHistoryItems, int historyChunkSize) async { + final Map resolved = {}; + + if (missingHistoryItems.isEmpty) return resolved; + + if (!shouldUseBatchFetching) { + await Future.wait(missingHistoryItems.map((item) async { + final txid = (item['tx_hash'] as String).trim(); + if (txid.isEmpty) return; + + try { + final tx = await fetchTransactionInfo( + hash: txid, + height: item['height'] as int?, + retryOnFailure: true, + ); + + if (tx == null) return; + + resolved[tx.id] = tx; + + _trackAccountResolution(tx); + + _applyLitecoinPegOutTag(tx); + + transactionHistory.addOne(tx); + await transactionHistory.save(); + } catch (e, stacktrace) { + _onError?.call(FlutterErrorDetails( + exception: '$txid - $e', + stack: stacktrace, + library: runtimeType.toString(), + )); + } + })); + + return resolved; + } + + try { for (var i = 0; i < missingHistoryItems.length; i += historyChunkSize) { final end = (i + historyChunkSize < missingHistoryItems.length) ? i + historyChunkSize @@ -2730,45 +3275,233 @@ abstract class ElectrumWalletBase retryDelay: const Duration(seconds: 1), ); + bool chunkUpdatedHistory = false; + for (final txid in hashes) { final tx = infosByHash[txid]; if (tx == null) continue; - historiesWithDetails[tx.id] = tx; + resolved[tx.id] = tx; - // Litecoin peg-out tagging - if (this is LitecoinWallet) { - for (final tx2 in transactionHistory.transactions.values) { - final heightDiff = ((tx2.height ?? 0) - (tx.height ?? 0)).abs(); - if (tx2.additionalInfo["isPegOut"] == true && - tx2.amount == tx.amount && - heightDiff <= 5) { - tx.additionalInfo["fromPegOut"] = true; - } - } - } + _trackAccountResolution(tx); + + _applyLitecoinPegOutTag(tx); transactionHistory.addOne(tx); - didUpdateHistory = true; + chunkUpdatedHistory = true; + } + + if (chunkUpdatedHistory) { + await transactionHistory.save(); } } - if (didUpdateHistory) { - await transactionHistory.save(); - } - - return historiesWithDetails; + return resolved; } catch (e, stacktrace) { - final prefix = lastTxId.isNotEmpty ? '$lastTxId - ' : ''; _onError?.call(FlutterErrorDetails( - exception: '$prefix$e', + exception: '$e', stack: stacktrace, library: runtimeType.toString(), )); - return {}; + return resolved; } } + Future fetchBitcoinTransactionsForInitialRestore( + Map historiesWithDetails, + ) async { + int accountIndex = 0; + + while (accountIndex < maxProbAccounts) { + final isAccountProbe = accountIndex > 0; + + if (!mainHdByTypeAndAccount.containsKey(accountIndex)) { + _prepareHdForAccount(accountIndex, currency); + } + + if (!walletAddresses.accountIndexes.contains(accountIndex)) { + walletAddresses.accountIndexes.add(accountIndex); + await walletAddresses.prepareAccountAddresses( + accountIndex, + types: isAccountProbe ? EXTRA_ACCOUNT_ADDRESS_TYPES : BITCOIN_ADDRESS_TYPES, + ); + } + + final probeTypes = isAccountProbe ? EXTRA_ACCOUNT_ADDRESS_TYPES : BITCOIN_ADDRESS_TYPES; + + for (final addressType in probeTypes) { + if (shouldUseBatchFetching) { + await fetchTransactionsForAddressTypeBatch( + historiesWithDetails, + addressType, + accountIndex: accountIndex, + ); + } else { + await fetchTransactionsForAddressType( + historiesWithDetails, + addressType, + accountIndex: accountIndex, + ); + } + } + + final accountHasHistory = walletAddresses.allAddresses + .where((addr) => addr.accountIndex == accountIndex) + .any((addr) => addr.isUsed); + + if (!accountHasHistory) { + final persistedAccounts = await walletInfo.getAccounts(); + final isUserAccount = + persistedAccounts.any((account) => account.accountIndex == accountIndex); + + if (isAccountProbe && !isUserAccount) { + walletAddresses.accountIndexes.remove(accountIndex); + walletAddresses.removeAddressesForAccount(accountIndex); + } + break; + } + + // If the account has history, add it to the wallet (if it's not the first account). + if (isAccountProbe) { + await walletInfo.addAccount( + accountIndex: accountIndex, + label: "Account $accountIndex", + ); + } + // Move on to the next account index + accountIndex++; + } + if (electrumClient.isConnected) { + await walletInfo.setAccountDiscoveryLimit(maxProbAccounts); + } + } + + Future fetchTransactionsForAddressTypeBatch( + Map historiesWithDetails, + BitcoinAddressType type, { + int? accountIndex, + }) async { + final addressesByType = walletAddresses.allAddresses + .where((addr) => addr.type == type) + .where((addr) => accountIndex == null || addr.accountIndex == accountIndex) + .toList(); + + walletAddresses.hiddenAddresses + .addAll(addressesByType.where((addr) => addr.isHidden).map((e) => e.address)); + await walletAddresses.saveAddressesInBox(); + + final accountIndexes = addressesByType.map((addr) => addr.accountIndex).toSet(); + + for (final account in accountIndexes) { + for (final isHidden in [false, true]) { + for (final isLegacyDerivation in [false, true]) { + await fetchTransactionsForAddressesBranchBatch( + historiesWithDetails, + type, + getAddressBranchByType( + type: type, + hidden: isHidden, + legacy: isLegacyDerivation, + accountIndex: account, + ), + isHidden: isHidden, + isLegacyDerivation: isLegacyDerivation, + accountIndex: account, + ); + } + } + } + } + + + Future fetchTransactionsForAddressesBranchBatch( + Map historiesWithDetails, + BitcoinAddressType type, + List branchAddresses, { + required bool isHidden, + required bool isLegacyDerivation, + required int accountIndex, + }) async { + if (branchAddresses.isEmpty) return; + + final tip = await getCurrentChainTip(); + final currentBranch = [...branchAddresses]; + + final initialHistory = + await _processChunksToMap( + items: currentBranch, + chunkSize: addressHistoryChunkSize, + processChunk: (chunk) async => + (await _fetchAddressHistories(chunk, tip, addressHistoryChunkSize)).transactions, + ); + + if (initialHistory.isNotEmpty) { + historiesWithDetails.addAll(initialHistory); + } + + final gapLimit = isHidden + ? ElectrumWalletAddressesBase.defaultChangeAddressesCount + : ElectrumWalletAddressesBase.defaultReceiveAddressesCount; + + final highestUsedIndex = _highestUsedIndex(currentBranch); + final shouldDiscover = + highestUsedIndex >= 0 && highestUsedIndex >= currentBranch.length - gapLimit; + + if (!shouldDiscover) return; + + final newAddresses = await walletAddresses.discoverAddressesBatch( + currentBranch, + isHidden, + (newAddresses) async { + final newHistory = + await _fetchAddressHistories(newAddresses, tip, discoveryHistoryChunkSize); + + if (newHistory.transactions.isNotEmpty) { + historiesWithDetails.addAll(newHistory.transactions); + } + + return newAddresses + .where((addressRecord) => addressRecord.isUsed) + .map((addressRecord) => addressRecord.address) + .toSet(); + }, + type: type, + isLegacyDerivation: isLegacyDerivation, + accountIndex: accountIndex, + ); + + if (newAddresses.isNotEmpty) { + currentBranch.addAll(newAddresses); + + if (isHidden) { + walletAddresses.hiddenAddresses.addAll(newAddresses.map((e) => e.address)); + await walletAddresses.saveAddressesInBox(); + } + } + } + + List getAddressBranchByType({ + required BitcoinAddressType type, + required bool hidden, + required bool legacy, + int? accountIndex, + }) => + walletAddresses.allAddresses + .where((addr) => + addr.type == type && + (accountIndex == null || addr.accountIndex == accountIndex) && + addr.isHidden == hidden && + addr.isLegacyDerivation == legacy) + .toList() + ..sort((a, b) => a.index.compareTo(b.index)); + + int _highestUsedIndex(List addresses) { + for (int i = addresses.length - 1; i >= 0; i--) { + if (addresses[i].isUsed) return i; + } + return -1; + } + Future>> _getTransactionVerboseBatch(List hashes) { return electrumClient.getBatchTransactionVerbose( hashes, @@ -2790,6 +3523,20 @@ abstract class ElectrumWalletBase ); } + Future>>> _getListUnspentBatch(List scriptHashes) { + return electrumClient.getBatchUnspent( + scriptHashes, + timeout: transactionBatchTimeoutMs, + ); + } + + Future>> _getBalanceBatch(List scriptHashes) { + return electrumClient.getBatchBalance( + scriptHashes, + timeout: transactionBatchTimeoutMs, + ); + } + Future> fetchTransactionInfoBatch({ required List hashes, Map? heightsByHash, @@ -2829,9 +3576,11 @@ abstract class ElectrumWalletBase required Map result, required Map? heightsByHash, }) async { + final addressesByAccount = addressesSetByAccount; + for (var i = 0; i < txIds.length; i += transactionChunkSize) { final end = - (i + transactionChunkSize < txIds.length) ? i + transactionChunkSize : txIds.length; + (i + transactionChunkSize < txIds.length) ? i + transactionChunkSize : txIds.length; final chunk = txIds.sublist(i, end); final bundlesByHash = await getTransactionExpandedBatch( @@ -2847,14 +3596,17 @@ abstract class ElectrumWalletBase continue; } + final owner = _accountForTransaction(bundle, addressesByAccount); + final info = ElectrumTransactionInfo.fromElectrumBundle( bundle, walletInfo.type, network, - addresses: addressesSet, + addresses: owner != null ? addressesByAccount[owner]! : addressesSet, height: heightsByHash?[txId], ); info.id = txId; + info.accountIndex = owner; result[txId] = info; } catch (_) { result[txId] = null; @@ -2874,7 +3626,16 @@ abstract class ElectrumWalletBase final inputTxIdsByHash = _collectInputTxIdsByHash(originalByHash); - final inputVerboseByTxId = await _fetchInputTransactionVerboseBatch(inputTxIdsByHash); + final allInputTxids = {}; + for (final txids in inputTxIdsByHash.values) { + allInputTxids.addAll(txids); + } + + final inputTxIds = allInputTxids.toList(growable: false); + + final inputVerboseByTxId = inputTxIds.isEmpty + ? >{} + : await _fetchTransactionVerboseBatch(inputTxIds); final parsedInputTxById = _parseTransactions(inputVerboseByTxId); @@ -2965,63 +3726,6 @@ abstract class ElectrumWalletBase return inputTxIdsByHash; } - Future>> _fetchInputTransactionVerboseBatch( - Map> inputTxidsByHash) async { - final allInputTxids = {}; - for (final txids in inputTxidsByHash.values) { - allInputTxids.addAll(txids); - } - - final inputTxIds = allInputTxids.toList(growable: false); - - final verboseTransactionByHash = - await _processChunksToMap>( - items: inputTxIds, - chunkSize: inputTransactionChunkSize, - processChunk: _getTransactionVerboseBatch, - onChunkError: (chunk, error) { - if (error is electrum.RequestFailedTimeoutException) { - printV( - 'fetchInputTransactionVerboseBatch timeout for ${chunk.length} txs: ${error.method}', - ); - } else { - printV( - 'fetchInputTransactionVerboseBatch failed for ${chunk.length} txs: $error,', - ); - } - }, - ); - - final emptyHex = []; - for (final txId in inputTxIds) { - final vTx = verboseTransactionByHash[txId]; - if (vTx == null || vTx.isEmpty || vTx['hex'] == null) { - emptyHex.add(txId); - } - } - - final hexByHash = await _processChunksToMap( - items: emptyHex, - chunkSize: inputTransactionChunkSize, - processChunk: _getTransactionHexBatch, - ); - - for (final txId in inputTxIds) { - final verbose = verboseTransactionByHash[txId] ?? {}; - if ((verbose['hex'] as String?) == null) { - final hex = hexByHash[txId]; - if (hex != null && hex.isNotEmpty) { - verboseTransactionByHash[txId] = { - ...verbose, - 'hex': hex, - }; - } - } - } - - return verboseTransactionByHash; - } - Future> _buildTransactionBundlesBatch({ required List unique, required Map? heightsByHash, @@ -3068,22 +3772,11 @@ abstract class ElectrumWalletBase } } - final ins = []; final inputTxids = inputTxidsByHash[txid] ?? const []; - bool allInputsPresent = true; - for (final inputTxid in inputTxids) { - final inTx = parsedInputTxById[inputTxid]; - if (inTx == null) { - allInputsPresent = false; - break; - } - ins.add(inTx); - } - - if (!allInputsPresent || ins.length != original.inputs.length) { - continue; - } + final ins = [ + for (final inputTxid in inputTxids) parsedInputTxById[inputTxid], + ]; bundles[txid] = ElectrumTransactionBundle( original, @@ -3174,10 +3867,14 @@ abstract class ElectrumWalletBase Future updateTransactions() async { printV("updateTransactions() called!"); + + if (_isTransactionUpdating) { + printV("updateTransactions: already running, skipping"); + return; + } + + _isTransactionUpdating = true; try { - if (_isTransactionUpdating) { - return; - } currentChainTip = await getUpdatedChainTip(); bool updated = false; @@ -3200,13 +3897,12 @@ abstract class ElectrumWalletBase await transactionHistory.save(); } - _isTransactionUpdating = true; await fetchTransactions(); walletAddresses.updateReceiveAddresses(); - _isTransactionUpdating = false; } catch (e, stacktrace) { printV(stacktrace); printV(e); + } finally { _isTransactionUpdating = false; } } @@ -3239,6 +3935,7 @@ abstract class ElectrumWalletBase await updateBalance(); await _fetchAddressHistory(address, await getCurrentChainTip()); + await resolveQueuedTransactionDetails(); } catch (e, s) { printV("sub error: $e"); _onError?.call(FlutterErrorDetails( @@ -3253,23 +3950,96 @@ abstract class ElectrumWalletBase })); } + Future>> fetchBalancesBatch( + List addresses, + ) async { + final scriptHashes = addresses.map((address) => address.getScriptHash(network)).toList(); + + if (scriptHashes.isEmpty) { + return >[]; + } + + try { + final balancesByScriptHash = await _processChunksToMap>( + items: scriptHashes, + chunkSize: addressHistoryChunkSize, + processChunk: _getBalanceBatch, + ); + + final balances = scriptHashes + .map((scriptHash) => balancesByScriptHash[scriptHash] ?? {}) + .toList(); + + final hasMissingBalance = balances.any((balance) => balance['confirmed'] == null); + if (hasMissingBalance) { + printV('fetchBalancesBatch returned missing balances, falling back to regular flow'); + return fetchBalancesRegular(addresses); + } + + return balances; + } catch (e) { + printV('fetchBalancesBatch failed, falling back to regular flow: $e'); + return fetchBalancesRegular(addresses); + } + } + + Future>> fetchBalancesRegular( + List addresses, + ) async { + final balanceFutures = >>[]; + + for (final address in addresses) { + final sh = address.getScriptHash(network); + balanceFutures.add(electrumClient.getBalance(sh)); + } + + return Future.wait(balanceFutures); + } + Future fetchBalances() async { final addresses = walletAddresses.allAddresses .where((address) => address.address.isNotEmpty) .where((address) => RegexUtils.addressTypeFromStr(address.address, network) is! MwebAddress) .toList(); - final balanceFutures = >>[]; - for (var i = 0; i < addresses.length; i++) { - final addressRecord = addresses[i]; - final sh = addressRecord.getScriptHash(network); - final balanceFuture = electrumClient.getBalance(sh); - balanceFutures.add(balanceFuture); + + List> balances; + try { + balances = shouldUseBatchFetching + ? await fetchBalancesBatch(addresses) + : await fetchBalancesRegular(addresses); + } catch (e) { + printV("fetchBalances network error, returning last known balance: $e"); + syncStatus = LostConnectionSyncStatus(); + return balance[currency] ?? + ElectrumBalance( + confirmed: Money.zero(currency), + unconfirmed: Money.zero(currency), + frozen: Money.zero(currency), + ); + } + + printV( + 'Fetched balances for ${addresses.length} addresses. Batch fetching: $shouldUseBatchFetching'); + + // If the balances list is empty (no addresses or all fetches silently returned nothing), + // preserve the last known balance rather than returning zero. + if (balances.isEmpty) { + return balance[currency] ?? + ElectrumBalance( + confirmed: Money.zero(currency), + unconfirmed: Money.zero(currency), + frozen: Money.zero(currency), + ); } var totalFrozen = 0; var totalConfirmed = 0; var totalUnconfirmed = 0; + final confirmedByAccount = {}; + final unconfirmedByAccount = {}; + final frozenByAccount = {}; + if (hasSilentPaymentsScanning) { // Add values from unspent coins that are not fetched by the address list // i.e. scanned silent payments @@ -3277,8 +4047,13 @@ abstract class ElectrumWalletBase if (tx.unspents != null) { tx.unspents!.forEach((unspent) { if (unspent.bitcoinAddressRecord is BitcoinSilentPaymentAddressRecord) { - if (unspent.isFrozen) totalFrozen += unspent.value; + final account = unspent.bitcoinAddressRecord.accountIndex; + if (unspent.isFrozen) { + totalFrozen += unspent.value; + frozenByAccount[account] = (frozenByAccount[account] ?? 0) + unspent.value; + } totalConfirmed += unspent.value; + confirmedByAccount[account] = (confirmedByAccount[account] ?? 0) + unspent.value; } }); } @@ -3295,18 +4070,25 @@ abstract class ElectrumWalletBase element.value == info.value) { if (info.isFrozen) { totalFrozen += element.value; + final account = element.bitcoinAddressRecord.accountIndex; + frozenByAccount[account] = (frozenByAccount[account] ?? 0) + element.value; } } }); }); - final balances = await Future.wait(balanceFutures); - - if (balances.isNotEmpty && balances.first['confirmed'] == null) { + // Check if ALL returned balances have null confirmed — indicates server is unreachable. + final allNull = balances.every((b) => b['confirmed'] == null); + if (allNull) { // if we got null balance responses from the server, set our connection status to lost and return our last known balance: printV("got null balance responses from the server, setting connection status to lost"); syncStatus = LostConnectionSyncStatus(); - return balance[currency] ?? ElectrumBalance(confirmed: 0, unconfirmed: 0, frozen: 0); + return balance[currency] ?? + ElectrumBalance( + confirmed: Money.zero(currency), + unconfirmed: Money.zero(currency), + frozen: Money.zero(currency), + ); } for (var i = 0; i < balances.length; i++) { @@ -3317,6 +4099,10 @@ abstract class ElectrumWalletBase totalConfirmed += confirmed; totalUnconfirmed += unconfirmed; + final account = addressRecord.accountIndex; + confirmedByAccount[account] = (confirmedByAccount[account] ?? 0) + confirmed; + unconfirmedByAccount[account] = (unconfirmedByAccount[account] ?? 0) + unconfirmed; + addressRecord.balance = confirmed + unconfirmed; if (confirmed > 0 || unconfirmed > 0) { addressRecord.setAsUsed(); @@ -3324,17 +4110,89 @@ abstract class ElectrumWalletBase } } + if (hasAccountsSupport) { + final perAccount = {}; + final accounts = { + ...confirmedByAccount.keys, + ...unconfirmedByAccount.keys, + ...frozenByAccount.keys, + ...walletAddresses.accountIndexes, + }; + + for (final account in accounts) { + perAccount[account] = ElectrumBalance( + confirmed: Money.fromInt(confirmedByAccount[account] ?? 0, currency), + unconfirmed: Money.fromInt(unconfirmedByAccount[account] ?? 0, currency), + frozen: Money.fromInt(frozenByAccount[account] ?? 0, currency), + ); + } + + accountBalances = ObservableMap.of(perAccount); + } + return ElectrumBalance( - confirmed: totalConfirmed, - unconfirmed: totalUnconfirmed, - frozen: totalFrozen, + confirmed: Money.fromInt(totalConfirmed, currency), + unconfirmed: Money.fromInt(totalUnconfirmed, currency), + frozen: Money.fromInt(totalFrozen, currency), ); } + List get currentAccountBitcoinTransactions { + final all = transactionHistory.transactions.values; + if (!hasAccountsSupport) return all.toList(); + + final accountIndex = currentAccountIndex; + final accountAddresses = walletAddresses.allAddresses + .where((a) => a.accountIndex == accountIndex) + .map((a) => a.address) + .toSet(); + + return all.where((tx) { + + if (tx.additionalInfo["isLightning"] == true) return accountIndex == 0; + if (tx.isReceivedSilentPayment) return accountIndex == 0; + + // Locally created transactions store the account index explicitly, + // so use it first instead of checking the addresses. + if (tx.accountIndex != null) return tx.accountIndex == accountIndex; + + return (tx.inputAddresses ?? const []).any(accountAddresses.contains) || + (tx.outputAddresses ?? const []).any(accountAddresses.contains); + }).toList(); + } + Future updateBalance() async { printV("updateBalance() called!"); - balance[currency] = await fetchBalances(); - await save(); + + if (!electrumClient.isConnected) { + printV("updateBalance: not connected, keeping existing balance"); + return; + } + + try { + final fetchedTotal = await fetchBalances(); + + if (!electrumClient.isConnected || syncStatus is LostConnectionSyncStatus) { + printV("updateBalance: connection lost during fetch, keeping existing balance"); + return; + } + + if (type == WalletType.bitcoin && hasAccountsSupport) { + // If the wallet has accounts support, we only want to update the balance for the current account. + final accountBalance = accountBalances[currentAccountIndex]; + if (accountBalance != null) { + balance[currency] = accountBalance.copy(); + } else { + printV("updateBalance: no balance for account $currentAccountIndex, keeping existing"); + } + } else { + balance[currency] = fetchedTotal; + } + + await save(); + } catch (e) { + printV("updateBalance failed: $e"); + } } @override @@ -3372,7 +4230,7 @@ abstract class ElectrumWalletBase } final hd = addressRecord != null - ? _hdFor(record: addressRecord).childKey(Bip32KeyIndex(addressRecord.index)) + ? _hdForAddressRecord(record: addressRecord).childKey(Bip32KeyIndex(addressRecord.index)) : mainHd; final priv = ECPrivate.fromHex(hd.privateKey.privKey.toHex()); @@ -3383,7 +4241,21 @@ abstract class ElectrumWalletBase return base64Encode(decodedSig); } - Future _checkIfBatchSupported() async { + void _applyLitecoinPegOutTag(ElectrumTransactionInfo tx) { + if (this is! LitecoinWallet) return; + + // if we have a peg out transaction with the same value + // that matches this received transaction, mark it as being from a peg out: + for (final tx2 in transactionHistory.transactions.values) { + final heightDiff = ((tx2.height ?? 0) - (tx.height ?? 0)).abs(); + // this isn't a perfect matching algorithm since we don't have the right input/output information from these transaction models (the addresses are in different formats), but this should be more than good enough for now as it's extremely unlikely a user receives the EXACT same amount from 2 different sources and one of them is a peg out and the other isn't WITHIN 5 blocks of each other + if (tx2.additionalInfo["isPegOut"] == true && tx2.amount == tx.amount && heightDiff <= 5) { + tx.additionalInfo["fromPegOut"] = true; + } + } + } + + Future checkIfBatchSupported() async { if (_isBatchSupported != null) { printV('[BATCH_TEST] Already checked: $_isBatchSupported'); return; @@ -3402,12 +4274,21 @@ abstract class ElectrumWalletBase printV('[BATCH_TEST] Start: hashes=${hashes.length}, timeout=${batchTestTimeoutMs}ms'); - await electrumClient.callBatchWithTimeout( + final result = await electrumClient.callBatchWithTimeout( method: 'blockchain.scripthash.get_history', paramsList: paramsList, timeout: batchTestTimeoutMs, ); + final hasError = result.any((item) => + item is Map && item.containsKey('error') && item['error'] != null); + + if (hasError) { + _isBatchSupported = false; + printV('[BATCH_TEST] Result: supported=false (server returned error)'); + return; + } + _isBatchSupported = true; printV('[BATCH_TEST] Result: supported=true'); } on electrum.RequestFailedTimeoutException catch (e) { @@ -3531,7 +4412,6 @@ abstract class ElectrumWalletBase syncStatus = ConnectingSyncStatus(); } break; - default: } } @@ -3582,6 +4462,7 @@ abstract class ElectrumWalletBase for (int i = 0; i < bundle.originalTransaction.inputs.length; i++) { final input = bundle.originalTransaction.inputs[i]; final inputTransaction = bundle.ins[i]; + if (inputTransaction == null) continue; final vout = input.txIndex; final outTransaction = inputTransaction.outputs[vout]; final address = addressFromOutputScript(outTransaction.scriptPubKey, network); @@ -3617,12 +4498,6 @@ abstract class ElectrumWalletBase } } - @override - String formatCryptoAmount(String amount) { - final amountBigInt = BigInt.parse(amount); - return currency.formatAmount(amountBigInt); - } - /// Checks the health of the socket connection /// and triggers a full reconnection if needed @override @@ -3770,21 +4645,19 @@ abstract class ElectrumWalletBase } } - Bip32Slip10Secp256k1 _hdFor({required BaseBitcoinAddressRecord record}) { + Bip32Slip10Secp256k1 _hdForAddressRecord({required BaseBitcoinAddressRecord record}) { final addrType = record.type; if (record.isLegacyDerivation) { - if (record.isHidden) { - return walletAddresses.legacySideHd; - } else { - return walletAddresses.legacyMainHd; - } + return record.isHidden ? walletAddresses.legacySideHd : walletAddresses.legacyMainHd; } + final accountIndex = record.accountIndex; + if (record.isHidden) { - return sideHdByType[addrType] ?? sideHd; + return sideHdByTypeAndAccount[accountIndex]?[addrType] ?? sideHd; } else { - return mainHdByType[addrType] ?? mainHd; + return mainHdByTypeAndAccount[accountIndex]?[addrType] ?? mainHd; } } } @@ -4083,8 +4956,8 @@ Future _handleScanSilentPayments(ScanData scanData) async { WalletType.bitcoin, id: txid, height: tweakHeight, - amount: 0, - fee: 0, + amount: Money.zero(CryptoCurrency.btc), + fee: Money.zero(CryptoCurrency.btc), direction: TransactionDirection.incoming, isReplaced: false, date: scanData.network == BitcoinNetwork.mainnet @@ -4150,7 +5023,7 @@ Future _handleScanSilentPayments(ScanData scanData) async { txInfo.unspents!.add(unspent); } - txInfo.amount += unspent.value; + txInfo.amount += Money.fromInt(unspent.value, txInfo.amount.currency); }); }); }); @@ -4220,8 +5093,8 @@ class EstimatedTxResult { final List utxos; final List inputPrivKeyInfos; final Map publicKeys; // PubKey to derivationPath - final int fee; - final int amount; + final Money fee; + final Money amount; final bool spendsSilentPayment; // final bool sendsToSilentPayment; @@ -4279,3 +5152,27 @@ class UtxoDetails { required this.spendsUnconfirmedTX, }); } + +class AddressHistoryScan { + AddressHistoryScan({ + required this.historyByScriptHash, + required this.hadFailures, + }); + + final Map>> historyByScriptHash; + final bool hadFailures; +} + +class AddressHistoryResult { + AddressHistoryResult({ + required this.transactions, + required this.missingHistoryItems, + required this.hasHistory, + required this.hadFailures, + }); + + final Map transactions; + final List> missingHistoryItems; + final bool hasHistory; + final bool hadFailures; +} \ No newline at end of file diff --git a/cw_bitcoin/lib/electrum_wallet_addresses.dart b/cw_bitcoin/lib/electrum_wallet_addresses.dart index b9058540e..37fb57775 100644 --- a/cw_bitcoin/lib/electrum_wallet_addresses.dart +++ b/cw_bitcoin/lib/electrum_wallet_addresses.dart @@ -1,5 +1,7 @@ +import 'dart:async' show Zone; import 'dart:io' show Platform; import 'dart:math'; +import "package:collection/collection.dart"; import 'package:bitcoin_base/bitcoin_base.dart'; import 'package:blockchain_utils/blockchain_utils.dart'; @@ -20,6 +22,15 @@ import 'package:mobx/mobx.dart'; part 'electrum_wallet_addresses.g.dart'; +class UnsupportedAddressTypeForAccountException implements Exception { + UnsupportedAddressTypeForAccountException(this.message); + + final String message; + + @override + String toString() => message; +} + abstract class ElectrumWalletAddresses = ElectrumWalletAddressesBase with _$ElectrumWalletAddresses; const List BITCOIN_ADDRESS_TYPES = [ @@ -30,6 +41,11 @@ const List BITCOIN_ADDRESS_TYPES = [ P2shAddressType.p2wpkhInP2sh, ]; +const List LEGACY_DUPLICATE_ADDRESS_TYPES = [ + SegwitAddresType.p2wpkh, + SegwitAddresType.p2wsh, +]; + const List LITECOIN_ADDRESS_TYPES = [ SegwitAddresType.p2wpkh, SegwitAddresType.mweb, @@ -43,11 +59,15 @@ const List DOGECOIN_ADDRESS_TYPES = [ P2pkhAddressType.p2pkh, ]; +const List EXTRA_ACCOUNT_ADDRESS_TYPES = [SegwitAddresType.p2wpkh]; + abstract class ElectrumWalletAddressesBase extends WalletAddresses with Store { ElectrumWalletAddressesBase( WalletInfo walletInfo, { - required this.mainHdByType, - required this.sideHdByType, + required this.mainHdByTypeAndAccount, + required this.sideHdByTypeAndAccount, + required this.accountIndexes, + required this.currentAccountIndex, required this.legacyMainHd, required this.legacySideHd, required this.network, @@ -72,12 +92,12 @@ abstract class ElectrumWalletAddressesBase extends WalletAddresses with Store { .toSet()), currentReceiveAddressIndexByType = initialRegularAddressIndex ?? {}, currentChangeAddressIndexByType = initialChangeAddressIndex ?? {}, - _addressPageType = initialAddressPageType ?? - (walletInfo.addressPageType != null - ? walletInfo.addressPageType == LightningAddressType.p2l.value - ? LightningAddressType.p2l - : BitcoinAddressType.fromValue(walletInfo.addressPageType!) - : SegwitAddresType.p2wpkh), + _addressPageType = _resolveInitialAddressPageType( + initialAddressPageType: initialAddressPageType, + walletInfo: walletInfo, + mainHdByTypeAndAccount: mainHdByTypeAndAccount, + currentAccountIndex: currentAccountIndex, + ), silentAddresses = ObservableList.of( (initialSilentAddresses ?? []).toSet()), currentSilentAddressIndex = initialSilentAddressIndex, @@ -142,12 +162,16 @@ abstract class ElectrumWalletAddressesBase extends WalletAddresses with Store { // TODO: add this variable in `litecoin_wallet_addresses` and just add a cast in cw_bitcoin to use it final ObservableList mwebAddresses; final BasedUtxoNetwork network; - Map mainHdByType; - Map sideHdByType; + final Map> mainHdByTypeAndAccount; + final Map> sideHdByTypeAndAccount; + List accountIndexes; + + @observable + int currentAccountIndex; final Bip32Slip10Secp256k1 legacyMainHd; final Bip32Slip10Secp256k1 legacySideHd; final bool isHardwareWallet; - final LightningWallet? lightningWallet; + LightningWallet? lightningWallet; @observable ObservableMap lockedReceiveAddressByType; @@ -170,9 +194,22 @@ abstract class ElectrumWalletAddressesBase extends WalletAddresses with Store { @computed List get allAddresses => _addresses; + @observable + bool addressRefreshToggle = false; + + @action + String getFreshAddress() { + addressRefreshToggle = !addressRefreshToggle; + return address; + } + + @override + String get addressForExchange => getFreshAddress(); + @override @computed String get address { + final _ = addressRefreshToggle; if (addressPageType == SilentPaymentsAddresType.p2sp) { if (activeSilentAddress != null) { return activeSilentAddress!; @@ -186,8 +223,16 @@ abstract class ElectrumWalletAddressesBase extends WalletAddresses with Store { "Error: Unable to fetch your Lightning address, please check your network connection."; } - final typeMatchingAddressesAll = - _addresses.where((addr) => !addr.isHidden && _isAddressPageTypeMatch(addr)).toList(); + final accountIndexForCheck = walletInfo.type == WalletType.bitcoin ? currentAccountIndex : 0; + if (!_isAddressTypeSupportedForAccount(addressPageType, accountIndexForCheck)) { + printV("addressPageType $addressPageType is not supported for account $accountIndexForCheck"); + return ""; + } + + final typeMatchingAddressesAll = _addresses + .where((addr) => + _isCurrentAccountAddress(addr) && !addr.isHidden && _isAddressPageTypeMatch(addr)) + .toList(); // Prefer standard derivation addresses for the current/active address, // but keep legacy addresses present in the overall address lists. @@ -196,8 +241,9 @@ abstract class ElectrumWalletAddressesBase extends WalletAddresses with Store { ...typeMatchingAddressesAll.where((a) => a.isLegacyDerivation), ]; - final typeMatchingReceiveAddressesAll = - typeMatchingAddressesAll.where((addr) => !addr.isUsed).toList(); + final typeMatchingReceiveAddressesAll = typeMatchingAddressesAll + .where((addr) => !addr.isUsed && !hiddenAddresses.contains(addr.address)) + .toList(); final typeMatchingReceiveAddresses = [ ...typeMatchingReceiveAddressesAll.where((a) => !a.isLegacyDerivation), ...typeMatchingReceiveAddressesAll.where((a) => a.isLegacyDerivation), @@ -220,10 +266,15 @@ abstract class ElectrumWalletAddressesBase extends WalletAddresses with Store { } final locked = lockedReceiveAddressByType[addressPageType]; - if (locked != null) return locked; + if (locked != null && !hiddenAddresses.contains(locked)) return locked; final prev = previousAddressRecord; - if (prev != null && prev.type == addressPageType && !prev.isUsed && !prev.isLegacyDerivation) { + if (prev != null && + _isCurrentAccountAddress(prev) && + prev.type == addressPageType && + !prev.isUsed && + !prev.isLegacyDerivation && + !hiddenAddresses.contains(prev.address)) { return prev.address; } @@ -285,8 +336,7 @@ abstract class ElectrumWalletAddressesBase extends WalletAddresses with Store { return silentAddress?.toString() ?? ''; } - final mainHd = mainHdByType[addressPageType] ?? mainHdByType.values.first; - return getAddress(index: 0, hd: mainHd, addressType: addressPageType); + return _firstAddressForType(addressPageType); } String get payjoinCompatibleAddress { @@ -295,7 +345,18 @@ abstract class ElectrumWalletAddressesBase extends WalletAddresses with Store { ? SegwitAddresType.p2wpkh : addressPageType; - final mainHd = mainHdByType[addrType] ?? mainHdByType.values.first; + return _firstAddressForType(addrType); + } + + String _firstAddressForType(BitcoinAddressType addrType) { + final mainMap = walletInfo.type == WalletType.bitcoin + ? mainHdByTypeAndAccount[currentAccountIndex] + : mainHdByTypeAndAccount[0]; + + final mainHd = mainMap?[addrType] ?? mainMap?.values.first; + + if (mainHd == null) return ''; + return getAddress(index: 0, hd: mainHd, addressType: addrType); } @@ -337,7 +398,11 @@ abstract class ElectrumWalletAddressesBase extends WalletAddresses with Store { }); @override - Future init() async { + Future init({List accountIndexes = const []}) async { + final effectiveAccountIndexes = + accountIndexes.isNotEmpty ? accountIndexes : this.accountIndexes; + + if (accountIndexes.isNotEmpty) this.accountIndexes = accountIndexes; if (walletInfo.type == WalletType.bitcoinCash) { await _generateInitialAddresses(type: P2pkhAddressType.p2pkh); } else if (walletInfo.type == WalletType.litecoin) { @@ -348,28 +413,20 @@ abstract class ElectrumWalletAddressesBase extends WalletAddresses with Store { } else if (walletInfo.type == WalletType.dogecoin) { await _generateInitialAddresses(type: P2pkhAddressType.p2pkh); } else if (walletInfo.type == WalletType.bitcoin) { - await _generateInitialAddresses(isLegacyDerivation: true); - await _generateInitialAddresses(); - if (!isHardwareWallet) { - await _generateInitialAddresses(type: P2pkhAddressType.p2pkh, isLegacyDerivation: true); - await _generateInitialAddresses(type: P2pkhAddressType.p2pkh); + for (final accountIndex in effectiveAccountIndexes) { - await _generateInitialAddresses( - type: P2shAddressType.p2wpkhInP2sh, isLegacyDerivation: true); - await _generateInitialAddresses(type: P2shAddressType.p2wpkhInP2sh); - - await _generateInitialAddresses(type: SegwitAddresType.p2tr, isLegacyDerivation: true); - await _generateInitialAddresses(type: SegwitAddresType.p2tr); - - await _generateInitialAddresses(type: SegwitAddresType.p2wsh, isLegacyDerivation: true); - await _generateInitialAddresses(type: SegwitAddresType.p2wsh); + await prepareAccountAddresses( + accountIndex, + types: accountIndex == 0 ? BITCOIN_ADDRESS_TYPES : EXTRA_ACCOUNT_ADDRESS_TYPES, + includeLegacy: accountIndex == 0, + ); } } updateAddressesByMatch(); updateReceiveAddresses(); updateChangeAddresses(); - _validateAddresses(); + await _validateAddresses(); await updateAddressesInBox(); if (currentReceiveAddressIndex >= receiveAddresses.length) { @@ -389,9 +446,21 @@ abstract class ElectrumWalletAddressesBase extends WalletAddresses with Store { updateChangeAddresses(); if (changeAddresses.isEmpty) { - final newAddresses = await _createNewAddresses(gap, - startIndex: totalCountOfChangeAddresses > 0 ? totalCountOfChangeAddresses - 1 : 0, - isHidden: true); + final accountChangeCount = _addresses + .where((addressRecord) => + _isCurrentAccountAddress(addressRecord) && + addressRecord.isHidden && + addressRecord.type == addressPageType) + .length; + + final newAddresses = await _createNewAddresses( + gap, + startIndex: accountChangeCount, + isHidden: true, + type: addressPageType, + accountIndex: currentAccountIndex, + ); + addAddresses(newAddresses); } @@ -405,6 +474,11 @@ abstract class ElectrumWalletAddressesBase extends WalletAddresses with Store { return address; } + bool _isCurrentAccountAddress(BitcoinAddressRecord addressRecord) { + return walletInfo.type != WalletType.bitcoin || + addressRecord.accountIndex == currentAccountIndex; + } + Map get labels { final G = ECPublic.fromBytes(BigintUtils.toBytes(Curves.generatorSecp256k1.x, length: 32)); final labels = {}; @@ -422,8 +496,35 @@ abstract class ElectrumWalletAddressesBase extends WalletAddresses with Store { return labels; } + Future prepareAccountAddresses( + int accountIndex, { + List types = BITCOIN_ADDRESS_TYPES, + bool includeLegacy = false, + }) async { + for (final type in types) { + final shouldSkipHardwareWalletType = isHardwareWallet && type != SegwitAddresType.p2wpkh; + + if (shouldSkipHardwareWalletType) continue; + + await _generateInitialAddresses(accountIndex: accountIndex, type: type); + + // Legacy derivation for these types is identical to the standard one. + if (includeLegacy && !LEGACY_DUPLICATE_ADDRESS_TYPES.contains(type)) { + await _generateInitialAddresses( + accountIndex: accountIndex, + type: type, + isLegacyDerivation: true, + ); + } + } + } + @action BaseBitcoinAddressRecord generateNewAddress({String label = ''}) { + if (addressPageType is LightningAddressType) { + throw Exception("Lightning addresses cannot be rotated"); + } + if (addressPageType == SilentPaymentsAddresType.p2sp && silentAddress != null) { final currentSilentAddressIndex = silentAddresses .where((addressRecord) => addressRecord.type != SegwitAddresType.p2tr) @@ -449,14 +550,28 @@ abstract class ElectrumWalletAddressesBase extends WalletAddresses with Store { return address; } - final newAddressIndex = addressesByReceiveType.fold( - 0, (int acc, addressRecord) => addressRecord.isHidden == false ? acc + 1 : acc); + final accountIndex = walletInfo.type == WalletType.bitcoin ? currentAccountIndex : 0; - final hd = _hdFor(isHidden: false, type: addressPageType, isLegacyDerivation: false); + final newAddressIndex = addressesByReceiveType.fold( + 0, + (int acc, addressRecord) => addressRecord is BitcoinAddressRecord && + addressRecord.accountIndex == accountIndex && + addressRecord.isHidden == false + ? acc + 1 + : acc); + + final hd = _hdForAddressGeneration( + isHidden: false, + type: addressPageType, + isLegacyDerivation: false, + accountIndex: accountIndex, + ); final address = BitcoinAddressRecord( getAddress(index: newAddressIndex, hd: hd, addressType: addressPageType), index: newAddressIndex, + accountIndex: accountIndex, isHidden: false, + isHiddenChecked: true, isLegacyDerivation: false, name: label, type: addressPageType, @@ -486,45 +601,54 @@ abstract class ElectrumWalletAddressesBase extends WalletAddresses with Store { void addBitcoinAddressTypes() { final lastP2wpkh = _addresses .where((addressRecord) => - _isUnusedReceiveAddressByType(addressRecord, SegwitAddresType.p2wpkh)) - .toList() - .last; - if (lastP2wpkh.address != address) { - addressesMap[lastP2wpkh.address] = 'P2WPKH'; - } else { - addressesMap[address] = 'Active - P2WPKH'; + _isUnusedReceiveAddressByType(addressRecord, SegwitAddresType.p2wpkh)) + .lastOrNull; + if (lastP2wpkh != null) { + if (lastP2wpkh.address != address) { + addressesMap[lastP2wpkh.address] = 'P2WPKH'; + } else { + addressesMap[address] = 'Active - P2WPKH'; + } } - final lastP2pkh = _addresses.firstWhere( - (addressRecord) => _isUnusedReceiveAddressByType(addressRecord, P2pkhAddressType.p2pkh)); - if (lastP2pkh.address != address) { - addressesMap[lastP2pkh.address] = 'P2PKH'; - } else { - addressesMap[address] = 'Active - P2PKH'; + final lastP2pkh = _addresses.firstWhereOrNull( + (addressRecord) => _isUnusedReceiveAddressByType(addressRecord, P2pkhAddressType.p2pkh)); + if (lastP2pkh != null) { + if (lastP2pkh.address != address) { + addressesMap[lastP2pkh.address] = 'P2PKH'; + } else { + addressesMap[address] = 'Active - P2PKH'; + } } - final lastP2sh = _addresses.firstWhere((addressRecord) => + final lastP2sh = _addresses.firstWhereOrNull((addressRecord) => _isUnusedReceiveAddressByType(addressRecord, P2shAddressType.p2wpkhInP2sh)); - if (lastP2sh.address != address) { - addressesMap[lastP2sh.address] = 'P2SH'; - } else { - addressesMap[address] = 'Active - P2SH'; + if (lastP2sh != null) { + if (lastP2sh.address != address) { + addressesMap[lastP2sh.address] = 'P2SH'; + } else { + addressesMap[address] = 'Active - P2SH'; + } } - final lastP2tr = _addresses.firstWhere( - (addressRecord) => _isUnusedReceiveAddressByType(addressRecord, SegwitAddresType.p2tr)); - if (lastP2tr.address != address) { - addressesMap[lastP2tr.address] = 'P2TR'; - } else { - addressesMap[address] = 'Active - P2TR'; + final lastP2tr = _addresses.firstWhereOrNull( + (addressRecord) => _isUnusedReceiveAddressByType(addressRecord, SegwitAddresType.p2tr)); + if (lastP2tr != null) { + if (lastP2tr.address != address) { + addressesMap[lastP2tr.address] = 'P2TR'; + } else { + addressesMap[address] = 'Active - P2TR'; + } } - final lastP2wsh = _addresses.firstWhere( - (addressRecord) => _isUnusedReceiveAddressByType(addressRecord, SegwitAddresType.p2wsh)); - if (lastP2wsh.address != address) { - addressesMap[lastP2wsh.address] = 'P2WSH'; - } else { - addressesMap[address] = 'Active - P2WSH'; + final lastP2wsh = _addresses.firstWhereOrNull( + (addressRecord) => _isUnusedReceiveAddressByType(addressRecord, SegwitAddresType.p2wsh)); + if (lastP2wsh != null) { + if (lastP2wsh.address != address) { + addressesMap[lastP2wsh.address] = 'P2WSH'; + } else { + addressesMap[address] = 'Active - P2WSH'; + } } final firstSilentAddressRecord = silentAddresses.firstOrNull; @@ -656,15 +780,20 @@ abstract class ElectrumWalletAddressesBase extends WalletAddresses with Store { addressesByReceiveType.clear(); addressesByReceiveType.addAll( - _addresses.where(_isAddressPageTypeMatch).toList(), + _addresses + .where((addressRecord) => + _isCurrentAccountAddress(addressRecord) && _isAddressPageTypeMatch(addressRecord)) + .toList(), ); } @action void updateReceiveAddresses() { receiveAddresses.removeRange(0, receiveAddresses.length); - final newAddresses = - _addresses.where((addressRecord) => !addressRecord.isHidden && !addressRecord.isUsed); + final newAddresses = _addresses.where((addressRecord) => + _isCurrentAccountAddress(addressRecord) && + !addressRecord.isHidden && + !addressRecord.isUsed); receiveAddresses.addAll(newAddresses); } @@ -672,6 +801,7 @@ abstract class ElectrumWalletAddressesBase extends WalletAddresses with Store { void updateChangeAddresses() { changeAddresses.removeRange(0, changeAddresses.length); final newAddresses = _addresses.where((addressRecord) => + _isCurrentAccountAddress(addressRecord) && addressRecord.isHidden && !addressRecord.isUsed && // TODO: feature to change change address type. For now fixed to p2wpkh, the cheapest type @@ -686,6 +816,7 @@ abstract class ElectrumWalletAddressesBase extends WalletAddresses with Store { Future Function(BitcoinAddressRecord) getAddressHistory, { BitcoinAddressType type = SegwitAddresType.p2wpkh, required bool isLegacyDerivation, + int accountIndex = 0, }) async { final newAddresses = await _createNewAddresses( gap, @@ -693,6 +824,7 @@ abstract class ElectrumWalletAddressesBase extends WalletAddresses with Store { isHidden: isHidden, isLegacyDerivation: isLegacyDerivation, type: type, + accountIndex: accountIndex, ); addAddresses(newAddresses); @@ -708,58 +840,67 @@ abstract class ElectrumWalletAddressesBase extends WalletAddresses with Store { getAddressHistory, type: type, isLegacyDerivation: isLegacyDerivation, + accountIndex: accountIndex, ); } } @action - Future discoverAddressesBatch( + Future> discoverAddressesBatch( List addressList, bool isHidden, Future> Function(List) getUsedAddresses, { BitcoinAddressType type = SegwitAddresType.p2wpkh, + required bool isLegacyDerivation, + int accountIndex = 0, }) async { final newAddresses = await _createNewAddresses( gap, startIndex: addressList.length, isHidden: isHidden, type: type, + isLegacyDerivation: isLegacyDerivation, + accountIndex: accountIndex, ); addAddresses(newAddresses); final usedAddresses = await getUsedAddresses(newAddresses); - final isLastAddressUsed = - newAddresses.isNotEmpty && usedAddresses.contains(newAddresses.last.address); - if (!isLastAddressUsed) { - return false; + final hasUsedAddressInGap = + newAddresses.any((addressRecord) => usedAddresses.contains(addressRecord.address)); + + if (!hasUsedAddressInGap) { + return newAddresses; } final updatedAddressList = [...addressList, ...newAddresses]; - await discoverAddressesBatch( + final moreNewAddresses = await discoverAddressesBatch( updatedAddressList, isHidden, getUsedAddresses, type: type, + isLegacyDerivation: isLegacyDerivation, + accountIndex: accountIndex, ); - return true; + return [...newAddresses, ...moreNewAddresses]; } - Future _generateInitialAddresses( - {BitcoinAddressType type = SegwitAddresType.p2wpkh, bool isLegacyDerivation = false}) async { - // Legacy derivation produces the same addresses as standard for these types. - // Don't generate a legacy set to avoid duplicates. - if (isLegacyDerivation && (type == SegwitAddresType.p2wpkh || type == SegwitAddresType.p2wsh)) { - return; - } + Future _generateInitialAddresses({ + BitcoinAddressType type = SegwitAddresType.p2wpkh, + bool isLegacyDerivation = false, + int accountIndex = 0, + }) async { + if (isLegacyDerivation && accountIndex != 0) return; var countOfReceiveAddresses = 0; var countOfHiddenAddresses = 0; _addresses.forEach((addr) { - if (addr.type == type && addr.isLegacyDerivation == isLegacyDerivation) { + if (addr.accountIndex == accountIndex && + addr.type == type && + addr.isLegacyDerivation == isLegacyDerivation) { if (addr.isHidden) { countOfHiddenAddresses += 1; } else { @@ -774,7 +915,9 @@ abstract class ElectrumWalletAddressesBase extends WalletAddresses with Store { startIndex: countOfReceiveAddresses, isHidden: false, type: type, - isLegacyDerivation: isLegacyDerivation); + isLegacyDerivation: isLegacyDerivation, + accountIndex: accountIndex); + addAddresses(newAddresses); } @@ -784,31 +927,49 @@ abstract class ElectrumWalletAddressesBase extends WalletAddresses with Store { startIndex: countOfHiddenAddresses, isHidden: true, type: type, - isLegacyDerivation: isLegacyDerivation); + isLegacyDerivation: isLegacyDerivation, + accountIndex: accountIndex); addAddresses(newAddresses); } } - Future> _createNewAddresses(int count, - {int startIndex = 0, - bool isHidden = false, - BitcoinAddressType? type, - bool isLegacyDerivation = false}) async { + Future> _createNewAddresses( + int count, { + int startIndex = 0, + bool isHidden = false, + BitcoinAddressType? type, + bool isLegacyDerivation = false, + int accountIndex = 0, + }) async { final list = []; for (var i = startIndex; i < count + startIndex; i++) { final addrType = type ?? addressPageType; - final hd = _hdFor(isHidden: isHidden, type: addrType, isLegacyDerivation: isLegacyDerivation); - final address = BitcoinAddressRecord( - await getAddressAsync(index: i, hd: hd, addressType: addrType), - index: i, - isHidden: isHidden, - isLegacyDerivation: isLegacyDerivation, - type: addrType, - network: network, - ); - list.add(address); + try { + final hd = _hdForAddressGeneration( + isHidden: isHidden, + type: addrType, + isLegacyDerivation: isLegacyDerivation, + accountIndex: accountIndex, + ); + + final address = BitcoinAddressRecord( + await getAddressAsync(index: i, hd: hd, addressType: addrType), + index: i, + isHidden: isHidden, + isHiddenChecked: true, + isLegacyDerivation: isLegacyDerivation, + type: addrType, + network: network, + accountIndex: accountIndex, + ); + list.add(address); + } on UnsupportedAddressTypeForAccountException catch (e) { + printV("_createNewAddresses: skipping index $i, type $addrType, " + "account $accountIndex: $e"); + return []; + } } return list; @@ -821,6 +982,8 @@ abstract class ElectrumWalletAddressesBase extends WalletAddresses with Store { this._addresses.clear(); this._addresses.addAll(addressesSet); updateAddressesByMatch(); + updateReceiveAddresses(); + updateChangeAddresses(); } @action @@ -841,27 +1004,52 @@ abstract class ElectrumWalletAddressesBase extends WalletAddresses with Store { updateAddressesByMatch(); } - void _validateAddresses() { - _addresses.forEach((element) async { - if (element.type == SegwitAddresType.mweb) { - // this would add a ton of startup lag for mweb addresses since we have 1000 of them - return; + static const _validationTimeSlice = Duration(milliseconds: 16); + + Future _validateAddresses() async { + final addresses = _addresses.toList(); + final slice = Stopwatch()..start(); + + for (final element in addresses) { + try { + await _validateAddress(element); + } catch (e, s) { + Zone.current.handleUncaughtError(e, s); } - final mainHd = _hdFor( - isHidden: false, type: element.type, isLegacyDerivation: element.isLegacyDerivation); - final sideHd = _hdFor( - isHidden: true, type: element.type, isLegacyDerivation: element.isLegacyDerivation); - if (!element.isHidden && - element.address != - await getAddressAsync(index: element.index, hd: mainHd, addressType: element.type)) { - element.isHidden = true; - } else if (element.isHidden && - element.address != - await getAddressAsync(index: element.index, hd: sideHd, addressType: element.type)) { - element.isHidden = false; + if (slice.elapsed >= _validationTimeSlice) { + await Future.delayed(Duration.zero); + slice.reset(); } - }); + } + } + + Future _validateAddress(BitcoinAddressRecord element) async { + if (element.isHiddenChecked) return; + + if (element.type == SegwitAddresType.mweb) { + // this would add a ton of startup lag for mweb addresses since we have 1000 of them + return; + } + + try { + // Relabel only when the address re-derives from the other chain. A record from a path these + // keys don't produce matches neither chain, so it keeps its label instead of flipping. + final otherChainHd = _hdForAddressGeneration( + isHidden: !element.isHidden, + type: element.type, + isLegacyDerivation: element.isLegacyDerivation, + accountIndex: element.accountIndex, + ); + final otherChainAddress = + await getAddressAsync(index: element.index, hd: otherChainHd, addressType: element.type); + if (element.address == otherChainAddress) { + element.isHidden = !element.isHidden; + } + element.isHiddenChecked = true; + } on UnsupportedAddressTypeForAccountException catch (e) { + printV("_validateAddresses: skipping ${element.address}: $e"); + } } @override @@ -869,7 +1057,19 @@ abstract class ElectrumWalletAddressesBase extends WalletAddresses with Store { @action Future setAddressType(BitcoinAddressType type) async { - _addressPageType = type; + + final needsAccountScopedHd = + type is! LightningAddressType && type != SilentPaymentsAddresType.p2sp; + + BitcoinAddressType resolvedType = type; + if (needsAccountScopedHd) { + final accountIndex = walletInfo.type == WalletType.bitcoin ? currentAccountIndex : 0; + if (!_isAddressTypeSupportedForAccount(type, accountIndex)) { + resolvedType = EXTRA_ACCOUNT_ADDRESS_TYPES.first; + } + } + + _addressPageType = resolvedType; updateAddressesByMatch(); walletInfo.addressPageType = addressPageType.toString(); await walletInfo.save(); @@ -881,8 +1081,15 @@ abstract class ElectrumWalletAddressesBase extends WalletAddresses with Store { bool _isAddressByType(BitcoinAddressRecord addr, BitcoinAddressType type) => addr.type == type; - bool _isUnusedReceiveAddressByType(BitcoinAddressRecord addr, BitcoinAddressType type) => - !addr.isHidden && !addr.isUsed && addr.type == type; + bool _isUnusedReceiveAddressByType( + BitcoinAddressRecord addressRecord, + BitcoinAddressType type, + ) { + return _isCurrentAccountAddress(addressRecord) && + !addressRecord.isHidden && + !addressRecord.isUsed && + addressRecord.type == type; + } @action void deleteSilentPaymentAddress(String address) { @@ -893,16 +1100,84 @@ abstract class ElectrumWalletAddressesBase extends WalletAddresses with Store { updateAddressesByMatch(); } - Bip32Slip10Secp256k1 _hdFor({ + // Remove all addresses associated with a specific account index. + @action + Future removeAddressesForAccount(int accountIndex) async { + _addresses.removeWhere((addr) => addr.accountIndex == accountIndex); + updateAddressesByMatch(); + updateReceiveAddresses(); + updateChangeAddresses(); + await updateAddressesInBox(); + } + + + bool _isAddressTypeSupportedForAccount(BitcoinAddressType type, int accountIndex) { + final effectiveAccountIndex = walletInfo.type == WalletType.bitcoin ? accountIndex : 0; + return mainHdByTypeAndAccount[effectiveAccountIndex]?.containsKey(type) ?? false; + } + + static BitcoinAddressType _resolveInitialAddressPageType({ + required BitcoinAddressType? initialAddressPageType, + required WalletInfo walletInfo, + required Map> mainHdByTypeAndAccount, + required int currentAccountIndex, + }) { + final resolved = initialAddressPageType ?? + (walletInfo.addressPageType != null + ? walletInfo.addressPageType == LightningAddressType.p2l.value + ? LightningAddressType.p2l + : BitcoinAddressType.fromValue(walletInfo.addressPageType!) + : SegwitAddresType.p2wpkh); + + // Non-account-scoped address types (Lightning, Silent Payments) don't need + // an HD map entry. + if (resolved is LightningAddressType || resolved == SilentPaymentsAddresType.p2sp) { + return resolved; + } + + final effectiveAccountIndex = walletInfo.type == WalletType.bitcoin ? currentAccountIndex : 0; + + final isValid = mainHdByTypeAndAccount[effectiveAccountIndex]?.containsKey(resolved) ?? false; + + if (!isValid) { + return EXTRA_ACCOUNT_ADDRESS_TYPES.first; + } + + return resolved; + } + + Bip32Slip10Secp256k1 _hdForAddressGeneration({ required bool isHidden, required BitcoinAddressType type, required bool isLegacyDerivation, + int accountIndex = 0, }) { - if (isLegacyDerivation) return isHidden ? legacySideHd : legacyMainHd; + if (isLegacyDerivation) { + if (accountIndex != 0) { + throw UnsupportedAddressTypeForAccountException( + "Legacy derivation is only supported for the first account"); + } + + return isHidden ? legacySideHd : legacyMainHd; + } + + final effectiveAccountIndex = walletInfo.type == WalletType.bitcoin ? accountIndex : 0; + + final map = isHidden + ? sideHdByTypeAndAccount[effectiveAccountIndex] + : mainHdByTypeAndAccount[effectiveAccountIndex]; + + if (map == null) { + throw UnsupportedAddressTypeForAccountException( + "HD map not found for account $effectiveAccountIndex"); + } - final map = isHidden ? sideHdByType : mainHdByType; final hd = map[type]; - if (hd == null) throw Exception("HD not found for type $type"); + if (hd == null) { + throw UnsupportedAddressTypeForAccountException( + "HD not found for account $accountIndex type $type"); + } + return hd; } diff --git a/cw_bitcoin/lib/electrum_wallet_snapshot.dart b/cw_bitcoin/lib/electrum_wallet_snapshot.dart index 810706374..67c822f01 100644 --- a/cw_bitcoin/lib/electrum_wallet_snapshot.dart +++ b/cw_bitcoin/lib/electrum_wallet_snapshot.dart @@ -2,6 +2,8 @@ import 'dart:convert'; import 'package:bitcoin_base/bitcoin_base.dart'; import 'package:cw_bitcoin/bitcoin_address_record.dart'; import 'package:cw_bitcoin/electrum_balance.dart'; +import 'package:cw_core/amount/money.dart'; +import 'package:cw_core/currency_for_wallet_type.dart'; import 'package:cw_core/encryption_file_utils.dart'; import 'package:cw_bitcoin/electrum_derivations.dart'; import 'package:cw_core/pathForWallet.dart'; @@ -92,9 +94,15 @@ class ElectrumWalletSnapshot { final useLightning = data['useLightning'] as bool? ?? true; final cachedLightningAddress = data['cachedLightningAddress'] as String?; - final balance = ElectrumBalance.fromJSON(data['balance'] as String?) ?? - ElectrumBalance(confirmed: 0, unconfirmed: 0, frozen: 0); - final lightningBalance = ElectrumBalance.fromJSON(data['lightningBalance'] as String?); + final currency = walletTypeToCryptoCurrency(type); + final balance = ElectrumBalance.fromJSON(data['balance'] as String?, currency) ?? + ElectrumBalance( + confirmed: Money.zero(currency), + unconfirmed: Money.zero(currency), + frozen: Money.zero(currency), + ); + final lightningBalance = + ElectrumBalance.fromJSON(data['lightningBalance'] as String?, currency); var regularAddressIndexByType = {SegwitAddresType.p2wpkh.toString(): 0}; var changeAddressIndexByType = {SegwitAddresType.p2wpkh.toString(): 0}; diff --git a/cw_bitcoin/lib/hardware/bitbox_service.dart b/cw_bitcoin/lib/hardware/bitbox_service.dart index a71dcacbc..4b3ea5915 100644 --- a/cw_bitcoin/lib/hardware/bitbox_service.dart +++ b/cw_bitcoin/lib/hardware/bitbox_service.dart @@ -106,11 +106,11 @@ class LitecoinBitboxService extends HardwareWalletService @override Future signLitecoinTransaction({ - required List outputs, + required List outputs, required List inputs, required Map publicKeys, }) async { - final psbt = PSBTTransactionBuild(inputs: inputs, outputs: outputs, cwOutputs: []).psbt; + final psbt = PSBTTransactionBuild(inputs: inputs, outputs: outputs).psbt; final signedPsbt = await manager.signBTCPsbt(bitboxCoinType, base64Encode(psbt.asPsbtV0())); final transactionRes = PsbtV2() diff --git a/cw_bitcoin/lib/hardware/bitcoin_hardware_wallet_service.dart b/cw_bitcoin/lib/hardware/bitcoin_hardware_wallet_service.dart index 885868d3c..22954d113 100644 --- a/cw_bitcoin/lib/hardware/bitcoin_hardware_wallet_service.dart +++ b/cw_bitcoin/lib/hardware/bitcoin_hardware_wallet_service.dart @@ -1,8 +1,7 @@ -import 'dart:typed_data'; +import "dart:typed_data"; -import 'package:cw_bitcoin/electrum_wallet.dart'; -import 'package:bitcoin_base/bitcoin_base.dart'; -import 'package:cw_bitcoin/psbt/transaction_builder.dart'; +import "package:cw_bitcoin/electrum_wallet.dart"; +import "package:cw_bitcoin/psbt/transaction_builder.dart"; mixin BitcoinHardwareWalletService { Future getMasterFingerprint() async => Uint8List.fromList([0, 0, 0, 0]); @@ -10,7 +9,7 @@ mixin BitcoinHardwareWalletService { mixin LitecoinHardwareWalletService on BitcoinHardwareWalletService { Future signLitecoinTransaction({ - required List outputs, + required List outputs, required List inputs, required Map publicKeys, }); diff --git a/cw_bitcoin/lib/hardware/litecoin_ledger_service.dart b/cw_bitcoin/lib/hardware/litecoin_ledger_service.dart index aa73fc106..852f77881 100644 --- a/cw_bitcoin/lib/hardware/litecoin_ledger_service.dart +++ b/cw_bitcoin/lib/hardware/litecoin_ledger_service.dart @@ -1,16 +1,16 @@ -import 'dart:async'; -import 'dart:typed_data'; +import "dart:async"; +import "dart:typed_data"; -import 'package:bitcoin_base/bitcoin_base.dart'; -import 'package:blockchain_utils/blockchain_utils.dart'; -import 'package:cw_bitcoin/electrum_wallet.dart'; -import 'package:cw_bitcoin/hardware/bitcoin_hardware_wallet_service.dart'; -import 'package:cw_bitcoin/psbt/transaction_builder.dart'; -import 'package:cw_bitcoin/utils.dart'; -import 'package:cw_core/hardware/hardware_account_data.dart'; -import 'package:cw_core/hardware/hardware_wallet_service.dart'; -import 'package:ledger_flutter_plus/ledger_flutter_plus.dart'; -import 'package:ledger_litecoin/ledger_litecoin.dart'; +import "package:bitcoin_base/bitcoin_base.dart"; +import "package:blockchain_utils/blockchain_utils.dart"; +import "package:cw_bitcoin/electrum_wallet.dart"; +import "package:cw_bitcoin/hardware/bitcoin_hardware_wallet_service.dart"; +import "package:cw_bitcoin/psbt/transaction_builder.dart"; +import "package:cw_bitcoin/utils.dart"; +import "package:cw_core/hardware/hardware_account_data.dart"; +import "package:cw_core/hardware/hardware_wallet_service.dart"; +import "package:ledger_flutter_plus/ledger_flutter_plus.dart"; +import "package:ledger_litecoin/ledger_litecoin.dart"; class LitecoinLedgerService extends HardwareWalletService with BitcoinHardwareWalletService, LitecoinHardwareWalletService { @@ -31,18 +31,21 @@ class LitecoinLedgerService extends HardwareWalletService for (final i in indexRange) { final derivationPath = "m/84'/2'/$i'"; final xpub = await litecoinLedgerApp.getXPubKey( - accountsDerivationPath: derivationPath, - xPubVersion: int.parse(hex.encode(xpubVersion.public), radix: 16)); + accountsDerivationPath: derivationPath, + xPubVersion: int.parse(hex.encode(xpubVersion.public), radix: 16), + ); final hd = Bip32Slip10Secp256k1.fromExtendedKey(xpub, xpubVersion).childKey(Bip32KeyIndex(0)); final address = generateP2WPKHAddress(hd: hd, index: 0, network: LitecoinNetwork.mainnet); - accounts.add(HardwareAccountData( - address: address, - accountIndex: i, - derivationPath: derivationPath, - xpub: xpub, - )); + accounts.add( + HardwareAccountData( + address: address, + accountIndex: i, + derivationPath: derivationPath, + xpub: xpub, + ), + ); } return accounts; @@ -50,7 +53,7 @@ class LitecoinLedgerService extends HardwareWalletService @override Future signLitecoinTransaction({ - required List outputs, + required List outputs, required List inputs, required Map publicKeys, }) { @@ -58,31 +61,39 @@ class LitecoinLedgerService extends HardwareWalletService for (final utxo in inputs) { final publicKeyAndDerivationPath = publicKeys[utxo.ownerDetails.address.pubKeyHash()]!; - readyInputs.add(LedgerTransaction( - rawTx: utxo.rawTx, - outputIndex: utxo.utxo.vout, - ownerPublicKey: Uint8List.fromList(hex.decode(publicKeyAndDerivationPath.publicKey)), - ownerDerivationPath: publicKeyAndDerivationPath.derivationPath, - )); + readyInputs.add( + LedgerTransaction( + rawTx: utxo.rawTx, + outputIndex: utxo.utxo.vout, + ownerPublicKey: Uint8List.fromList(hex.decode(publicKeyAndDerivationPath.publicKey)), + ownerDerivationPath: publicKeyAndDerivationPath.derivationPath, + ), + ); } // Check if we have the key to one of the output addresses to hide change on the device String? changePath; for (final output in outputs) { - final maybeChangePath = publicKeys[(output as BitcoinOutput).address.pubKeyHash()]; - if (maybeChangePath != null) changePath ??= maybeChangePath.derivationPath; + if (output.isChange) { + changePath ??= output.changeDerivationPath; + } } return litecoinLedgerApp.createTransaction( - inputs: readyInputs, - outputs: outputs - .map((e) => TransactionOutput.fromBigInt((e as BitcoinOutput).value, - Uint8List.fromList(e.address.toScriptPubKey().toBytes()))) - .toList(), - changePath: changePath, - sigHashType: 0x01, - additionals: ["bech32"], - isSegWit: true, - useTrustedInputForSegwit: true); + inputs: readyInputs, + outputs: outputs + .map( + (e) => TransactionOutput.fromBigInt( + (e as BitcoinOutput).value, + Uint8List.fromList(e.address.toScriptPubKey().toBytes()), + ), + ) + .toList(), + changePath: changePath, + sigHashType: 0x01, + additionals: ["bech32"], + isSegWit: true, + useTrustedInputForSegwit: true, + ); } } diff --git a/cw_bitcoin/lib/hardware/litecoin_trezor_service.dart b/cw_bitcoin/lib/hardware/litecoin_trezor_service.dart new file mode 100644 index 000000000..4858e30fe --- /dev/null +++ b/cw_bitcoin/lib/hardware/litecoin_trezor_service.dart @@ -0,0 +1,100 @@ +import 'dart:async'; + +import 'package:bitcoin_base/bitcoin_base.dart'; +import 'package:blockchain_utils/blockchain_utils.dart'; +import 'package:cw_bitcoin/electrum_wallet.dart'; +import 'package:cw_bitcoin/hardware/bitcoin_hardware_wallet_service.dart'; +import 'package:cw_bitcoin/psbt/transaction_builder.dart'; +import 'package:cw_bitcoin/utils.dart'; +import 'package:cw_core/hardware/hardware_account_data.dart'; +import 'package:cw_core/hardware/hardware_wallet_service.dart'; +import 'package:trezor_connect/trezor_connect.dart'; + +class LitecoinTrezorService extends HardwareWalletService + with BitcoinHardwareWalletService, LitecoinHardwareWalletService { + LitecoinTrezorService(this.connect); + + final TrezorConnect connect; + + @override + Future> getAvailableAccounts({int index = 0, int limit = 5}) async { + final indexRange = List.generate(limit, (i) => i + index); + final requestParams = []; + final xpubVersion = Bip44Conf.litecoinMainNet.altKeyNetVer; + + for (final i in indexRange) { + final derivationPath = "m/84'/2'/$i'"; + requestParams.add(TrezorGetPublicKeyParams(path: derivationPath, coin: "LTC")); + } + + final accounts = await connect.getPublicKeyBundle(requestParams); + + return accounts?.map((account) { + final hd = Bip32Slip10Secp256k1.fromExtendedKey(account.xpub, xpubVersion) + .childKey(Bip32KeyIndex(0)); + + final address = generateP2WPKHAddress(hd: hd, index: 0, network: LitecoinNetwork.mainnet); + return HardwareAccountData( + address: address, + xpub: account.xpub, + accountIndex: account.path[2] - 0x80000000, // unharden the path to get the index + derivationPath: account.serializedPath, + ); + }).toList() ?? + []; + } + + @override + Future signLitecoinTransaction({ + required List outputs, + required List inputs, + required Map publicKeys, + }) async { + final readyInputs = inputs + .map((input) => TrezorTxInput( + prevHash: input.utxo.txHash, + prevIndex: input.utxo.vout, + amount: input.utxo.value.toInt(), + addressPath: Bip32PathParser.parse(input.ownerDerivationPath).toList(), + scriptType: "SPENDWITNESS", + )) + .toList(); + + final readyOutputs = outputs.map((output) { + final maybeChangePath = publicKeys[(output as BitcoinOutput).address.pubKeyHash()]; + + return TrezorTxOutput( + amount: output.toOutput.amount.toInt(), + address: maybeChangePath != null + ? null + : output.toOutput.scriptPubKey.toAddress(network: LitecoinNetwork.mainnet), + scriptType: _getScriptType(output.toOutput.scriptPubKey.getAddressType()!), + addressPath: maybeChangePath != null + ? Bip32PathParser.parse(maybeChangePath.derivationPath).toList() + : null, + ); + }).toList(); + + final signedTx = + await connect.signTransaction(coin: 'LTC', inputs: readyInputs, outputs: readyOutputs); + + return signedTx!.serializedTx; + } +} + +String _getScriptType(BitcoinAddressType addressType) { + switch (addressType) { + case P2pkhAddressType.p2pkh: + return "PAYTOADDRESS"; + case P2shAddressType.p2wpkhInP2sh: + return "PAYTOSCRIPTHASH"; + case SegwitAddresType.p2tr: + return "PAYTOTAPROOT"; + case SegwitAddresType.p2wsh: + return "PAYTOP2SHWITNESS"; + case SegwitAddresType.p2wpkh: + return "PAYTOWITNESS"; + default: + throw Exception("Unknown Address Type"); + } +} diff --git a/cw_bitcoin/lib/hardware/trezor_service.dart b/cw_bitcoin/lib/hardware/trezor_service.dart index 8308aa4a8..238a01ee0 100644 --- a/cw_bitcoin/lib/hardware/trezor_service.dart +++ b/cw_bitcoin/lib/hardware/trezor_service.dart @@ -1,180 +1,178 @@ import 'dart:async'; import 'dart:convert'; -import 'dart:typed_data'; import 'package:bitcoin_base/bitcoin_base.dart'; import 'package:blockchain_utils/blockchain_utils.dart'; -import 'package:cw_bitcoin/electrum_wallet.dart'; import 'package:cw_bitcoin/hardware/bitcoin_hardware_wallet_service.dart'; -import 'package:cw_bitcoin/psbt/transaction_builder.dart'; import 'package:cw_bitcoin/utils.dart'; import 'package:cw_core/hardware/hardware_account_data.dart'; import 'package:cw_core/hardware/hardware_wallet_service.dart'; +import 'package:flutter/foundation.dart'; import 'package:ledger_bitcoin/psbt.dart'; -import 'package:trezor_connect/trezor_connect.dart'; +import 'package:ledger_bitcoin/src/utils/uint8list_extension.dart' as ext; +import 'package:trezor_flutter/trezor_flutter.dart' as sdk; class BitcoinTrezorService extends HardwareWalletService with BitcoinHardwareWalletService { - BitcoinTrezorService(this.connect); + BitcoinTrezorService(this.client) : _trezorBitcoin = sdk.TrezorBitcoin(client); - final TrezorConnect connect; + final sdk.TrezorClient client; + final sdk.TrezorBitcoin _trezorBitcoin; + + @override + Future getMasterFingerprint() async { + final publicKey = await _trezorBitcoin.getPublicKey(derivationPath: "m/84'/0'/0'"); + final fingerprintBuffer = ByteData(4)..setUint32(0, publicKey.$2); + return fingerprintBuffer.buffer.asUint8List(); + } @override Future> getAvailableAccounts({int index = 0, int limit = 5}) async { + final accounts = []; final indexRange = List.generate(limit, (i) => i + index); - final requestParams = []; for (final i in indexRange) { - requestParams.add(TrezorGetPublicKeyParams(path: "m/84'/0'/$i'")); + final derivationPath = "m/84'/0'/$i'"; + final xPub = + await _trezorBitcoin.getPublicKey(derivationPath: derivationPath, ignoreXpubMagic: true); + final hd = Bip32Slip10Secp256k1.fromExtendedKey(xPub.$1).childKey(Bip32KeyIndex(0)); + + final address = generateP2WPKHAddress(hd: hd, index: 0, network: BitcoinNetwork.mainnet); + + accounts.add( + HardwareAccountData( + address: address, + accountIndex: i, + derivationPath: derivationPath, + xpub: xPub.$1, + ), + ); } - final accounts = await connect.getPublicKeyBundle(requestParams); - - return accounts?.map((account) { - final hd = Bip32Slip10Secp256k1.fromExtendedKey(account.xpub).childKey(Bip32KeyIndex(0)); - final address = generateP2WPKHAddress(hd: hd, index: 0, network: BitcoinNetwork.mainnet); - return HardwareAccountData( - address: address, - xpub: account.xpub, - accountIndex: account.path[2] - 0x80000000, // unharden the path to get the index - derivationPath: account.serializedPath, - ); - }).toList() ?? - []; + return accounts; } @override Future signTransaction({required String transaction}) async { final psbt = PsbtV2()..deserialize(base64Decode(transaction)); + final masterFingerprint = await getMasterFingerprint(); + + final inputs = []; + final prevTxs = {}; - final inputs = []; final inputCount = psbt.getGlobalInputCount(); for (var i = 0; i < inputCount; i++) { - final inputTxRaw = psbt.getInputNonWitnessUtxo(i); - final inputTx = BtcTransaction.fromRaw(hex.encode(inputTxRaw!)); - final inputOutputIndex = psbt.getInputOutputIndex(i); + final rawPrevTx = psbt.getInputNonWitnessUtxo(i); + if (rawPrevTx == null) { + throw StateError("PSBT input $i is missing its previous transaction"); + } + final prevTx = BtcTransaction.fromRaw(hex.encode(rawPrevTx)); + final vout = psbt.getInputOutputIndex(i); - final publicKeys = psbt.inputMaps[i].keys.where((e) => e.startsWith("06")); - final pubkey = Uint8List.fromList(hex.decode(publicKeys.first.substring(2))); + final txidBytes = psbt.getInputPreviousTxid(i).reversed.toList(); - inputs.add(TrezorTxInput( - prevHash: hex.encode(psbt.getInputPreviousTxid(i).reversed.toList()), - prevIndex: inputOutputIndex, - amount: inputTx.outputs[inputOutputIndex].amount.toInt(), - addressPath: psbt.getInputBip32Derivation(i, pubkey)!.$2, + final pubkey = _firstDerivationPubkey(psbt.inputMaps[i], "06"); + if (pubkey == null) { + throw StateError("PSBT input $i is missing its BIP32 derivation"); + } + + final derivation = psbt.getInputBip32Derivation(i, pubkey)!; + final path = derivation.$2; + + if (!listEquals(derivation.$1, masterFingerprint)) { + throw Exception("Fingerprint missmatch with the PSBT"); + } + + inputs.add( + sdk.TrezorTxInput( + addressPath: path, + prevHash: txidBytes, + prevIndex: vout, + amount: prevTx.outputs[vout].amount.toInt(), sequence: psbt.getInputSequence(i), - scriptType: "SPENDWITNESS")); + scriptType: "SPENDWITNESS", + ), + ); + + prevTxs[hex.encode(txidBytes)] = sdk.TrezorPrevTx( + meta: sdk.TrezorPrevTxMeta( + version: Uint8List.fromList(prevTx.version).readUint32LE(0), + lockTime: Uint8List.fromList(prevTx.locktime).readUint32LE(0), + inputsCount: prevTx.inputs.length, + outputsCount: prevTx.outputs.length, + ), + inputs: prevTx.inputs + .map( + (e) => sdk.TrezorPrevInput( + prevHash: hex.decode(e.txId), + prevIndex: e.txIndex, + scriptSig: e.scriptSig.toBytes(), + sequence: Uint8List.fromList(e.sequence).readUint32LE(0), + ), + ) + .toList(), + outputs: prevTx.outputs + .map( + (e) => sdk.TrezorPrevOutput( + amount: e.amount.toInt(), + scriptPubkey: e.scriptPubKey.toBytes(), + ), + ) + .toList(), + ); } - final outputs = []; + final outputs = []; final outputCount = psbt.getGlobalOutputCount(); for (var i = 0; i < outputCount; i++) { - final script = Script.fromRaw(byteData: psbt.getOutputScript(i)); - outputs.add(TrezorTxOutput( - amount: psbt.getOutputAmount(i), - address: script.toAddress(), - scriptType: _getScriptType(script.getAddressType()!) - // ToDo: addressPath: psbt.getOutputBip32Derivation(i, pubkey).$2, // To highlight change outputs + final amount = psbt.getOutputAmount(i); + + // An output carrying our own BIP32 derivation is change: identify it + // by path so the device verifies it internally instead of displaying + // it as a payment. + final changePubkey = _firstDerivationPubkey(psbt.outputMaps[i], "02"); + if (changePubkey != null) { + final (fingerprint, path) = psbt.getOutputBip32Derivation(i, changePubkey); + if (listEquals(fingerprint, masterFingerprint)) { + outputs.add(sdk.TrezorTxOutput( + addressPath: path, + amount: amount, + scriptType: "PAYTOWITNESS", )); + continue; + } + } + + final script = Script.fromRaw(byteData: psbt.getOutputScript(i)); + outputs.add(sdk.TrezorTxOutput( + address: script.toAddress(), + amount: amount, + scriptType: "PAYTOADDRESS", + )); } - final signedTx = await connect.signTransaction(coin: 'btc', inputs: inputs, outputs: outputs); + final signed = await _trezorBitcoin.signTransaction( + inputs: inputs, + outputs: outputs, + prevTxs: prevTxs, + version: psbt.getGlobalTxVersion(), + lockTime: psbt.getGlobalFallbackLocktime() ?? 0, + ); - return Uint8List.fromList(BytesUtils.fromHexString(signedTx!.serializedTx)); + return signed.serializedTx; } @override - Future signMessage({required Uint8List message, String? derivationPath}) async { - final sig = await connect.signMessage(derivationPath ?? "m/84'/0'/0'/0/0", - message: hex.encode(message), hex: true); - return base64Decode(sig!.signature); - } -} - -class LitecoinTrezorService extends HardwareWalletService - with BitcoinHardwareWalletService, LitecoinHardwareWalletService { - LitecoinTrezorService(this.connect); - - final TrezorConnect connect; - - @override - Future> getAvailableAccounts({int index = 0, int limit = 5}) async { - final indexRange = List.generate(limit, (i) => i + index); - final requestParams = []; - final xpubVersion = Bip44Conf.litecoinMainNet.altKeyNetVer; - - for (final i in indexRange) { - final derivationPath = "m/84'/2'/$i'"; - requestParams.add(TrezorGetPublicKeyParams(path: derivationPath, coin: "LTC")); - } - - final accounts = await connect.getPublicKeyBundle(requestParams); - - return accounts?.map((account) { - final hd = Bip32Slip10Secp256k1.fromExtendedKey(account.xpub, xpubVersion) - .childKey(Bip32KeyIndex(0)); - - final address = generateP2WPKHAddress(hd: hd, index: 0, network: LitecoinNetwork.mainnet); - return HardwareAccountData( - address: address, - xpub: account.xpub, - accountIndex: account.path[2] - 0x80000000, // unharden the path to get the index - derivationPath: account.serializedPath, - ); - }).toList() ?? - []; - } - - @override - Future signLitecoinTransaction({ - required List outputs, - required List inputs, - required Map publicKeys, - }) async { - final readyInputs = inputs - .map((input) => TrezorTxInput( - prevHash: input.utxo.txHash, - prevIndex: input.utxo.vout, - amount: input.utxo.value.toInt(), - addressPath: Bip32PathParser.parse(input.ownerDerivationPath).toList(), - scriptType: "SPENDWITNESS", - )) - .toList(); - - final readyOutputs = outputs.map((output) { - final maybeChangePath = publicKeys[(output as BitcoinOutput).address.pubKeyHash()]; - - return TrezorTxOutput( - amount: output.toOutput.amount.toInt(), - address: maybeChangePath != null - ? null - : output.toOutput.scriptPubKey.toAddress(network: LitecoinNetwork.mainnet), - scriptType: _getScriptType(output.toOutput.scriptPubKey.getAddressType()!), - addressPath: maybeChangePath != null - ? Bip32PathParser.parse(maybeChangePath.derivationPath).toList() - : null, + Future signMessage({required Uint8List message, String? derivationPath}) => + _trezorBitcoin.signMessage( + derivationPath: derivationPath ?? "m/84'/0'/0'/0/0", + message: message, ); - }).toList(); - final signedTx = - await connect.signTransaction(coin: 'LTC', inputs: readyInputs, outputs: readyOutputs); - - return signedTx!.serializedTx; - } -} - -String _getScriptType(BitcoinAddressType addressType) { - switch (addressType) { - case P2pkhAddressType.p2pkh: - return "PAYTOADDRESS"; - case P2shAddressType.p2wpkhInP2sh: - return "PAYTOSCRIPTHASH"; - case SegwitAddresType.p2tr: - return "PAYTOTAPROOT"; - case SegwitAddresType.p2wsh: - return "PAYTOP2SHWITNESS"; - case SegwitAddresType.p2wpkh: - return "PAYTOWITNESS"; - default: - throw Exception("Unknown Address Type"); + /// Extracts the pubkey from the first BIP32-derivation record + /// ([keyTypeHex] `06` for inputs, `02` for outputs) of a PSBT key-value map. + Uint8List? _firstDerivationPubkey(Map map, String keyTypeHex) { + final key = map.keys.where((mapKey) => mapKey.startsWith(keyTypeHex)).firstOrNull; + if (key == null) return null; + return Uint8List.fromList(hex.decode(key.substring(2))); } } diff --git a/cw_bitcoin/lib/lightning/lightning_wallet.dart b/cw_bitcoin/lib/lightning/lightning_wallet.dart index 528ea913f..49b4979e7 100644 --- a/cw_bitcoin/lib/lightning/lightning_wallet.dart +++ b/cw_bitcoin/lib/lightning/lightning_wallet.dart @@ -6,6 +6,9 @@ import 'package:breez_sdk_spark_flutter/breez_sdk_spark.dart'; import 'package:cw_bitcoin/bitcoin_transaction_priority.dart'; import 'package:cw_bitcoin/electrum_transaction_info.dart'; import 'package:cw_bitcoin/lightning/pending_lightning_transaction.dart'; +import 'package:cw_core/amount/money.dart'; +import 'package:cw_core/crypto_currency.dart'; +import 'package:cw_core/currency.dart'; import 'package:cw_core/transaction_direction.dart'; import 'package:cw_core/utils/print_verbose.dart'; import 'package:cw_core/wallet_type.dart'; @@ -14,30 +17,34 @@ bool _breezSdkSparkLibUninitialized = true; Stream? _logStream; class LightningWallet { + LightningWallet({ + required this.mnemonic, + required this.apiKey, + required this.lnurlDomain, + this.network = Network.mainnet, + this.passphrase, + this.seedBytes, + this.cachedAddress, + }); + final String mnemonic; final String? passphrase; final Uint8List? seedBytes; final String apiKey; final String lnurlDomain; final Network network; - late BreezSdk sdk; + BreezSdk? _sdk; String? cachedAddress; static int MAX_RETRIES = 10; - LightningWallet({ - required this.mnemonic, - this.passphrase, - this.seedBytes, - required this.apiKey, - required this.lnurlDomain, - this.network = Network.mainnet, - this.cachedAddress, - }); - static bool get isAvailable => Platform.isIOS || Platform.isAndroid || Platform.isMacOS; + Currency get currency => CryptoCurrency.btcln; + + BreezSdk get sdk => _sdk!; + StreamSubscription? _eventSubscription; Stream? _eventStream; @@ -47,9 +54,25 @@ class LightningWallet { void _subscribeToLogStream(File logFile) { _logSubscription = _logStream?.listen((logEntry) { - logFile.writeAsStringSync("[${logEntry.level}] ${logEntry.line}\n", mode: FileMode.append); + try { + // Check if file exists before writing (optional, but safer) + if (!logFile.existsSync()) { + logFile.createSync(recursive: true); + } + logFile.writeAsStringSync("[${logEntry.level}] ${logEntry.line}\n", mode: FileMode.append); + } catch (e) { + // Silently fail or use printV(e) so it doesn't crash the app + printV("Failed to write to log: $e"); + } }, onError: (e) { - logFile.writeAsStringSync("[ERROR] $e\n", mode: FileMode.append); + try { + if (!logFile.existsSync()) { + logFile.createSync(recursive: true); + } + logFile.writeAsStringSync("[ERROR] $e\n", mode: FileMode.append); + } catch (err) { + printV("Failed to write error to log: $err"); + } }); } @@ -64,10 +87,11 @@ class LightningWallet { ? Seed.entropy(seedBytes!) : Seed.mnemonic(mnemonic: mnemonic, passphrase: passphrase); final config = defaultConfig(network: Network.mainnet).copyWith( - lnurlDomain: lnurlDomain, - apiKey: apiKey, - privateEnabledDefault: true, - maxDepositClaimFee: MaxFee.rate(satPerVbyte: BigInt.from(5))); + lnurlDomain: lnurlDomain, + apiKey: apiKey, + privateEnabledDefault: true, + maxDepositClaimFee: MaxFee.rate(satPerVbyte: BigInt.from(5)), + ); final connectRequest = ConnectRequest( config: config, @@ -75,7 +99,7 @@ class LightningWallet { storageDir: "$appPath/.breez/", ); - sdk = await connect(request: connectRequest); + _sdk = await connect(request: connectRequest); _eventStream ??= sdk.addEventListener().asBroadcastStream(); _logStream ??= initLogging().asBroadcastStream(); @@ -87,7 +111,7 @@ class LightningWallet { printV(e); } - await sdk.syncWallet(request: SyncWalletRequest()); + await sdk.syncWallet(request: const SyncWalletRequest()); return true; } catch (e) { @@ -98,7 +122,9 @@ class LightningWallet { Future close() async { _eventSubscription?.cancel(); - await sdk.disconnect(); + try { + await _sdk?.disconnect(); + } catch (_) {} _logSubscription?.cancel(); } @@ -114,30 +140,33 @@ class LightningWallet { } } catch (_) {} // No need to log here since it should be in the lightning log retries++; - await Future.delayed(Duration(milliseconds: 500)); + await Future.delayed(const Duration(milliseconds: 500)); } return cachedAddress; } Future getDepositAddress() async => (await sdk.receivePayment( - request: ReceivePaymentRequest(paymentMethod: ReceivePaymentMethod.bitcoinAddress()))) - .paymentRequest; + request: const ReceivePaymentRequest(paymentMethod: ReceivePaymentMethod.bitcoinAddress()), + )) + .paymentRequest; - Future getBalance() async { + Future getBalance() async { try { - return (await sdk.getInfo(request: GetInfoRequest(ensureSynced: true))).balanceSats; + return Money( + (await sdk.getInfo(request: const GetInfoRequest(ensureSynced: true))).balanceSats, + CryptoCurrency.btcln, + ); } on SdkError_Generic catch (_) { } on SdkError_NetworkError catch (_) {} - return BigInt.zero; + return Money.zero(CryptoCurrency.btcln); } - Future registerAddress(String username) async { - return (await sdk.registerLightningAddress( - request: RegisterLightningAddressRequest(username: username))) - .lightningAddress; - } + Future registerAddress(String username) async => (await sdk.registerLightningAddress( + request: RegisterLightningAddressRequest(username: username), + )) + .lightningAddress; Future getBolt11Invoice(BigInt? amount, String description) async { try { @@ -154,7 +183,9 @@ class LightningWallet { } on SdkError_NetworkError catch (_) { return null; } on SdkError_SparkError catch (e) { - if (!e.field0.contains("dns") && !e.field0.contains("TimedOut")) rethrow; + if (!e.field0.contains("dns") && !e.field0.contains("TimedOut")) { + rethrow; + } return null; } } @@ -170,17 +201,22 @@ class LightningWallet { } } - Future createTransaction(String address, BigInt? amountSats, - BitcoinTransactionPriority? priority, bool feesIncluded) async { + Future createTransaction( + String address, + BigInt? amountSats, + BitcoinTransactionPriority? priority, + bool feesIncluded, + ) async { final inputType = await sdk.parse(input: address); final feePolicy = feesIncluded ? FeePolicy.feesIncluded : FeePolicy.feesExcluded; if (inputType is InputType_Bolt11Invoice) { final request = PrepareSendPaymentRequest( - paymentRequest: inputType.field0.invoice.bolt11, - amount: amountSats, - feePolicy: feePolicy); + paymentRequest: PaymentRequest.input(input: inputType.field0.invoice.bolt11), + amount: amountSats, + feePolicy: feePolicy, + ); final prepareResponse = await sdk.prepareSendPayment(request: request); final paymentMethod = prepareResponse.paymentMethod; @@ -188,17 +224,23 @@ class LightningWallet { final lightningFeeSats = paymentMethod.lightningFeeSats; final sparkTransferFeeSats = paymentMethod.sparkTransferFeeSats; + final baseAmount = request.amount ?? amountSats; + final amount = baseAmount != null + ? Money(baseAmount, currency) + : Money(paymentMethod.invoiceDetails.amountMsat ?? BigInt.zero, currency) / + BigInt.from(1000); + return PendingLightningTransaction( id: paymentMethod.invoiceDetails.paymentHash, - amount: request.amount?.toInt() ?? - amountSats?.toInt() ?? - ((paymentMethod.invoiceDetails.amountMsat?.toInt() ?? 0) / 1000).round(), - fee: lightningFeeSats.toInt() + (sparkTransferFeeSats?.toInt() ?? 0), + amount: amount, + fee: Money(lightningFeeSats + (sparkTransferFeeSats ?? BigInt.zero), currency), commitOverride: () async { try { final res = await sdk.sendPayment( - request: SendPaymentRequest(prepareResponse: prepareResponse)); + request: SendPaymentRequest(prepareResponse: prepareResponse), + ); printV(res.payment.status.name); + return res.payment.id; } on SdkError_SparkError catch (e) { if (e.field0.contains("AlreadyExists")) { throw Exception("Invoice already paid"); @@ -209,19 +251,19 @@ class LightningWallet { ); } } else if (inputType is InputType_LightningAddress || inputType is InputType_LnurlPay) { - final optionalValidateSuccessActionUrl = true; + const optionalValidateSuccessActionUrl = true; PrepareLnurlPayRequest request; if (inputType is InputType_LightningAddress) { request = PrepareLnurlPayRequest( - amountSats: amountSats!, + amount: amountSats!, payRequest: inputType.field0.payRequest, validateSuccessActionUrl: optionalValidateSuccessActionUrl, feePolicy: feePolicy, ); } else { request = PrepareLnurlPayRequest( - amountSats: amountSats!, + amount: amountSats!, payRequest: (inputType as InputType_LnurlPay).field0, validateSuccessActionUrl: optionalValidateSuccessActionUrl, feePolicy: feePolicy, @@ -230,21 +272,20 @@ class LightningWallet { final prepareResponse = await sdk.prepareLnurlPay(request: request); - final feeSats = prepareResponse.feeSats; - return PendingLightningTransaction( id: prepareResponse.invoiceDetails.paymentHash, - amount: prepareResponse.amountSats.toInt(), - fee: feeSats.toInt(), + amount: Money(prepareResponse.amountSats, currency), + fee: Money(prepareResponse.feeSats, currency), commitOverride: () async { final res = await sdk.lnurlPay(request: LnurlPayRequest(prepareResponse: prepareResponse)); printV(res.payment.status.name); + return res.payment.id; }, ); } else if (inputType is InputType_BitcoinAddress) { final request = PrepareSendPaymentRequest( - paymentRequest: inputType.field0.address, + paymentRequest: PaymentRequest.input(input: inputType.field0.address), amount: amountSats, feePolicy: feePolicy, ); @@ -255,32 +296,33 @@ class LightningWallet { final feeQuote = paymentMethod.feeQuote; OnchainConfirmationSpeed onchainConfirmationSpeed; - int fee; + BigInt fee; switch (priority) { case BitcoinTransactionPriority.fast: - fee = (feeQuote.speedFast.userFeeSat + feeQuote.speedFast.l1BroadcastFeeSat).toInt(); + fee = feeQuote.speedFast.userFeeSat + feeQuote.speedFast.l1BroadcastFeeSat; onchainConfirmationSpeed = OnchainConfirmationSpeed.fast; break; case BitcoinTransactionPriority.medium: - fee = - (feeQuote.speedMedium.userFeeSat + feeQuote.speedMedium.l1BroadcastFeeSat).toInt(); + fee = feeQuote.speedMedium.userFeeSat + feeQuote.speedMedium.l1BroadcastFeeSat; onchainConfirmationSpeed = OnchainConfirmationSpeed.medium; break; case BitcoinTransactionPriority.slow: default: - fee = (feeQuote.speedSlow.userFeeSat + feeQuote.speedSlow.l1BroadcastFeeSat).toInt(); + fee = feeQuote.speedSlow.userFeeSat + feeQuote.speedSlow.l1BroadcastFeeSat; onchainConfirmationSpeed = OnchainConfirmationSpeed.slow; } return PendingLightningTransaction( id: "", // ToDo: Find out where to get it - amount: prepareResponse.amount.toInt(), - fee: fee, + amount: Money(prepareResponse.amount, currency), + fee: Money(fee, currency), commitOverride: () async { final options = SendPaymentOptions.bitcoinAddress(confirmationSpeed: onchainConfirmationSpeed); - await sdk.sendPayment( - request: SendPaymentRequest(prepareResponse: prepareResponse, options: options)); + final res = await sdk.sendPayment( + request: SendPaymentRequest(prepareResponse: prepareResponse, options: options), + ); + return res.payment.id; }, ); } @@ -296,7 +338,7 @@ class LightningWallet { // statusFilter: [PaymentStatus.completed], fromTimestamp: fromDate != null ? BigInt.from((fromDate.millisecondsSinceEpoch / 1000).round()) : null, - assetFilter: AssetFilter.bitcoin(), + assetFilter: const AssetFilter.bitcoin(), offset: 0, limit: 50, sortAscending: false, // Sort order (true = oldest first, false = newest first) @@ -327,7 +369,7 @@ class LightningWallet { /// Future>> getUnclaimedDeposits() async { final unclaimedDeposits = >[]; - final response = await sdk.listUnclaimedDeposits(request: ListUnclaimedDepositsRequest()); + final response = await sdk.listUnclaimedDeposits(request: const ListUnclaimedDepositsRequest()); for (final deposit in response.deposits) { final unclaimedDeposit = { "txId": deposit.txid, @@ -350,7 +392,7 @@ class LightningWallet { return unclaimedDeposits; } - Future claimDeposit(String txId, int vout, BigInt newFee) async { + Future claimDeposit(String txId, int vout, BigInt newFee) async { final response = await sdk.claimDeposit( request: ClaimDepositRequest( txid: txId, @@ -359,11 +401,18 @@ class LightningWallet { ), ); - return _getElectrumTransactionInfoFromPayment(response.payment); + if (response.payment == null) { + return null; + } + return _getElectrumTransactionInfoFromPayment(response.payment!); } Future refundDeposit( - String txId, int vout, String destinationAddress, BigInt feeRate) async { + String txId, + int vout, + String destinationAddress, + BigInt feeRate, + ) async { final response = await sdk.refundDeposit( request: RefundDepositRequest( txid: txId, @@ -404,7 +453,7 @@ class LightningWallet { } ElectrumTransactionInfo _getElectrumTransactionInfoFromPayment(Payment payment) { - TransactionDirection direction = TransactionDirection.outgoing; + var direction = TransactionDirection.outgoing; if (payment.paymentType == PaymentType.receive) { direction = TransactionDirection.incoming; @@ -413,30 +462,37 @@ class LightningWallet { direction = TransactionDirection.incoming; } + String? preimage; + if (payment.details != null && payment.details is PaymentDetails_Lightning) { + preimage = (payment.details as PaymentDetails_Lightning).htlcDetails.preimage; + } + return ElectrumTransactionInfo( WalletType.bitcoin, id: payment.id, - amount: payment.amount.toInt(), + amount: Money(payment.amount, currency), direction: direction, isPending: payment.status == PaymentStatus.pending, - fee: payment.fees.toInt(), + fee: Money(payment.fees, currency), date: DateTime.fromMillisecondsSinceEpoch(payment.timestamp.toInt() * 1000), confirmations: payment.status == PaymentStatus.pending ? 0 : 10, - additionalInfo: {"isLightning": true}, + additionalInfo: { + "isLightning": true, + if (preimage != null) "preimage": preimage, + }, ); } - ElectrumTransactionInfo _getElectrumTransactionInfoFromDepositInfo(DepositInfo deposit) { - return ElectrumTransactionInfo( - WalletType.bitcoin, - id: deposit.txid, - amount: deposit.amountSats.toInt(), - direction: TransactionDirection.incoming, - isPending: true, - fee: 0, - date: DateTime.now(), - confirmations: 0, - additionalInfo: {"isLightning": true, "isSparkDeposit": true}, - ); - } + ElectrumTransactionInfo _getElectrumTransactionInfoFromDepositInfo(DepositInfo deposit) => + ElectrumTransactionInfo( + WalletType.bitcoin, + id: deposit.txid, + amount: Money(deposit.amountSats, currency), + direction: TransactionDirection.incoming, + isPending: true, + fee: Money.zero(currency), + date: DateTime.now(), + confirmations: 0, + additionalInfo: {"isLightning": true, "isSparkDeposit": true}, + ); } diff --git a/cw_bitcoin/lib/lightning/pending_lightning_transaction.dart b/cw_bitcoin/lib/lightning/pending_lightning_transaction.dart index a5ff93884..237a6b824 100644 --- a/cw_bitcoin/lib/lightning/pending_lightning_transaction.dart +++ b/cw_bitcoin/lib/lightning/pending_lightning_transaction.dart @@ -1,4 +1,4 @@ -import 'package:cw_bitcoin/bitcoin_amount_format.dart'; +import 'package:cw_core/amount/money.dart'; import 'package:cw_core/pending_transaction.dart'; class PendingLightningTransaction with PendingTransaction { @@ -10,33 +10,31 @@ class PendingLightningTransaction with PendingTransaction { required this.commitOverride, }); - final int amount; - final int fee; final bool isSendAll; - Future Function() commitOverride; + Future Function() commitOverride; final List _listeners = []; @override - final String id; + String id; + + @override + final Money amount; + + @override + final Money fee; @override String get hex => ""; @override - String get amountFormatted => bitcoinAmountToString(amount: amount); - - @override - String get feeFormatted => "$feeFormattedValue BTC"; - - @override - String get feeFormattedValue => bitcoinAmountToString(amount: fee); + String get amountFormatted => amount.toString(); @override int? get outputCount => 1; @override Future commit() async { - await commitOverride.call(); + id = await commitOverride.call(); _listeners.forEach((e) => e.call()); } diff --git a/cw_bitcoin/lib/litecoin_wallet.dart b/cw_bitcoin/lib/litecoin_wallet.dart index f76a55e96..d07f4b8f6 100644 --- a/cw_bitcoin/lib/litecoin_wallet.dart +++ b/cw_bitcoin/lib/litecoin_wallet.dart @@ -5,6 +5,7 @@ import 'package:collection/collection.dart'; import 'package:crypto/crypto.dart'; import 'package:cw_bitcoin/address_from_output.dart'; import 'package:cw_bitcoin/bitcoin_transaction_credentials.dart'; +import 'package:cw_core/amount/money.dart'; import 'package:cw_core/cake_hive.dart'; import 'package:cw_core/mweb_utxo.dart'; import 'package:cw_core/unspent_coin_type.dart'; @@ -17,12 +18,9 @@ import 'package:bitcoin_base/bitcoin_base.dart'; import 'package:bitcoin_base/src/crypto/keypair/sign_utils.dart'; import 'package:blockchain_utils/blockchain_utils.dart'; import 'package:blockchain_utils/signer/ecdsa_signing_key.dart'; -import 'package:collection/collection.dart'; import 'package:convert/convert.dart' as convert; -import 'package:crypto/crypto.dart'; import 'package:cw_bitcoin/bitcoin_address_record.dart'; import 'package:cw_bitcoin/bitcoin_mnemonic.dart'; -import 'package:cw_bitcoin/bitcoin_transaction_credentials.dart'; import 'package:cw_bitcoin/bitcoin_transaction_priority.dart'; import 'package:cw_bitcoin/bitcoin_unspent.dart'; import 'package:cw_bitcoin/electrum_balance.dart'; @@ -32,28 +30,22 @@ import 'package:cw_bitcoin/electrum_wallet.dart'; import 'package:cw_bitcoin/electrum_wallet_snapshot.dart'; import 'package:cw_bitcoin/hardware/bitcoin_hardware_wallet_service.dart'; import 'package:cw_bitcoin/litecoin_wallet_addresses.dart'; +import 'package:cw_bitcoin/output_ordering.dart'; import 'package:cw_bitcoin/pending_bitcoin_transaction.dart'; import 'package:cw_bitcoin/psbt/transaction_builder.dart'; import 'package:cw_bitcoin/utils.dart'; -import 'package:cw_core/cake_hive.dart'; import 'package:cw_core/crypto_currency.dart'; import 'package:cw_core/encryption_file_utils.dart'; -import 'package:cw_core/mweb_utxo.dart'; -import 'package:cw_core/node.dart'; import 'package:cw_core/output_info.dart'; import 'package:cw_core/pending_transaction.dart'; import 'package:cw_core/sync_status.dart'; import 'package:cw_core/transaction_direction.dart'; import 'package:cw_core/transaction_priority.dart'; -import 'package:cw_core/unspent_coin_type.dart'; import 'package:cw_core/unspent_coins_info.dart'; -import 'package:cw_core/utils/print_verbose.dart'; import 'package:cw_core/wallet_info.dart'; import 'package:cw_core/wallet_keys_file.dart'; import 'package:cw_core/wallet_type.dart'; import 'package:cw_mweb/cw_mweb.dart'; -import 'package:cw_mweb/mwebd.pbgrpc.dart'; -import 'package:fixnum/fixnum.dart'; import 'package:flutter/foundation.dart'; import 'package:grpc/grpc.dart'; import 'package:hive/hive.dart'; @@ -123,8 +115,10 @@ abstract class LitecoinWalletBase extends ElectrumWallet with Store { initialRegularAddressIndex: initialRegularAddressIndex, initialChangeAddressIndex: initialChangeAddressIndex, initialMwebAddresses: initialMwebAddresses, - mainHdByType: mainHdByType, - sideHdByType: sideHdByType, + mainHdByTypeAndAccount: mainHdByTypeAndAccount, + sideHdByTypeAndAccount: sideHdByTypeAndAccount, + accountIndexes: [currentAccountIndex], + currentAccountIndex: currentAccountIndex, legacyMainHd: mainHd, legacySideHd: sideHd, network: network, @@ -384,6 +378,7 @@ abstract class LitecoinWalletBase extends ElectrumWallet with Store { } catch (e) { printV("failed to subscribe for updates: $e"); } + await checkIfBatchSupported(); updateFeeRates(); _feeRatesTimer?.cancel(); _feeRatesTimer = @@ -508,22 +503,25 @@ abstract class LitecoinWalletBase extends ElectrumWallet with Store { mwebUtxosBox = await CakeHive.openBox(boxName); } - @override - Future renameWalletFiles(String newWalletName) async { - // rename the hive box: - final oldBoxName = "${walletInfo.name.replaceAll(" ", "_")}_${MwebUtxo.boxName}"; - final newBoxName = "${newWalletName.replaceAll(" ", "_")}_${MwebUtxo.boxName}"; + static Future copyMwebBox({ + required String fromName, + required String toName, + }) async { + final oldBoxName = "${fromName.replaceAll(" ", "_")}_${MwebUtxo.boxName}"; + final newBoxName = "${toName.replaceAll(" ", "_")}_${MwebUtxo.boxName}"; + if (oldBoxName == newBoxName) return; final oldBox = await CakeHive.openBox(oldBoxName); - mwebUtxosBox = await CakeHive.openBox(newBoxName); + final newBox = await CakeHive.openBox(newBoxName); for (final key in oldBox.keys) { - final value = oldBox.get(key); - await oldBox.delete(key); - await mwebUtxosBox.put(key, value!); + await newBox.put(key, oldBox.get(key)!); } - oldBox.deleteFromDisk(); + } - await super.renameWalletFiles(newWalletName); + static Future deleteMwebBox(String name) async { + final boxName = "${name.replaceAll(" ", "_")}_${MwebUtxo.boxName}"; + final box = await CakeHive.openBox(boxName); + await box.deleteFromDisk(); } @action @@ -589,8 +587,8 @@ abstract class LitecoinWalletBase extends ElectrumWallet with Store { WalletType.litecoin, id: utxo.outputId, height: utxo.height, - amount: utxo.value.toInt(), - fee: 0, + amount: Money.fromInt(utxo.value, currency), + fee: Money.zero(currency), direction: TransactionDirection.incoming, isPending: utxo.height == 0, date: date, @@ -736,9 +734,7 @@ abstract class LitecoinWalletBase extends ElectrumWallet with Store { Future checkMwebUtxosSpent() async { printV("checkMwebUtxosSpent() called!"); - if (!mwebEnabled) { - return; - } + if (!mwebEnabled) return; final pendingOutgoingTransactions = transactionHistory.transactions.values .where((tx) => tx.direction == TransactionDirection.outgoing && tx.isPending); @@ -765,8 +761,8 @@ abstract class LitecoinWalletBase extends ElectrumWallet with Store { final height = await electrumClient.getCurrentBlockChainTip(); if (height == null || status.blockHeaderHeight != height) return; if (status.mwebUtxosHeight != height) return; // we aren't synced - int amount = 0; - Set inputAddresses = {}; + var amount = 0; + var inputAddresses = {}; var output = convert.AccumulatorSink(); var input = sha256.startChunkedConversion(output); @@ -779,8 +775,8 @@ abstract class LitecoinWalletBase extends ElectrumWallet with Store { if (!inputAddresses.contains(utxo.address)) { addressRecord.txCount++; } - addressRecord.balance -= utxo.value.toInt(); - amount += utxo.value.toInt(); + addressRecord.balance -= utxo.value; + amount += utxo.value; inputAddresses.add(utxo.address); input.add(hex.decode(outputId)); } @@ -792,8 +788,8 @@ abstract class LitecoinWalletBase extends ElectrumWallet with Store { WalletType.litecoin, id: digest.toString(), height: height, - amount: amount, - fee: 0, + amount: Money.fromInt(amount, currency), + fee: Money.zero(currency), direction: TransactionDirection.outgoing, isPending: false, date: DateTime.fromMillisecondsSinceEpoch(status.blockTime * 1000), @@ -923,10 +919,8 @@ abstract class LitecoinWalletBase extends ElectrumWallet with Store { // update unspent balances: await updateUnspent(); - int confirmed = balance.confirmed; - int unconfirmed = balance.unconfirmed; - int confirmedMweb = 0; - int unconfirmedMweb = 0; + var confirmedMweb = 0; + var unconfirmedMweb = 0; try { mwebUtxosBox.values.forEach((utxo) { bool isConfirmed = utxo.height > 0; @@ -935,20 +929,20 @@ abstract class LitecoinWalletBase extends ElectrumWallet with Store { "utxo: ${isConfirmed ? "confirmed" : "unconfirmed"} ${utxo.spent ? "spent" : "unspent"} ${utxo.outputId} ${utxo.height} ${utxo.value}"); if (isConfirmed) { - confirmedMweb += utxo.value.toInt(); + confirmedMweb += utxo.value; } if (isConfirmed && utxo.spent) { - unconfirmedMweb -= utxo.value.toInt(); + unconfirmedMweb -= utxo.value; } if (!isConfirmed && !utxo.spent) { - unconfirmedMweb += utxo.value.toInt(); + unconfirmedMweb += utxo.value; } }); } catch (_) {} - for (var addressRecord in walletAddresses.allAddresses) { + for (final addressRecord in walletAddresses.allAddresses) { addressRecord.balance = 0; addressRecord.txCount = 0; } @@ -992,11 +986,11 @@ abstract class LitecoinWalletBase extends ElectrumWallet with Store { } return ElectrumBalance( - confirmed: confirmed, - unconfirmed: unconfirmed, - frozen: balance.frozen.toInt(), - secondConfirmed: confirmedMweb, - secondUnconfirmed: unconfirmedMweb, + confirmed: balance.confirmed, + unconfirmed: balance.unconfirmed, + frozen: balance.frozen, + secondConfirmed: Money.fromInt(confirmedMweb, currency), + secondUnconfirmed: Money.fromInt(unconfirmedMweb, currency), ); } @@ -1373,9 +1367,10 @@ abstract class LitecoinWalletBase extends ElectrumWallet with Store { final tx = PendingBitcoinTransaction( btcTx, type, + accountIndex: 0, electrumClient: electrumClient, - amount: 0, - fee: resp.fee.toInt(), + amount: Money.zero(currency), + fee: Money.fromInt(resp.fee.toInt(), currency), feeRate: "", network: network, hasChange: resp.recipient.length > 1, @@ -1564,7 +1559,7 @@ abstract class LitecoinWalletBase extends ElectrumWallet with Store { @override Future buildHardwareWalletTransaction({ - required List outputs, + required List outputs, required BigInt fee, required BasedUtxoNetwork network, required List utxos, @@ -1576,25 +1571,48 @@ abstract class LitecoinWalletBase extends ElectrumWallet with Store { BitcoinOrdering outputOrdering = BitcoinOrdering.bip69, }) async { final masterFingerprint = - await (hardwareWalletService as BitcoinHardwareWalletService).getMasterFingerprint(); + await (hardwareWalletService! as BitcoinHardwareWalletService).getMasterFingerprint(); final readyInputs = []; for (final utxo in utxos) { final rawTx = await electrumClient.getTransactionHex(hash: utxo.utxo.txHash); final publicKeyAndDerivationPath = publicKeys[utxo.ownerDetails.address.pubKeyHash()]!; - readyInputs.add(PSBTReadyUtxoWithAddress( - utxo: utxo.utxo, - rawTx: rawTx, - ownerDetails: utxo.ownerDetails, - ownerDerivationPath: publicKeyAndDerivationPath.derivationPath, - ownerMasterFingerprint: masterFingerprint, - ownerPublicKey: publicKeyAndDerivationPath.publicKey, - )); + readyInputs.add( + PSBTReadyUtxoWithAddress( + utxo: utxo.utxo, + rawTx: rawTx, + ownerDetails: utxo.ownerDetails, + ownerDerivationPath: publicKeyAndDerivationPath.derivationPath, + ownerMasterFingerprint: masterFingerprint, + ownerPublicKey: publicKeyAndDerivationPath.publicKey, + ), + ); } - final rawHex = await (hardwareWalletService as LitecoinHardwareWalletService) - .signLitecoinTransaction(outputs: outputs, inputs: readyInputs, publicKeys: publicKeys); + final orderedOutputs = + orderOutputs(outputs, outputOrdering).map((o) { + if (o.isChange && publicKeys.containsKey(o.address.pubKeyHash())) { + final changeKey = publicKeys[o.address.pubKeyHash()]!; + return PSBTReadyBitcoinOutput( + address: o.address, + value: o.value, + isSilentPayment: o.isSilentPayment, + isChange: o.isChange, + changeMasterFingerprint: masterFingerprint, + changeDerivationPath: changeKey.derivationPath, + changePublicKey: changeKey.publicKey, + ); + } + return PSBTReadyBitcoinOutput.fromOutput(o); + }); + + final rawHex = + await (hardwareWalletService! as LitecoinHardwareWalletService).signLitecoinTransaction( + outputs: orderedOutputs.toList(), + inputs: readyInputs, + publicKeys: publicKeys, + ); return BtcTransaction.fromRaw(rawHex); } diff --git a/cw_bitcoin/lib/litecoin_wallet_addresses.dart b/cw_bitcoin/lib/litecoin_wallet_addresses.dart index 53128cc9c..3c66dd63d 100644 --- a/cw_bitcoin/lib/litecoin_wallet_addresses.dart +++ b/cw_bitcoin/lib/litecoin_wallet_addresses.dart @@ -25,8 +25,10 @@ class LitecoinWalletAddresses = LitecoinWalletAddressesBase with _$LitecoinWalle abstract class LitecoinWalletAddressesBase extends ElectrumWalletAddresses with Store { LitecoinWalletAddressesBase( WalletInfo walletInfo, { - required super.mainHdByType, - required super.sideHdByType, + required super.mainHdByTypeAndAccount, + required super.sideHdByTypeAndAccount, + required super.accountIndexes, + required super.currentAccountIndex, required super.legacyMainHd, required super.legacySideHd, required super.network, @@ -66,9 +68,9 @@ abstract class LitecoinWalletAddressesBase extends ElectrumWalletAddresses with List.filled(32, 0); @override - Future init() async { + Future init({List accountIndexes = const []}) async { if (!isHardwareWallet) await initMwebAddresses(); - await super.init(); + await super.init(accountIndexes: accountIndexes); } @computed @@ -230,13 +232,25 @@ abstract class LitecoinWalletAddressesBase extends ElectrumWalletAddresses with @override String get addressForExchange { // don't use mweb addresses for exchange refund address: - try { - final addresses = receiveAddresses - .where((element) => element.type == SegwitAddresType.p2wpkh && !element.isUsed); - return addresses.first.address; - } catch (_) { - return receiveAddresses.first.address; + + final current = getFreshAddress(); + final bool isMweb = + receiveAddresses.any((e) => e.address == current && e.type == SegwitAddresType.mweb); + + if (isMweb) { + final segwit = receiveAddresses + .where((e) => + e.type == SegwitAddresType.p2wpkh && + !e.isUsed && + !e.isHidden && + !hiddenAddresses.contains(e.address)) + .map((e) => e.address) + .toList(); + + return segwit.isNotEmpty ? segwit.first : current; } + + return current; } @override diff --git a/cw_bitcoin/lib/litecoin_wallet_service.dart b/cw_bitcoin/lib/litecoin_wallet_service.dart index 4414c95b7..09ab924d1 100644 --- a/cw_bitcoin/lib/litecoin_wallet_service.dart +++ b/cw_bitcoin/lib/litecoin_wallet_service.dart @@ -12,8 +12,6 @@ import 'package:cw_core/wallet_service.dart'; import 'package:cw_core/pathForWallet.dart'; import 'package:cw_core/wallet_type.dart'; import 'package:cw_core/wallet_info.dart'; -import 'package:cw_core/wallet_base.dart'; -import 'package:collection/collection.dart'; import 'package:bip39/bip39.dart' as bip39; import 'package:path_provider/path_provider.dart'; @@ -140,26 +138,11 @@ class LitecoinWalletService extends WalletService< @override Future rename(String currentName, String password, String newName) async { - final currentWalletInfo = await WalletInfo.get(currentName, getType()); - if (currentWalletInfo == null) { - throw Exception('Wallet not found'); - } - final currentWallet = await LitecoinWalletBase.open( - password: password, - name: currentName, - walletInfo: currentWalletInfo, - unspentCoinsInfo: unspentCoinsInfoSource, - encryptionFileUtils: encryptionFileUtilsFor(isDirect), - ); + if (currentName == newName) return; - await currentWallet.renameWalletFiles(newName); - await saveBackup(newName); - - final newWalletInfo = currentWalletInfo; - newWalletInfo.id = WalletBase.idFor(newName, getType()); - newWalletInfo.name = newName; - - await newWalletInfo.save(); + await LitecoinWalletBase.copyMwebBox(fromName: currentName, toName: newName); + await super.rename(currentName, password, newName); + await LitecoinWalletBase.deleteMwebBox(currentName); } @override diff --git a/cw_bitcoin/lib/locktime.dart b/cw_bitcoin/lib/locktime.dart new file mode 100644 index 000000000..639e6e1d0 --- /dev/null +++ b/cw_bitcoin/lib/locktime.dart @@ -0,0 +1,20 @@ +import "dart:typed_data"; + +/// Anti-fee-sniping locktime, set to the current chain tip (exact-tip). +/// +/// Matches the exact-tip cluster (payjoin-cli, ldk-node, Bull Bitcoin) and Core's +/// default for externally-constructed transactions, and takes Cake off its unique +/// `nLockTime = 0` fingerprint. It intentionally skips the ~10% backdate +/// Core/Electrum apply for delayed-broadcast privacy, which does not apply here. +/// Returns 0 when not synced or the tip is unknown, to avoid emitting a stale +/// height. +int antiFeeSnipingLocktime({ + required int chainTip, + required bool synced, +}) { + if (!synced || chainTip <= 0) return 0; + return chainTip; +} + +/// Little-endian 4-byte encoding for `BtcTransaction.locktime`. +List locktimeToBytes(int locktime) => (ByteData(4)..setUint32(0, locktime, Endian.little)).buffer.asUint8List(); diff --git a/cw_bitcoin/lib/output_ordering.dart b/cw_bitcoin/lib/output_ordering.dart new file mode 100644 index 000000000..36a980e93 --- /dev/null +++ b/cw_bitcoin/lib/output_ordering.dart @@ -0,0 +1,21 @@ +import 'dart:math'; + +import 'package:bitcoin_base/bitcoin_base.dart'; + +/// Applies [ordering] to a transaction's [outputs] before building. +/// +/// Returns a new list; the input is never mutated. Only [BitcoinOrdering.shuffle] +/// reorders (using a secure RNG by default) so the change output is no longer +/// placed deterministically last. Any other value preserves the given order, +/// matching the pre-existing hardware-wallet behavior. +List orderOutputs( + List outputs, + BitcoinOrdering ordering, { + Random? rng, +}) { + final result = List.of(outputs); + if (ordering == BitcoinOrdering.shuffle) { + result.shuffle(rng ?? Random.secure()); + } + return result; +} diff --git a/cw_bitcoin/lib/payjoin/manager.dart b/cw_bitcoin/lib/payjoin/manager.dart index 00187505d..ba0d616ad 100644 --- a/cw_bitcoin/lib/payjoin/manager.dart +++ b/cw_bitcoin/lib/payjoin/manager.dart @@ -290,11 +290,14 @@ class PayjoinManager { break; case PayjoinReceiverRequestTypes.getCandidateInputs: - utxos = _wallet.getUtxoWithPrivateKeys(); + utxos = _wallet.getUtxoWithPrivateKeys(confirmedOnly: true); if (utxos.isEmpty) { await _wallet.updateAllUnspents(); - utxos = _wallet.getUtxoWithPrivateKeys(); + utxos = _wallet.getUtxoWithPrivateKeys(confirmedOnly: true); } + // Candidates arrive in wallet scan order (address, then age), which is + // predictable; shuffle so the receiver's input choice can't mirror it. + utxos.shuffle(Random.secure()); mainToIsolateSendPort?.send({ 'requestId': message['requestId'], 'result': utxos, diff --git a/cw_bitcoin/lib/payjoin/payjoin_receive_worker.dart b/cw_bitcoin/lib/payjoin/payjoin_receive_worker.dart index 5580e1c08..f1980e39e 100644 --- a/cw_bitcoin/lib/payjoin/payjoin_receive_worker.dart +++ b/cw_bitcoin/lib/payjoin/payjoin_receive_worker.dart @@ -175,7 +175,15 @@ class PayjoinReceiverWorker { final unspent = listUnspent as List; if (unspent.isEmpty) throw RecoverableError('No unspent outputs available'); - final selectedUtxo = await _inputPairFromUtxo(unspent[0]); + final candidateInputs = await Future.wait(unspent.map(_inputPairFromUtxo)); + + // Prefer a UTXO that avoids the Unnecessary Input Heuristic (UIH2); + // fall back to the first candidate if none preserves privacy. + InputPair selectedUtxo = candidateInputs.first; + try { + selectedUtxo = await pj5.tryPreservingPrivacy(candidateInputs: candidateInputs); + } catch (_) {} + final pj6 = await pj5.contributeInputs(replacementInputs: [selectedUtxo]); final pj7 = await pj6.commitInputs(); diff --git a/cw_bitcoin/lib/pending_bitcoin_transaction.dart b/cw_bitcoin/lib/pending_bitcoin_transaction.dart index f0bebb76b..50c96311f 100644 --- a/cw_bitcoin/lib/pending_bitcoin_transaction.dart +++ b/cw_bitcoin/lib/pending_bitcoin_transaction.dart @@ -1,7 +1,6 @@ -import 'dart:typed_data'; - import 'package:bbqrdart/bbqrdart.dart'; import 'package:cw_bitcoin/electrum_wallet.dart'; +import 'package:cw_core/amount/money.dart'; import 'package:flutter/foundation.dart'; import 'package:grpc/grpc.dart'; import 'package:cw_bitcoin/exceptions.dart'; @@ -9,7 +8,6 @@ import 'package:bitcoin_base/bitcoin_base.dart'; import 'package:blockchain_utils/blockchain_utils.dart'; import 'package:cw_core/pending_transaction.dart'; import 'package:cw_bitcoin/electrum.dart'; -import 'package:cw_bitcoin/bitcoin_amount_format.dart'; import 'package:cw_bitcoin/electrum_transaction_info.dart'; import 'package:cw_core/transaction_direction.dart'; import 'package:cw_core/wallet_type.dart'; @@ -23,6 +21,7 @@ class PendingBitcoinTransaction with PendingTransaction { PendingBitcoinTransaction( this._tx, this.type, { + required this.accountIndex, required this.electrumClient, required this.amount, required this.fee, @@ -33,6 +32,7 @@ class PendingBitcoinTransaction with PendingTransaction { this.hasTaprootInputs = false, this.isMweb = false, this.utxos = const [], + this.derivedOutputs = const [], this.publicKeys, this.commitOverride, this.unsignedPsbt, @@ -41,9 +41,8 @@ class PendingBitcoinTransaction with PendingTransaction { final WalletType type; final BtcTransaction _tx; + final int? accountIndex; final ElectrumClient electrumClient; - final int amount; - final int fee; final String feeRate; final BasedUtxoNetwork? network; final bool isSendAll; @@ -51,6 +50,9 @@ class PendingBitcoinTransaction with PendingTransaction { final bool hasTaprootInputs; final bool isViewOnly; List utxos; + + final List derivedOutputs; + bool isMweb; String? changeAddressOverride; String? idOverride; @@ -61,6 +63,12 @@ class PendingBitcoinTransaction with PendingTransaction { Uint8List? unsignedPsbt; + @override + final Money amount; + + @override + final Money fee; + @override String get id => idOverride ?? _tx.txId(); @@ -68,28 +76,7 @@ class PendingBitcoinTransaction with PendingTransaction { String get hex => hexOverride ?? _tx.serialize(); @override - String get amountFormatted => bitcoinAmountToString(amount: amount); - - @override - String get feeFormatted => "$feeFormattedValue ${_feeCurrency}"; - - String get _feeCurrency { - switch (type) { - case WalletType.bitcoin: - return "BTC"; - case WalletType.litecoin: - return "LTC"; - case WalletType.bitcoinCash: - return "BCH"; - case WalletType.dogecoin: - return "DOGE"; - default: - return type.name; - } - } - - @override - String get feeFormattedValue => bitcoinAmountToString(amount: fee); + String get amountFormatted => amount.toString(); @override int? get outputCount => _tx.outputs.length; @@ -98,6 +85,13 @@ class PendingBitcoinTransaction with PendingTransaction { bool get hasSilentPayment => _tx.hasSilentPayment; + List get stealthAddresses => derivedOutputs + .where((out) => !out.isChange) + .map((out) => out.isSilentPayment + ? out.address.toAddress(network ?? BitcoinNetwork.mainnet) + : null) + .toList(); + PendingChange? get change { try { final change = _tx.outputs.firstWhere((out) => out.isChange); @@ -180,18 +174,21 @@ class PendingBitcoinTransaction with PendingTransaction { void addListener(void Function(ElectrumTransactionInfo transaction) listener) => _listeners.add(listener); - ElectrumTransactionInfo transactionInfo() => ElectrumTransactionInfo(type, - id: id, - height: 0, - amount: amount, - direction: TransactionDirection.outgoing, - date: DateTime.now(), - isPending: true, - isReplaced: false, - confirmations: 0, - inputAddresses: _tx.inputs.map((input) => input.txId).toList(), - outputAddresses: outputAddresses, - fee: fee); + ElectrumTransactionInfo transactionInfo() => ElectrumTransactionInfo( + type, + id: id, + height: 0, + amount: amount, + direction: TransactionDirection.outgoing, + date: DateTime.now(), + isPending: true, + isReplaced: false, + confirmations: 0, + accountIndex: accountIndex, + inputAddresses: _tx.inputs.map((input) => input.txId).toList(), + outputAddresses: outputAddresses, + fee: fee, + ); @override bool shouldCommitUR() => isViewOnly; diff --git a/cw_bitcoin/lib/psbt/transaction_builder.dart b/cw_bitcoin/lib/psbt/transaction_builder.dart index 7d2ead134..5eea75794 100644 --- a/cw_bitcoin/lib/psbt/transaction_builder.dart +++ b/cw_bitcoin/lib/psbt/transaction_builder.dart @@ -1,33 +1,31 @@ -import 'dart:convert'; -import 'dart:typed_data'; +import "dart:convert"; +import "dart:typed_data"; -import 'package:bitcoin_base/bitcoin_base.dart'; -import 'package:convert/convert.dart'; -import 'package:cw_core/output_info.dart'; -import 'package:cw_core/utils/print_verbose.dart'; -import 'package:ledger_bitcoin/psbt.dart'; +import "package:bitcoin_base/bitcoin_base.dart"; +import "package:convert/convert.dart"; +import "package:cw_core/output_info.dart"; +import "package:cw_core/utils/print_verbose.dart"; +import "package:ledger_bitcoin/psbt.dart"; class PSBTTransactionBuild { - final PsbtV2 psbt = PsbtV2(); - - PSBTTransactionBuild( - {required List inputs, - required List outputs, - required List cwOutputs, - bool enableRBF = true}) { + PSBTTransactionBuild({ + required List inputs, + required List outputs, + bool enableRBF = true, + int locktime = 0, + }) { psbt.setGlobalTxVersion(2); + psbt.setGlobalFallbackLocktime(locktime); psbt.setGlobalInputCount(inputs.length); psbt.setGlobalOutputCount(outputs.length); for (var i = 0; i < inputs.length; i++) { final input = inputs[i]; - printV(input.utxo.isP2tr()); - printV(input.utxo.isSegwit()); - printV(input.utxo.isP2shSegwit()); - psbt.setInputPreviousTxId( - i, Uint8List.fromList(hex.decode(input.utxo.txHash).reversed.toList())); + i, + Uint8List.fromList(hex.decode(input.utxo.txHash).reversed.toList()), + ); psbt.setInputOutputIndex(i, input.utxo.vout); psbt.setInputSequence(i, enableRBF ? 0xfffffffd : 0xffffffff); @@ -45,75 +43,98 @@ class PSBTTransactionBuild { for (var i = 0; i < outputs.length; i++) { final output = outputs[i]; - if (output is BitcoinOutput) { - psbt.setOutputScript(i, Uint8List.fromList(output.address.toScriptPubKey().toBytes())); - psbt.setOutputAmount(i, output.value.toInt()); - if (cwOutputs.isNotEmpty) { - try { - final cwOutput = cwOutputs - .where((e) => [e.address, e.extractedAddress] - .map((e) => e?.toLowerCase()) - .contains(output.address.toAddress().toLowerCase())) - .firstOrNull; - if (cwOutput != null && - cwOutput.extra.containsKey('bip353_name') && - cwOutput.extra.containsKey('bip353_proof')) { - final bip353Name = utf8.encode(cwOutput.extra['bip353_name'] as String); - final bip353Proof = base64.decode(cwOutput.extra['bip353_proof'] as String); + psbt.setOutputScript(i, Uint8List.fromList(output.address.toScriptPubKey().toBytes())); + psbt.setOutputAmount(i, output.value.toInt()); - if (bip353Name.length > 255) { - printV('BIP353 name is too long, skipping'); - continue; - } - final proof = Uint8List.fromList([ - bip353Name.length, - ...bip353Name, - ...bip353Proof, - ]); + if (output.isChange && + output.changeDerivationPath != null && + output.changePublicKey != null && + output.changeMasterFingerprint != null) { + psbt.setOutputBip32Derivation( + i, + Uint8List.fromList(hex.decode(output.changePublicKey!)), + output.changeMasterFingerprint!, + BIPPath.fromString(output.changeDerivationPath!).toPathArray(), + ); + } - psbt.setOutputDNSSECProof(i, proof); + if (output.outputInfo != null) { + try { + final cwOutput = output.outputInfo!; + if (cwOutput.extra.containsKey("bip353_name") && + cwOutput.extra.containsKey("bip353_proof")) { + final bip353Name = utf8.encode(cwOutput.extra["bip353_name"] as String); + final bip353Proof = base64.decode(cwOutput.extra["bip353_proof"] as String); + + if (bip353Name.length > 255) { + printV("BIP353 name is too long, skipping"); + continue; } - } catch (e) { - printV('Error setting DNSSEC proof: $e'); + final proof = Uint8List.fromList([ + bip353Name.length, + ...bip353Name, + ...bip353Proof, + ]); + + psbt.setOutputDNSSECProof(i, proof); } + } catch (e) { + printV("Error setting DNSSEC proof: $e"); } } } } + final PsbtV2 psbt = PsbtV2(); + void setInputP2pkh(int i, PSBTReadyUtxoWithAddress input) { psbt.setInputNonWitnessUtxo(i, Uint8List.fromList(hex.decode(input.rawTx))); - psbt.setInputBip32Derivation(i, Uint8List.fromList(hex.decode(input.ownerPublicKey)), - input.ownerMasterFingerprint, BIPPath.fromString(input.ownerDerivationPath).toPathArray()); + psbt.setInputBip32Derivation( + i, + Uint8List.fromList(hex.decode(input.ownerPublicKey)), + input.ownerMasterFingerprint, + BIPPath.fromString(input.ownerDerivationPath).toPathArray(), + ); } void setInputSegwit(int i, PSBTReadyUtxoWithAddress input) { psbt.setInputNonWitnessUtxo(i, Uint8List.fromList(hex.decode(input.rawTx))); - psbt.setInputBip32Derivation(i, Uint8List.fromList(hex.decode(input.ownerPublicKey)), - input.ownerMasterFingerprint, BIPPath.fromString(input.ownerDerivationPath).toPathArray()); + psbt.setInputBip32Derivation( + i, + Uint8List.fromList(hex.decode(input.ownerPublicKey)), + input.ownerMasterFingerprint, + BIPPath.fromString(input.ownerDerivationPath).toPathArray(), + ); - psbt.setInputWitnessUtxo(i, Uint8List.fromList(bigIntToUint64LE(input.utxo.value)), - Uint8List.fromList(input.ownerDetails.address.toScriptPubKey().toBytes())); + psbt.setInputWitnessUtxo( + i, + Uint8List.fromList(bigIntToUint64LE(input.utxo.value)), + Uint8List.fromList(input.ownerDetails.address.toScriptPubKey().toBytes()), + ); } void setInputP2shSegwit(int i, PSBTReadyUtxoWithAddress input) { psbt.setInputNonWitnessUtxo(i, Uint8List.fromList(hex.decode(input.rawTx))); - psbt.setInputBip32Derivation(i, Uint8List.fromList(hex.decode(input.ownerPublicKey)), - input.ownerMasterFingerprint, BIPPath.fromString(input.ownerDerivationPath).toPathArray()); + psbt.setInputBip32Derivation( + i, + Uint8List.fromList(hex.decode(input.ownerPublicKey)), + input.ownerMasterFingerprint, + BIPPath.fromString(input.ownerDerivationPath).toPathArray(), + ); psbt.setInputRedeemScript( - i, Uint8List.fromList(input.ownerDetails.address.toScriptPubKey().toBytes())); - psbt.setInputWitnessUtxo(i, Uint8List.fromList(bigIntToUint64LE(input.utxo.value)), - Uint8List.fromList(input.ownerDetails.address.toScriptPubKey().toBytes())); + i, + Uint8List.fromList(input.ownerDetails.address.toScriptPubKey().toBytes()), + ); + psbt.setInputWitnessUtxo( + i, + Uint8List.fromList(bigIntToUint64LE(input.utxo.value)), + Uint8List.fromList(input.ownerDetails.address.toScriptPubKey().toBytes()), + ); } } class PSBTReadyUtxoWithAddress extends UtxoWithAddress { - final String rawTx; - final String ownerDerivationPath; - final Uint8List ownerMasterFingerprint; - final String ownerPublicKey; - PSBTReadyUtxoWithAddress({ required super.utxo, required this.rawTx, @@ -122,4 +143,34 @@ class PSBTReadyUtxoWithAddress extends UtxoWithAddress { required this.ownerMasterFingerprint, required this.ownerPublicKey, }); + + final String rawTx; + final String ownerDerivationPath; + final Uint8List ownerMasterFingerprint; + final String ownerPublicKey; +} + +class PSBTReadyBitcoinOutput extends BitcoinOutput { + PSBTReadyBitcoinOutput({ + required super.address, + required super.value, + super.isSilentPayment = false, + super.isChange = false, + this.changeDerivationPath, + this.changeMasterFingerprint, + this.changePublicKey, + this.outputInfo, + }); + + factory PSBTReadyBitcoinOutput.fromOutput(BitcoinOutput output) => PSBTReadyBitcoinOutput( + address: output.address, + value: output.value, + isSilentPayment: output.isSilentPayment, + isChange: output.isChange, + ); + + final String? changeDerivationPath; + final Uint8List? changeMasterFingerprint; + final String? changePublicKey; + final OutputInfo? outputInfo; } diff --git a/cw_bitcoin/lib/psbt/v0_deserialize.dart b/cw_bitcoin/lib/psbt/v0_deserialize.dart index 541eb6928..d458a1fce 100644 --- a/cw_bitcoin/lib/psbt/v0_deserialize.dart +++ b/cw_bitcoin/lib/psbt/v0_deserialize.dart @@ -20,6 +20,7 @@ extension PsbtSigner on PsbtV2 { setGlobalInputCount(tx.inputs.length); setGlobalOutputCount(tx.outputs.length); setGlobalTxVersion(Uint8List.fromList(tx.version).readUint32LE(0)); + setGlobalFallbackLocktime(Uint8List.fromList(tx.locktime).readUint32LE(0)); for (var i = 0; i < getGlobalInputCount(); i++) { inputMaps.insert(i, {}); diff --git a/cw_bitcoin/pubspec.lock b/cw_bitcoin/pubspec.lock deleted file mode 100644 index 7dd12af86..000000000 --- a/cw_bitcoin/pubspec.lock +++ /dev/null @@ -1,1387 +0,0 @@ -# Generated by pub -# See https://dart.dev/tools/pub/glossary#lockfile -packages: - _fe_analyzer_shared: - dependency: transitive - description: - name: _fe_analyzer_shared - sha256: "16e298750b6d0af7ce8a3ba7c18c69c3785d11b15ec83f6dcd0ad2a0009b3cab" - url: "https://pub.dev" - source: hosted - version: "76.0.0" - _macros: - dependency: transitive - description: dart - source: sdk - version: "0.3.3" - analyzer: - dependency: transitive - description: - name: analyzer - sha256: "1f14db053a8c23e260789e9b0980fa27f2680dd640932cae5e1137cce0e46e1e" - url: "https://pub.dev" - source: hosted - version: "6.11.0" - args: - dependency: transitive - description: - name: args - sha256: d0481093c50b1da8910eb0bb301626d4d8eb7284aa739614d2b394ee09e3ea04 - url: "https://pub.dev" - source: hosted - version: "2.7.0" - asn1lib: - dependency: transitive - description: - name: asn1lib - sha256: "9a8f69025044eb466b9b60ef3bc3ac99b4dc6c158ae9c56d25eeccf5bc56d024" - url: "https://pub.dev" - source: hosted - version: "1.6.5" - async: - dependency: transitive - description: - name: async - sha256: "758e6d74e971c3e5aceb4110bfd6698efc7f501675bcfe0c775459a8140750eb" - url: "https://pub.dev" - source: hosted - version: "2.13.0" - bbqrdart: - dependency: "direct main" - description: - path: "." - ref: e867e3d0156d0b29858100f30adc2625b9dae586 - resolved-ref: e867e3d0156d0b29858100f30adc2625b9dae586 - url: "https://github.com/mrcyjanek/bbqrdart" - source: git - version: "1.0.0" - bech32: - dependency: transitive - description: - path: "." - ref: "05755063b593aa6cca0a4820a318e0ce17de6192" - resolved-ref: "05755063b593aa6cca0a4820a318e0ce17de6192" - url: "https://github.com/cake-tech/bech32.git" - source: git - version: "0.2.2" - bip32: - dependency: transitive - description: - name: bip32 - sha256: "54787cd7a111e9d37394aabbf53d1fc5e2e0e0af2cd01c459147a97c0e3f8a97" - url: "https://pub.dev" - source: hosted - version: "2.0.0" - bip39: - dependency: transitive - description: - name: bip39 - sha256: de1ee27ebe7d96b84bb3a04a4132a0a3007dcdd5ad27dd14aa87a29d97c45edc - url: "https://pub.dev" - source: hosted - version: "1.0.6" - bitbox: - dependency: "direct main" - description: - path: "." - ref: Add-Support-For-OP-Return-data - resolved-ref: "57b78afb85bd2c30d3cdb9f7884f3878a62be442" - url: "https://github.com/cake-tech/bitbox-flutter.git" - source: git - version: "1.0.1" - bitbox_flutter: - dependency: "direct main" - description: - path: "../scripts/bitbox_flutter" - relative: true - source: path - version: "0.0.1" - bitcoin_base: - dependency: "direct overridden" - description: - path: "." - ref: cake-update-v11 - resolved-ref: bda0e2904effa799ec835b4578cc3fc5d31caa78 - url: "https://github.com/cake-tech/bitcoin_base" - source: git - version: "4.7.0" - blockchain_utils: - dependency: "direct main" - description: - path: "." - ref: cake-update-v2 - resolved-ref: "59fdf29d72068e0522a96a8953ed7272833a9f57" - url: "https://github.com/cake-tech/blockchain_utils" - source: git - version: "3.3.0" - bluez: - dependency: transitive - description: - name: bluez - sha256: "61a7204381925896a374301498f2f5399e59827c6498ae1e924aaa598751b545" - url: "https://pub.dev" - source: hosted - version: "0.8.3" - boolean_selector: - dependency: transitive - description: - name: boolean_selector - sha256: "8aab1771e1243a5063b8b0ff68042d67334e3feab9e95b9490f9a6ebf73b42ea" - url: "https://pub.dev" - source: hosted - version: "2.1.2" - breez_sdk_spark_flutter: - dependency: "direct main" - description: - path: "." - ref: "v0.11.0" - resolved-ref: "4a6110477c526ddf236c6b5d4ad064802062f5ad" - url: "https://github.com/breez/breez-sdk-spark-flutter" - source: git - version: "0.11.0" - bs58check: - dependency: transitive - description: - name: bs58check - sha256: c4a164d42b25c2f6bc88a8beccb9fc7d01440f3c60ba23663a20a70faf484ea9 - url: "https://pub.dev" - source: hosted - version: "1.0.2" - build: - dependency: transitive - description: - name: build - sha256: cef23f1eda9b57566c81e2133d196f8e3df48f244b317368d65c5943d91148f0 - url: "https://pub.dev" - source: hosted - version: "2.4.2" - build_cli_annotations: - dependency: transitive - description: - name: build_cli_annotations - sha256: b59d2769769efd6c9ff6d4c4cede0be115a566afc591705c2040b707534b1172 - url: "https://pub.dev" - source: hosted - version: "2.1.0" - build_config: - dependency: transitive - description: - name: build_config - sha256: "4ae2de3e1e67ea270081eaee972e1bd8f027d459f249e0f1186730784c2e7e33" - url: "https://pub.dev" - source: hosted - version: "1.1.2" - build_daemon: - dependency: transitive - description: - name: build_daemon - sha256: "8e928697a82be082206edb0b9c99c5a4ad6bc31c9e9b8b2f291ae65cd4a25daa" - url: "https://pub.dev" - source: hosted - version: "4.0.4" - build_resolvers: - dependency: "direct dev" - description: - name: build_resolvers - sha256: b9e4fda21d846e192628e7a4f6deda6888c36b5b69ba02ff291a01fd529140f0 - url: "https://pub.dev" - source: hosted - version: "2.4.4" - build_runner: - dependency: "direct dev" - description: - name: build_runner - sha256: "058fe9dce1de7d69c4b84fada934df3e0153dd000758c4d65964d0166779aa99" - url: "https://pub.dev" - source: hosted - version: "2.4.15" - build_runner_core: - dependency: transitive - description: - name: build_runner_core - sha256: "22e3aa1c80e0ada3722fe5b63fd43d9c8990759d0a2cf489c8c5d7b2bdebc021" - url: "https://pub.dev" - source: hosted - version: "8.0.0" - built_collection: - dependency: transitive - description: - name: built_collection - sha256: "376e3dd27b51ea877c28d525560790aee2e6fbb5f20e2f85d5081027d94e2100" - url: "https://pub.dev" - source: hosted - version: "5.1.1" - built_value: - dependency: transitive - description: - name: built_value - sha256: ba95c961bafcd8686d1cf63be864eb59447e795e124d98d6a27d91fcd13602fb - url: "https://pub.dev" - source: hosted - version: "8.11.1" - cake_backup: - dependency: transitive - description: - path: "." - ref: "3aba867dcab6737f6707782f5db15d71f303db38" - resolved-ref: "3aba867dcab6737f6707782f5db15d71f303db38" - url: "https://github.com/cake-tech/cake_backup.git" - source: git - version: "1.0.0+1" - characters: - dependency: transitive - description: - name: characters - sha256: f71061c654a3380576a52b451dd5532377954cf9dbd272a78fc8479606670803 - url: "https://pub.dev" - source: hosted - version: "1.4.0" - checked_yaml: - dependency: transitive - description: - name: checked_yaml - sha256: feb6bed21949061731a7a75fc5d2aa727cf160b91af9a3e464c5e3a32e28b5ff - url: "https://pub.dev" - source: hosted - version: "2.0.3" - cli_util: - dependency: transitive - description: - name: cli_util - sha256: ff6785f7e9e3c38ac98b2fb035701789de90154024a75b6cb926445e83197d1c - url: "https://pub.dev" - source: hosted - version: "0.4.2" - clock: - dependency: transitive - description: - name: clock - sha256: fddb70d9b5277016c77a80201021d40a2247104d9f4aa7bab7157b7e3f05b84b - url: "https://pub.dev" - source: hosted - version: "1.1.2" - code_builder: - dependency: transitive - description: - name: code_builder - sha256: "0ec10bf4a89e4c613960bf1e8b42c64127021740fb21640c29c909826a5eea3e" - url: "https://pub.dev" - source: hosted - version: "4.10.1" - collection: - dependency: transitive - description: - name: collection - sha256: "2f5709ae4d3d59dd8f7cd309b4e023046b57d8a6c82130785d2b0e5868084e76" - url: "https://pub.dev" - source: hosted - version: "1.19.1" - convert: - dependency: transitive - description: - name: convert - sha256: b30acd5944035672bc15c6b7a8b47d773e41e2f17de064350988c5d02adb1c68 - url: "https://pub.dev" - source: hosted - version: "3.1.2" - crypto: - dependency: transitive - description: - name: crypto - sha256: "1e445881f28f22d6140f181e07737b22f1e099a5e1ff94b0af2f9e4a463f4855" - url: "https://pub.dev" - source: hosted - version: "3.0.6" - cryptography: - dependency: "direct main" - description: - name: cryptography - sha256: d146b76d33d94548cf035233fbc2f4338c1242fa119013bead807d033fc4ae05 - url: "https://pub.dev" - source: hosted - version: "2.7.0" - cupertino_icons: - dependency: transitive - description: - name: cupertino_icons - sha256: ba631d1c7f7bef6b729a622b7b752645a2d076dba9976925b8f25725a30e1ee6 - url: "https://pub.dev" - source: hosted - version: "1.0.8" - cw_core: - dependency: "direct main" - description: - path: "../cw_core" - relative: true - source: path - version: "0.0.1" - cw_mweb: - dependency: "direct main" - description: - path: "../cw_mweb" - relative: true - source: path - version: "0.0.1" - dart_style: - dependency: transitive - description: - name: dart_style - sha256: "7306ab8a2359a48d22310ad823521d723acfed60ee1f7e37388e8986853b6820" - url: "https://pub.dev" - source: hosted - version: "2.3.8" - dart_varuint_bitcoin: - dependency: transitive - description: - name: dart_varuint_bitcoin - sha256: "4f0ccc9733fb54148b9d3688eea822b7aaabf5cc00025998f8c09a1d45b31b4b" - url: "https://pub.dev" - source: hosted - version: "1.0.3" - dbus: - dependency: transitive - description: - name: dbus - sha256: "79e0c23480ff85dc68de79e2cd6334add97e48f7f4865d17686dd6ea81a47e8c" - url: "https://pub.dev" - source: hosted - version: "0.7.11" - decimal: - dependency: transitive - description: - name: decimal - sha256: "24a261d5d5c87e86c7651c417a5dbdf8bcd7080dd592533910e8d0505a279f21" - url: "https://pub.dev" - source: hosted - version: "2.3.3" - encrypt: - dependency: transitive - description: - name: encrypt - sha256: "62d9aa4670cc2a8798bab89b39fc71b6dfbacf615de6cf5001fb39f7e4a996a2" - url: "https://pub.dev" - source: hosted - version: "5.0.3" - fake_async: - dependency: transitive - description: - name: fake_async - sha256: "5368f224a74523e8d2e7399ea1638b37aecfca824a3cc4dfdf77bf1fa905ac44" - url: "https://pub.dev" - source: hosted - version: "1.3.3" - ffi: - dependency: "direct overridden" - description: - name: ffi - sha256: "7bf0adc28a23d395f19f3f1eb21dd7cfd1dd9f8e1c50051c069122e6853bc878" - url: "https://pub.dev" - source: hosted - version: "2.1.0" - ffigen: - dependency: transitive - description: - name: ffigen - sha256: "3e12e80ccb6539bb3917217bb6f32709220efb737de0d0fa8736da0b7cb507da" - url: "https://pub.dev" - source: hosted - version: "12.0.0" - file: - dependency: transitive - description: - name: file - sha256: a3b4f84adafef897088c160faf7dfffb7696046cb13ae90b508c2cbc95d3b8d4 - url: "https://pub.dev" - source: hosted - version: "7.0.1" - fixnum: - dependency: transitive - description: - name: fixnum - sha256: b6dc7065e46c974bc7c5f143080a6764ec7a4be6da1285ececdc37be96de53be - url: "https://pub.dev" - source: hosted - version: "1.1.1" - flutter: - dependency: "direct main" - description: flutter - source: sdk - version: "0.0.0" - flutter_mobx: - dependency: "direct main" - description: - name: flutter_mobx - sha256: ba5e93467866a2991259dc51cffd41ef45f695c667c2b8e7b087bf24118b50fe - url: "https://pub.dev" - source: hosted - version: "2.3.0" - flutter_rust_bridge: - dependency: transitive - description: - name: flutter_rust_bridge - sha256: "37ef40bc6f863652e865f0b2563ea07f0d3c58d8efad803cc01933a4b2ee067e" - url: "https://pub.dev" - source: hosted - version: "2.11.1" - flutter_test: - dependency: "direct dev" - description: flutter - source: sdk - version: "0.0.0" - flutter_web_bluetooth: - dependency: transitive - description: - name: flutter_web_bluetooth - sha256: ad26a1b3fef95b86ea5f63793b9a0cdc1a33490f35d754e4e711046cae3ebbf8 - url: "https://pub.dev" - source: hosted - version: "1.1.0" - flutter_web_plugins: - dependency: transitive - description: flutter - source: sdk - version: "0.0.0" - freezed_annotation: - dependency: transitive - description: - name: freezed_annotation - sha256: "7294967ff0a6d98638e7acb774aac3af2550777accd8149c90af5b014e6d44d8" - url: "https://pub.dev" - source: hosted - version: "3.1.0" - frontend_server_client: - dependency: transitive - description: - name: frontend_server_client - sha256: f64a0333a82f30b0cca061bc3d143813a486dc086b574bfb233b7c1372427694 - url: "https://pub.dev" - source: hosted - version: "4.0.0" - glob: - dependency: transitive - description: - name: glob - sha256: c3f1ee72c96f8f78935e18aa8cecced9ab132419e8625dc187e1c2408efc20de - url: "https://pub.dev" - source: hosted - version: "2.1.3" - google_identity_services_web: - dependency: transitive - description: - name: google_identity_services_web - sha256: "5d187c46dc59e02646e10fe82665fc3884a9b71bc1c90c2b8b749316d33ee454" - url: "https://pub.dev" - source: hosted - version: "0.3.3+1" - googleapis_auth: - dependency: transitive - description: - name: googleapis_auth - sha256: befd71383a955535060acde8792e7efc11d2fccd03dd1d3ec434e85b68775938 - url: "https://pub.dev" - source: hosted - version: "1.6.0" - graphs: - dependency: transitive - description: - name: graphs - sha256: "741bbf84165310a68ff28fe9e727332eef1407342fca52759cb21ad8177bb8d0" - url: "https://pub.dev" - source: hosted - version: "2.3.2" - grpc: - dependency: "direct main" - description: - name: grpc - sha256: "5b99b7a420937d4361ece68b798c9af8e04b5bc128a7859f2a4be87427694813" - url: "https://pub.dev" - source: hosted - version: "4.0.1" - hex: - dependency: transitive - description: - name: hex - sha256: "4e7cd54e4b59ba026432a6be2dd9d96e4c5205725194997193bf871703b82c4a" - url: "https://pub.dev" - source: hosted - version: "0.2.0" - hive: - dependency: transitive - description: - name: hive - sha256: "8dcf6db979d7933da8217edcec84e9df1bdb4e4edc7fc77dbd5aa74356d6d941" - url: "https://pub.dev" - source: hosted - version: "2.2.3" - hive_generator: - dependency: "direct dev" - description: - name: hive_generator - sha256: "06cb8f58ace74de61f63500564931f9505368f45f98958bd7a6c35ba24159db4" - url: "https://pub.dev" - source: hosted - version: "2.0.1" - http: - dependency: "direct main" - description: - name: http - sha256: bb2ce4590bc2667c96f318d68cac1b5a7987ec819351d32b1c987239a815e007 - url: "https://pub.dev" - source: hosted - version: "1.5.0" - http2: - dependency: transitive - description: - name: http2 - sha256: "382d3aefc5bd6dc68c6b892d7664f29b5beb3251611ae946a98d35158a82bbfa" - url: "https://pub.dev" - source: hosted - version: "2.3.1" - http_multi_server: - dependency: transitive - description: - name: http_multi_server - sha256: aa6199f908078bb1c5efb8d8638d4ae191aac11b311132c3ef48ce352fb52ef8 - url: "https://pub.dev" - source: hosted - version: "3.2.2" - http_parser: - dependency: transitive - description: - name: http_parser - sha256: "178d74305e7866013777bab2c3d8726205dc5a4dd935297175b19a23a2e66571" - url: "https://pub.dev" - source: hosted - version: "4.1.2" - intl: - dependency: "direct main" - description: - name: intl - sha256: d6f56758b7d3014a48af9701c085700aac781a92a87a62b1333b46d8879661cf - url: "https://pub.dev" - source: hosted - version: "0.19.0" - io: - dependency: transitive - description: - name: io - sha256: dfd5a80599cf0165756e3181807ed3e77daf6dd4137caaad72d0b7931597650b - url: "https://pub.dev" - source: hosted - version: "1.0.5" - js: - dependency: transitive - description: - name: js - sha256: f2c445dce49627136094980615a031419f7f3eb393237e4ecd97ac15dea343f3 - url: "https://pub.dev" - source: hosted - version: "0.6.7" - json_annotation: - dependency: transitive - description: - name: json_annotation - sha256: "1ce844379ca14835a50d2f019a3099f419082cfdd231cd86a142af94dd5c6bb1" - url: "https://pub.dev" - source: hosted - version: "4.9.0" - leak_tracker: - dependency: transitive - description: - name: leak_tracker - sha256: "6bb818ecbdffe216e81182c2f0714a2e62b593f4a4f13098713ff1685dfb6ab0" - url: "https://pub.dev" - source: hosted - version: "10.0.9" - leak_tracker_flutter_testing: - dependency: transitive - description: - name: leak_tracker_flutter_testing - sha256: f8b613e7e6a13ec79cfdc0e97638fddb3ab848452eff057653abd3edba760573 - url: "https://pub.dev" - source: hosted - version: "3.0.9" - leak_tracker_testing: - dependency: transitive - description: - name: leak_tracker_testing - sha256: "6ba465d5d76e67ddf503e1161d1f4a6bc42306f9d66ca1e8f079a47290fb06d3" - url: "https://pub.dev" - source: hosted - version: "3.0.1" - ledger_bitcoin: - dependency: "direct main" - description: - path: "packages/ledger-bitcoin" - ref: "75bba8632c529debf30ee2b1858530009fa6440b" - resolved-ref: "75bba8632c529debf30ee2b1858530009fa6440b" - url: "https://github.com/cake-tech/ledger-flutter-plus-plugins" - source: git - version: "0.0.3" - ledger_flutter_plus: - dependency: "direct main" - description: - name: ledger_flutter_plus - sha256: "531da5daba5731d9eca2732881ef2f039b97bf8aa3564e7098dfa99a9b07a8e6" - url: "https://pub.dev" - source: hosted - version: "1.5.3" - ledger_litecoin: - dependency: "direct main" - description: - path: "packages/ledger-litecoin" - ref: HEAD - resolved-ref: e8ab1a98493dfdd2277edde07a66b8d378ca70f6 - url: "https://github.com/cake-tech/ledger-flutter-plus-plugins" - source: git - version: "0.0.2" - ledger_usb_plus: - dependency: transitive - description: - name: ledger_usb_plus - sha256: "21cc5d976cf7edb3518bd2a0c4164139cbb0817d2e4f2054707fc4edfdf9ce87" - url: "https://pub.dev" - source: hosted - version: "1.0.4" - logging: - dependency: transitive - description: - name: logging - sha256: c8245ada5f1717ed44271ed1c26b8ce85ca3228fd2ffdb75468ab01979309d61 - url: "https://pub.dev" - source: hosted - version: "1.3.0" - macros: - dependency: transitive - description: - name: macros - sha256: "1d9e801cd66f7ea3663c45fc708450db1fa57f988142c64289142c9b7ee80656" - url: "https://pub.dev" - source: hosted - version: "0.1.3-main.0" - matcher: - dependency: transitive - description: - name: matcher - sha256: dc58c723c3c24bf8d3e2d3ad3f2f9d7bd9cf43ec6feaa64181775e60190153f2 - url: "https://pub.dev" - source: hosted - version: "0.12.17" - material_color_utilities: - dependency: transitive - description: - name: material_color_utilities - sha256: f7142bb1154231d7ea5f96bc7bde4bda2a0945d2806bb11670e30b850d56bdec - url: "https://pub.dev" - source: hosted - version: "0.11.1" - meta: - dependency: transitive - description: - name: meta - sha256: e3641ec5d63ebf0d9b41bd43201a66e3fc79a65db5f61fc181f04cd27aab950c - url: "https://pub.dev" - source: hosted - version: "1.16.0" - mime: - dependency: transitive - description: - name: mime - sha256: "41a20518f0cb1256669420fdba0cd90d21561e560ac240f26ef8322e45bb7ed6" - url: "https://pub.dev" - source: hosted - version: "2.0.0" - mobx: - dependency: "direct main" - description: - name: mobx - sha256: bf1a90e5bcfd2851fc6984e20eef69557c65d9e4d0a88f5be4cf72c9819ce6b0 - url: "https://pub.dev" - source: hosted - version: "2.5.0" - mobx_codegen: - dependency: "direct dev" - description: - name: mobx_codegen - sha256: e0abbbc651a69550440f6b65c99ec222a1e2a4afd7baec8ba0f3088c7ca582a8 - url: "https://pub.dev" - source: hosted - version: "2.7.1" - mockito: - dependency: transitive - description: - name: mockito - sha256: f99d8d072e249f719a5531735d146d8cf04c580d93920b04de75bef6dfb2daf6 - url: "https://pub.dev" - source: hosted - version: "5.4.5" - nested: - dependency: transitive - description: - name: nested - sha256: "03bac4c528c64c95c722ec99280375a6f2fc708eec17c7b3f07253b626cd2a20" - url: "https://pub.dev" - source: hosted - version: "1.0.0" - on_chain: - dependency: transitive - description: - path: "." - ref: "096865a8c6b89c260beadfec04f7e184c40a3273" - resolved-ref: "096865a8c6b89c260beadfec04f7e184c40a3273" - url: "https://github.com/cake-tech/on_chain.git" - source: git - version: "3.7.0" - package_config: - dependency: transitive - description: - name: package_config - sha256: f096c55ebb7deb7e384101542bfba8c52696c1b56fca2eb62827989ef2353bbc - url: "https://pub.dev" - source: hosted - version: "2.2.0" - path: - dependency: transitive - description: - name: path - sha256: "75cca69d1490965be98c73ceaea117e8a04dd21217b37b292c9ddbec0d955bc5" - url: "https://pub.dev" - source: hosted - version: "1.9.1" - path_provider: - dependency: "direct main" - description: - name: path_provider - sha256: "50c5dd5b6e1aaf6fb3a78b33f6aa3afca52bf903a8a5298f53101fdaee55bbcd" - url: "https://pub.dev" - source: hosted - version: "2.1.5" - path_provider_android: - dependency: transitive - description: - name: path_provider_android - sha256: d0d310befe2c8ab9e7f393288ccbb11b60c019c6b5afc21973eeee4dda2b35e9 - url: "https://pub.dev" - source: hosted - version: "2.2.17" - path_provider_foundation: - dependency: transitive - description: - name: path_provider_foundation - sha256: "4843174df4d288f5e29185bd6e72a6fbdf5a4a4602717eed565497429f179942" - url: "https://pub.dev" - source: hosted - version: "2.4.1" - path_provider_linux: - dependency: transitive - description: - name: path_provider_linux - sha256: f7a1fe3a634fe7734c8d3f2766ad746ae2a2884abe22e241a8b301bf5cac3279 - url: "https://pub.dev" - source: hosted - version: "2.2.1" - path_provider_platform_interface: - dependency: transitive - description: - name: path_provider_platform_interface - sha256: "88f5779f72ba699763fa3a3b06aa4bf6de76c8e5de842cf6f29e2e06476c2334" - url: "https://pub.dev" - source: hosted - version: "2.1.2" - path_provider_windows: - dependency: transitive - description: - name: path_provider_windows - sha256: bd6f00dbd873bfb70d0761682da2b3a2c2fccc2b9e84c495821639601d81afe7 - url: "https://pub.dev" - source: hosted - version: "2.3.0" - payjoin_flutter: - dependency: "direct main" - description: - path: "." - ref: b28c825b1d63381e8c2b25ef2ccabacbf833ce53 - resolved-ref: b28c825b1d63381e8c2b25ef2ccabacbf833ce53 - url: "https://github.com/konstantinullrich/payjoin-flutter" - source: git - version: "0.23.0" - petitparser: - dependency: transitive - description: - name: petitparser - sha256: c15605cd28af66339f8eb6fbe0e541bfe2d1b72d5825efc6598f3e0a31b9ad27 - url: "https://pub.dev" - source: hosted - version: "6.0.2" - platform: - dependency: transitive - description: - name: platform - sha256: "5d6b1b0036a5f331ebc77c850ebc8506cbc1e9416c27e59b439f917a902a4984" - url: "https://pub.dev" - source: hosted - version: "3.1.6" - plugin_platform_interface: - dependency: transitive - description: - name: plugin_platform_interface - sha256: "4820fbfdb9478b1ebae27888254d445073732dae3d6ea81f0b7e06d5dedc3f02" - url: "https://pub.dev" - source: hosted - version: "2.1.8" - pointycastle: - dependency: "direct overridden" - description: - name: pointycastle - sha256: "43ac87de6e10afabc85c445745a7b799e04de84cebaa4fd7bf55a5e1e9604d29" - url: "https://pub.dev" - source: hosted - version: "3.7.4" - pool: - dependency: transitive - description: - name: pool - sha256: "20fe868b6314b322ea036ba325e6fc0711a22948856475e2c2b6306e8ab39c2a" - url: "https://pub.dev" - source: hosted - version: "1.5.1" - protobuf: - dependency: "direct overridden" - description: - name: protobuf - sha256: "68645b24e0716782e58948f8467fd42a880f255096a821f9e7d0ec625b00c84d" - url: "https://pub.dev" - source: hosted - version: "3.1.0" - provider: - dependency: transitive - description: - name: provider - sha256: "4abbd070a04e9ddc287673bf5a030c7ca8b685ff70218720abab8b092f53dd84" - url: "https://pub.dev" - source: hosted - version: "6.1.5" - pub_semver: - dependency: transitive - description: - name: pub_semver - sha256: "5bfcf68ca79ef689f8990d1160781b4bad40a3bd5e5218ad4076ddb7f4081585" - url: "https://pub.dev" - source: hosted - version: "2.2.0" - pubspec_parse: - dependency: transitive - description: - name: pubspec_parse - sha256: "0560ba233314abbed0a48a2956f7f022cce7c3e1e73df540277da7544cad4082" - url: "https://pub.dev" - source: hosted - version: "1.5.0" - quiver: - dependency: transitive - description: - name: quiver - sha256: ea0b925899e64ecdfbf9c7becb60d5b50e706ade44a85b2363be2a22d88117d2 - url: "https://pub.dev" - source: hosted - version: "3.2.2" - rational: - dependency: transitive - description: - name: rational - sha256: cb808fb6f1a839e6fc5f7d8cb3b0a10e1db48b3be102de73938c627f0b636336 - url: "https://pub.dev" - source: hosted - version: "2.2.3" - rxdart: - dependency: "direct main" - description: - name: rxdart - sha256: "5c3004a4a8dbb94bd4bf5412a4def4acdaa12e12f269737a5751369e12d1a962" - url: "https://pub.dev" - source: hosted - version: "0.28.0" - shared_preferences: - dependency: "direct main" - description: - name: shared_preferences - sha256: "6e8bf70b7fef813df4e9a36f658ac46d107db4b4cfe1048b477d4e453a8159f5" - url: "https://pub.dev" - source: hosted - version: "2.5.3" - shared_preferences_android: - dependency: transitive - description: - name: shared_preferences_android - sha256: "5bcf0772a761b04f8c6bf814721713de6f3e5d9d89caf8d3fe031b02a342379e" - url: "https://pub.dev" - source: hosted - version: "2.4.11" - shared_preferences_foundation: - dependency: transitive - description: - name: shared_preferences_foundation - sha256: "6a52cfcdaeac77cad8c97b539ff688ccfc458c007b4db12be584fbe5c0e49e03" - url: "https://pub.dev" - source: hosted - version: "2.5.4" - shared_preferences_linux: - dependency: transitive - description: - name: shared_preferences_linux - sha256: "580abfd40f415611503cae30adf626e6656dfb2f0cee8f465ece7b6defb40f2f" - url: "https://pub.dev" - source: hosted - version: "2.4.1" - shared_preferences_platform_interface: - dependency: transitive - description: - name: shared_preferences_platform_interface - sha256: "57cbf196c486bc2cf1f02b85784932c6094376284b3ad5779d1b1c6c6a816b80" - url: "https://pub.dev" - source: hosted - version: "2.4.1" - shared_preferences_web: - dependency: transitive - description: - name: shared_preferences_web - sha256: c49bd060261c9a3f0ff445892695d6212ff603ef3115edbb448509d407600019 - url: "https://pub.dev" - source: hosted - version: "2.4.3" - shared_preferences_windows: - dependency: transitive - description: - name: shared_preferences_windows - sha256: "94ef0f72b2d71bc3e700e025db3710911bd51a71cefb65cc609dd0d9a982e3c1" - url: "https://pub.dev" - source: hosted - version: "2.4.1" - shelf: - dependency: transitive - description: - name: shelf - sha256: e7dd780a7ffb623c57850b33f43309312fc863fb6aa3d276a754bb299839ef12 - url: "https://pub.dev" - source: hosted - version: "1.4.2" - shelf_web_socket: - dependency: transitive - description: - name: shelf_web_socket - sha256: "3632775c8e90d6c9712f883e633716432a27758216dfb61bd86a8321c0580925" - url: "https://pub.dev" - source: hosted - version: "3.0.0" - sky_engine: - dependency: transitive - description: flutter - source: sdk - version: "0.0.0" - socks5_proxy: - dependency: transitive - description: - path: "." - ref: "27ad7c2efae8d7460325c74b90f660085cbd0685" - resolved-ref: "27ad7c2efae8d7460325c74b90f660085cbd0685" - url: "https://github.com/LacticWhale/socks_dart" - source: git - version: "2.1.0" - socks_socket: - dependency: "direct main" - description: - path: "." - ref: e6232c53c1595469931ababa878759a067c02e94 - resolved-ref: e6232c53c1595469931ababa878759a067c02e94 - url: "https://github.com/sneurlax/socks_socket" - source: git - version: "1.1.1" - source_gen: - dependency: transitive - description: - name: source_gen - sha256: "14658ba5f669685cd3d63701d01b31ea748310f7ab854e471962670abcf57832" - url: "https://pub.dev" - source: hosted - version: "1.5.0" - source_helper: - dependency: transitive - description: - name: source_helper - sha256: "86d247119aedce8e63f4751bd9626fc9613255935558447569ad42f9f5b48b3c" - url: "https://pub.dev" - source: hosted - version: "1.3.5" - source_span: - dependency: transitive - description: - name: source_span - sha256: "254ee5351d6cb365c859e20ee823c3bb479bf4a293c22d17a9f1bf144ce86f7c" - url: "https://pub.dev" - source: hosted - version: "1.10.1" - sp_scanner: - dependency: "direct main" - description: - path: "." - ref: "sp_v4.0.1" - resolved-ref: "40ed3ce8319744907bef3b25bc6e0af8428c8724" - url: "https://github.com/cake-tech/sp_scanner" - source: git - version: "0.0.1" - sprintf: - dependency: transitive - description: - name: sprintf - sha256: "1fc9ffe69d4df602376b52949af107d8f5703b77cda567c4d7d86a0693120f23" - url: "https://pub.dev" - source: hosted - version: "7.0.0" - sqflite: - dependency: transitive - description: - name: sqflite - sha256: "2d7299468485dca85efeeadf5d38986909c5eb0cd71fd3db2c2f000e6c9454bb" - url: "https://pub.dev" - source: hosted - version: "2.4.1" - sqflite_android: - dependency: transitive - description: - name: sqflite_android - sha256: "78f489aab276260cdd26676d2169446c7ecd3484bbd5fead4ca14f3ed4dd9ee3" - url: "https://pub.dev" - source: hosted - version: "2.4.0" - sqflite_common: - dependency: transitive - description: - name: sqflite_common - sha256: "761b9740ecbd4d3e66b8916d784e581861fd3c3553eda85e167bc49fdb68f709" - url: "https://pub.dev" - source: hosted - version: "2.5.4+6" - sqflite_common_ffi: - dependency: transitive - description: - name: sqflite_common_ffi - sha256: "883dd810b2b49e6e8c3b980df1829ef550a94e3f87deab5d864917d27ca6bf36" - url: "https://pub.dev" - source: hosted - version: "2.3.4+4" - sqflite_darwin: - dependency: transitive - description: - name: sqflite_darwin - sha256: "22adfd9a2c7d634041e96d6241e6e1c8138ca6817018afc5d443fef91dcefa9c" - url: "https://pub.dev" - source: hosted - version: "2.4.1+1" - sqflite_platform_interface: - dependency: transitive - description: - name: sqflite_platform_interface - sha256: "8dd4515c7bdcae0a785b0062859336de775e8c65db81ae33dd5445f35be61920" - url: "https://pub.dev" - source: hosted - version: "2.4.0" - sqlite3: - dependency: transitive - description: - name: sqlite3 - sha256: f393d92c71bdcc118d6203d07c991b9be0f84b1a6f89dd4f7eed348131329924 - url: "https://pub.dev" - source: hosted - version: "2.9.0" - sqlite3_flutter_libs: - dependency: transitive - description: - name: sqlite3_flutter_libs - sha256: "69c80d812ef2500202ebd22002cbfc1b6565e9ff56b2f971e757fac5d42294df" - url: "https://pub.dev" - source: hosted - version: "0.5.40" - stack_trace: - dependency: transitive - description: - name: stack_trace - sha256: "8b27215b45d22309b5cddda1aa2b19bdfec9df0e765f2de506401c071d38d1b1" - url: "https://pub.dev" - source: hosted - version: "1.12.1" - stream_channel: - dependency: transitive - description: - name: stream_channel - sha256: "969e04c80b8bcdf826f8f16579c7b14d780458bd97f56d107d3950fdbeef059d" - url: "https://pub.dev" - source: hosted - version: "2.1.4" - stream_transform: - dependency: transitive - description: - name: stream_transform - sha256: ad47125e588cfd37a9a7f86c7d6356dde8dfe89d071d293f80ca9e9273a33871 - url: "https://pub.dev" - source: hosted - version: "2.1.1" - string_scanner: - dependency: transitive - description: - name: string_scanner - sha256: "921cd31725b72fe181906c6a94d987c78e3b98c2e205b397ea399d4054872b43" - url: "https://pub.dev" - source: hosted - version: "1.4.1" - synchronized: - dependency: transitive - description: - name: synchronized - sha256: "69fe30f3a8b04a0be0c15ae6490fc859a78ef4c43ae2dd5e8a623d45bfcf9225" - url: "https://pub.dev" - source: hosted - version: "3.3.0+3" - term_glyph: - dependency: transitive - description: - name: term_glyph - sha256: "7f554798625ea768a7518313e58f83891c7f5024f88e46e7182a4558850a4b8e" - url: "https://pub.dev" - source: hosted - version: "1.2.2" - test_api: - dependency: transitive - description: - name: test_api - sha256: fb31f383e2ee25fbbfe06b40fe21e1e458d14080e3c67e7ba0acfde4df4e0bbd - url: "https://pub.dev" - source: hosted - version: "0.7.4" - timing: - dependency: transitive - description: - name: timing - sha256: "62ee18aca144e4a9f29d212f5a4c6a053be252b895ab14b5821996cff4ed90fe" - url: "https://pub.dev" - source: hosted - version: "1.0.2" - torch_dart: - dependency: transitive - description: - path: "../scripts/torch_dart" - relative: true - source: path - version: "0.0.1" - trezor_connect: - dependency: "direct main" - description: - path: "." - ref: d1242cea90f84b00200e7bcab914a5af750e23fb - resolved-ref: d1242cea90f84b00200e7bcab914a5af750e23fb - url: "https://github.com/cake-tech/trezor_connect" - source: git - version: "0.0.1" - tuple: - dependency: transitive - description: - name: tuple - sha256: a97ce2013f240b2f3807bcbaf218765b6f301c3eff91092bcfa23a039e7dd151 - url: "https://pub.dev" - source: hosted - version: "2.0.2" - typed_data: - dependency: transitive - description: - name: typed_data - sha256: f9049c039ebfeb4cf7a7104a675823cd72dba8297f264b6637062516699fa006 - url: "https://pub.dev" - source: hosted - version: "1.4.0" - universal_ble: - dependency: transitive - description: - name: universal_ble - sha256: "35d210e93a5938c6a6d1fd3c710cf4ac90b1bdd1b11c8eb2beeb32600672e6e6" - url: "https://pub.dev" - source: hosted - version: "0.17.0" - universal_platform: - dependency: transitive - description: - name: universal_platform - sha256: "64e16458a0ea9b99260ceb5467a214c1f298d647c659af1bff6d3bf82536b1ec" - url: "https://pub.dev" - source: hosted - version: "1.1.0" - unorm_dart: - dependency: transitive - description: - name: unorm_dart - sha256: "8e3870a1caa60bde8352f9597dd3535d8068613269444f8e35ea8925ec84c1f5" - url: "https://pub.dev" - source: hosted - version: "0.3.1+1" - ur: - dependency: "direct main" - description: - path: "." - ref: "5738f70d0ec3d50977ac3dd01fed62939600238b" - resolved-ref: "5738f70d0ec3d50977ac3dd01fed62939600238b" - url: "https://github.com/bukata-sa/bc-ur-dart" - source: git - version: "0.1.0" - url_launcher: - dependency: transitive - description: - name: url_launcher - sha256: f6a7e5c4835bb4e3026a04793a4199ca2d14c739ec378fdfe23fc8075d0439f8 - url: "https://pub.dev" - source: hosted - version: "6.3.2" - url_launcher_android: - dependency: transitive - description: - name: url_launcher_android - sha256: "81777b08c498a292d93ff2feead633174c386291e35612f8da438d6e92c4447e" - url: "https://pub.dev" - source: hosted - version: "6.3.20" - url_launcher_ios: - dependency: transitive - description: - name: url_launcher_ios - sha256: d80b3f567a617cb923546034cc94bfe44eb15f989fe670b37f26abdb9d939cb7 - url: "https://pub.dev" - source: hosted - version: "6.3.4" - url_launcher_linux: - dependency: transitive - description: - name: url_launcher_linux - sha256: "4e9ba368772369e3e08f231d2301b4ef72b9ff87c31192ef471b380ef29a4935" - url: "https://pub.dev" - source: hosted - version: "3.2.1" - url_launcher_macos: - dependency: transitive - description: - name: url_launcher_macos - sha256: c043a77d6600ac9c38300567f33ef12b0ef4f4783a2c1f00231d2b1941fea13f - url: "https://pub.dev" - source: hosted - version: "3.2.3" - url_launcher_platform_interface: - dependency: transitive - description: - name: url_launcher_platform_interface - sha256: "552f8a1e663569be95a8190206a38187b531910283c3e982193e4f2733f01029" - url: "https://pub.dev" - source: hosted - version: "2.3.2" - url_launcher_web: - dependency: transitive - description: - name: url_launcher_web - sha256: "4bd2b7b4dc4d4d0b94e5babfffbca8eac1a126c7f3d6ecbc1a11013faa3abba2" - url: "https://pub.dev" - source: hosted - version: "2.4.1" - url_launcher_windows: - dependency: transitive - description: - name: url_launcher_windows - sha256: "3284b6d2ac454cf34f114e1d3319866fdd1e19cdc329999057e44ffe936cfa77" - url: "https://pub.dev" - source: hosted - version: "3.1.4" - uuid: - dependency: transitive - description: - name: uuid - sha256: a5be9ef6618a7ac1e964353ef476418026db906c4facdedaa299b7a2e71690ff - url: "https://pub.dev" - source: hosted - version: "4.5.1" - vector_math: - dependency: transitive - description: - name: vector_math - sha256: "80b3257d1492ce4d091729e3a67a60407d227c27241d6927be0130c98e741803" - url: "https://pub.dev" - source: hosted - version: "2.1.4" - vm_service: - dependency: transitive - description: - name: vm_service - sha256: ddfa8d30d89985b96407efce8acbdd124701f96741f2d981ca860662f1c0dc02 - url: "https://pub.dev" - source: hosted - version: "15.0.0" - watcher: - dependency: "direct overridden" - description: - name: watcher - sha256: "0b7fd4a0bbc4b92641dbf20adfd7e3fd1398fe17102d94b674234563e110088a" - url: "https://pub.dev" - source: hosted - version: "1.1.2" - web: - dependency: transitive - description: - name: web - sha256: "868d88a33d8a87b18ffc05f9f030ba328ffefba92d6c127917a2ba740f9cfe4a" - url: "https://pub.dev" - source: hosted - version: "1.1.1" - web_socket: - dependency: transitive - description: - name: web_socket - sha256: "34d64019aa8e36bf9842ac014bb5d2f5586ca73df5e4d9bf5c936975cae6982c" - url: "https://pub.dev" - source: hosted - version: "1.0.1" - web_socket_channel: - dependency: transitive - description: - name: web_socket_channel - sha256: d645757fb0f4773d602444000a8131ff5d48c9e47adfe9772652dd1a4f2d45c8 - url: "https://pub.dev" - source: hosted - version: "3.0.3" - xdg_directories: - dependency: transitive - description: - name: xdg_directories - sha256: "7a3f37b05d989967cdddcbb571f1ea834867ae2faa29725fd085180e0883aa15" - url: "https://pub.dev" - source: hosted - version: "1.1.0" - xml: - dependency: transitive - description: - name: xml - sha256: b015a8ad1c488f66851d762d3090a21c600e479dc75e68328c52774040cf9226 - url: "https://pub.dev" - source: hosted - version: "6.5.0" - yaml: - dependency: transitive - description: - name: yaml - sha256: b9da305ac7c39faa3f030eccd175340f968459dae4af175130b3fc47e40d76ce - url: "https://pub.dev" - source: hosted - version: "3.1.3" - yaml_edit: - dependency: transitive - description: - name: yaml_edit - sha256: fb38626579fb345ad00e674e2af3a5c9b0cc4b9bfb8fd7f7ff322c7c9e62aef5 - url: "https://pub.dev" - source: hosted - version: "2.2.2" -sdks: - dart: ">=3.8.0 <4.0.0" - flutter: ">=3.29.0" diff --git a/cw_bitcoin/pubspec.yaml b/cw_bitcoin/pubspec.yaml index 78bc2d9ca..3b3a358bf 100644 --- a/cw_bitcoin/pubspec.yaml +++ b/cw_bitcoin/pubspec.yaml @@ -6,7 +6,7 @@ author: Hash Bags homepage: https://github.com/Such-Software/hash-wallet environment: - sdk: ">=2.17.5 <3.0.0" + sdk: ">=3.0.0 <4.0.0" flutter: ">=1.20.0" dependencies: @@ -14,8 +14,8 @@ dependencies: sdk: flutter path_provider: ^2.0.11 http: ^1.1.0 - mobx: ^2.0.7+4 - flutter_mobx: ^2.0.6+1 + mobx: ^2.6.0 + flutter_mobx: ^2.3.0 intl: any shared_preferences: ^2.0.15 cw_core: @@ -29,7 +29,7 @@ dependencies: blockchain_utils: git: url: https://github.com/cake-tech/blockchain_utils - ref: cake-update-v2 + ref: 59fdf29d72068e0522a96a8953ed7272833a9f57 cw_mweb: path: ../cw_mweb grpc: 4.0.1 @@ -55,6 +55,11 @@ dependencies: git: url: https://github.com/cake-tech/trezor_connect ref: d1242cea90f84b00200e7bcab914a5af750e23fb + trezor_flutter: + git: + url: https://github.com/cake-tech/trezor-flutter + ref: 591d4b8c821b9c10561f848b866324a9a8dcb489 + path: trezor-flutter bitbox_flutter: path: ../scripts/bitbox_flutter socks_socket: @@ -72,15 +77,15 @@ dependencies: breez_sdk_spark_flutter: git: url: https://github.com/breez/breez-sdk-spark-flutter - ref: v0.11.0 + ref: v0.23.0 dev_dependencies: flutter_test: sdk: flutter - build_runner: ^2.4.15 - build_resolvers: ^2.4.4 - mobx_codegen: ^2.0.7 - hive_generator: ^2.0.1 + build_runner: ^2.15.0 + mobx_codegen: ^2.7.6 +# hive_generator: ^2.0.1 + hive: 2.0.1 dependency_overrides: watcher: ^1.1.0 @@ -88,7 +93,7 @@ dependency_overrides: bitcoin_base: git: url: https://github.com/cake-tech/bitcoin_base - ref: cake-update-v11 + ref: 4e41f96f4838139895c65c3f49109d05af5d46aa pointycastle: 3.7.4 ffi: 2.1.0 intl: any diff --git a/cw_bitcoin/pubspec_overrides.yaml b/cw_bitcoin/pubspec_overrides.yaml new file mode 120000 index 000000000..d957432d3 --- /dev/null +++ b/cw_bitcoin/pubspec_overrides.yaml @@ -0,0 +1 @@ +../pubspec_overrides.yaml \ No newline at end of file diff --git a/cw_bitcoin/test/coin_selection_test.dart b/cw_bitcoin/test/coin_selection_test.dart new file mode 100644 index 000000000..8708075d0 --- /dev/null +++ b/cw_bitcoin/test/coin_selection_test.dart @@ -0,0 +1,100 @@ +import 'dart:math'; +import "package:cw_bitcoin/coin_selection.dart"; +import "package:flutter_test/flutter_test.dart"; +void main() { + test('effectiveValue', () { + expect(effectiveValue(10000, 136), 9864); + expect(effectiveValue(100, 136), -36); + }); + test('BnB finds changeless match in window', () { + final r = branchAndBound([200,100,90,80], 300, 50); + expect(r, isNotNull); expect(r!.hasChange, isFalse); + final vals=[200,100,90,80]; final sum=r.indices.map((i)=>vals[i]).reduce((a,b)=>a+b); + expect(sum>=300 && sum<=350, isTrue); + }); + test('BnB null when no subset in window', () { + expect(branchAndBound([1000,900], 300, 5), isNull); + }); + test('SRD with change', () { + final r = singleRandomDraw([500,500,500,500], 700, 50, Random(1)); + expect(r, isNotNull); expect(r!.hasChange, isTrue); + }); + test('SRD randomizes across seeds', () { + final a = (singleRandomDraw([100,101,102,103,104,105],150,10,Random(1))!.indices..sort()); + final b = (singleRandomDraw([100,101,102,103,104,105],150,10,Random(9))!.indices..sort()); + expect(a, isNot(equals(b))); + }); + test('SRD null on insufficient funds', () { + expect(singleRandomDraw([100,100], 500, 10, Random(1)), isNull); + }); + test('selectCoins prefers changeless BnB, else SRD', () { + expect(selectCoins(values:[200,100,90],target:300,inputCost:0,costOfChange:20,minChange:10).hasChange, isFalse); + expect(selectCoins(values:[500,500,500],target:700,inputCost:0,costOfChange:5,minChange:10,rng:Random(1)).hasChange, isTrue); + }); + test('selectCoins drops non-positive effective values', () { + // coin of value 50 with inputCost 136 -> effective -86 -> dropped; only the 1000 usable + final r = selectCoins(values:[50,1000],target:500,inputCost:136,costOfChange:5,minChange:10,rng:Random(1)); + expect(r.indices, equals([1])); + }); + test('changelessMatch finds a set whose effective sum lands in the window', () { + // inputCost 100: values [50, 400, 300, 200] -> eff [dropped, 300, 200, 100] + // target 500, window 46: eff {300, 200} = 500, exact + final r = changelessMatch( + values: [50, 400, 300, 200], target: 500, inputCosts: [100, 100, 100, 100], window: 46); + expect(r, isNotNull); + expect(r!.hasChange, isFalse); + final effSum = r.indices.map((i) => [50, 400, 300, 200][i] - 100).reduce((a, b) => a + b); + expect(effSum >= 500 && effSum <= 546, isTrue); + expect(r.indices.contains(0), isFalse); // negative-eff coin never selected + }); + test('changelessMatch returns null when no subset lands in the window', () { + expect( + changelessMatch(values: [10000, 9000], target: 500, inputCosts: [100, 100], window: 46), + isNull); + }); + test('selectCoins throws when insufficient', () { + expect(() => selectCoins(values:[100,100],target:500,inputCost:0,costOfChange:5,minChange:10), + throwsA(isA())); + }); + test('changeless pipeline: leftover absorbed into fee is non-negative and below dust', () { + // Mirrors the _createUTXOS / estimateTxForAmount arithmetic with the wallet's + // 68*inputs + 34*outputs + 10 vBytes model, at 10 sat/vB with 1 recipient output. + const feeRate = 10; + const amount = 50000; + final values = [60700, 30000, 21800, 9000, 5000]; + const target = amount + (34 * 1 + 10) * feeRate; + final r = changelessMatch( + values: values, + target: target, + inputCosts: List.filled(values.length, 68 * feeRate), + window: 546); + expect(r, isNotNull); + final inAmount = r!.indices.map((i) => values[i]).reduce((a, b) => a + b); + final feeNoChange = (68 * r.indices.length + 34 * 1 + 10) * feeRate; + final leftover = inAmount - amount - feeNoChange; + expect(leftover >= 0, isTrue); // the caller never recurses for more inputs + expect(leftover <= 546, isTrue); // fee overpay is bounded by the dust limit + }); + test('BnB terminates and returns null on large pools with no possible match', () { + // 300 even effective values, odd target, zero window: no subset can ever match, + // so the search must stop at maxTries instead of exploring 2^300 branches. + final values = List.generate(300, (i) => 1000000 + i * 2); + final r = changelessMatch( + values: values, target: 1500001, inputCosts: List.filled(300, 10), window: 0); + expect(r, isNull); + }); + test('changelessMatch charges each input its own script-type cost', () { + // Legacy input (148 vB) vs segwit input (68 vB) at 10 sat/vB: same value, but + // the legacy coin's effective value is 800 lower. Target only reachable when + // the cheaper segwit coin is chosen: eff segwit = 10000-680 = 9320. + const feeRate = 10; + final r = changelessMatch( + values: [10000, 10000], + target: 9320, + inputCosts: [148 * feeRate, 68 * feeRate], + window: 0, + ); + expect(r, isNotNull); + expect(r!.indices, equals([1])); + }); +} diff --git a/cw_bitcoin/test/locktime_test.dart b/cw_bitcoin/test/locktime_test.dart new file mode 100644 index 000000000..cd20e0205 --- /dev/null +++ b/cw_bitcoin/test/locktime_test.dart @@ -0,0 +1,32 @@ +import 'package:cw_bitcoin/locktime.dart'; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + group('antiFeeSnipingLocktime', () { + test('returns 0 when not synced', () { + expect(antiFeeSnipingLocktime(chainTip: 800000, synced: false), 0); + }); + + test('returns 0 when chainTip <= 0', () { + expect(antiFeeSnipingLocktime(chainTip: 0, synced: true), 0); + }); + + test('returns the current tip when synced', () { + expect(antiFeeSnipingLocktime(chainTip: 800000, synced: true), 800000); + }); + }); + + group('locktimeToBytes', () { + test('encodes 0 as four zero bytes', () { + expect(locktimeToBytes(0), [0, 0, 0, 0]); + }); + + test('encodes a block height little-endian', () { + expect(locktimeToBytes(800000), [0x00, 0x35, 0x0C, 0x00]); + }); + + test('encodes max 32-bit value', () { + expect(locktimeToBytes(0xFFFFFFFF), [0xFF, 0xFF, 0xFF, 0xFF]); + }); + }); +} diff --git a/cw_bitcoin/test/output_ordering_test.dart b/cw_bitcoin/test/output_ordering_test.dart new file mode 100644 index 000000000..d0fc7de05 --- /dev/null +++ b/cw_bitcoin/test/output_ordering_test.dart @@ -0,0 +1,52 @@ +import 'dart:math'; + +import 'package:bitcoin_base/bitcoin_base.dart'; +import 'package:cw_bitcoin/output_ordering.dart'; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + group('orderOutputs', () { + test('none preserves the given order', () { + final outputs = [0, 1, 2, 3, 4]; + expect(orderOutputs(outputs, BitcoinOrdering.none), outputs); + }); + + test('does not mutate the input list', () { + final outputs = [0, 1, 2, 3, 4]; + orderOutputs(outputs, BitcoinOrdering.shuffle, rng: Random(1)); + expect(outputs, [0, 1, 2, 3, 4]); + }); + + test('returns a new list instance', () { + final outputs = [0, 1, 2]; + expect(identical(orderOutputs(outputs, BitcoinOrdering.none), outputs), isFalse); + }); + + test('shuffle preserves the multiset of outputs', () { + final outputs = [0, 1, 2, 3, 4, 5, 6, 7]; + final shuffled = orderOutputs(outputs, BitcoinOrdering.shuffle, rng: Random(7)); + expect(shuffled.length, outputs.length); + expect(shuffled.toSet(), outputs.toSet()); + }); + + test('shuffle is deterministic for a given seed', () { + final outputs = [0, 1, 2, 3, 4, 5]; + final a = orderOutputs(outputs, BitcoinOrdering.shuffle, rng: Random(42)); + final b = orderOutputs(outputs, BitcoinOrdering.shuffle, rng: Random(42)); + expect(a, b); + }); + + test('shuffle does not keep the change output deterministically last', () { + // Last element (99) models the change output, appended last today. + final outputs = [0, 1, 2, 3, 99]; + final changePositions = {}; + for (var seed = 0; seed < 50; seed++) { + final shuffled = orderOutputs(outputs, BitcoinOrdering.shuffle, rng: Random(seed)); + changePositions.add(shuffled.indexOf(99)); + } + // Across seeds the change lands in more than one position, and not always last. + expect(changePositions.length, greaterThan(1)); + expect(changePositions, isNot(equals({outputs.length - 1}))); + }); + }); +} diff --git a/cw_bitcoin_cash/.gitignore b/cw_bitcoin_cash/.gitignore index 96486fd93..32aee4b25 100644 --- a/cw_bitcoin_cash/.gitignore +++ b/cw_bitcoin_cash/.gitignore @@ -22,8 +22,6 @@ migrate_working_dir/ #.vscode/ # Flutter/Dart/Pub related -# Libraries should not include pubspec.lock, per https://dart.dev/guides/libraries/private-files#pubspeclock. -/pubspec.lock **/doc/api/ .dart_tool/ .packages diff --git a/cw_bitcoin_cash/analysis_options.yaml b/cw_bitcoin_cash/analysis_options.yaml deleted file mode 100644 index a5744c1cf..000000000 --- a/cw_bitcoin_cash/analysis_options.yaml +++ /dev/null @@ -1,4 +0,0 @@ -include: package:flutter_lints/flutter.yaml - -# Additional information about this file can be found at -# https://dart.dev/guides/language/analysis-options diff --git a/cw_bitcoin_cash/lib/src/bitcoin_cash_wallet.dart b/cw_bitcoin_cash/lib/src/bitcoin_cash_wallet.dart index 18b9ea1c9..2f4a4dc49 100644 --- a/cw_bitcoin_cash/lib/src/bitcoin_cash_wallet.dart +++ b/cw_bitcoin_cash/lib/src/bitcoin_cash_wallet.dart @@ -56,8 +56,10 @@ abstract class BitcoinCashWalletBase extends ElectrumWallet with Store { initialAddresses: initialAddresses, initialRegularAddressIndex: initialRegularAddressIndex, initialChangeAddressIndex: initialChangeAddressIndex, - mainHdByType: mainHdByType, - sideHdByType: sideHdByType, + mainHdByTypeAndAccount: mainHdByTypeAndAccount, + sideHdByTypeAndAccount: sideHdByTypeAndAccount, + accountIndexes: [currentAccountIndex], + currentAccountIndex: currentAccountIndex, legacyMainHd: mainHd, legacySideHd: sideHd, network: network, @@ -148,6 +150,7 @@ abstract class BitcoinCashWalletBase extends ElectrumWallet with Store { addr.address, index: addr.index, isHidden: addr.isHidden, + isHiddenChecked: addr.isHiddenChecked, name: addr.name, type: P2pkhAddressType.p2pkh, network: BitcoinCashNetwork.mainnet, @@ -157,6 +160,7 @@ abstract class BitcoinCashWalletBase extends ElectrumWallet with Store { AddressUtils.getCashAddrFormat(addr.address), index: addr.index, isHidden: addr.isHidden, + isHiddenChecked: addr.isHiddenChecked, name: addr.name, type: P2pkhAddressType.p2pkh, network: BitcoinCashNetwork.mainnet, diff --git a/cw_bitcoin_cash/lib/src/bitcoin_cash_wallet_addresses.dart b/cw_bitcoin_cash/lib/src/bitcoin_cash_wallet_addresses.dart index d45f5293f..c6451c729 100644 --- a/cw_bitcoin_cash/lib/src/bitcoin_cash_wallet_addresses.dart +++ b/cw_bitcoin_cash/lib/src/bitcoin_cash_wallet_addresses.dart @@ -13,8 +13,10 @@ class BitcoinCashWalletAddresses = BitcoinCashWalletAddressesBase with _$Bitcoin abstract class BitcoinCashWalletAddressesBase extends ElectrumWalletAddresses with Store { BitcoinCashWalletAddressesBase( WalletInfo walletInfo, { - required super.mainHdByType, - required super.sideHdByType, + required super.mainHdByTypeAndAccount, + required super.sideHdByTypeAndAccount, + required super.accountIndexes, + required super.currentAccountIndex, required super.legacyMainHd, required super.legacySideHd, required super.network, diff --git a/cw_bitcoin_cash/lib/src/bitcoin_cash_wallet_service.dart b/cw_bitcoin_cash/lib/src/bitcoin_cash_wallet_service.dart index aad2f170c..b8888c5ea 100644 --- a/cw_bitcoin_cash/lib/src/bitcoin_cash_wallet_service.dart +++ b/cw_bitcoin_cash/lib/src/bitcoin_cash_wallet_service.dart @@ -1,13 +1,11 @@ import 'dart:io'; import 'package:bip39/bip39.dart'; -import 'package:collection/collection.dart'; import 'package:cw_bitcoin/bitcoin_mnemonics_bip39.dart'; import 'package:cw_bitcoin_cash/cw_bitcoin_cash.dart'; import 'package:cw_core/encryption_file_utils.dart'; import 'package:cw_core/pathForWallet.dart'; import 'package:cw_core/unspent_coins_info.dart'; -import 'package:cw_core/wallet_base.dart'; import 'package:cw_core/wallet_info.dart'; import 'package:cw_core/wallet_service.dart'; import 'package:cw_core/wallet_type.dart'; @@ -100,29 +98,6 @@ class BitcoinCashWalletService extends WalletService< } } - @override - Future rename(String currentName, String password, String newName) async { - final currentWalletInfo = await WalletInfo.get(currentName, getType()); - if (currentWalletInfo == null) { - throw Exception('Wallet not found'); - } - final currentWallet = await BitcoinCashWalletBase.open( - password: password, - name: currentName, - walletInfo: currentWalletInfo, - unspentCoinsInfo: unspentCoinsInfoSource, - encryptionFileUtils: encryptionFileUtilsFor(isDirect)); - - await currentWallet.renameWalletFiles(newName); - await saveBackup(newName); - - final newWalletInfo = currentWalletInfo; - newWalletInfo.id = WalletBase.idFor(newName, getType()); - newWalletInfo.name = newName; - - await newWalletInfo.save(); - } - @override Future restoreFromHardwareWallet(BitcoinCashNewWalletCredentials credentials) { throw UnimplementedError( diff --git a/cw_bitcoin_cash/lib/src/pending_bitcoin_cash_transaction.dart b/cw_bitcoin_cash/lib/src/pending_bitcoin_cash_transaction.dart deleted file mode 100644 index 440910f1b..000000000 --- a/cw_bitcoin_cash/lib/src/pending_bitcoin_cash_transaction.dart +++ /dev/null @@ -1,96 +0,0 @@ -import 'package:cw_bitcoin/exceptions.dart'; -import 'package:bitbox/bitbox.dart' as bitbox; -import 'package:cw_core/pending_transaction.dart'; -import 'package:cw_bitcoin/electrum.dart'; -import 'package:cw_bitcoin/bitcoin_amount_format.dart'; -import 'package:cw_bitcoin/electrum_transaction_info.dart'; -import 'package:cw_core/transaction_direction.dart'; -import 'package:cw_core/wallet_type.dart'; - -class PendingBitcoinCashTransaction with PendingTransaction { - PendingBitcoinCashTransaction(this._tx, this.type, - {required this.electrumClient, - required this.amount, - required this.fee, - required this.hasChange, - required this.isSendAll}) - : _listeners = []; - - final WalletType type; - final bitbox.Transaction _tx; - final ElectrumClient electrumClient; - final int amount; - final int fee; - final bool hasChange; - final bool isSendAll; - - @override - String get id => _tx.getId(); - - @override - String get hex => _tx.toHex(); - - @override - String get amountFormatted => bitcoinAmountToString(amount: amount); - - @override - String get feeFormatted => "$feeFormattedValue BCH"; - - @override - String get feeFormattedValue => bitcoinAmountToString(amount: fee); - - final List _listeners; - - @override - Future commit() async { - int? callId; - - final result = await electrumClient.broadcastTransaction( - transactionRaw: hex, idCallback: (id) => callId = id); - - if (result.isEmpty) { - if (callId != null) { - final error = electrumClient.getErrorMessage(callId!); - - if (error.contains("dust")) { - if (hasChange) { - throw BitcoinTransactionCommitFailedDustChange(); - } else if (!isSendAll) { - throw BitcoinTransactionCommitFailedDustOutput(); - } else { - throw BitcoinTransactionCommitFailedDustOutputSendAll(); - } - } - - if (error.contains("bad-txns-vout-negative")) { - throw BitcoinTransactionCommitFailedVoutNegative(); - } - throw BitcoinTransactionCommitFailed(errorMessage: error); - } - - throw BitcoinTransactionCommitFailed(); - } - - _listeners.forEach((listener) => listener(transactionInfo())); - } - - void addListener(void Function(ElectrumTransactionInfo transaction) listener) => - _listeners.add(listener); - - ElectrumTransactionInfo transactionInfo() => ElectrumTransactionInfo( - type, - id: id, - height: 0, - amount: amount, - direction: TransactionDirection.outgoing, - date: DateTime.now(), - isPending: true, - confirmations: 0, - fee: fee, - isReplaced: false, - ); - @override - Future> commitUR() { - throw UnimplementedError(); - } -} diff --git a/cw_bitcoin_cash/pubspec.yaml b/cw_bitcoin_cash/pubspec.yaml index 07751a407..9639b852b 100644 --- a/cw_bitcoin_cash/pubspec.yaml +++ b/cw_bitcoin_cash/pubspec.yaml @@ -15,8 +15,8 @@ dependencies: bip39: ^1.0.6 bip32: ^2.0.0 path_provider: ^2.0.11 - mobx: ^2.0.7+4 - flutter_mobx: ^2.0.6+1 + mobx: ^2.6.0 + flutter_mobx: ^2.3.0 cw_core: path: ../cw_core cw_bitcoin: @@ -28,21 +28,22 @@ dependencies: blockchain_utils: git: url: https://github.com/cake-tech/blockchain_utils - ref: cake-update-v2 + ref: 59fdf29d72068e0522a96a8953ed7272833a9f57 dev_dependencies: flutter_test: sdk: flutter - build_runner: ^2.4.15 - mobx_codegen: ^2.0.7 - hive_generator: ^2.0.1 + build_runner: ^2.15.0 + mobx_codegen: ^2.7.6 +# hive_generator: ^2.0.1 + hive: 2.0.1 dependency_overrides: watcher: ^1.1.0 bitcoin_base: git: url: https://github.com/cake-tech/bitcoin_base - ref: cake-update-v11 + ref: 4e41f96f4838139895c65c3f49109d05af5d46aa # For information on the generic Dart part of this file, see the # following page: https://dart.dev/tools/pub/pubspec diff --git a/cw_bitcoin_cash/pubspec_overrides.yaml b/cw_bitcoin_cash/pubspec_overrides.yaml new file mode 120000 index 000000000..d957432d3 --- /dev/null +++ b/cw_bitcoin_cash/pubspec_overrides.yaml @@ -0,0 +1 @@ +../pubspec_overrides.yaml \ No newline at end of file diff --git a/cw_core/build.yaml b/cw_core/build.yaml new file mode 100644 index 000000000..81a9c96df --- /dev/null +++ b/cw_core/build.yaml @@ -0,0 +1,5 @@ +targets: + $default: + sources: + exclude: + - "**/.secrets.g.dart" diff --git a/cw_core/lib/account.dart b/cw_core/lib/account.dart index 3061cfe23..3f899ddcc 100644 --- a/cw_core/lib/account.dart +++ b/cw_core/lib/account.dart @@ -1,12 +1,32 @@ class Account { - Account({required this.id, required this.label, this.balance}); + Account({ + required this.id, + required this.label, + String? balance, + }) : balance = balance ?? "0.00"; - Account.fromMap(Map map) - : this.id = map['id'] == null ? 0 : int.parse(map['id'] as String), - this.label = (map['label'] ?? '') as String, - this.balance = (map['balance'] ?? '0.00') as String; + Account.fromMap(Map map) + : id = map["id"] is int ? map["id"] as int : int.tryParse(map["id"]?.toString() ?? "") ?? 0, + label = (map["label"] ?? "") as String, + balance = (map["balance"] ?? "0.00") as String; final int id; final String label; - final String? balance; + final String balance; + + Account copyWith({ + int? id, + String? label, + String? balance, + }) => Account( + id: id ?? this.id, + label: label ?? this.label, + balance: balance ?? this.balance, + ); + + Map toMap() => { + "id": id.toString(), + "label": label, + "balance": balance, + }; } diff --git a/cw_core/lib/account_list.dart b/cw_core/lib/account_list.dart deleted file mode 100644 index 49b931211..000000000 --- a/cw_core/lib/account_list.dart +++ /dev/null @@ -1,15 +0,0 @@ -import 'package:mobx/mobx.dart'; - -abstract class AccountList { - ObservableList get accounts; - - void update(); - - List getAll(); - - Future addAccount({required String label}); - - Future setLabelAccount({required int accountIndex, required String label}); - - void refresh(); -} diff --git a/cw_core/lib/address_info.dart b/cw_core/lib/address_info.dart index 63dc023ab..8d2ec6ebf 100644 --- a/cw_core/lib/address_info.dart +++ b/cw_core/lib/address_info.dart @@ -1,21 +1,21 @@ import 'package:cw_core/hive_type_ids.dart'; import 'package:hive/hive.dart'; -part 'address_info.g.dart'; +part 'address_info.part.dart'; -@HiveType(typeId: ADDRESS_INFO_TYPE_ID) +// @HiveType(typeId: ADDRESS_INFO_TYPE_ID) class AddressInfo extends HiveObject { AddressInfo({required this.address, this.accountIndex, required this.label}); static const typeId = ADDRESS_INFO_TYPE_ID; static const boxName = 'AddressInfo'; - @HiveField(0) + // @HiveField(0) int? accountIndex; - @HiveField(1, defaultValue: '') + // @HiveField(1, defaultValue: '') String address; - @HiveField(2, defaultValue: '') + // @HiveField(2, defaultValue: '') String label; } diff --git a/cw_core/lib/address_info.part.dart b/cw_core/lib/address_info.part.dart new file mode 100644 index 000000000..84f430942 --- /dev/null +++ b/cw_core/lib/address_info.part.dart @@ -0,0 +1,45 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'address_info.dart'; + +// ************************************************************************** +// TypeAdapterGenerator +// ************************************************************************** + +class AddressInfoAdapter extends TypeAdapter { + @override + final int typeId = 11; + + @override + AddressInfo read(BinaryReader reader) { + final numOfFields = reader.readByte(); + final fields = { + for (int i = 0; i < numOfFields; i++) reader.readByte(): reader.read(), + }; + return AddressInfo( + address: fields[1] == null ? '' : fields[1] as String, + accountIndex: fields[0] as int?, + label: fields[2] == null ? '' : fields[2] as String, + ); + } + + @override + void write(BinaryWriter writer, AddressInfo obj) { + writer + ..writeByte(3) + ..writeByte(0) + ..write(obj.accountIndex) + ..writeByte(1) + ..write(obj.address) + ..writeByte(2) + ..write(obj.label); + } + + @override + int get hashCode => typeId.hashCode; + + @override + bool operator ==(Object other) => + identical(this, other) || + other is AddressInfoAdapter && runtimeType == other.runtimeType && typeId == other.typeId; +} diff --git a/cw_core/lib/amount/amount_sanitizer.dart b/cw_core/lib/amount/amount_sanitizer.dart new file mode 100644 index 000000000..f8c320a4c --- /dev/null +++ b/cw_core/lib/amount/amount_sanitizer.dart @@ -0,0 +1,15 @@ +String _smartAmountSanitizer(String amount) { + if (amount.contains(RegExp(r'^(?=.*[.])(?=.*,).*$'))) { + if (amount.indexOf(".") > amount.indexOf(",")) { + amount = amount.replaceAll(",", ""); + } else { + amount = amount.replaceAll(".", ""); + } + } + + return amount.replaceAll(",", "."); +} + +extension SanitizerUserAmounts on String { + String sanitized() => _smartAmountSanitizer(this); +} diff --git a/cw_core/lib/amount/exchange_rate.dart b/cw_core/lib/amount/exchange_rate.dart new file mode 100644 index 000000000..ce3f31325 --- /dev/null +++ b/cw_core/lib/amount/exchange_rate.dart @@ -0,0 +1,48 @@ +import 'package:cw_core/amount/money.dart'; +import 'package:cw_core/currency.dart'; + +class ExchangeRate { + const ExchangeRate({required this.base, required this.quote}); + + /// The currency being priced (e.g. BTC in a BTC/USD pair). + final Currency base; + + /// The price of one whole unit of [base], in the quote currency + /// (e.g. 45000 USD in a BTC/USD pair). + final Money quote; + + /// Converts [amount] between the base and quote currencies. + /// + /// Results are truncated toward zero. + /// + /// Throws an [ArgumentError] if [amount]'s currency is not part of + /// this pair. + Money convert(Money amount) { + if (base == quote.currency && base == amount.currency) { + return amount; + } + + if (amount.currency == base) { + final scale = BigInt.from(10).pow(amount.decimals); + + return quote.isZero + ? Money.zero(quote.currency) + : Money(amount.amount * quote.amount ~/ scale, quote.currency, quote.decimals); + } + + if (amount.currency == quote.currency) { + if (quote.isZero) { + return Money.zero(base); + } + + final numerator = amount.amount * BigInt.from(10).pow(base.decimals + quote.decimals); + final denominator = quote.amount * BigInt.from(10).pow(amount.decimals); + + return Money(numerator ~/ denominator, base); + } + + throw ArgumentError( + "Unable to convert ${amount.currency.symbol} in ${base.symbol}/${quote.currency.symbol} pair", + ); + } +} diff --git a/cw_core/lib/amount/money.dart b/cw_core/lib/amount/money.dart new file mode 100644 index 000000000..a4c258b54 --- /dev/null +++ b/cw_core/lib/amount/money.dart @@ -0,0 +1,316 @@ +import "dart:math"; + +import "package:cw_core/amount/utils.dart"; +import "package:cw_core/crypto_amount_format.dart"; +import "package:cw_core/crypto_currency.dart"; +import "package:cw_core/currency.dart"; +import "package:cw_core/format_fixed.dart"; +import "package:cw_core/parse_fixed.dart"; + +export "money_local.dart"; + +class Money implements Comparable { + const Money(this.amount, this.currency, [int? overrideDecimals]) + : _overrideDecimals = overrideDecimals; + + factory Money.zero(Currency currency) => Money(BigInt.zero, currency); + + factory Money.fromInt(int amount, Currency currency) => Money(BigInt.from(amount), currency); + + /// Parse the [source] and turn it into [Money] trimming trailing 0s + /// + /// Throws a [FormatException] if the [source] is not a valid decimal or + /// not in canonical representation or if it is a decimal when [isBaseUnit] + factory Money.parse( + String source, + Currency currency, { + bool isBaseUnit = false, + bool strictParsing = true, + }) { + if (!isBaseUnit) { + source = trimTrailingFractionZeros(source); + } + final decimals = strictParsing ? currency.decimals : _getActualDecimals(source, currency); + final amount = isBaseUnit ? BigInt.parse(source) : parseFixed(source, decimals); + + return Money(amount, currency, decimals); + } + + /// Parse the [source] and turn it into [Money] if possible trimming trailing 0s + /// + /// As [parse] except that this method returns `null` if the input is not + /// valid or if it is a decimal when [isBaseUnit] + static Money? tryParse( + String source, + Currency currency, { + bool isBaseUnit = false, + bool strictParsing = true, + }) { + try { + if (!isBaseUnit) { + source = trimTrailingFractionZeros(source); + } + final decimals = strictParsing ? currency.decimals : _getActualDecimals(source, currency); + final amount = isBaseUnit ? BigInt.tryParse(source) : tryParseFixed(source, decimals); + + return amount != null ? Money(amount, currency, decimals) : null; + } catch (_) { + return null; + } + } + + static Money? trySafeParse(source, Currency currency, {bool isBaseUnit = false}) { + final amount = isBaseUnit + ? BigInt.tryParse(source.toString()) + : tryParseFixed(source.toString().withDecimals(currency.decimals), currency.decimals); + + return amount != null ? Money(amount, currency) : null; + } + + final BigInt amount; + final Currency currency; + + final int? _overrideDecimals; + + /// Returns the amount of decimals of [currency] + int get decimals => _overrideDecimals ?? currency.decimals; + + /// Returns the sign of this [BigInt] amount. + /// Returns 0 for zero, -1 for values less than zero and +1 for values + /// greater than zero. + int get sign => amount.sign; + + /// Returns `true` when amount of this money is zero. + bool get isZero => amount == BigInt.zero; + + /// Returns `true` when amount of this money is negative. + bool get isNegative => amount.isNegative; + + /// Compares this to [other]. + /// + /// [other] has to be in same currency, [ArgumentError] will be thrown + /// otherwise. + @override + int compareTo(Money other) { + _assertSameCurrency(other); + + final aligned = _align(other); + return aligned.a.compareTo(aligned.b); + } + + /// Returns `true` if [other] is the same amount of money in + /// the same currency. + @override + bool operator ==(Object other) { + if (identical(this, other)) { + return true; + } + + if (other is! Money || other.currency != currency) { + return false; + } + + final aligned = _align(other); + return aligned.a == aligned.b; + } + + /// Returns `true` when this money is less than [other]. + /// + /// Both operands have to be in same currency, [ArgumentError] will be thrown + /// otherwise. + bool operator <(Money other) { + _assertSameCurrency(other, "Cannot compare money in different currencies."); + + final aligned = _align(other); + return aligned.a < aligned.b; + } + + /// Returns `true` when this money is less than or equal to [other]. + /// + /// Both operands have to be in same currency, [ArgumentError] will be thrown + /// otherwise. + bool operator <=(Money other) { + _assertSameCurrency(other, "Cannot compare money in different currencies."); + + final aligned = _align(other); + return aligned.a <= aligned.b; + } + + /// Returns `true` when this money is greater than [other]. + /// + /// Both operands have to be in same currency, [ArgumentError] will be thrown + /// otherwise. + bool operator >(Money other) { + _assertSameCurrency(other, "Cannot compare money in different currencies."); + + final aligned = _align(other); + return aligned.a > aligned.b; + } + + /// Returns `true` when this money is greater than or equal to [other]. + /// + /// Both operands have to be in same currency, [ArgumentError] will be thrown + /// otherwise. + bool operator >=(Money other) { + _assertSameCurrency(other, "Cannot compare money in different currencies."); + + final aligned = _align(other); + return aligned.a >= aligned.b; + } + + /// Adds the amount of [other] to this amount. + /// + /// Both operands must be in same currency, [ArgumentError] will be thrown + /// otherwise. + Money operator +(Money other) { + _assertSameCurrency(other); + + final aligned = _align(other); + return copyWith(amount: aligned.a + aligned.b, decimals: aligned.decimals); + } + + /// unary minus operator. + Money operator -() => copyWith(amount: -amount); + + /// Subtracts the amount of [other] from this amount. + /// + /// Both operands must be in same currency, [ArgumentError] will be thrown + /// otherwise. + Money operator -(Money other) { + _assertSameCurrency(other); + + final aligned = _align(other); + return copyWith(amount: aligned.a - aligned.b, decimals: aligned.decimals); + } + + /// Returns [Money] multiplied by [other]. + /// + /// The result is again [Money]. + Money operator *(BigInt other) => copyWith(amount: amount * other); + + /// Returns [Money] divided by [other]. + /// + /// The result is again [Money]. + Money operator /(BigInt other) { + if (other == BigInt.zero) { + throw Exception("Division by zero."); + } + + final neg = (amount.isNegative) ^ (other.isNegative); + final A = amount.abs(); + final B = other.abs(); + + final q = A ~/ B; + final r = A % B; + + // Compare 2*r with B to detect half/up/down + final twiceR = r << 1; + final mag = (twiceR >= B) ? q + BigInt.one : q; + + return copyWith(amount: neg ? -mag : mag); + } + + /// Creates a copy of this [Money] object with optional new values + /// for [amount], [currency]. + /// + /// If just [currency] is provided and the decimals missmatch + /// the amount will be transformed to keep its canonical representation + Money copyWith({BigInt? amount, Currency? currency, int? decimals}) { + if (currency != null && amount == null && decimals == null && + currency.decimals != this.decimals) { + return Money( + _transformAmount(this.amount, this.decimals, currency.decimals), + currency, + currency.decimals, + ); + } + + return Money(amount ?? this.amount, currency ?? this.currency, decimals ?? this.decimals); + } + + void _assertSameCurrency(Money other, [String? message]) { + if (currency != other.currency) { + throw ArgumentError(message ?? "Cannot operate with money values in different currencies."); + } + } + + static BigInt _transformAmount(BigInt source, int sourceDecimals, int targetDecimals) { + final diff = targetDecimals - sourceDecimals; + + return diff == 0 + ? source + : diff > 0 + ? source * BigInt.from(10).pow(diff) + : source ~/ BigInt.from(10).pow(-diff); + } + + ({BigInt a, BigInt b, int decimals}) _align(Money other) { + final decimals = max(this.decimals, other.decimals); + return ( + a: _transformAmount(amount, this.decimals, decimals), + b: _transformAmount(other.amount, other.decimals, decimals), + decimals: decimals, + ); + } + + static int _getActualDecimals(String value, Currency currency) { + final comps = value.split("."); + if (comps.length > 2) { + throw FormatException("Money._getActualDecimals: too many decimal points, value, $value"); + } + + return max(comps.length == 2 ? comps[1].length : 0, currency.decimals); + } + + @override + int get hashCode { + final value = formatFixed(amount, this.decimals, trimZeros: true); + final decimals = _getActualDecimals(value, currency); + + return Object.hash(value, decimals, currency); + } + + @override + String toString() => formatFixed(amount, decimals); + + String toStringWithSymbol({ + int? fractionalDigits, + bool trimZeros = true, + bool useBaseUnit = false, + bool withSymbolPrefix = false, + }) { + final amount = toStringWithPrecision( + fractionalDigits: fractionalDigits, + trimZeros: trimZeros, + useBaseUnit: useBaseUnit, + ); + final symbol = getSymbol(useBaseUnit: useBaseUnit); + + return withSymbolPrefix ? "$symbol $amount" : "$amount $symbol"; + } + + String toStringWithPrecision({ + int? fractionalDigits, + bool trimZeros = true, + bool useBaseUnit = false, + }) => + formatFixed( + decimals != currency.decimals + ? _transformAmount(amount, decimals, currency.decimals) + : amount, + useBaseUnit ? 0 : currency.decimals, + fractionalDigits: fractionalDigits, + trimZeros: trimZeros, + ); + + // To Override the symbol with the ticker of the base unit + String getSymbol({required bool useBaseUnit}) { + if (useBaseUnit && [CryptoCurrency.btc, CryptoCurrency.btcln].contains(currency)) { + return "sats"; + } + return currency.symbol; + } + + /// new [Money] with absolute value of this instance + Money abs() => copyWith(amount: amount.abs()); +} diff --git a/cw_core/lib/amount/money_double.dart b/cw_core/lib/amount/money_double.dart new file mode 100644 index 000000000..6c8aa766c --- /dev/null +++ b/cw_core/lib/amount/money_double.dart @@ -0,0 +1,16 @@ +import "dart:math"; + +import "package:cw_core/amount/money.dart"; +import "package:cw_core/amount/utils.dart"; +import "package:cw_core/currency.dart"; + +/// Turn a double representation of a currency amount to a proper Money representation +/// truncating currencies with more than 20 decimals because double can not handle more +extension ToMoney on double { + Money? tryToMoney(Currency currency) => Money.tryParse(_toSafeString(currency), currency); + + Money toMoney(Currency currency) => Money.parse(_toSafeString(currency), currency); + + String _toSafeString(Currency currency) => + trimTrailingFractionZeros(toStringAsFixed(min(currency.decimals, 20))); +} diff --git a/cw_core/lib/amount/money_local.dart b/cw_core/lib/amount/money_local.dart new file mode 100644 index 000000000..8f88985c7 --- /dev/null +++ b/cw_core/lib/amount/money_local.dart @@ -0,0 +1,44 @@ +import "package:cw_core/amount/money.dart"; +import "package:cw_core/crypto_amount_format.dart"; + +extension WithLocalSeparator on Money { + String toLocalStringWithSymbol({ + int? fractionalDigits, + bool trimZeros = true, + bool useBaseUnit = false, + bool withSymbolPrefix = false, + String? locale, + }) { + final amount = toLocalStringWithPrecision( + fractionalDigits: fractionalDigits, + trimZeros: trimZeros, + useBaseUnit: useBaseUnit, + locale: locale, + ); + final symbol = getSymbol(useBaseUnit: useBaseUnit); + + return withSymbolPrefix ? "$symbol $amount" : "$amount $symbol"; + } + + String toLocalStringWithPrecision({ + int? fractionalDigits, + bool trimZeros = true, + bool useBaseUnit = false, + String? locale, + }) => + _withLocalSeparator( + toStringWithPrecision( + fractionalDigits: fractionalDigits, + trimZeros: trimZeros, + useBaseUnit: useBaseUnit, + ), + locale: locale, + ); + + String _withLocalSeparator(String amount, {String? locale}) { + final isNegative = amount.startsWith("-"); + final formatted = (isNegative ? amount.substring(1) : amount).withLocalSeperator(locale); + + return isNegative ? "-$formatted" : formatted; + } +} diff --git a/cw_core/lib/amount/utils.dart b/cw_core/lib/amount/utils.dart new file mode 100644 index 000000000..28c61bb83 --- /dev/null +++ b/cw_core/lib/amount/utils.dart @@ -0,0 +1,13 @@ +String trimTrailingFractionZeros(String value) { + if (!value.contains('.')) { + return value; + } + + var end = value.length; + while (end > 0 && value[end - 1] == "0") { + end--; + } + + final trimmed = end == value.length ? value : value.substring(0, end); + return trimmed.endsWith('.') ? trimmed.substring(0, trimmed.length - 1) : trimmed; +} diff --git a/cw_core/lib/amount_converter.dart b/cw_core/lib/amount_converter.dart index 69439d064..a7f528827 100644 --- a/cw_core/lib/amount_converter.dart +++ b/cw_core/lib/amount_converter.dart @@ -1,77 +1,4 @@ -import 'package:decimal/decimal.dart'; -import 'package:decimal/intl.dart'; -import 'package:intl/intl.dart'; -import 'package:cw_core/crypto_currency.dart'; - class AmountConverter { - static const _moneroAmountLength = 12; - static const _moneroAmountDivider = 1000000000000; - static const _wowneroAmountLength = 11; - static const _wowneroAmountDivider = 100000000000; - static const _bitcoinAmountDivider = 100000000; - static const _bitcoinAmountLength = 8; - static final _bitcoinAmountFormat = NumberFormat() - ..maximumFractionDigits = _bitcoinAmountLength - ..minimumFractionDigits = 1; - static final _moneroAmountFormat = NumberFormat() - ..maximumFractionDigits = _moneroAmountLength - ..minimumFractionDigits = 1; - static final _wowneroAmountFormat = NumberFormat() - ..maximumFractionDigits = _wowneroAmountLength - ..minimumFractionDigits = 1; - - static String amountIntToString(CryptoCurrency cryptoCurrency, int amount) { - switch (cryptoCurrency) { - case CryptoCurrency.xmr: - return _moneroAmountToString(amount); - case CryptoCurrency.wow: - return _wowneroAmountToString(amount); - case CryptoCurrency.btc: - case CryptoCurrency.bch: - case CryptoCurrency.ltc: - case CryptoCurrency.dcr: - case CryptoCurrency.doge: - return _bitcoinAmountToString(amount); - case CryptoCurrency.xhv: - case CryptoCurrency.xag: - case CryptoCurrency.xau: - case CryptoCurrency.xaud: - case CryptoCurrency.xbtc: - case CryptoCurrency.xcad: - case CryptoCurrency.xchf: - case CryptoCurrency.xcny: - case CryptoCurrency.xeur: - case CryptoCurrency.xgbp: - case CryptoCurrency.xjpy: - case CryptoCurrency.xnok: - case CryptoCurrency.xnzd: - case CryptoCurrency.xusd: - return _moneroAmountToString(amount); - case CryptoCurrency.zano: - return _moneroAmountToStringUsingDecimals(amount); - default: - return ''; - } - } - - static double cryptoAmountToDouble({required num amount, required num divider}) => - amount / divider; - - static String _moneroAmountToString(int amount) => _moneroAmountFormat - .format(cryptoAmountToDouble(amount: amount, divider: _moneroAmountDivider)); - - static String _bitcoinAmountToString(int amount) => _bitcoinAmountFormat - .format(cryptoAmountToDouble(amount: amount, divider: _bitcoinAmountDivider)); - - static String _wowneroAmountToString(int amount) => _wowneroAmountFormat - .format(cryptoAmountToDouble(amount: amount, divider: _wowneroAmountDivider)); - - static Decimal cryptoAmountToDecimal({required int amount, required int divider}) => - (Decimal.fromInt(amount) / Decimal.fromInt(divider)).toDecimal(); - - static String _moneroAmountToStringUsingDecimals(int amount) => _moneroAmountFormat - .format(DecimalIntl(cryptoAmountToDecimal(amount: amount, divider: _moneroAmountDivider))); - static String toBaseUnits(String amount, int decimals) { amount = amount.trim(); if (amount.isEmpty) return '0'; diff --git a/cw_core/lib/balance.dart b/cw_core/lib/balance.dart index bd5c9e8cf..4e400b93e 100644 --- a/cw_core/lib/balance.dart +++ b/cw_core/lib/balance.dart @@ -1,26 +1,20 @@ +import 'package:cw_core/amount/money.dart'; + /// Balance Data class with all amounts in the lowest possible currency (e.g. satoshis or wei) abstract class Balance { - const Balance(this.available, this.additional, - {this.secondAvailable, this.secondAdditional, this.frozen}); + const Balance( + this.available, + this.unavailable, { + this.secondAvailable, + this.secondUnavailable, + this.frozen, + }); - Balance.fromInt(int available, int additional, - {int? secondAvailable, int? secondAdditional, int? frozen}) - : available = BigInt.from(available), - additional = BigInt.from(additional), - secondAvailable = secondAvailable == null ? null : BigInt.from(secondAvailable), - secondAdditional = secondAdditional == null ? null : BigInt.from(secondAdditional), - frozen = frozen == null ? null : BigInt.from(frozen) {} + final Money available; + final Money unavailable; - final BigInt available; - final BigInt additional; + final Money? secondAvailable; + final Money? secondUnavailable; - final BigInt? secondAvailable; - final BigInt? secondAdditional; - - final BigInt? frozen; - - BigInt get fullAvailableBalance => available; - - @deprecated - String get formattedUnAvailableBalance => ''; + final Money? frozen; } diff --git a/cw_core/lib/balance_card_layout.dart b/cw_core/lib/balance_card_layout.dart new file mode 100644 index 000000000..9ec521959 --- /dev/null +++ b/cw_core/lib/balance_card_layout.dart @@ -0,0 +1,125 @@ +import "package:cw_core/balance_card_style_settings.dart"; + +class BalanceCardLayout { + BalanceCardLayout._( + this._settings, { + required List visible, + required List hidden, + required this.needsRepair, + }) : visible = List.unmodifiable(visible), + hidden = List.unmodifiable(hidden); + + factory BalanceCardLayout.resolve({ + required List accountIndices, + required List settings, + }) { + final List accounts = []; + for (final accountIndex in accountIndices) { + if (!accounts.contains(accountIndex)) { + accounts.add(accountIndex); + } + } + + final settingsByAccount = {}; + for (final setting in settings) { + if (accounts.contains(setting.accountIndex)) { + settingsByAccount.putIfAbsent(setting.accountIndex, () => setting); + } + } + + final hidden = [ + for (final accountIndex in accounts) + if (settingsByAccount[accountIndex]?.hidden ?? false) accountIndex, + ]..sort(); + + final visible = [ + for (final accountIndex in accounts) + if (!hidden.contains(accountIndex)) accountIndex, + ]; + + visible.sort((a, b) => _compareStackPosition(a, b, settingsByAccount)); + + var needsRepair = false; + for (var position = 0; position < visible.length; position++) { + final stored = settingsByAccount[visible[position]]; + if (stored != null && stored.cardOrder != position) { + needsRepair = true; + } + } + + return BalanceCardLayout._( + settingsByAccount, + visible: visible, + hidden: hidden, + needsRepair: needsRepair, + ); + } + + final Map _settings; + + final List visible; + final List hidden; + + // flag when db order is broken + final bool needsRepair; + + List get designOrder => designOrderOf(visible); + + static List stackFromOrder(Map cardOrder, {bool forceSingleCard = false}) { + final positions = cardOrder.keys.toList()..sort(); + final accountIndices = [for (final position in positions) cardOrder[position]!]; + + if (accountIndices.isEmpty || !forceSingleCard) { + return accountIndices; + } + return [accountIndices.first]; + } + + static List designOrderOf(Iterable accountIndices) => + List.of(accountIndices)..sort(); + + Map get orders => + {for (var position = 0; position < visible.length; position++) visible[position]: position}; + + BalanceCardStyleSettings? settingFor(int accountIndex) => _settings[accountIndex]; + + BalanceCardLayout unhiding(int accountIndex) { + if (!hidden.contains(accountIndex)) { + return this; + } + + return BalanceCardLayout._( + _settings, + visible: [...visible, accountIndex], + hidden: [ + for (final hiddenAccount in hidden) + if (hiddenAccount != accountIndex) hiddenAccount, + ], + needsRepair: true, + ); + } + + static int _compareStackPosition( + int a, + int b, + Map settingsByAccount, + ) { + final orderA = _storedOrder(settingsByAccount[a]); + final orderB = _storedOrder(settingsByAccount[b]); + + if (orderA != orderB) { + if (orderA == null) { + return 1; + } + if (orderB == null) { + return -1; + } + return orderA.compareTo(orderB); + } + + return a.compareTo(b); + } + + static int? _storedOrder(BalanceCardStyleSettings? setting) => + setting == null || setting.cardOrder < 0 ? null : setting.cardOrder; +} diff --git a/cw_core/lib/balance_card_style_settings.dart b/cw_core/lib/balance_card_style_settings.dart index 9d9907fe8..526d9dfee 100644 --- a/cw_core/lib/balance_card_style_settings.dart +++ b/cw_core/lib/balance_card_style_settings.dart @@ -7,7 +7,10 @@ class BalanceCardStyleSettings { final int accountIndex; final int gradientIndex; final bool useSpecialDesign; + final bool hidden; final String backgroundImagePath; + final int iconStyleIndex; + final bool isGradientOnly; final int cardOrder; BalanceCardStyleSettings( @@ -15,7 +18,10 @@ class BalanceCardStyleSettings { required this.accountIndex, required this.gradientIndex, required this.useSpecialDesign, + required this.hidden, required this.backgroundImagePath, + this.iconStyleIndex = 0, + this.isGradientOnly = false, required this.cardOrder}); static const tableName = "BalanceCardStyleSettings"; @@ -25,8 +31,11 @@ class BalanceCardStyleSettings { "walletInfoId": walletInfoId, "accountIndex": accountIndex, "gradientIndex": gradientIndex, + "hidden": hidden ? 1 : 0, "useSpecialDesign": useSpecialDesign ? 1 : 0, "backgroundImagePath": backgroundImagePath, + "iconStyleIndex": iconStyleIndex, + "isGradientOnly": isGradientOnly ? 1 : 0, "cardOrder": cardOrder, }; return ret; @@ -36,22 +45,61 @@ class BalanceCardStyleSettings { return BalanceCardStyleSettings( walletInfoId: json["walletInfoId"] as int, accountIndex: json["accountIndex"] as int, + hidden: json["hidden"] == 1, gradientIndex: json["gradientIndex"] as int, useSpecialDesign: json["useSpecialDesign"] == 1, backgroundImagePath: json["backgroundImagePath"] as String? ?? "", + iconStyleIndex: json["iconStyleIndex"] as int? ?? 0, + isGradientOnly: json["isGradientOnly"] == 1, cardOrder: json["cardOrder"] as int? ?? -1, ); } - static BalanceCardStyleSettings fromCardDesign( - int walletInfoId, int accountIndex, int cardOrder, CardDesign design) { + BalanceCardStyleSettings copyWith({ + int? walletInfoId, + int? accountIndex, + int? gradientIndex, + int? iconStyleIndex, + bool? isGradientOnly, + bool? useSpecialDesign, + bool? hidden, + String? backgroundImagePath, + int? cardOrder, + }) { + return BalanceCardStyleSettings( + walletInfoId: walletInfoId ?? this.walletInfoId, + accountIndex: accountIndex ?? this.accountIndex, + gradientIndex: gradientIndex ?? this.gradientIndex, + useSpecialDesign: useSpecialDesign ?? this.useSpecialDesign, + hidden: hidden ?? this.hidden, + backgroundImagePath: backgroundImagePath ?? this.backgroundImagePath, + cardOrder: cardOrder ?? this.cardOrder, + iconStyleIndex: iconStyleIndex ?? this.iconStyleIndex, + isGradientOnly: isGradientOnly ?? this.isGradientOnly + ); + } + + static BalanceCardStyleSettings fromCardDesign({ + required int walletInfoId, + required int accountIndex, + required int cardOrder, + required CardDesign design, + int iconStyleIndex = 0, + int? gradientIndexOverride, + bool hidden = false + }) { + final int gradientIndex = + gradientIndexOverride ?? CardDesign.allGradients.indexOf(design.gradient); return BalanceCardStyleSettings( walletInfoId: walletInfoId, accountIndex: accountIndex, - gradientIndex: CardDesign.allGradients.indexOf(design.gradient), + gradientIndex: gradientIndex, useSpecialDesign: design.backgroundType == CardDesignBackgroundTypes.svgFull, + hidden: hidden, backgroundImagePath: design.backgroundType == CardDesignBackgroundTypes.image ? design.imagePath : "", + iconStyleIndex: iconStyleIndex, + isGradientOnly: design.backgroundType == CardDesignBackgroundTypes.gradientOnly, cardOrder: cardOrder, ); } @@ -80,6 +128,58 @@ class BalanceCardStyleSettings { } Future insert() async { - db!.insert(tableName, toJson(), conflictAlgorithm: ConflictAlgorithm.replace); + await db!.insert(tableName, toJson(), conflictAlgorithm: ConflictAlgorithm.replace); } + + static Future setVisibleOrder(int walletInfoId, Map orderByAccountIndex) => + _setOrder(walletInfoId, orderByAccountIndex, unhide: true); + + static Future setOrder(int walletInfoId, Map orderByAccountIndex) => + _setOrder(walletInfoId, orderByAccountIndex, unhide: false); + + static Future _setOrder( + int walletInfoId, + Map orderByAccountIndex, { + required bool unhide, + }) async { + if (orderByAccountIndex.isEmpty) { + return; + } + + final entries = orderByAccountIndex.entries.toList(); + final batch = db!.batch(); + + for (final entry in entries) { + batch.update( + tableName, + {"cardOrder": entry.value, if (unhide) "hidden": 0}, + where: "walletInfoId = ? AND accountIndex = ?", + whereArgs: [walletInfoId, entry.key], + ); + } + + final updatedRowCounts = await batch.commit(); + + for (int i = 0; i < entries.length; i++) { + if (updatedRowCounts[i] == 0) { + await db!.insert( + tableName, + positionOnly(walletInfoId, entries[i].key, entries[i].value).toJson(), + conflictAlgorithm: ConflictAlgorithm.ignore, + ); + } + } + } + + static BalanceCardStyleSettings positionOnly( + int walletInfoId, int accountIndex, int cardOrder) => + BalanceCardStyleSettings( + walletInfoId: walletInfoId, + accountIndex: accountIndex, + gradientIndex: -1, + useSpecialDesign: true, + hidden: false, + backgroundImagePath: "", + cardOrder: cardOrder, + ); } diff --git a/cw_core/lib/card_design.dart b/cw_core/lib/card_design.dart index 155465e96..aa9696121 100644 --- a/cw_core/lib/card_design.dart +++ b/cw_core/lib/card_design.dart @@ -3,7 +3,7 @@ import 'package:cw_core/crypto_currency.dart'; import 'package:cw_core/utils/print_verbose.dart'; import 'package:flutter/material.dart'; -enum CardDesignBackgroundTypes { image, svgIcon, svgFull } +enum CardDesignBackgroundTypes { image, svgIcon, svgFull, gradientOnly } class CardColorCombination { final Color textColor; @@ -33,18 +33,27 @@ class CardColorCombination { ); } +class CardIconPath { + final String path; + final bool preColored; + + const CardIconPath(this.path, {this.preColored = false}); +} + class CardDesign { final Gradient gradient; final String imagePath; final CardDesignBackgroundTypes backgroundType; final CardColorCombination colors; + final bool preColoredIcon; const CardDesign( {this.backgroundType = CardDesignBackgroundTypes.svgIcon, this.gradient = const LinearGradient( colors: [Colors.black], begin: Alignment.topCenter, end: Alignment.bottomCenter), this.imagePath = "assets/new-ui/blank.svg", - this.colors = CardColorCombination.dark}); + this.colors = CardColorCombination.dark, + this.preColoredIcon = false}); static const LinearGradient gradientOrange = LinearGradient( colors: [Color(0xFFFF7C02), Color(0xFFFF5602)], @@ -120,6 +129,9 @@ class CardDesign { static const genericDefault = CardDesign(gradient: gradientBlue); + static const gradientOnlyDesign = + CardDesign(backgroundType: CardDesignBackgroundTypes.gradientOnly, gradient: gradientBlue); + static const btc = CardDesign(imagePath: "assets/new-ui/balance_card_icons/bitcoin.svg"); static const eth = CardDesign(imagePath: "assets/new-ui/balance_card_icons/ethereum.svg"); @@ -156,6 +168,8 @@ class CardDesign { static const bnb = CardDesign(imagePath: "assets/new-ui/balance_card_icons/bnb.svg"); + static const robinhood = CardDesign(imagePath: "assets/new-ui/balance_card_icons/robinhood.svg"); + static const ethSpecial = CardDesign( gradient: const LinearGradient( colors: [Color(0xFF6259FF), Color(0xFF3B20E6)], @@ -311,11 +325,20 @@ class CardDesign { backgroundType: CardDesignBackgroundTypes.svgFull, imagePath: "assets/new-ui/balance_card_backgrounds/bnb.svg"); + static const robinhoodSpecial = CardDesign( + gradient: const LinearGradient( + colors: [Color(0xFFBDFE00), Color(0xFF85BE00)], + begin: Alignment.topCenter, + end: Alignment.bottomCenter), + backgroundType: CardDesignBackgroundTypes.svgFull, + imagePath: "assets/new-ui/balance_card_backgrounds/robinhood.svg"); + CardDesign withGradient(Gradient gradient) => CardDesign( gradient: gradient, colors: preferredColorCombinations[gradient] ?? colors, imagePath: imagePath, - backgroundType: backgroundType); + backgroundType: backgroundType, + preColoredIcon: preColoredIcon); CardDesign withGradientAndColorCombination( Gradient gradient, CardColorCombination cardColorCombination) => @@ -323,7 +346,61 @@ class CardDesign { gradient: gradient, colors: cardColorCombination, imagePath: imagePath, - backgroundType: backgroundType); + backgroundType: backgroundType, + preColoredIcon: preColoredIcon); + + CardDesign withIcon(CardIconPath icon) => CardDesign( + gradient: gradient, + colors: colors, + imagePath: icon.path, + backgroundType: backgroundType, + preColoredIcon: icon.preColored); + + static const String _balanceCardIconPrefix = "assets/new-ui/balance_card_icons"; + static const String _chainIconPrefix = "assets/new-ui/card_icons/chain_icons"; + static const String _ogIconPrefix = "assets/new-ui/card_icons/og_icons"; + static const String _outlineIconPrefix = "assets/new-ui/card_icons/outline_icons"; + static const String _symbolIconPrefix = "assets/new-ui/card_icons/symbol_icons"; + static const String _genericCakeIcon = "$_balanceCardIconPrefix/cake-card-icon.svg"; + + static const Map _iconNames = { + CryptoCurrency.arbEth: _CurrencyIconNames(ticker: 'arb', longName: 'arbitrum'), + CryptoCurrency.baseEth: + _CurrencyIconNames(ticker: 'base', longName: 'base', chainFile: 'base_icon'), + CryptoCurrency.robEth: _CurrencyIconNames(ticker: "rob", longName: "robinhood"), + CryptoCurrency.bch: + _CurrencyIconNames(ticker: 'bch', longName: 'bitcoin_cash', chainFile: 'bitcoin-cash'), + CryptoCurrency.btc: _CurrencyIconNames(ticker: 'btc', longName: 'bitcoin', outlineFile: 'BTC'), + CryptoCurrency.bnb: _CurrencyIconNames(ticker: 'bnb', longName: 'bnb'), + CryptoCurrency.dcr: _CurrencyIconNames(ticker: 'dcr', longName: 'decred'), + CryptoCurrency.doge: _CurrencyIconNames(ticker: 'doge', longName: 'dogecoin'), + CryptoCurrency.eth: _CurrencyIconNames(ticker: 'eth', longName: 'ethereum'), + CryptoCurrency.btcln: _CurrencyIconNames(ticker: 'ln', longName: 'lightning'), + CryptoCurrency.ltc: _CurrencyIconNames(ticker: 'ltc', longName: 'litecoin'), + CryptoCurrency.xmr: + _CurrencyIconNames(ticker: 'xmr', longName: 'monero', ogPath: 'assets/images/xmr-og.webp'), + CryptoCurrency.nano: _CurrencyIconNames(ticker: 'xno', longName: 'nano'), + CryptoCurrency.maticpoly: _CurrencyIconNames(ticker: 'pol', longName: 'polygon'), + CryptoCurrency.sol: _CurrencyIconNames(ticker: 'sol', longName: 'solana'), + CryptoCurrency.trx: + _CurrencyIconNames(ticker: 'trx', longName: 'tron', ogPath: '$_ogIconPrefix/tron-og.svg'), + CryptoCurrency.zano: _CurrencyIconNames(ticker: 'zano', longName: 'zano'), + CryptoCurrency.zec: _CurrencyIconNames(ticker: 'zec', longName: 'zcash'), + }; + + static List iconPathsForWalletType(CryptoCurrency currency) { + final n = _iconNames[currency]; + if (n == null) return const []; + + return [ + CardIconPath('$_symbolIconPrefix/${n.ticker}-symbol.svg'), + CardIconPath('$_outlineIconPrefix/${n.outlineFile ?? n.ticker}-outline.svg'), + CardIconPath('$_balanceCardIconPrefix/${n.longName}.svg'), + CardIconPath('$_chainIconPrefix/${n.chainFile ?? n.longName}.svg', preColored: true), + CardIconPath(n.ogPath ?? '$_ogIconPrefix/${n.ticker}-og.svg', preColored: true), + const CardIconPath(_genericCakeIcon), + ]; + } static const List all = [ genericDefault, @@ -343,6 +420,7 @@ class CardDesign { arbitrum, zec, bnb, + robinhood, ethSpecial, btcSpecial, xmrSpecial, @@ -357,7 +435,8 @@ class CardDesign { zanoSpecial, arbitrumSpecial, zecSpecial, - bnbSpecial + bnbSpecial, + robinhoodSpecial, ]; static CardDesign forCurrencySpecial(CryptoCurrency currency) { @@ -387,6 +466,7 @@ class CardDesign { CryptoCurrency.zec: zec, CryptoCurrency.dcr: dcr, CryptoCurrency.bnb: bnb, + CryptoCurrency.robEth: robinhood, }; static const Map specialDesignsForCurrencies = { @@ -408,6 +488,7 @@ class CardDesign { CryptoCurrency.arbEth: arbitrumSpecial, CryptoCurrency.zec: zecSpecial, CryptoCurrency.bnb: bnbSpecial, + CryptoCurrency.robEth: robinhoodSpecial, }; static Map preferredColorCombinations = { @@ -423,29 +504,89 @@ class CardDesign { CardDesign.gradientBlack: CardColorCombination.black, }; + CardDesign withIconStyleIndex(int iconStyleIndex, CryptoCurrency currency) { + final paths = iconPathsForWalletType(currency); + if (paths.isEmpty) return this; + if (iconStyleIndex < 0 || iconStyleIndex >= paths.length) return this; + return withIcon(paths[iconStyleIndex]); + } + + static Gradient gradientForStoredIndex( + int gradientIndex, + CryptoCurrency walletCurrency, + ) { + final n = CardDesign.allGradients.length; + final special = specialDesignsForCurrencies[walletCurrency]; + if (gradientIndex >= 0 && gradientIndex < n) { + return CardDesign.allGradients[gradientIndex]; + } + if (gradientIndex == n && special != null) { + return special.gradient; + } + return gradientBlue; + } + static CardDesign fromStyleSettings( BalanceCardStyleSettings? setting, CryptoCurrency walletCurrency) { if (setting == null) { return CardDesign.forCurrencySpecial(walletCurrency); - } else if (setting.backgroundImagePath.isNotEmpty) { - return CardDesign( - imagePath: setting.backgroundImagePath, + } + + if (setting.isGradientOnly) { + final gradient = gradientForStoredIndex(setting.gradientIndex, walletCurrency); + final textColors = preferredColorCombinations[gradient] ?? + specialDesignsForCurrencies[walletCurrency]?.colors ?? + gradientOnlyDesign.colors; + return gradientOnlyDesign.withGradientAndColorCombination(gradient, textColors); + } + + if (setting.backgroundImagePath.isNotEmpty) { + return CardDesign(imagePath: setting.backgroundImagePath); + } + + if (setting.useSpecialDesign && setting.gradientIndex != -1) { + return CardDesign.forCurrencySpecial(walletCurrency).withGradient( + gradientForStoredIndex(setting.gradientIndex, walletCurrency), ); - } else if (setting.useSpecialDesign && setting.gradientIndex != -1) { - return CardDesign.forCurrencySpecial(walletCurrency) - .withGradient(CardDesign.allGradients[setting.gradientIndex]); - } else if (!setting.useSpecialDesign && setting.gradientIndex == -1) { + } + + if (!setting.useSpecialDesign && setting.gradientIndex == -1) { final specialColors = specialDesignsForCurrencies[walletCurrency] ?? genericDefault; - return CardDesign.forCurrencyIcon(walletCurrency) + final design = CardDesign.forCurrencyIcon(walletCurrency) .withGradientAndColorCombination(specialColors.gradient, specialColors.colors); - } else if (setting.useSpecialDesign) { - return CardDesign.forCurrencySpecial(walletCurrency); - } else if (setting.gradientIndex != -1) { - return CardDesign.forCurrencyIcon(walletCurrency) - .withGradient(CardDesign.allGradients[setting.gradientIndex]); - } else { - printV("somehow, the user saved the design settings with literally no customization?"); + return design.withIconStyleIndex(setting.iconStyleIndex, walletCurrency); + } + if (setting.useSpecialDesign) { return CardDesign.forCurrencySpecial(walletCurrency); } + if (setting.gradientIndex != -1) { + final baseIcon = CardDesign.forCurrencyIcon(walletCurrency); + final gradient = gradientForStoredIndex(setting.gradientIndex, walletCurrency); + final textColors = preferredColorCombinations[gradient] ?? + specialDesignsForCurrencies[walletCurrency]?.colors ?? + baseIcon.colors; + return baseIcon + .withGradientAndColorCombination(gradient, textColors) + .withIconStyleIndex(setting.iconStyleIndex, walletCurrency); + } + printV("somehow, the user saved the design settings with literally no " + "customization?"); + return CardDesign.forCurrencySpecial(walletCurrency); } } + +class _CurrencyIconNames { + final String ticker; + final String longName; + final String? outlineFile; + final String? chainFile; + final String? ogPath; + + const _CurrencyIconNames({ + required this.ticker, + required this.longName, + this.outlineFile, + this.chainFile, + this.ogPath, + }); +} diff --git a/cw_core/lib/crypto_amount_format.dart b/cw_core/lib/crypto_amount_format.dart index 2c1d96f4e..ddefd9eec 100644 --- a/cw_core/lib/crypto_amount_format.dart +++ b/cw_core/lib/crypto_amount_format.dart @@ -67,7 +67,10 @@ extension MaxDecimals on String { return [parts.first.withLocalSeperator(locale), ...parts.sublist(1)].join(" "); } - final formater = NumberFormat("#,###", locale); + final formater = NumberFormat( + "#,###", + Intl.verifiedLocale(locale, NumberFormat.localeExists, onFailure: (_) => "en"), + ); final parts = replaceAll(",", "").split("."); if (parts.first.contains("< 0")) parts.first = "0"; diff --git a/cw_core/lib/crypto_currency.dart b/cw_core/lib/crypto_currency.dart index 70b9ed07c..9ae8af458 100644 --- a/cw_core/lib/crypto_currency.dart +++ b/cw_core/lib/crypto_currency.dart @@ -1,8 +1,9 @@ +import 'package:cw_core/amount/money.dart'; import 'package:cw_core/currency.dart'; +import 'package:cw_core/currency_groups.dart'; import 'package:cw_core/enumerable_item.dart'; import 'package:collection/collection.dart'; import 'package:cw_core/format_fixed.dart'; -import 'package:cw_core/parse_fixed.dart'; class CryptoCurrency extends EnumerableItem with Serializable implements Currency { const CryptoCurrency({ @@ -17,18 +18,34 @@ class CryptoCurrency extends EnumerableItem with Serializable implemen this.chainIconPath, this.enabled = false, this.isPotentialScam = false, + this.groups = const {}, }) : super(title: title, raw: raw); - final String name; - final String? tag; - final String? fullName; - final String? iconPath; final String? flatIconPath; final String? chainIconPath; - @override - final int decimals; final bool enabled; final bool isPotentialScam; + final Set groups; + + @override + final String name; + + @override + final String? fullName; + + @override + final String? tag; + + @override + final String? iconPath; + + @override + final int decimals; + + @override + String get symbol => title; + @override + String get apiString => "crypto.$title"; set enabled(bool value) => this.enabled = value; @@ -126,6 +143,7 @@ class CryptoCurrency extends EnumerableItem with Serializable implemen CryptoCurrency.arbEth, CryptoCurrency.usdcArb, CryptoCurrency.usdtArb, + CryptoCurrency.robEth, ]; static const havenCurrencies = [ @@ -197,7 +215,8 @@ class CryptoCurrency extends EnumerableItem with Serializable implemen raw: 5, name: 'dai', iconPath: 'assets/new-ui/crypto_full_icons/dai.svg', - decimals: 18); + decimals: 18, + groups: const {CurrencyGroups.stablecoin}); static const dash = CryptoCurrency( title: 'DASH', fullName: 'Dash', @@ -254,7 +273,8 @@ class CryptoCurrency extends EnumerableItem with Serializable implemen raw: 12, name: 'usdt', iconPath: 'assets/new-ui/crypto_full_icons/usdt.svg', - decimals: 6); + decimals: 6, + groups: const {CurrencyGroups.stablecoin}); static const usdterc20 = CryptoCurrency( title: 'USDT', tag: 'ETH', @@ -262,7 +282,8 @@ class CryptoCurrency extends EnumerableItem with Serializable implemen raw: 13, name: 'usdterc20', iconPath: 'assets/new-ui/crypto_full_icons/usdt.svg', - decimals: 6); + decimals: 6, + groups: const {CurrencyGroups.stablecoin}); static const xlm = CryptoCurrency( title: 'XLM', fullName: 'Stellar', @@ -359,7 +380,8 @@ class CryptoCurrency extends EnumerableItem with Serializable implemen raw: 36, name: 'usdttrc20', iconPath: 'assets/new-ui/crypto_full_icons/usdt.svg', - decimals: 6); + decimals: 6, + groups: const {CurrencyGroups.stablecoin}); static const hbar = CryptoCurrency( title: 'HBAR', fullName: 'Hedera', @@ -390,7 +412,8 @@ class CryptoCurrency extends EnumerableItem with Serializable implemen raw: 40, name: 'usdc', iconPath: 'assets/new-ui/crypto_full_icons/usdc.svg', - decimals: 6); + decimals: 6, + groups: const {CurrencyGroups.stablecoin}); static const usdcsol = CryptoCurrency( title: 'USDC', tag: 'SOL', @@ -398,7 +421,8 @@ class CryptoCurrency extends EnumerableItem with Serializable implemen raw: 41, name: 'usdcsol', iconPath: 'assets/new-ui/crypto_full_icons/usdc.svg', - decimals: 6); + decimals: 6, + groups: const {CurrencyGroups.stablecoin}); static const zaddr = CryptoCurrency( title: 'ZZEC', tag: 'ZEC', @@ -437,7 +461,8 @@ class CryptoCurrency extends EnumerableItem with Serializable implemen raw: 46, name: 'usdcpoly', iconPath: 'assets/new-ui/crypto_full_icons/usdc.svg', - decimals: 6); + decimals: 6, + groups: const {CurrencyGroups.stablecoin}); static const dcr = CryptoCurrency( title: 'DCR', fullName: 'Decred', @@ -758,7 +783,8 @@ class CryptoCurrency extends EnumerableItem with Serializable implemen raw: 87, name: 'usdtpoly', iconPath: 'assets/new-ui/crypto_full_icons/usdt.svg', - decimals: 6); + decimals: 6, + groups: const {CurrencyGroups.stablecoin}); static const usdcEPoly = CryptoCurrency( title: 'USDC.E', tag: 'POL', @@ -788,7 +814,8 @@ class CryptoCurrency extends EnumerableItem with Serializable implemen raw: 91, name: 'usdtsol', iconPath: 'assets/new-ui/crypto_full_icons/usdt.svg', - decimals: 6); + decimals: 6, + groups: const {CurrencyGroups.stablecoin}); static const usdcTrc20 = CryptoCurrency( title: 'USDC', tag: 'TRX', @@ -796,7 +823,8 @@ class CryptoCurrency extends EnumerableItem with Serializable implemen raw: 92, name: 'usdctrc20', iconPath: 'assets/new-ui/crypto_full_icons/usdc.svg', - decimals: 6); + decimals: 6, + groups: const {CurrencyGroups.stablecoin}); static const tbtc = CryptoCurrency( title: 'tBTC', fullName: 'Testnet Bitcoin', @@ -844,7 +872,8 @@ class CryptoCurrency extends EnumerableItem with Serializable implemen raw: 98, name: 'deuro', iconPath: 'assets/images/deuro_icon.png', - decimals: 18); + decimals: 18, + groups: const {CurrencyGroups.stablecoin}); static const usdtbsc = CryptoCurrency( title: 'USDT', tag: 'BSC', @@ -852,7 +881,8 @@ class CryptoCurrency extends EnumerableItem with Serializable implemen raw: 99, name: 'usdtbsc', iconPath: 'assets/new-ui/crypto_full_icons/usdt.svg', - decimals: 18); + decimals: 18, + groups: const {CurrencyGroups.stablecoin}); static const ndeps = CryptoCurrency( title: 'NDEPS', tag: 'ETH', @@ -891,7 +921,7 @@ class CryptoCurrency extends EnumerableItem with Serializable implemen fullName: 'Ethereum', raw: 104, name: 'baseth', - iconPath: 'assets/new-ui/crypto_full_icons/base.svg', + iconPath: 'assets/new-ui/crypto_full_icons/ethereum.svg', decimals: 18, flatIconPath: "assets/new-ui/balance_card_icons/base.svg", chainIconPath: "assets/new-ui/chain_badges/base.svg"); @@ -911,7 +941,8 @@ class CryptoCurrency extends EnumerableItem with Serializable implemen name: 'arbeth', iconPath: 'assets/new-ui/crypto_full_icons/ethereum.svg', decimals: 18, - flatIconPath: "assets/new-ui/balance_card_icons/arbitrum.svg"); + flatIconPath: "assets/new-ui/balance_card_icons/arbitrum.svg", + chainIconPath: "assets/new-ui/chain_badges/arbitrum.svg"); static const zec = CryptoCurrency( title: 'ZEC', fullName: 'Zcash', @@ -927,7 +958,8 @@ class CryptoCurrency extends EnumerableItem with Serializable implemen raw: 108, name: 'usdcarb', iconPath: 'assets/new-ui/crypto_full_icons/usdc.svg', - decimals: 6); + decimals: 6, + groups: const {CurrencyGroups.stablecoin}); static const usdtArb = CryptoCurrency( title: 'USDT', tag: 'ARB', @@ -935,7 +967,8 @@ class CryptoCurrency extends EnumerableItem with Serializable implemen raw: 109, name: 'usdtarb', iconPath: 'assets/new-ui/crypto_full_icons/usdt.svg', - decimals: 6); + decimals: 6, + groups: const {CurrencyGroups.stablecoin}); static const ltcmweb = CryptoCurrency( title: 'LTC', fullName: 'Litecoin MWeb', @@ -943,6 +976,16 @@ class CryptoCurrency extends EnumerableItem with Serializable implemen name: 'ltcmweb', iconPath: 'assets/new-ui/crypto_full_icons/litecoin.svg', decimals: 8); + static const robEth = CryptoCurrency( + title: "ETH", + tag: "ROB", + fullName: "Ethereum", + raw: 111, + name: "robeth", + iconPath: "assets/new-ui/crypto_full_icons/ethereum.svg", + decimals: 18, + flatIconPath: "assets/new-ui/balance_card_icons/robinhood.svg", + chainIconPath: "assets/new-ui/chain_badges/robinhood.svg"); static final Map _rawCurrencyMap = [ ...all, @@ -968,7 +1011,14 @@ class CryptoCurrency extends EnumerableItem with Serializable implemen ...zcashCurrencies ].fold>({}, (acc, item) { if (item.fullName != null) { - acc.addAll({item.fullName!.toLowerCase(): item}); + final fullName = item.fullName!.toLowerCase(); + final existing = acc[fullName]; + + // Mainnet ETH and the L2 ETHs share the "Ethereum" full name, + // so we make the untagged coin keeps the name + if (existing == null || existing.tag != null) { + acc[fullName] = item; + } } return acc; }); @@ -978,6 +1028,7 @@ class CryptoCurrency extends EnumerableItem with Serializable implemen 'bitcoin': btc, 'bitcoincash': bch, 'polygon': maticpoly, + 'solana': sol, 'nano-gpt': nano, 'secret': scrt, 'stellar': xlm, @@ -993,6 +1044,8 @@ class CryptoCurrency extends EnumerableItem with Serializable implemen 'base': baseEth, 'arbitrum': arbEth, 'bsc': bnb, + "rob": robEth, + "robinhood": robEth, }; static CryptoCurrency deserialize({required int raw}) { @@ -1094,6 +1147,7 @@ class CryptoCurrency extends EnumerableItem with Serializable implemen int? decimals, bool? enabled, bool? isPotentialScam, + Set? groups, }) => CryptoCurrency( title: title ?? this.title, @@ -1105,6 +1159,7 @@ class CryptoCurrency extends EnumerableItem with Serializable implemen decimals: decimals ?? this.decimals, enabled: enabled ?? this.enabled, isPotentialScam: isPotentialScam ?? this.isPotentialScam, + groups: groups ?? this.groups, ); @override @@ -1112,13 +1167,13 @@ class CryptoCurrency extends EnumerableItem with Serializable implemen bool titleAndTagEqual(CryptoCurrency other) => title == other.title && tag == other.tag; + @override + Money parseAmount(String value) => Money.parse(value, this); + + @override + Money? tryParseAmount(String value) => Money.tryParse(value, this); + /// Format the raw amount into its decimal representation eg. turn Sats into Bitcoin String formatAmount(BigInt amount, {int? fractionalDigits, bool trimZeros = true}) => formatFixed(amount, decimals, fractionalDigits: fractionalDigits, trimZeros: trimZeros); - - /// Parse the [value] and turn it into the smallest denomination eg. turn Bitcoin into Sats - BigInt parseAmount(String value) => parseFixed(value, decimals); - - /// Try parsing the [value] and turn it into the smallest denomination eg. turn Bitcoin into Sats - BigInt? tryParseAmount(String value) => tryParseFixed(value, decimals); } diff --git a/cw_core/lib/currencies_with_memo.dart b/cw_core/lib/currencies_with_memo.dart new file mode 100644 index 000000000..e12a14b31 --- /dev/null +++ b/cw_core/lib/currencies_with_memo.dart @@ -0,0 +1,13 @@ +import 'package:cw_core/crypto_currency.dart'; + +enum MemoLabelType { destinationTag, memo } + +const Map _currenciesRequiringMemo = { + CryptoCurrency.xrp: MemoLabelType.destinationTag, + CryptoCurrency.xlm: MemoLabelType.memo, + CryptoCurrency.ton: MemoLabelType.memo, + CryptoCurrency.eos: MemoLabelType.memo, + CryptoCurrency.hbar: MemoLabelType.memo, +}; + +MemoLabelType? memoLabelTypeFor(CryptoCurrency currency) => _currenciesRequiringMemo[currency]; diff --git a/cw_core/lib/currency.dart b/cw_core/lib/currency.dart index f43455a3e..1e84d42e3 100644 --- a/cw_core/lib/currency.dart +++ b/cw_core/lib/currency.dart @@ -1,7 +1,24 @@ +import 'package:cw_core/amount/money.dart'; + abstract class Currency { + String get symbol; String get name; - String? get tag; String? get fullName; String? get iconPath; int get decimals; + String? get tag; + + /// Parse the [value] and turn it into [Money] + Money parseAmount(String value); + + /// Try parsing the [value] and turn it into [Money] + Money? tryParseAmount(String value); + + @override + bool operator ==(Object other) => + other is Currency && other.decimals == decimals && other.symbol == symbol && other.tag == tag; + + @override + int get hashCode => decimals.hashCode ^ symbol.hashCode ^ tag.hashCode; + String get apiString; } diff --git a/cw_core/lib/currency_for_wallet_type.dart b/cw_core/lib/currency_for_wallet_type.dart index 7640c1fba..afec90394 100644 --- a/cw_core/lib/currency_for_wallet_type.dart +++ b/cw_core/lib/currency_for_wallet_type.dart @@ -54,6 +54,8 @@ CryptoCurrency getCryptoCurrencyByChainId(int chainId) { return CryptoCurrency.arbEth; case 56: return CryptoCurrency.bnb; + case 4663: + return CryptoCurrency.robEth; default: return CryptoCurrency.eth; } @@ -73,6 +75,8 @@ int? getChainIdByCryptoCurrency(CryptoCurrency currency) { return 42161; case CryptoCurrency.bnb: return 56; + case CryptoCurrency.robEth: + return 4663; default: return null; } @@ -97,5 +101,40 @@ String getCryptoCurrencyIconForWalletListItem(WalletType type, return "assets/new-ui/crypto_full_icons/base.svg"; } + return walletTypeToCryptoCurrency(type, isTestnet: isTestnet, chainId: chainId).iconPath ?? ""; } + +String? symbolIconPathForWalletType(WalletType type) { + const prefix = "assets/new-ui/card_icons/symbol_icons"; + switch (type) { + case WalletType.monero: + return "$prefix/xmr-symbol.svg"; + case WalletType.bitcoin: + return "$prefix/btc-symbol.svg"; + case WalletType.litecoin: + return "$prefix/ltc-symbol.svg"; + case WalletType.ethereum: + return "$prefix/eth-symbol.svg"; + case WalletType.base: + return "$prefix/base-symbol.svg"; + case WalletType.arbitrum: + return "$prefix/arb-symbol.svg"; + case WalletType.bsc: + return "$prefix/bnb-symbol.svg"; + case WalletType.bitcoinCash: + return "$prefix/bch-symbol.svg"; + case WalletType.polygon: + return "$prefix/pol-symbol.svg"; + case WalletType.dogecoin: + return "$prefix/doge-symbol.svg"; + case WalletType.nano: + return "$prefix/xno-symbol.svg"; + case WalletType.wownero: + case WalletType.banano: + case WalletType.none: + return null; + } +} + +bool isMonochromeSymbolIcon(String path) => path.contains("/symbol_icons/"); diff --git a/cw_core/lib/currency_groups.dart b/cw_core/lib/currency_groups.dart new file mode 100644 index 000000000..b5770ff90 --- /dev/null +++ b/cw_core/lib/currency_groups.dart @@ -0,0 +1,4 @@ +abstract class CurrencyGroups { + static const stablecoin = 'stablecoin'; + static const tokenizedStock = 'tokenized_stock'; +} diff --git a/cw_core/lib/db/sqlite.dart b/cw_core/lib/db/sqlite.dart index 4e61cb5bc..30b1be318 100644 --- a/cw_core/lib/db/sqlite.dart +++ b/cw_core/lib/db/sqlite.dart @@ -1,28 +1,54 @@ -import 'dart:io'; +import "dart:io"; -import 'package:cw_core/root_dir.dart'; -import 'package:cw_core/utils/print_verbose.dart'; -import 'package:sqflite_common_ffi/sqflite_ffi.dart'; +import "package:cw_core/db/sqlite_debug.dart"; +import "package:cw_core/root_dir.dart"; +import "package:cw_core/utils/print_verbose.dart"; +import "package:cw_core/wallet_type.dart"; +import "package:flutter/foundation.dart"; +import "package:path/path.dart" as p; +import "package:sqflite_common_ffi/sqflite_ffi.dart"; Database? db; Future _addColumnIfNotExists( - Database db, { - required String table, - required String column, - required String definition, -}) async { + Database db, { + required String table, + required String column, + required String definition, + }) async { final result = await db.rawQuery("PRAGMA table_info($table)"); - final columnExists = result.any((row) => row['name'] == column); + final columnExists = result.any((row) => row["name"] == column); if (!columnExists) { await db.execute( - 'ALTER TABLE $table ADD COLUMN $column $definition;', + "ALTER TABLE $table ADD COLUMN $column $definition;", ); } } +Future sqliteDebugMarkerFile() async { + final appDir = await getAppDir(); + final dbDebugMarker = p.join(appDir.path, ".sqlite_db_debug"); + return File(dbDebugMarker); +} + Future initDb({String? pathOverride}) async { + if (!kDebugMode && !kProfileMode) { + await _initDb(pathOverride: pathOverride); + return; + } + final dbDebugMarker = await sqliteDebugMarkerFile(); + try { + if (dbDebugMarker.existsSync()) { + throw Exception("Debug marker is present"); + } + await _initDb(pathOverride: pathOverride); + } catch (e, s) { + await handleSqliteError(e, s); + } +} + +Future _initDb({String? pathOverride}) async { if (Platform.isLinux || Platform.isWindows) { databaseFactory = databaseFactoryFfi; } @@ -38,11 +64,13 @@ Future initDb({String? pathOverride}) async { } } await db?.close(); - db = await openDatabase(dbFile.path, version: 6, - onUpgrade: (Database db, int oldVersion, int newVersion) async { - printV("migrating: $oldVersion, $newVersion"); - if (oldVersion <= 1) { - await db.execute(''' + db = await openDatabase( + dbFile.path, + version: 15, + onUpgrade: (db, oldVersion, newVersion) async { + printV("migrating: $oldVersion, $newVersion"); + if (oldVersion <= 1) { + await db.execute(""" DELETE FROM WalletInfo WHERE walletInfoId NOT IN ( SELECT MIN(walletInfoId) @@ -52,10 +80,10 @@ WHERE walletInfoId NOT IN ( CREATE UNIQUE INDEX IF NOT EXISTS idx_walletinfo_id_unique ON WalletInfo (id); -'''); - } - if (oldVersion <= 2) { - await db.execute(''' +"""); + } + if (oldVersion <= 2) { + await db.execute(''' CREATE TABLE IF NOT EXISTS BalanceCardStyleSettings ( walletInfoId INTEGER, accountIndex INTEGER DEFAULT -1, @@ -66,37 +94,116 @@ CREATE TABLE IF NOT EXISTS BalanceCardStyleSettings ( FOREIGN KEY (walletInfoId) REFERENCES WalletInfo(walletInfoId) ); '''); - await _addColumnIfNotExists( - db, - table: 'WalletInfo', - column: 'receiveInfoboxDismissed', - definition: 'BOOLEAN DEFAULT FALSE', - ); + await _addColumnIfNotExists( + db, + table: "WalletInfo", + column: "receiveInfoboxDismissed", + definition: "BOOLEAN DEFAULT FALSE", + ); - await _addColumnIfNotExists( - db, - table: 'BalanceCardStyleSettings', - column: 'cardOrder', - definition: 'INTEGER DEFAULT 0', - ); - } - if (oldVersion <= 3) { - await _addColumnIfNotExists(db, - table: "WalletInfo", column: "showCombinedBalance", definition: "BOOLEAN DEFAULT TRUE"); - // null - primary token (eth, sol etc) - // not null - address of fav token - // if address doesn't correspond to a valid token, fallback to primary token - await _addColumnIfNotExists(db, - table: "WalletInfo", column: "favoriteTokenAddress", definition: "TEXT DEFAULT NULL"); - } - if (oldVersion <= 4) { - await _createBridgeTransferTable(db); - } - if (oldVersion <= 5) { - await _createTradeTable(db); - } - }, onCreate: (Database db, int version) async { - await db.execute(''' + await _addColumnIfNotExists( + db, + table: 'BalanceCardStyleSettings', + column: 'cardOrder', + definition: 'INTEGER DEFAULT 0', + ); + } + if (oldVersion <= 3) { + await _addColumnIfNotExists(db, + table: "WalletInfo", column: "showCombinedBalance", definition: "BOOLEAN DEFAULT TRUE"); + // null - primary token (eth, sol etc) + // not null - address of fav token + // if address doesn't correspond to a valid token, fallback to primary token + await _addColumnIfNotExists(db, + table: "WalletInfo", column: "favoriteTokenAddress", definition: "TEXT DEFAULT NULL"); + } + + if (oldVersion <= 4) { + await _createBridgeTransferTable(db); + } + + if (oldVersion <= 5) { + await _createTradeTable(db); + } + if (oldVersion <= 6) { + await _addColumnIfNotExists( + db, + table: 'Trade', + column: 'toAddressExtraId', + definition: 'TEXT', + ); + } + if (oldVersion <= 7) { + await _createNodeTable(db); + } + if (oldVersion <= 8) { + await _addColumnIfNotExists( + db, + table: 'BalanceCardStyleSettings', + column: 'iconStyleIndex', + definition: 'INTEGER DEFAULT 0', + ); + await _addColumnIfNotExists( + db, + table: 'BalanceCardStyleSettings', + column: 'isGradientOnly', + definition: 'BOOLEAN DEFAULT FALSE', + ); + } + if (oldVersion <= 9) { + await _createErc20TokenTable(db); + await _createSplTokenTable(db); + await _createTronTokenTable(db); + } + if (oldVersion <= 10) { + await _createImportedNFTTable(db); + } + if (oldVersion <= 11) { + await _addColumnIfNotExists( + db, + table: "WalletInfo", + column: "showSeedBackupReminder", + definition: "BOOLEAN DEFAULT FALSE", + ); + } + if (oldVersion <= 12) { + await _createChartsTables(db); + } + + if (oldVersion <= 13) { + await _createWalletInfoAccountTable(db); + await _addColumnIfNotExists( + db, + table: "WalletInfo", + column: "accountDiscoveryLimit", + definition: "INTEGER DEFAULT NULL", + ); + await _addColumnIfNotExists( + db, + table: "WalletInfo", + column: "isMultiAccountsEnabled", + definition: "INTEGER DEFAULT NULL", + ); + await _addColumnIfNotExists( + db, + table: "WalletInfo", + column: "currentAccountIndex", + definition: "INTEGER NOT NULL DEFAULT 0", + ); + + await _migrateBitcoinCardStylesForAccounts(db); + } + if(oldVersion <= 14) { + await _addColumnIfNotExists( + db, + table: "BalanceCardStyleSettings", + column: "hidden", + definition: "BOOLEAN DEFAULT FALSE", + ); + } + }, + onCreate: (Database db, int version) async { + await db.execute(''' CREATE TABLE WalletInfo ( walletInfoId INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT, id TEXT NOT NULL, @@ -119,13 +226,17 @@ CREATE TABLE WalletInfo ( hashedWalletIdentifier TEXT, isNonSeedWallet INTEGER DEFAULT (0) NOT NULL, sortOrder INTEGER DEFAULT (0) NOT NULL, + currentAccountIndex INTEGER NOT NULL DEFAULT 0, receiveInfoboxDismissed BOOLEAN DEFAULT FALSE, showCombinedBalance BOOLEAN DEFAULT TRUE, - favoriteTokenAddress TEXT DEFAULT NULL + favoriteTokenAddress TEXT DEFAULT NULL, + accountDiscoveryLimit INTEGER DEFAULT NULL, + isMultiAccountsEnabled INTEGER DEFAULT NULL, + showSeedBackupReminder BOOLEAN DEFAULT FALSE ); '''); - await db.execute(''' + await db.execute(""" CREATE TABLE WalletInfoDerivationInfo ( walletInfoDerivationInfoId INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT, address TEXT NOT NULL, @@ -136,9 +247,9 @@ CREATE TABLE WalletInfoDerivationInfo ( scriptType TEXT, description TEXT ); -'''); +"""); - await db.execute(''' + await db.execute(''' CREATE TABLE WalletInfoAddress ( walletInfoAddressId INTEGER PRIMARY KEY AUTOINCREMENT, walletInfoId INTEGER, @@ -148,7 +259,7 @@ CREATE TABLE WalletInfoAddress ( ); '''); - await db.execute(''' + await db.execute(""" CREATE TABLE WalletInfoAddressInfo ( walletInfoAddressInfoId INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT, walletInfoId INTEGER NOT NULL, @@ -158,9 +269,9 @@ CREATE TABLE WalletInfoAddressInfo ( mapValueLabel TEXT NOT NULL, CONSTRAINT WalletInfoAddressInfo_WalletInfo_FK FOREIGN KEY (walletInfoId) REFERENCES WalletInfo(walletInfoId) ); -'''); +"""); - await db.execute(''' + await db.execute(''' CREATE TABLE "WalletInfoAddressMap" ( walletInfoAddressMapId INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT, walletInfoId INTEGER NOT NULL, @@ -169,29 +280,58 @@ CREATE TABLE "WalletInfoAddressMap" ( CONSTRAINT WalletInfoAddress_WalletInfo_FK FOREIGN KEY (walletInfoId) REFERENCES WalletInfo(walletInfoId) ); '''); - await db.execute(''' + await db.execute(""" CREATE UNIQUE INDEX IF NOT EXISTS idx_walletinfo_id_unique ON WalletInfo (id); -'''); - await db.execute(''' +"""); + await db.execute(''' CREATE TABLE BalanceCardStyleSettings ( walletInfoId INTEGER, accountIndex INTEGER DEFAULT -1, gradientIndex INTEGER DEFAULT -1, useSpecialDesign BOOLEAN DEFAULT FALSE, backgroundImagePath TEXT DEFAULT "", + iconStyleIndex INTEGER DEFAULT 0, + isGradientOnly BOOLEAN DEFAULT FALSE, cardOrder INTEGER DEFAULT 0, + hidden BOOLEAN DEFAULT FALSE, PRIMARY KEY (walletInfoId, accountIndex), FOREIGN KEY (walletInfoId) REFERENCES WalletInfo(walletInfoId) ); '''); - await _createBridgeTransferTable(db); - await _createTradeTable(db); - }); + await _createBridgeTransferTable(db); + await _createNodeTable(db); + await _createTradeTable(db); + await _createChartsTables(db); + await _createErc20TokenTable(db); + await _createSplTokenTable(db); + await _createTronTokenTable(db); + await _createImportedNFTTable(db); + await _createWalletInfoAccountTable(db); + }, + ); +} + +Future _createChartsTables(Database db) async { + await db.execute(""" +CREATE TABLE PriceData ( + fromCurrency TEXT NOT NULL, + toCurrency TEXT NOT NULL, + timestamp INTEGER NOT NULL, + price TEXT NOT NULL, + PRIMARY KEY (fromCurrency, toCurrency, timestamp) +); +"""); + await db.execute(""" +CREATE TABLE ChartsAssets ( + asset TEXT PRIMARY KEY, + isFavorite BOOLEAN DEFAULT FALSE +); + """); } Future _createTradeTable(Database db) async { - await db.execute(''' + await db.execute(""" CREATE TABLE IF NOT EXISTS Trade ( tradeId INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT, id TEXT NOT NULL, @@ -225,6 +365,7 @@ CREATE TABLE IF NOT EXISTS Trade ( refundAddress TEXT, walletId TEXT, payoutAddress TEXT, + toAddressExtraId TEXT, password TEXT, providerId TEXT, providerName TEXT, @@ -245,11 +386,48 @@ CREATE TABLE IF NOT EXISTS Trade ( chainId INTEGER, fee REAL ); -'''); - await db.execute(''' +"""); + await db.execute(""" CREATE UNIQUE INDEX IF NOT EXISTS idx_trade_id_unique ON Trade (id); -'''); +"""); +} + +Future _createWalletInfoAccountTable(Database db) async { + await db.execute(""" +CREATE TABLE IF NOT EXISTS WalletInfoAccount ( + walletInfoId INTEGER NOT NULL, + accountIndex INTEGER NOT NULL, + label TEXT NOT NULL, + PRIMARY KEY (walletInfoId, accountIndex), + CONSTRAINT WalletInfoAccount_WalletInfo_FK FOREIGN KEY (walletInfoId) REFERENCES WalletInfo(walletInfoId) +); +"""); + + await db.execute(""" +CREATE INDEX IF NOT EXISTS idx_walletinfoaccount_walletinfoid +ON WalletInfoAccount(walletInfoId); +"""); +} + + +Future _migrateBitcoinCardStylesForAccounts(Database db) async { + const bitcoinWallets = 'SELECT walletInfoId FROM WalletInfo WHERE "type" = ?'; + final btc = WalletType.bitcoin.index; + + // Lightning: 0 -> -2 (first, so accountIndex 0 is free for the Bitcoin card) + await db.rawUpdate( + "UPDATE OR REPLACE BalanceCardStyleSettings SET accountIndex = -2 " + "WHERE accountIndex = 0 AND walletInfoId IN ($bitcoinWallets)", + [btc], + ); + + // Bitcoin: -1 -> 0 (primary account, first in order) + await db.rawUpdate( + "UPDATE OR REPLACE BalanceCardStyleSettings SET accountIndex = 0, cardOrder = 0 " + "WHERE accountIndex = -1 AND walletInfoId IN ($bitcoinWallets)", + [btc], + ); } Future> dumpDb() async { @@ -265,7 +443,7 @@ Future> dumpDb() async { Future> _getTableNames(Database db) async { final tableNames = await db.rawQuery('SELECT name FROM sqlite_master WHERE type = "table"'); - return tableNames.map((e) => (e["name"]).toString()).toList(); + return tableNames.map((e) => e["name"].toString()).toList(); } Future> _dumpDb() async { @@ -288,7 +466,7 @@ Future> dumpCustomDb(String path) async { } Future _createBridgeTransferTable(Database db) async { - await db.execute(''' + await db.execute(""" CREATE TABLE IF NOT EXISTS BridgeTransfer ( id TEXT NOT NULL PRIMARY KEY, wallet_id TEXT NOT NULL, @@ -307,9 +485,116 @@ CREATE TABLE IF NOT EXISTS BridgeTransfer ( error_message TEXT, status_message TEXT ); -'''); - await db.execute(''' +"""); + await db.execute(""" CREATE INDEX IF NOT EXISTS idx_bridgetransfer_wallet_id ON BridgeTransfer(wallet_id); -'''); +"""); +} + +Future _createErc20TokenTable(Database db) async { + await db.execute(""" +CREATE TABLE IF NOT EXISTS Erc20Token ( + Erc20TokenId INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT, + walletName TEXT NOT NULL, + chainId INTEGER NOT NULL, + name TEXT NOT NULL DEFAULT '', + symbol TEXT NOT NULL DEFAULT '', + contractAddress TEXT NOT NULL, + decimal INTEGER NOT NULL DEFAULT 0, + enabled INTEGER NOT NULL DEFAULT 1, + iconPath TEXT, + tag TEXT, + isPotentialScam INTEGER NOT NULL DEFAULT 0 +); +"""); + await db.execute(""" +CREATE UNIQUE INDEX IF NOT EXISTS idx_erc20token_wallet_chain_contract +ON Erc20Token (walletName, chainId, contractAddress); +"""); +} + +Future _createSplTokenTable(Database db) async { + await db.execute(""" +CREATE TABLE IF NOT EXISTS SPLToken ( + SPLTokenId INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT, + walletName TEXT NOT NULL, + name TEXT NOT NULL DEFAULT '', + symbol TEXT NOT NULL DEFAULT '', + mintAddress TEXT NOT NULL, + decimal INTEGER NOT NULL DEFAULT 0, + mint TEXT NOT NULL DEFAULT '', + enabled INTEGER NOT NULL DEFAULT 1, + iconPath TEXT, + tag TEXT, + isPotentialScam INTEGER NOT NULL DEFAULT 0 +); +"""); + await db.execute(""" +CREATE UNIQUE INDEX IF NOT EXISTS idx_spltoken_wallet_mint +ON SPLToken (walletName, mintAddress); +"""); +} + +Future _createImportedNFTTable(Database db) async { + await db.execute(""" +CREATE TABLE IF NOT EXISTS ImportedNFT ( + ImportedNFTId INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT, + walletName TEXT NOT NULL, + chain TEXT NOT NULL, + identifier TEXT NOT NULL, + name TEXT, + symbol TEXT, + description TEXT, + imageUrl TEXT, + isOwned INTEGER +); +"""); + await db.execute(""" +CREATE UNIQUE INDEX IF NOT EXISTS idx_importednft_wallet_chain_identifier +ON ImportedNFT (walletName, chain, identifier); +"""); +} + +Future _createTronTokenTable(Database db) async { + await db.execute(""" +CREATE TABLE IF NOT EXISTS TronToken ( + TronTokenId INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT, + walletName TEXT NOT NULL, + name TEXT NOT NULL DEFAULT '', + symbol TEXT NOT NULL DEFAULT '', + contractAddress TEXT NOT NULL, + decimal INTEGER NOT NULL DEFAULT 0, + enabled INTEGER NOT NULL DEFAULT 1, + iconPath TEXT, + tag TEXT, + isPotentialScam INTEGER NOT NULL DEFAULT 0 +); +"""); + await db.execute(""" +CREATE UNIQUE INDEX IF NOT EXISTS idx_trontoken_wallet_contract +ON TronToken (walletName, contractAddress); +"""); +} + +Future _createNodeTable(Database db) async { + await db.execute(""" +CREATE TABLE Node ( +NodeId INTEGER PRIMARY KEY, +uri TEXT NOT NULL, +path TEXT, +login TEXT, +label TEXT, +password TEXT, +isPow INTEGER NOT NULL, +useSSL INTEGER, +typeRaw INTEGER NOT NULL, +trusted INTEGER NOT NULL, +socksProxyAddress TEXT, +isEnabledForAutoSwitching BOOLEAN DEFAULT FALSE, +isOfficial BOOLEAN DEFAULT FALSE, +isBuiltin BOOLEAN DEFAULT FALSE, +isDefault BOOLEAN DEFAULT FALSE +); + """); } diff --git a/cw_core/lib/db/sqlite_debug.dart b/cw_core/lib/db/sqlite_debug.dart new file mode 100644 index 000000000..dc1419144 --- /dev/null +++ b/cw_core/lib/db/sqlite_debug.dart @@ -0,0 +1,177 @@ +import 'dart:convert'; +import 'dart:io'; +import 'dart:typed_data'; + +import 'package:cw_core/db/sqlite.dart'; +import 'package:cw_core/root_dir.dart'; +import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; + +Future handleSqliteError(final dynamic error, final StackTrace stackTrace) async { + try { + final f = await sqliteDebugMarkerFile(); + f.createSync(); + } catch (_) {} + runApp( + SqliteErrorHandlerApp( + error: error, + stackTrace: stackTrace, + ), + ); + await Future.delayed(Duration(hours: 24)); +} + +class SqliteErrorHandlerApp extends StatelessWidget { + const SqliteErrorHandlerApp({ + super.key, + required this.error, + required this.stackTrace, + }); + + final dynamic error; + final StackTrace? stackTrace; + @override + Widget build(final BuildContext context) { + return MaterialApp( + debugShowCheckedModeBanner: false, + title: 'Cake Wallet Crash', + themeMode: ThemeMode.light, + home: SqliteErrorHandler( + error: error, + stackTrace: stackTrace, + ), + ); + } +} + +class SqliteErrorHandler extends StatefulWidget { + SqliteErrorHandler({ + super.key, + required this.error, + required this.stackTrace, + }); + + final dynamic error; + final StackTrace? stackTrace; + + @override + State createState() => _SqliteErrorHandlerState(); +} + +class _SqliteErrorHandlerState extends State { + String log = ""; + + @override + Widget build(final BuildContext context) { + return Scaffold( + appBar: AppBar( + title: Text("Sqlite Recovery Screen"), + ), + body: SingleChildScrollView( + child: Padding( + padding: const EdgeInsets.all(8.0), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: _buildNotice(), + ), + ), + ), + ); + } + + Widget _text(final dynamic element) { + return SelectableText( + element.toString(), + ); + } + + void _dbgRemoveDebugMarker() async { + final f = await sqliteDebugMarkerFile(); + f.delete(); + } + + void _corruptTheDb() async { + final dbFile = File("${(await getAppDir()).path}/cake.db"); + await writeBytesToFile(dbFile); + } + + void _dumpDb() async { + try { + final dbFile = File("${(await getAppDir()).path}/cake.db"); + final jsonText = JsonEncoder.withIndent(" ").convert(await dumpCustomDb(dbFile.path)); + setState(() { + log = jsonText; + }); + } catch (e, s) { + setState(() { + log = """ +e: $e + +s: $s +"""; + }); + } + } + + void _removeDb() async { + try { + File("${(await getAppDir()).path}/cake.db.old").deleteSync(); + } catch (_) {} + final dbFile = File("${(await getAppDir()).path}/cake.db"); + dbFile.renameSync("${(await getAppDir()).path}/cake.db.old"); + } + + List _debugOptions() { + return [ + _debugButton("Remove debug marker", _dbgRemoveDebugMarker), + if (kDebugMode || kProfileMode) _debugButton("[DO NOT PRESS] Corrupt the DB", _corruptTheDb), + _debugButton("Remove DB", _removeDb), + _debugButton("Dump DB", _dumpDb), + ]; + } + + Widget _debugButton(String text, VoidCallback callback) { + return ElevatedButton( + onPressed: callback, + child: Text(text), + ); + } + + List _buildNotice() { + return [ + _text("""Critical error occured in SQLite +This is very rare, usually caused by the device killing the app before it finishes writing file. +Here you can recover the database or recreate it to regain access to your wallets. +During this process some of the metadata may get lost. +If you need help, report it at https://hash.boats/report."""), + Divider(), + ..._debugOptions(), + if (log.isNotEmpty) ...[ + Divider(), + SelectableText( + log, + style: TextStyle( + fontSize: 8, + ), + ), + ], + Divider(), + _text(widget.error), + Divider(), + _text(widget.stackTrace), + ]; + } +} + +Future writeBytesToFile(File file) async { + const int seek = 5; + const int count = 50000; + final raf = await file.open(mode: FileMode.writeOnlyAppend); + try { + await raf.setPosition(seek); + final zeros = Uint8List.fromList(List.generate(count, (_) => 0x42)); + await raf.writeFrom(zeros); + } finally { + await raf.close(); + } +} diff --git a/cw_core/lib/encryption_file_utils.dart b/cw_core/lib/encryption_file_utils.dart index 72a601bc3..54729b735 100644 --- a/cw_core/lib/encryption_file_utils.dart +++ b/cw_core/lib/encryption_file_utils.dart @@ -1,41 +1,34 @@ -import 'dart:io'; -import 'dart:typed_data'; import 'package:cw_core/utils/file.dart' as file; -import 'package:cake_backup/backup.dart' as cwb; -EncryptionFileUtils encryptionFileUtilsFor(bool direct) => - direct ? XChaCha20EncryptionFileUtils() : Salsa20EncryhptionFileUtils(); +EncryptionFileUtils encryptionFileUtilsFor(bool isDirect) => + XChaCha20MigratingEncryptionFileUtils(isDirect: isDirect); abstract class EncryptionFileUtils { Future write({required String path, required String password, required String data}); Future read({required String path, required String password}); } -class Salsa20EncryhptionFileUtils extends EncryptionFileUtils { - // Requires legacy complex key + iv as password - @override - Future write( - {required String path, required String password, required String data}) async => - await file.write(path: path, password: password, data: data); +class XChaCha20MigratingEncryptionFileUtils extends EncryptionFileUtils { + XChaCha20MigratingEncryptionFileUtils({required this.isDirect}); - // Requires legacy complex key + iv as password - @override - Future read({required String path, required String password}) async => - await file.read(path: path, password: password); -} + final bool isDirect; -class XChaCha20EncryptionFileUtils extends EncryptionFileUtils { @override - Future write({required String path, required String password, required String data}) async { - final encrypted = await cwb.encrypt(password, Uint8List.fromList(data.codeUnits)); - await File(path).writeAsBytes(encrypted); + Future write({required String path, required String password, required String data}) { + return file.write( + path: path, + password: password, + data: data, + highEntropyPassphrase: !isDirect, + ); } @override - Future read({required String path, required String password}) async { - final file = File(path); - final encrypted = await file.readAsBytes(); - final bytes = await cwb.decrypt(password, encrypted); - return String.fromCharCodes(bytes); + Future read({required String path, required String password}) { + return file.read( + path: path, + password: password, + highEntropyPassphrase: !isDirect, + ); } } diff --git a/cw_core/lib/enumerable_item.dart b/cw_core/lib/enumerable_item.dart index e7a863909..3eac165be 100644 --- a/cw_core/lib/enumerable_item.dart +++ b/cw_core/lib/enumerable_item.dart @@ -1,5 +1,3 @@ -import 'package:flutter/foundation.dart'; - abstract class EnumerableItem { const EnumerableItem({required this.title, required this.raw}); diff --git a/cw_core/lib/enumerate.dart b/cw_core/lib/enumerate.dart index d92347e91..70fc2e7a6 100644 --- a/cw_core/lib/enumerate.dart +++ b/cw_core/lib/enumerate.dart @@ -2,7 +2,7 @@ abstract class Enumerate { String get value; @override - operator ==(other) { + bool operator ==(other) { if (identical(other, this)) return true; if (other is! Enumerate) return false; return other.runtimeType == runtimeType && value == other.value; diff --git a/cw_core/lib/erc20_token.dart b/cw_core/lib/erc20_token.dart index e19cbc63f..0132183b1 100644 --- a/cw_core/lib/erc20_token.dart +++ b/cw_core/lib/erc20_token.dart @@ -1,32 +1,8 @@ -import 'package:cw_core/crypto_currency.dart'; -import 'package:cw_core/hive_type_ids.dart'; -import 'package:hive/hive.dart'; - -part 'erc20_token.g.dart'; - -@HiveType(typeId: Erc20Token.typeId) -class Erc20Token extends CryptoCurrency with HiveObjectMixin { - @HiveField(0) - final String name; - @HiveField(1) - final String symbol; - @HiveField(2) - final String contractAddress; - @HiveField(3) - final int decimal; - @HiveField(4, defaultValue: true) - bool _enabled; - @HiveField(5) - String? iconPath; - @HiveField(6) - final String? tag; - @HiveField(7, defaultValue: false) - bool isPotentialScam; - - bool get enabled => _enabled; - - set enabled(bool value) => _enabled = value; +import "package:cw_core/crypto_currency.dart"; +import "package:cw_core/db/sqlite.dart"; +import "package:sqflite/sqflite.dart"; +class Erc20Token extends CryptoCurrency { Erc20Token({ required this.name, required this.symbol, @@ -36,6 +12,10 @@ class Erc20Token extends CryptoCurrency with HiveObjectMixin { this.iconPath, this.tag, this.isPotentialScam = false, + this.id = 0, + this.walletName, + this.chainId, + Set groups = const {}, }) : _enabled = enabled, super( name: symbol.toLowerCase(), @@ -45,38 +25,172 @@ class Erc20Token extends CryptoCurrency with HiveObjectMixin { iconPath: iconPath, decimals: decimal, isPotentialScam: isPotentialScam, + groups: groups, ); - Erc20Token.copyWith(Erc20Token other, {String? icon, String? tag, bool? enabled}) - : this.name = other.name, - this.symbol = other.symbol, - this.contractAddress = other.contractAddress, - this.decimal = other.decimal, - this._enabled = enabled ?? other.enabled, - this.tag = tag ?? other.tag, - this.iconPath = icon ?? other.iconPath, - this.isPotentialScam = other.isPotentialScam, + Erc20Token.copyWith( + Erc20Token other, { + String? icon, + super.tag, + bool? enabled, + String? walletName, + int? chainId, + }) : name = other.name, + symbol = other.symbol, + contractAddress = other.contractAddress, + decimal = other.decimal, + _enabled = enabled ?? other.enabled, + tag = tag ?? other.tag, + iconPath = icon ?? other.iconPath, + isPotentialScam = other.isPotentialScam, + id = 0, + walletName = walletName ?? other.walletName, + chainId = chainId ?? other.chainId, super( name: other.name, title: other.symbol.toUpperCase(), fullName: other.name, - tag: tag, iconPath: icon, decimals: other.decimal, isPotentialScam: other.isPotentialScam, + groups: other.groups, ); - static const typeId = ERC20_TOKEN_TYPE_ID; - static const boxName = 'Erc20Tokens'; - static const ethereumBoxName = 'EthereumErc20Tokens'; - static const polygonBoxName = 'PolygonErc20Tokens'; - static const baseBoxName = 'BaseErc20Tokens'; - static const arbitrumBoxName = 'ArbitrumErc20Tokens'; - static const bscBoxName = 'BscErc20Tokens'; + Erc20Token.fromMap(Map map) + : this( + name: map["name"] as String? ?? "", + symbol: map["symbol"] as String? ?? "", + contractAddress: map["contractAddress"] as String? ?? "", + decimal: (map["decimal"] ?? 0) as int, + enabled: _getBoolFromDB(map["enabled"], defaultValue: true), + iconPath: map["iconPath"] as String?, + tag: map["tag"] as String?, + isPotentialScam: _getBoolFromDB(map["isPotentialScam"]), + id: (map[selfIdColumn] ?? 0) as int, + walletName: map["walletName"] as String?, + chainId: map["chainId"] as int?, + ); + + @override + final String name; + + @override + final String symbol; + + @override + String? iconPath; + + @override + final String? tag; + + @override + bool isPotentialScam; + + @override + bool get enabled => _enabled; + + @override + set enabled(bool value) => _enabled = value; + + int id; + int? chainId; + bool _enabled; + final int decimal; + String? walletName; + final String contractAddress; + + static bool _getBoolFromDB(value, {bool? defaultValue}) { + if (value is bool) { + return value; + } else if (value is int) { + return value == 1; + } else { + return defaultValue ?? false; + } + } + + Map toMap() => { + selfIdColumn: id, + "walletName": walletName, + "chainId": chainId, + "name": name, + "symbol": symbol, + "contractAddress": contractAddress.toLowerCase(), + "decimal": decimal, + "enabled": _enabled ? 1 : 0, + "iconPath": iconPath, + "tag": tag, + "isPotentialScam": isPotentialScam ? 1 : 0, + }; + + static String get tableName => "Erc20Token"; + static String get selfIdColumn => "${tableName}Id"; + + Future save() async { + if (walletName == null || chainId == null) { + throw StateError("Erc20Token.save() requires walletName and chainId to be set"); + } + + final json = toMap(); + if (json[selfIdColumn] == 0) { + json[selfIdColumn] = null; + } + id = await db!.insert(tableName, json, conflictAlgorithm: ConflictAlgorithm.replace); + return id; + } + + static Future> selectList( + String where, + List whereArgs, { + String? orderBy, + }) async { + orderBy ??= selfIdColumn; + final list = await db!.query( + tableName, + where: where.isNotEmpty ? where : "1 = 1", + whereArgs: whereArgs.isNotEmpty ? whereArgs : null, + orderBy: orderBy, + ); + return List.generate(list.length, (index) => Erc20Token.fromMap(list[index])); + } + + static Future> getAllForWallet(String walletName, int chainId) => + selectList("walletName = ? AND chainId = ?", [walletName, chainId]); + + static Future getByContract( + String walletName, + int chainId, + String contractAddress, + ) async { + final list = await selectList( + "walletName = ? AND chainId = ? AND contractAddress = ?", + [walletName, chainId, contractAddress.toLowerCase()], + ); + return list.isEmpty ? null : list.first; + } + + static Future deleteForWallet(String walletName, int chainId, String contractAddress) => + db!.delete( + tableName, + where: "walletName = ? AND chainId = ? AND contractAddress = ?", + whereArgs: [walletName, chainId, contractAddress.toLowerCase()], + ); + + static Future deleteAllForWallet(String walletName) => + db!.delete(tableName, where: "walletName = ?", whereArgs: [walletName]); + + static Future renameWallet(String oldName, String newName) async { + await db!.delete(tableName, where: "walletName = ?", whereArgs: [newName]); + await db! + .update(tableName, {"walletName": newName}, where: "walletName = ?", whereArgs: [oldName]); + } @override bool operator ==(Object other) => other is Erc20Token && other.contractAddress == contractAddress; @override int get hashCode => contractAddress.hashCode; + + @override + String get apiString => "evm.$contractAddress"; } diff --git a/cw_core/lib/erc20_token_legacy.dart b/cw_core/lib/erc20_token_legacy.dart new file mode 100644 index 000000000..c965899cd --- /dev/null +++ b/cw_core/lib/erc20_token_legacy.dart @@ -0,0 +1,213 @@ +import "package:cw_core/cake_hive.dart"; +import "package:cw_core/db/sqlite.dart"; +import "package:cw_core/erc20_token.dart" as erc20_new; +import "package:cw_core/hive_type_ids.dart"; +import "package:cw_core/utils/print_verbose.dart"; +import "package:cw_core/wallet_info.dart"; +import "package:cw_core/wallet_type.dart"; +import "package:hive/hive.dart"; +import "package:sqflite/sqflite.dart"; + +part "erc20_token_legacy.part.dart"; + +Future performErc20TokenHiveMigration() async { + try { + if (!CakeHive.isAdapterRegistered(Erc20Token.typeId)) { + CakeHive.registerAdapter(Erc20TokenAdapter()); + } + + final wallets = await WalletInfo.getAll(); + await Erc20Token.migrateAllToSqlite(wallets); + } catch (e) { + printV("Error performing Erc20Token Hive migration: $e, continuing anyway"); + } +} + +// @HiveType(typeId: Erc20Token.typeId) +class Erc20Token extends HiveObject { + Erc20Token({ + required this.name, + required this.symbol, + required this.contractAddress, + required this.decimal, + bool enabled = true, + this.iconPath, + this.tag, + this.isPotentialScam = false, + }) : _enabled = enabled; + // @HiveField(0) + final String name; + // @HiveField(1) + final String symbol; + // @HiveField(2) + final String contractAddress; + // @HiveField(3) + final int decimal; + // @HiveField(4, defaultValue: true) + bool _enabled; + // @HiveField(5) + String? iconPath; + // @HiveField(6) + final String? tag; + // @HiveField(7, defaultValue: false) + bool isPotentialScam; + + bool get enabled => _enabled; + + set enabled(bool value) => _enabled = value; + + static const typeId = ERC20_TOKEN_TYPE_ID; + static const boxName = "Erc20Tokens"; + static const ethereumBoxName = "EthereumErc20Tokens"; + static const polygonBoxName = "PolygonErc20Tokens"; + static const baseBoxName = "BaseErc20Tokens"; + static const arbitrumBoxName = "ArbitrumErc20Tokens"; + static const bscBoxName = "BscErc20Tokens"; + + static const chainIdToBoxSuffix = { + 1: ethereumBoxName, + 137: polygonBoxName, + 8453: baseBoxName, + 42161: arbitrumBoxName, + 56: bscBoxName, + }; + + static const evmWalletTypes = [ + WalletType.ethereum, + WalletType.polygon, + WalletType.base, + WalletType.arbitrum, + WalletType.bsc, + ]; + + static Future migrateAllToSqlite(List wallets) async { + await _migrateLegacyGlobalBox(wallets); + + final sanitizedToRawNames = >{}; + for (final wallet in wallets) { + if (!evmWalletTypes.contains(wallet.type)) { + continue; + } + + sanitizedToRawNames + .putIfAbsent(wallet.name.replaceAll(" ", "_"), () => {}) + .add(wallet.name); + } + + for (final entry in sanitizedToRawNames.entries) { + for (final chainEntry in chainIdToBoxSuffix.entries) { + final tokenBoxName = "${entry.key}_${chainEntry.value}"; + try { + if (!await CakeHive.boxExists(tokenBoxName)) { + continue; + } + + final box = await CakeHive.openBox(tokenBoxName); + + for (final group in _mergeByLowercaseContract(box)) { + for (final rawName in entry.value) { + await group.token.migrateToSqlite(walletName: rawName, chainId: chainEntry.key); + } + await box.deleteAll(group.sourceKeys); + } + + await box.deleteFromDisk(); + } catch (e) { + printV("Error migrating erc20 token box $tokenBoxName: $e, continuing anyway"); + } + } + } + } + + static Future _migrateLegacyGlobalBox(List wallets) async { + try { + if (!await CakeHive.boxExists(boxName)) { + return; + } + + if (!wallets.any((wallet) => wallet.type == WalletType.ethereum)) { + return; + } + + final box = await CakeHive.openBox(boxName); + final ethereumWallets = + wallets.where((wallet) => wallet.type == WalletType.ethereum).toList(); + + for (final group in _mergeByLowercaseContract(box)) { + for (final wallet in ethereumWallets) { + await group.token.migrateToSqlite(walletName: wallet.name, chainId: 1); + } + await box.deleteAll(group.sourceKeys); + } + + await box.deleteFromDisk(); + } catch (e) { + printV("Error migrating legacy global erc20 token box: $e, continuing anyway"); + } + } + + static List<_MergedTokenGroup> _mergeByLowercaseContract(Box box) { + final groups = {}; + + for (final key in box.keys) { + final token = box.get(key); + if (token == null) { + continue; + } + + final lowerKey = token.contractAddress.toLowerCase(); + final existing = groups[lowerKey]; + + if (existing == null) { + groups[lowerKey] = _MergedTokenGroup(token: token, sourceKeys: [key]); + continue; + } + + groups[lowerKey] = _MergedTokenGroup( + token: Erc20Token( + name: token.name, + symbol: token.symbol, + contractAddress: lowerKey, + decimal: token.decimal, + enabled: token.enabled || existing.token.enabled, + iconPath: + (token.iconPath?.isNotEmpty ?? false) ? token.iconPath : existing.token.iconPath, + tag: token.tag ?? existing.token.tag, + isPotentialScam: token.isPotentialScam || existing.token.isPotentialScam, + ), + sourceKeys: [...existing.sourceKeys, key], + ); + } + + return groups.values.toList(); + } + + Future migrateToSqlite({required String walletName, required int chainId}) async { + final row = erc20_new.Erc20Token( + name: name, + symbol: symbol, + contractAddress: contractAddress, + decimal: decimal, + enabled: _enabled, + iconPath: iconPath, + tag: tag, + isPotentialScam: isPotentialScam, + walletName: walletName, + chainId: chainId, + ).toMap(); + row[erc20_new.Erc20Token.selfIdColumn] = null; + + await db!.insert( + erc20_new.Erc20Token.tableName, + row, + conflictAlgorithm: ConflictAlgorithm.replace, + ); + } +} + +class _MergedTokenGroup { + _MergedTokenGroup({required this.token, required this.sourceKeys}); + + final Erc20Token token; + final List sourceKeys; +} diff --git a/cw_core/lib/erc20_token_legacy.part.dart b/cw_core/lib/erc20_token_legacy.part.dart new file mode 100644 index 000000000..9614c0c1a --- /dev/null +++ b/cw_core/lib/erc20_token_legacy.part.dart @@ -0,0 +1,59 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of "erc20_token_legacy.dart"; + +// ************************************************************************** +// TypeAdapterGenerator +// ************************************************************************** + +class Erc20TokenAdapter extends TypeAdapter { + @override + final int typeId = 12; + + @override + Erc20Token read(BinaryReader reader) { + final numOfFields = reader.readByte(); + final fields = { + for (int i = 0; i < numOfFields; i++) reader.readByte(): reader.read(), + }; + return Erc20Token( + name: fields[0] as String, + symbol: fields[1] as String, + contractAddress: fields[2] as String, + decimal: fields[3] as int, + iconPath: fields[5] as String?, + tag: fields[6] as String?, + isPotentialScam: fields[7] == null ? false : fields[7] as bool, + ).._enabled = fields[4] == null ? true : fields[4] as bool; + } + + @override + void write(BinaryWriter writer, Erc20Token obj) { + writer + ..writeByte(8) + ..writeByte(0) + ..write(obj.name) + ..writeByte(1) + ..write(obj.symbol) + ..writeByte(2) + ..write(obj.contractAddress) + ..writeByte(3) + ..write(obj.decimal) + ..writeByte(4) + ..write(obj._enabled) + ..writeByte(5) + ..write(obj.iconPath) + ..writeByte(6) + ..write(obj.tag) + ..writeByte(7) + ..write(obj.isPotentialScam); + } + + @override + int get hashCode => typeId.hashCode; + + @override + bool operator ==(Object other) => + identical(this, other) || + other is Erc20TokenAdapter && runtimeType == other.runtimeType && typeId == other.typeId; +} diff --git a/cw_core/lib/exceptions.dart b/cw_core/lib/exceptions.dart index 1f2fcca60..b64a4115e 100644 --- a/cw_core/lib/exceptions.dart +++ b/cw_core/lib/exceptions.dart @@ -26,9 +26,7 @@ class TransactionCommitFailed implements Exception { TransactionCommitFailed({this.errorMessage}); @override - String toString() { - return errorMessage ?? "unknown error"; - } + String toString() => errorMessage ?? "unknown error"; } class TransactionCommitFailedDustChange implements Exception {} @@ -57,11 +55,35 @@ class SignSPLTokenTransactionRentException implements Exception {} class NoAssociatedTokenAccountException implements Exception {} -/// ============================================================================== -/// ============================================================================== +class NotAnNFTException implements Exception {} + +class AmbiguousTokenSymbolException implements Exception { + AmbiguousTokenSymbolException(this.symbol); + + final String symbol; +} class RestoreFromSeedException implements Exception { final String message; RestoreFromSeedException(this.message); } + +class WalletDeprecationException implements Exception { + final String seed; + final CryptoCurrency curr; + + @override + String toString() => "Wallet type no longer supported"; + + WalletDeprecationException({required this.seed, required this.curr}); +} + +class WalletSwitchException implements Exception { + WalletSwitchException(this.message); + + final String message; + + @override + String toString() => message; +} diff --git a/cw_core/lib/format_fixed.dart b/cw_core/lib/format_fixed.dart index 52c0e1470..4e02c48d5 100644 --- a/cw_core/lib/format_fixed.dart +++ b/cw_core/lib/format_fixed.dart @@ -4,33 +4,44 @@ String formatFixed(BigInt value, int? decimals, {int? fractionalDigits, bool tri decimals ??= 0; fractionalDigits ??= decimals; - var multiplier = getMultiplier(decimals); + final multiplier = getMultiplier(decimals); // Make sure wei is a big number (convert as necessary) - var negative = value.isNegative; - if (negative) value = value * BigInt.from(-1); + final negative = value.isNegative; + if (negative) { + value = -value; + } var fraction = value.modPow(BigInt.one, BigInt.parse(multiplier)).toString().padLeft(decimals, "0"); - if (fractionalDigits < 0) fractionalDigits = 0; - if (fractionalDigits > decimals) fractionalDigits = decimals; + if (fractionalDigits < 0) { + fractionalDigits = 0; + } + if (fractionalDigits > decimals) { + fractionalDigits = decimals; + } fraction = fraction.substring(0, fractionalDigits); if (trimZeros) { fraction = removeTrailing("0", fraction); } - final whole = (value ~/ BigInt.parse(multiplier)); + final whole = value ~/ BigInt.parse(multiplier); final valString = fraction.isEmpty ? "$whole" : "$whole.$fraction"; - if (negative) return "-$valString"; + if (negative) { + return "-$valString"; + } return valString; } String removeTrailing(String pattern, String from) { - if (pattern.isEmpty) return from; + if (pattern.isEmpty) { + return from; + } + var i = from.length; while (i > 0 && from.startsWith(pattern, i - pattern.length)) { i -= pattern.length; diff --git a/cw_core/lib/get_height_by_date.dart b/cw_core/lib/get_height_by_date.dart index 53453a125..b16378703 100644 --- a/cw_core/lib/get_height_by_date.dart +++ b/cw_core/lib/get_height_by_date.dart @@ -1,5 +1,4 @@ import 'package:cw_core/utils/proxy_wrapper.dart'; -import 'package:cw_core/utils/print_verbose.dart'; import 'package:intl/intl.dart'; import 'package:cw_core/get_height_by_date_xmr.dart'; import 'dart:convert'; @@ -7,7 +6,7 @@ import 'dart:convert'; // FIXME: Hardcoded values; Works only for monero int getMoneroHeigthByDate({required DateTime date}) => - MoneroHeight.getBlockHeightByTime(date..subtract(Duration(days: 1))); + MoneroHeight.getBlockHeightByTime(date.subtract(const Duration(days: 1))); const havenDates = { "2023-05": 1352995, diff --git a/cw_core/lib/get_height_by_date_zec.dart b/cw_core/lib/get_height_by_date_zec.dart new file mode 100644 index 000000000..2176e8bfc --- /dev/null +++ b/cw_core/lib/get_height_by_date_zec.dart @@ -0,0 +1,416 @@ +class ZcashHeight { + // for i in $(seq 1 331); do echo "_Checkpoint($(date -j -f "%Y-%m-%d %H:%M:%S" "$(curl -s "https://mainnet.zcashexplorer.app/blocks/${i}0000" | grep -oE '[0-9]{4}-[0-9]{2}-[0-9]{2} [0-9]{2}:[0-9]{2}:[0-9]{2}')" "+%s"), ${i}0000),"; done > points.txt + static final List<_Checkpoint> _points = [ + _Checkpoint(1479114667, 10000), + _Checkpoint(1480618460, 20000), + _Checkpoint(1482129143, 30000), + _Checkpoint(1483635610, 40000), + _Checkpoint(1485145514, 50000), + _Checkpoint(1486655742, 60000), + _Checkpoint(1488163642, 70000), + _Checkpoint(1489668244, 80000), + _Checkpoint(1491175107, 90000), + _Checkpoint(1492681094, 100000), + _Checkpoint(1494185626, 110000), + _Checkpoint(1495691260, 120000), + _Checkpoint(1497196465, 130000), + _Checkpoint(1498702428, 140000), + _Checkpoint(1500209116, 150000), + _Checkpoint(1501716823, 160000), + _Checkpoint(1503222893, 170000), + _Checkpoint(1504730866, 180000), + _Checkpoint(1506236963, 190000), + _Checkpoint(1507744233, 200000), + _Checkpoint(1509254578, 210000), + _Checkpoint(1510768449, 220000), + _Checkpoint(1512275502, 230000), + _Checkpoint(1513783951, 240000), + _Checkpoint(1515292290, 250000), + _Checkpoint(1516799219, 260000), + _Checkpoint(1518306180, 270000), + _Checkpoint(1519813817, 280000), + _Checkpoint(1521319150, 290000), + _Checkpoint(1522828107, 300000), + _Checkpoint(1524336223, 310000), + _Checkpoint(1525844954, 320000), + _Checkpoint(1527354342, 330000), + _Checkpoint(1528860301, 340000), + _Checkpoint(1530365206, 350000), + _Checkpoint(1531873693, 360000), + _Checkpoint(1533380392, 370000), + _Checkpoint(1534888873, 380000), + _Checkpoint(1536393381, 390000), + _Checkpoint(1537899117, 400000), + _Checkpoint(1539406330, 410000), + _Checkpoint(1540912214, 420000), + _Checkpoint(1542422891, 430000), + _Checkpoint(1543931111, 440000), + _Checkpoint(1545437758, 450000), + _Checkpoint(1546945529, 460000), + _Checkpoint(1548450107, 470000), + _Checkpoint(1549957767, 480000), + _Checkpoint(1551466960, 490000), + _Checkpoint(1552969806, 500000), + _Checkpoint(1554477648, 510000), + _Checkpoint(1555981451, 520000), + _Checkpoint(1557489241, 530000), + _Checkpoint(1558997282, 540000), + _Checkpoint(1560503840, 550000), + _Checkpoint(1562011717, 560000), + _Checkpoint(1563518091, 570000), + _Checkpoint(1565027136, 580000), + _Checkpoint(1566532760, 590000), + _Checkpoint(1568039395, 600000), + _Checkpoint(1569547911, 610000), + _Checkpoint(1571051853, 620000), + _Checkpoint(1572559831, 630000), + _Checkpoint(1574069743, 640000), + _Checkpoint(1575576895, 650000), + _Checkpoint(1576603014, 660000), + _Checkpoint(1577356346, 670000), + _Checkpoint(1578109375, 680000), + _Checkpoint(1578863180, 690000), + _Checkpoint(1579616443, 700000), + _Checkpoint(1580369806, 710000), + _Checkpoint(1581122786, 720000), + _Checkpoint(1581876634, 730000), + _Checkpoint(1582630471, 740000), + _Checkpoint(1583383678, 750000), + _Checkpoint(1584133944, 760000), + _Checkpoint(1584887940, 770000), + _Checkpoint(1585641057, 780000), + _Checkpoint(1586395543, 790000), + _Checkpoint(1587148500, 800000), + _Checkpoint(1587901493, 810000), + _Checkpoint(1588655183, 820000), + _Checkpoint(1589408476, 830000), + _Checkpoint(1590163464, 840000), + _Checkpoint(1590916379, 850000), + _Checkpoint(1591669786, 860000), + _Checkpoint(1592423887, 870000), + _Checkpoint(1593176771, 880000), + _Checkpoint(1593930930, 890000), + _Checkpoint(1594684380, 900000), + _Checkpoint(1595439209, 910000), + _Checkpoint(1596192463, 920000), + _Checkpoint(1596946009, 930000), + _Checkpoint(1597700256, 940000), + _Checkpoint(1598453538, 950000), + _Checkpoint(1599207483, 960000), + _Checkpoint(1599960598, 970000), + _Checkpoint(1600714563, 980000), + _Checkpoint(1601467884, 990000), + _Checkpoint(1602220941, 1000000), + _Checkpoint(1602974722, 1010000), + _Checkpoint(1603728848, 1020000), + _Checkpoint(1604485637, 1030000), + _Checkpoint(1605238381, 1040000), + _Checkpoint(1605992807, 1050000), + _Checkpoint(1606747750, 1060000), + _Checkpoint(1607500606, 1070000), + _Checkpoint(1608255278, 1080000), + _Checkpoint(1609008684, 1090000), + _Checkpoint(1609763782, 1100000), + _Checkpoint(1610517871, 1110000), + _Checkpoint(1611271575, 1120000), + _Checkpoint(1612025546, 1130000), + _Checkpoint(1612779940, 1140000), + _Checkpoint(1613533993, 1150000), + _Checkpoint(1614287669, 1160000), + _Checkpoint(1615042297, 1170000), + _Checkpoint(1615792993, 1180000), + _Checkpoint(1616546145, 1190000), + _Checkpoint(1617299867, 1200000), + _Checkpoint(1618054038, 1210000), + _Checkpoint(1618808513, 1220000), + _Checkpoint(1619563243, 1230000), + _Checkpoint(1620318159, 1240000), + _Checkpoint(1621071298, 1250000), + _Checkpoint(1621826156, 1260000), + _Checkpoint(1622580237, 1270000), + _Checkpoint(1623335553, 1280000), + _Checkpoint(1624090141, 1290000), + _Checkpoint(1624844712, 1300000), + _Checkpoint(1625597720, 1310000), + _Checkpoint(1626351652, 1320000), + _Checkpoint(1627106054, 1330000), + _Checkpoint(1627860648, 1340000), + _Checkpoint(1628614015, 1350000), + _Checkpoint(1629368398, 1360000), + _Checkpoint(1630122679, 1370000), + _Checkpoint(1630877190, 1380000), + _Checkpoint(1631631884, 1390000), + _Checkpoint(1632385851, 1400000), + _Checkpoint(1633141006, 1410000), + _Checkpoint(1633895592, 1420000), + _Checkpoint(1634649550, 1430000), + _Checkpoint(1635403121, 1440000), + _Checkpoint(1636157669, 1450000), + _Checkpoint(1636915133, 1460000), + _Checkpoint(1637670036, 1470000), + _Checkpoint(1638424121, 1480000), + _Checkpoint(1639177413, 1490000), + _Checkpoint(1639931234, 1500000), + _Checkpoint(1640684476, 1510000), + _Checkpoint(1641438827, 1520000), + _Checkpoint(1642192402, 1530000), + _Checkpoint(1642946383, 1540000), + _Checkpoint(1643700901, 1550000), + _Checkpoint(1644455522, 1560000), + _Checkpoint(1645208504, 1570000), + _Checkpoint(1645962081, 1580000), + _Checkpoint(1646715150, 1590000), + _Checkpoint(1647465727, 1600000), + _Checkpoint(1648219045, 1610000), + _Checkpoint(1648973131, 1620000), + _Checkpoint(1649727422, 1630000), + _Checkpoint(1650481408, 1640000), + _Checkpoint(1651234287, 1650000), + _Checkpoint(1651989865, 1660000), + _Checkpoint(1652743327, 1670000), + _Checkpoint(1653497455, 1680000), + _Checkpoint(1654252094, 1690000), + _Checkpoint(1655005944, 1700000), + _Checkpoint(1655759708, 1710000), + _Checkpoint(1656513765, 1720000), + _Checkpoint(1657268358, 1730000), + _Checkpoint(1658022453, 1740000), + _Checkpoint(1658777019, 1750000), + _Checkpoint(1659531133, 1760000), + _Checkpoint(1660283907, 1770000), + _Checkpoint(1661037645, 1780000), + _Checkpoint(1661791977, 1790000), + _Checkpoint(1662544394, 1800000), + _Checkpoint(1663298018, 1810000), + _Checkpoint(1664051450, 1820000), + _Checkpoint(1664805547, 1830000), + _Checkpoint(1665558994, 1840000), + _Checkpoint(1666312456, 1850000), + _Checkpoint(1667066473, 1860000), + _Checkpoint(1667823393, 1870000), + _Checkpoint(1668576623, 1880000), + _Checkpoint(1669330657, 1890000), + _Checkpoint(1670084156, 1900000), + _Checkpoint(1670837831, 1910000), + _Checkpoint(1671591094, 1920000), + _Checkpoint(1672343975, 1930000), + _Checkpoint(1673097703, 1940000), + _Checkpoint(1673852016, 1950000), + _Checkpoint(1674605810, 1960000), + _Checkpoint(1675360050, 1970000), + _Checkpoint(1676113710, 1980000), + _Checkpoint(1676867174, 1990000), + _Checkpoint(1677620242, 2000000), + _Checkpoint(1678374852, 2010000), + _Checkpoint(1679125063, 2020000), + _Checkpoint(1679878121, 2030000), + _Checkpoint(1680632254, 2040000), + _Checkpoint(1681386518, 2050000), + _Checkpoint(1682140381, 2060000), + _Checkpoint(1682894564, 2070000), + _Checkpoint(1683648178, 2080000), + _Checkpoint(1684401932, 2090000), + _Checkpoint(1685156464, 2100000), + _Checkpoint(1685910040, 2110000), + _Checkpoint(1686663406, 2120000), + _Checkpoint(1687417875, 2130000), + _Checkpoint(1688172069, 2140000), + _Checkpoint(1688925610, 2150000), + _Checkpoint(1689679500, 2160000), + _Checkpoint(1690432691, 2170000), + _Checkpoint(1691186349, 2180000), + _Checkpoint(1691940901, 2190000), + _Checkpoint(1692694780, 2200000), + _Checkpoint(1693449743, 2210000), + _Checkpoint(1694203665, 2220000), + _Checkpoint(1694957643, 2230000), + _Checkpoint(1695710712, 2240000), + _Checkpoint(1696465787, 2250000), + _Checkpoint(1697218701, 2260000), + _Checkpoint(1697973031, 2270000), + _Checkpoint(1698726864, 2280000), + _Checkpoint(1699483348, 2290000), + _Checkpoint(1700237650, 2300000), + _Checkpoint(1700991210, 2310000), + _Checkpoint(1701745742, 2320000), + _Checkpoint(1702500723, 2330000), + _Checkpoint(1703253121, 2340000), + _Checkpoint(1704007238, 2350000), + _Checkpoint(1704760935, 2360000), + _Checkpoint(1705514669, 2370000), + _Checkpoint(1706268087, 2380000), + _Checkpoint(1707021423, 2390000), + _Checkpoint(1707776257, 2400000), + _Checkpoint(1708529542, 2410000), + _Checkpoint(1709283065, 2420000), + _Checkpoint(1710037165, 2430000), + _Checkpoint(1710787780, 2440000), + _Checkpoint(1711541488, 2450000), + _Checkpoint(1712294996, 2460000), + _Checkpoint(1713048843, 2470000), + _Checkpoint(1713803486, 2480000), + _Checkpoint(1714557127, 2490000), + _Checkpoint(1715311181, 2500000), + _Checkpoint(1716064271, 2510000), + _Checkpoint(1716818792, 2520000), + _Checkpoint(1717572765, 2530000), + _Checkpoint(1718326333, 2540000), + _Checkpoint(1719080275, 2550000), + _Checkpoint(1719834297, 2560000), + _Checkpoint(1720588042, 2570000), + _Checkpoint(1721341832, 2580000), + _Checkpoint(1722095122, 2590000), + _Checkpoint(1722848604, 2600000), + _Checkpoint(1723602357, 2610000), + _Checkpoint(1724355843, 2620000), + _Checkpoint(1725110085, 2630000), + _Checkpoint(1725863681, 2640000), + _Checkpoint(1726616892, 2650000), + _Checkpoint(1727371443, 2660000), + _Checkpoint(1728124893, 2670000), + _Checkpoint(1728878141, 2680000), + _Checkpoint(1729631572, 2690000), + _Checkpoint(1730385888, 2700000), + _Checkpoint(1731142437, 2710000), + _Checkpoint(1731896266, 2720000), + _Checkpoint(1732650193, 2730000), + _Checkpoint(1733403626, 2740000), + _Checkpoint(1734157390, 2750000), + _Checkpoint(1734911442, 2760000), + _Checkpoint(1735665720, 2770000), + _Checkpoint(1736419845, 2780000), + _Checkpoint(1737173511, 2790000), + _Checkpoint(1737926795, 2800000), + _Checkpoint(1738681511, 2810000), + _Checkpoint(1739434236, 2820000), + _Checkpoint(1740188214, 2830000), + _Checkpoint(1740941352, 2840000), + _Checkpoint(1741692338, 2850000), + _Checkpoint(1742445247, 2860000), + _Checkpoint(1743198927, 2870000), + _Checkpoint(1743952947, 2880000), + _Checkpoint(1744706370, 2890000), + _Checkpoint(1745461377, 2900000), + _Checkpoint(1746214735, 2910000), + _Checkpoint(1746967908, 2920000), + _Checkpoint(1747722271, 2930000), + _Checkpoint(1748474582, 2940000), + _Checkpoint(1749228582, 2950000), + _Checkpoint(1749983372, 2960000), + _Checkpoint(1750737278, 2970000), + _Checkpoint(1751490762, 2980000), + _Checkpoint(1752243931, 2990000), + _Checkpoint(1752997873, 3000000), + _Checkpoint(1753751756, 3010000), + _Checkpoint(1754504935, 3020000), + _Checkpoint(1755259449, 3030000), + _Checkpoint(1756012904, 3040000), + _Checkpoint(1756765335, 3050000), + _Checkpoint(1757519206, 3060000), + _Checkpoint(1758272484, 3070000), + _Checkpoint(1759025597, 3080000), + _Checkpoint(1759778957, 3090000), + _Checkpoint(1760532780, 3100000), + _Checkpoint(1761286210, 3110000), + _Checkpoint(1762039783, 3120000), + _Checkpoint(1762796627, 3130000), + _Checkpoint(1763550532, 3140000), + _Checkpoint(1764303860, 3150000), + _Checkpoint(1765057381, 3160000), + _Checkpoint(1765811222, 3170000), + _Checkpoint(1766564356, 3180000), + _Checkpoint(1767318326, 3190000), + _Checkpoint(1768071956, 3200000), + _Checkpoint(1768824957, 3210000), + _Checkpoint(1769578781, 3220000), + _Checkpoint(1770332206, 3230000), + _Checkpoint(1771086265, 3240000), + _Checkpoint(1771840180, 3250000), + _Checkpoint(1772592783, 3260000), + _Checkpoint(1773343947, 3270000), + _Checkpoint(1774097085, 3280000), + _Checkpoint(1774850161, 3290000), + _Checkpoint(1775603525, 3300000), + _Checkpoint(1776358117, 3310000), + ]; + + static int getBlockHeightByTime(DateTime time) { + final t = time.toUtc().millisecondsSinceEpoch; + + if (_points.isEmpty) return 0; + + if (t <= _points.first.timeMs) { + return 1; + } + + for (int i = 0; i < _points.length - 1; i++) { + final p0 = _points[i]; + final p1 = _points[i + 1]; + + if (t >= p0.timeMs && t < p1.timeMs) { + return _interpolate( + p0.height, + p1.height, + p0.timeMs, + p1.timeMs, + t, + ); + } + } + + final last = _points[_points.length - 1]; + final prev = _points[_points.length - 2]; + + final avgBlockTimeMs = (last.timeMs - prev.timeMs) / (last.height - prev.height); + + final blocksSinceLast = ((t - last.timeMs) / avgBlockTimeMs).floor(); + + return last.height + blocksSinceLast; + } + + static DateTime getTimeByBlockHeight(int height) { + if (_points.isEmpty) return DateTime.fromMillisecondsSinceEpoch(0, isUtc: true); + if (height <= 1) { + return DateTime.fromMillisecondsSinceEpoch(_points.first.timeMs, isUtc: true); + } + + for (int i = 0; i < _points.length - 1; i++) { + final p0 = _points[i]; + final p1 = _points[i + 1]; + if (height >= p0.height && height < p1.height) { + final t = _interpolate( + p0.timeMs, + p1.timeMs, + p0.height, + p1.height, + height, + ); + return DateTime.fromMillisecondsSinceEpoch(t, isUtc: true); + } + } + + final last = _points[_points.length - 1]; + final prev = _points[_points.length - 2]; + final avgBlockTimeMs = (last.timeMs - prev.timeMs) / (last.height - prev.height); + final msSinceLast = ((height - last.height) * avgBlockTimeMs).floor(); + return DateTime.fromMillisecondsSinceEpoch(last.timeMs + msSinceLast, isUtc: true); + } + + static int _interpolate( + int h0, + int h1, + int t0, + int t1, + int t, + ) { + final ratio = (t - t0) / (t1 - t0); + return h0 + ((h1 - h0) * ratio).floor(); + } +} + +class _Checkpoint { + final int timeMs; + final int height; + + _Checkpoint(int timeSec, this.height) : timeMs = timeSec * 1000; +} diff --git a/cw_core/lib/hardware/device_connection_type.dart b/cw_core/lib/hardware/device_connection_type.dart index 39e0400c9..3b1dd9349 100644 --- a/cw_core/lib/hardware/device_connection_type.dart +++ b/cw_core/lib/hardware/device_connection_type.dart @@ -29,7 +29,7 @@ enum DeviceConnectionType { break; case HardwareWalletType.trezor: isSupported = [ - // WalletType.monero, + WalletType.monero, WalletType.bitcoin, WalletType.litecoin, WalletType.ethereum, diff --git a/cw_core/lib/hardware/hardware_wallet_service.dart b/cw_core/lib/hardware/hardware_wallet_service.dart index 19125ba84..94cbdc443 100644 --- a/cw_core/lib/hardware/hardware_wallet_service.dart +++ b/cw_core/lib/hardware/hardware_wallet_service.dart @@ -4,7 +4,8 @@ import 'dart:typed_data'; import 'package:cw_core/hardware/hardware_account_data.dart'; abstract class HardwareWalletService { - Future> getAvailableAccounts({int index = 0, int limit = 5}); + Future> getAvailableAccounts({int index = 0, int limit = 5}) => + throw UnimplementedError(); Future signTransaction({required String transaction}) => throw UnimplementedError(); diff --git a/cw_core/lib/imported_nft.dart b/cw_core/lib/imported_nft.dart new file mode 100644 index 000000000..2284f680f --- /dev/null +++ b/cw_core/lib/imported_nft.dart @@ -0,0 +1,138 @@ +import "package:cw_core/db/sqlite.dart"; +import "package:sqflite/sqflite.dart"; + +class ImportedNFT { + ImportedNFT({ + required this.walletName, + required this.chain, + required this.identifier, + this.name, + this.symbol, + this.description, + this.imageUrl, + this.isOwned, + this.id = 0, + }); + + ImportedNFT.copyWith(ImportedNFT other, {String? walletName}) + : id = 0, + walletName = walletName ?? other.walletName, + chain = other.chain, + identifier = other.identifier, + name = other.name, + symbol = other.symbol, + description = other.description, + imageUrl = other.imageUrl, + isOwned = other.isOwned; + + factory ImportedNFT.fromMap(Map map) => ImportedNFT( + id: map[selfIdColumn] as int? ?? 0, + walletName: map["walletName"]! as String, + chain: map["chain"]! as String, + identifier: map["identifier"]! as String, + name: map["name"] as String?, + symbol: map["symbol"] as String?, + description: map["description"] as String?, + imageUrl: map["imageUrl"] as String?, + isOwned: map["isOwned"] == null ? null : map["isOwned"] == 1, + ); + + int id; + final String walletName; + final String chain; + final String identifier; + final String? name; + final String? symbol; + final String? description; + final String? imageUrl; + final bool? isOwned; + + static String get tableName => "ImportedNFT"; + static String get selfIdColumn => "${tableName}Id"; + static const solanaChain = "solana"; + + Map toMap() => { + selfIdColumn: id, + "walletName": walletName, + "chain": chain, + "identifier": identifier, + "name": name, + "symbol": symbol, + "description": description, + "imageUrl": imageUrl, + "isOwned": isOwned == null ? null : (isOwned! ? 1 : 0), + }; + + Future save() async { + final row = toMap(); + + if (row[selfIdColumn] == 0) { + row[selfIdColumn] = null; + } + + id = await db!.insert(tableName, row, conflictAlgorithm: ConflictAlgorithm.replace); + + return id; + } + + static Future updateMetadata(ImportedNFT nft) => db!.update( + tableName, + nft.toMap()..remove(selfIdColumn), + where: "walletName = ? AND chain = ? AND identifier = ?", + whereArgs: [nft.walletName, nft.chain, nft.identifier], + ); + + static Future> getAllForWallet(String walletName, [String? chain]) async { + final rows = await db!.query( + tableName, + where: chain == null ? "walletName = ?" : "walletName = ? AND chain = ?", + whereArgs: chain == null ? [walletName] : [walletName, chain], + orderBy: selfIdColumn, + ); + + return rows.map(ImportedNFT.fromMap).toList(); + } + + static Future deleteOne(String walletName, String chain, String identifier) => db!.delete( + tableName, + where: "walletName = ? AND chain = ? AND identifier = ?", + whereArgs: [walletName, chain, identifier], + ); + + static String _scope(List? chains) => + chains == null ? "" : " AND chain IN (${List.filled(chains.length, "?").join(", ")})"; + + static Future deleteAllForWallet(String walletName, {List? chains}) async { + if (chains != null && chains.isEmpty) { + return 0; + } + + return db!.delete( + tableName, + where: "walletName = ?${_scope(chains)}", + whereArgs: [walletName, ...?chains], + ); + } + + static Future renameWallet( + String oldName, + String newName, { + List? chains, + }) async { + if (chains != null && chains.isEmpty) { + return; + } + + await db!.delete( + tableName, + where: "walletName = ?${_scope(chains)}", + whereArgs: [newName, ...?chains], + ); + await db!.update( + tableName, + {"walletName": newName}, + where: "walletName = ?${_scope(chains)}", + whereArgs: [oldName, ...?chains], + ); + } +} diff --git a/cw_core/lib/key.dart b/cw_core/lib/key.dart index 383cbfffe..99570bc7f 100644 --- a/cw_core/lib/key.dart +++ b/cw_core/lib/key.dart @@ -1,35 +1,8 @@ import 'package:encrypt/encrypt.dart' as encrypt; -const ivEncodedStringLength = 12; - String generateKey() { final key = encrypt.Key.fromSecureRandom(512); final iv = encrypt.IV.fromSecureRandom(8); return key.base64 + iv.base64; } - -List extractKeys(String key) { - final _key = key.substring(0, key.length - ivEncodedStringLength); - final iv = key.substring(key.length - ivEncodedStringLength); - - return [_key, iv]; -} - -Future encode( - {required encrypt.Key key, required encrypt.IV iv, required String data}) async { - final encrypter = encrypt.Encrypter(encrypt.Salsa20(key)); - final encrypted = encrypter.encrypt(data, iv: iv); - - return encrypted.base64; -} - -Future decode({required String password, required String data}) async { - final keys = extractKeys(password); - final key = encrypt.Key.fromBase64(keys.first); - final iv = encrypt.IV.fromBase64(keys.last); - final encrypter = encrypt.Encrypter(encrypt.Salsa20(key)); - final encrypted = encrypter.decrypt64(data, iv: iv); - - return encrypted; -} diff --git a/cw_core/lib/lnurl.dart b/cw_core/lib/lnurl.dart index 8fa727b41..e0d17685a 100644 --- a/cw_core/lib/lnurl.dart +++ b/cw_core/lib/lnurl.dart @@ -1,96 +1,180 @@ -import 'dart:convert'; +import "dart:convert"; -import 'package:bech32/bech32.dart'; -import 'package:cw_core/utils/proxy_wrapper.dart'; +import "package:bech32/bech32.dart"; +import "package:cw_core/amount/money.dart"; +import "package:cw_core/crypto_currency.dart"; +import "package:cw_core/utils/proxy_wrapper.dart"; const _BOLT_PREFIXES = ["lnbcrt", "lntbs", "lnbc", "lntb"]; -const _LUD17_PREFIXES = ['lnurlw', 'lnurlc', 'lnurlp', 'keyauth']; +const _LUD17_PREFIXES = ["lnurlw", "lnurlc", "lnurlp", "keyauth"]; bool isBolt11ZeroInvoice(String invoice) { - final request = Bech32Codec().decode(invoice.replaceFirst("lightning:", ""), invoice.length); + try { + final request = + const Bech32Codec().decode(invoice.replaceFirst("lightning:", ""), invoice.length); - final prefix = - _BOLT_PREFIXES.firstWhere((prefix) => request.hrp.startsWith(prefix), orElse: () => ""); + final prefix = + _BOLT_PREFIXES.firstWhere(request.hrp.startsWith, orElse: () => ""); - return request.hrp.length == prefix.length; + return request.hrp.length == prefix.length; + } catch (e) { + return false; + } } /// Get the amount of a Bolt 11 Invoice in int? _getAmountBolt11Msat(String invoice) { - final request = Bech32Codec().decode(invoice.replaceFirst("lightning:", ""), invoice.length); + final request = + const Bech32Codec().decode(invoice.replaceFirst("lightning:", ""), invoice.length); final prefix = _BOLT_PREFIXES.firstWhere( - (prefix) => request.hrp.startsWith(prefix), - orElse: () => throw FormatException('Invalid BOLT11 invoice: unknown HRP prefix.'), + request.hrp.startsWith, + orElse: () => throw const FormatException("Invalid BOLT11 invoice: unknown HRP prefix."), ); final amountPart = request.hrp.substring(prefix.length); - if (amountPart.isEmpty) return null; // zero-amount invoice + if (amountPart.isEmpty) { + return null; // zero-amount invoice + } - final hasMultiplier = RegExp(r'[munp]$').hasMatch(amountPart); - final multiplier = hasMultiplier ? amountPart[amountPart.length - 1] : ''; + final hasMultiplier = RegExp(r"[munp]$").hasMatch(amountPart); + final multiplier = hasMultiplier ? amountPart[amountPart.length - 1] : ""; final numberStr = hasMultiplier ? amountPart.substring(0, amountPart.length - 1) : amountPart; - if (numberStr.isEmpty || !RegExp(r'^\d+$').hasMatch(numberStr)) { - throw FormatException('Invalid BOLT11 invoice: invalid amount number in HRP.'); + if (numberStr.isEmpty || !RegExp(r"^\d+$").hasMatch(numberStr)) { + throw const FormatException("Invalid BOLT11 invoice: invalid amount number in HRP."); } final amount = int.parse(numberStr); switch (multiplier) { - case '': // bitcoin + case "": // bitcoin return amount * 100000000000; - case 'm': // milli-bitcoin + case "m": // milli-bitcoin return amount * 100000000; - case 'u': // micro-bitcoin + case "u": // micro-bitcoin return amount * 100000; - case 'n': // nano-bitcoin + case "n": // nano-bitcoin return amount * 100; - case 'p': + case "p": if (amount % 10 != 0) { - throw FormatException('Invalid BOLT11 invoice: amount not representable in whole msat.'); + throw const FormatException( + "Invalid BOLT11 invoice: amount not representable in whole msat.", + ); } return amount ~/ 10; default: - throw FormatException('Invalid BOLT11 invoice: unknown amount multiplier.'); + throw const FormatException("Invalid BOLT11 invoice: unknown amount multiplier."); } } -int? getBolt11Amount(String invoice) { +Money? getBolt11Amount(String invoice) { final msat = _getAmountBolt11Msat(invoice); - if (msat == null || msat % 1000 != 0) return null; - return msat ~/ 1000; + if (msat == null || msat % 1000 != 0) { + return null; + } + + return Money.fromInt(msat ~/ 1000, CryptoCurrency.btcln); } class LNURL { - static Future getPayRequestAmount(String lnurl) async { + static Future getPayRequestAmount(String lnurl) async { final url = decode(lnurl); final response = await ProxyWrapper().get(clearnetUri: url); - if (response.statusCode != 200) return null; + if (response.statusCode != 200) { + return null; + } try { final body = jsonDecode(response.body) as Map; final tag = body["tag"] as String?; - if (tag != "payRequest") return null; + if (tag != "payRequest") { + return null; + } final msat = body["minSendable"] as int?; final maxSendable = body["maxSendable"] as int?; // if minSendable and maxSendable are the same we assume a specific payment request - if (msat != maxSendable) return null; + if (msat != maxSendable) { + return null; + } - if (msat == null || msat % 1000 != 0) return null; - return msat ~/ 1000; + if (msat == null || msat % 1000 != 0) { + return null; + } + return Money.fromInt(msat ~/ 1000, CryptoCurrency.btcln); } catch (_) { return null; } } + static Future getWithdrawRequest(String lnurl) async { + final url = decode(lnurl); + final response = await ProxyWrapper().get(clearnetUri: url); + + if (response.statusCode != 200) { + return null; + } + + try { + final body = jsonDecode(response.body) as Map; + final tag = body["tag"] as String?; + + if (tag != "withdrawRequest") { + return null; + } + + final minWithdrawable = body["minWithdrawable"] as int?; + final maxWithdrawable = body["maxWithdrawable"] as int?; + + if ((minWithdrawable == null || minWithdrawable % 1000 != 0) || + (maxWithdrawable == null || maxWithdrawable % 1000 != 0)) { + return null; + } + + return LNURLWithdrawRequest( + callback: body["callback"] as String, + k1: body["k1"] as String, + description: body["defaultDescription"] as String, + minAmount: Money.fromInt(minWithdrawable ~/ 1000, CryptoCurrency.btcln), + maxAmount: Money.fromInt(maxWithdrawable ~/ 1000, CryptoCurrency.btcln), + ); + } catch (_) { + return null; + } + } + + Future commitWithdrawRequest(LNURLWithdrawRequest request, String invoice) async { + try { + final url = Uri.parse(request.callback); + url.queryParameters["k1"] = request.k1; + url.queryParameters["pr"] = invoice; + + final response = await ProxyWrapper().get(clearnetUri: url); + + if (response.statusCode != 200) { + return false; + } + + final body = jsonDecode(response.body) as Map; + final status = body["status"] as String?; + + if (status == "OK") { + return true; + } + + return false; + } catch (_) { + return false; + } + } + static String encode(String url) { final raw = _convert(utf8.encode(url), 8, 5, true); - return const Bech32Codec().encode(Bech32('lnurl', raw), 999); + return const Bech32Codec().encode(Bech32("lnurl", raw), 999); } static Uri decode(String encodedUrl) { @@ -108,9 +192,10 @@ class LNURL { if (_LUD17_PREFIXES.contains(decodedUri.scheme)) { /// If the non-bech32 LNURL is a Tor address, the port has to be http instead of https for the clearnet LNURL so check if the host ends with '.onion' or '.onion.' decodedUri = decodedUri.replace( - scheme: decodedUri.host.endsWith('onion') || decodedUri.host.endsWith('onion.') - ? 'http' - : 'https'); + scheme: decodedUri.host.endsWith("onion") || decodedUri.host.endsWith("onion.") + ? "http" + : "https", + ); } else { /// Try to parse the input as a lnUrl. Will throw an error if it fails. final lnUrl = _findLnUrl(encodedUrl); @@ -123,12 +208,30 @@ class LNURL { } } +class LNURLWithdrawRequest { + const LNURLWithdrawRequest({ + required this.callback, + required this.k1, + required this.description, + required this.minAmount, + required this.maxAmount, + }); + + final String callback; + final String k1; + final String description; + final Money minAmount; + final Money maxAmount; +} + /// Parse and return a given lnurl string if it's valid. Will remove /// `lightning:` from the beginning of it if present. String _findLnUrl(String input) { - final res = RegExp(r',*?((lnurl)([0-9]+[a-z0-9]+))').allMatches(input.toLowerCase()); + final res = RegExp(r",*?((lnurl)([0-9]+[a-z0-9]+))").allMatches(input.toLowerCase()); - if (res.length != 1) throw ArgumentError('Not a valid lnurl string'); + if (res.length != 1) { + throw ArgumentError("Not a valid lnurl string"); + } return res.first.group(0)!; } @@ -150,10 +253,16 @@ List _convert(List data, int inBits, int outBits, bool pad) { } if (pad) { - if (bits > 0) result.add((value << (outBits - bits)) & maxV); + if (bits > 0) { + result.add((value << (outBits - bits)) & maxV); + } } else { - if (bits >= inBits) throw Exception('[BECH32] Excess padding'); - if ((value << (outBits - bits)) & maxV > 0) throw Exception('[BECH32] Non-zero padding'); + if (bits >= inBits) { + throw Exception("[BECH32] Excess padding"); + } + if ((value << (outBits - bits)) & maxV > 0) { + throw Exception("[BECH32] Non-zero padding"); + } } return result; diff --git a/cw_core/lib/monero_balance.dart b/cw_core/lib/monero_balance.dart deleted file mode 100644 index 3a85d3cb9..000000000 --- a/cw_core/lib/monero_balance.dart +++ /dev/null @@ -1,17 +0,0 @@ -import 'package:cw_core/balance.dart'; -import 'package:cw_core/monero_amount_format.dart'; - -class MoneroBalance extends Balance { - MoneroBalance({required this.fullBalance, required this.unlockedBalance, this.frozenBalance = 0}) - : formattedFrozenBalance = moneroAmountToString(amount: frozenBalance), - super.fromInt(unlockedBalance, fullBalance - unlockedBalance, frozen: frozenBalance); - - final int fullBalance; - final int unlockedBalance; - final int frozenBalance; - final String formattedFrozenBalance; - - @override - String get formattedUnAvailableBalance => - formattedFrozenBalance == '0.0' ? '' : formattedFrozenBalance; -} diff --git a/cw_core/lib/mweb_utxo.dart b/cw_core/lib/mweb_utxo.dart index 5eab11734..ec5679eb3 100644 --- a/cw_core/lib/mweb_utxo.dart +++ b/cw_core/lib/mweb_utxo.dart @@ -1,9 +1,9 @@ import 'package:cw_core/hive_type_ids.dart'; import 'package:hive/hive.dart'; -part 'mweb_utxo.g.dart'; +part 'mweb_utxo.part.dart'; -@HiveType(typeId: MWEB_UTXO_TYPE_ID) +// @HiveType(typeId: MWEB_UTXO_TYPE_ID) class MwebUtxo extends HiveObject { MwebUtxo({ required this.height, @@ -17,21 +17,21 @@ class MwebUtxo extends HiveObject { static const typeId = MWEB_UTXO_TYPE_ID; static const boxName = 'MwebUtxo'; - @HiveField(0) + // @HiveField(0) int height; - @HiveField(1) + // @HiveField(1) int value; - @HiveField(2) + // @HiveField(2) String address; - @HiveField(3) + // @HiveField(3) String outputId; - @HiveField(4) + // @HiveField(4) int blockTime; - @HiveField(5, defaultValue: false) + // @HiveField(5, defaultValue: false) bool spent; } diff --git a/cw_core/lib/mweb_utxo.part.dart b/cw_core/lib/mweb_utxo.part.dart new file mode 100644 index 000000000..91e2661c0 --- /dev/null +++ b/cw_core/lib/mweb_utxo.part.dart @@ -0,0 +1,54 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'mweb_utxo.dart'; + +// ************************************************************************** +// TypeAdapterGenerator +// ************************************************************************** + +class MwebUtxoAdapter extends TypeAdapter { + @override + final int typeId = 20; + + @override + MwebUtxo read(BinaryReader reader) { + final numOfFields = reader.readByte(); + final fields = { + for (int i = 0; i < numOfFields; i++) reader.readByte(): reader.read(), + }; + return MwebUtxo( + height: fields[0] as int, + value: fields[1] as int, + address: fields[2] as String, + outputId: fields[3] as String, + blockTime: fields[4] as int, + spent: fields[5] == null ? false : fields[5] as bool, + ); + } + + @override + void write(BinaryWriter writer, MwebUtxo obj) { + writer + ..writeByte(6) + ..writeByte(0) + ..write(obj.height) + ..writeByte(1) + ..write(obj.value) + ..writeByte(2) + ..write(obj.address) + ..writeByte(3) + ..write(obj.outputId) + ..writeByte(4) + ..write(obj.blockTime) + ..writeByte(5) + ..write(obj.spent); + } + + @override + int get hashCode => typeId.hashCode; + + @override + bool operator ==(Object other) => + identical(this, other) || + other is MwebUtxoAdapter && runtimeType == other.runtimeType && typeId == other.typeId; +} diff --git a/cw_core/lib/nano_account.dart b/cw_core/lib/nano_account.dart index 91df62e75..dcf2710da 100644 --- a/cw_core/lib/nano_account.dart +++ b/cw_core/lib/nano_account.dart @@ -1,23 +1,23 @@ import 'package:cw_core/hive_type_ids.dart'; import 'package:hive/hive.dart'; -part 'nano_account.g.dart'; +part 'nano_account.part.dart'; -@HiveType(typeId: NanoAccount.typeId) +// @HiveType(typeId: NanoAccount.typeId) class NanoAccount extends HiveObject { NanoAccount({required this.label, required this.id, this.balance, this.isSelected = false}); static const typeId = NANO_ACCOUNT_TYPE_ID; - @HiveField(0) + // @HiveField(0) String label; - @HiveField(1) + // @HiveField(1) final int id; - @HiveField(2) + // @HiveField(2) bool isSelected; - @HiveField(3) + // @HiveField(3) String? balance; } diff --git a/cw_core/lib/nano_account.part.dart b/cw_core/lib/nano_account.part.dart new file mode 100644 index 000000000..be880fef5 --- /dev/null +++ b/cw_core/lib/nano_account.part.dart @@ -0,0 +1,48 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'nano_account.dart'; + +// ************************************************************************** +// TypeAdapterGenerator +// ************************************************************************** + +class NanoAccountAdapter extends TypeAdapter { + @override + final int typeId = 13; + + @override + NanoAccount read(BinaryReader reader) { + final numOfFields = reader.readByte(); + final fields = { + for (int i = 0; i < numOfFields; i++) reader.readByte(): reader.read(), + }; + return NanoAccount( + label: fields[0] as String, + id: fields[1] as int, + balance: fields[3] as String?, + isSelected: fields[2] as bool, + ); + } + + @override + void write(BinaryWriter writer, NanoAccount obj) { + writer + ..writeByte(4) + ..writeByte(0) + ..write(obj.label) + ..writeByte(1) + ..write(obj.id) + ..writeByte(2) + ..write(obj.isSelected) + ..writeByte(3) + ..write(obj.balance); + } + + @override + int get hashCode => typeId.hashCode; + + @override + bool operator ==(Object other) => + identical(this, other) || + other is NanoAccountAdapter && runtimeType == other.runtimeType && typeId == other.typeId; +} diff --git a/cw_core/lib/node.dart b/cw_core/lib/node.dart index 0a05b853d..e16c924e9 100644 --- a/cw_core/lib/node.dart +++ b/cw_core/lib/node.dart @@ -1,33 +1,60 @@ +import 'dart:convert'; import 'dart:io'; -import 'package:cw_core/keyable.dart'; +import 'dart:math' as math; + +import 'package:blockchain_utils/hex/hex.dart'; +import 'package:cw_core/node_list.dart'; +import 'package:cw_core/utils/print_verbose.dart'; import 'package:cw_core/utils/proxy_socket/abstract.dart'; import 'package:cw_core/utils/proxy_wrapper.dart'; -import 'package:cw_core/utils/print_verbose.dart'; -import 'dart:convert'; -import 'package:hive/hive.dart'; -import 'package:cw_core/hive_type_ids.dart'; import 'package:cw_core/wallet_type.dart'; -import 'dart:math' as math; -import 'package:convert/convert.dart'; - import 'package:crypto/crypto.dart'; +import 'package:sqflite/sqflite.dart'; -part 'node.g.dart'; +import 'db/sqlite.dart'; Uri createUriFromElectrumAddress(String address, String path) => Uri.tryParse('tcp://$address$path')!; -@HiveType(typeId: Node.typeId) -class Node extends HiveObject with Keyable { +Future validateBuiltinNodes() async { + // ensures nodes stored as builtin in the db correspond to the nodes in the .yml files + + final builtinFromDb = await Node.getAllBuiltin(); + final builtinFromList = await loadAllDefaultNodes(); + + for (final listNode in builtinFromList) { + // preserve proxy settings from user + try { + final matchedDbNode = builtinFromDb.firstWhere((dbNode) => dbNode.uri == listNode.uri); + listNode.socksProxyAddress = matchedDbNode.socksProxyAddress; + } catch (e) {} + } + + final dbSet = builtinFromDb.toSet(); + final listSet = builtinFromList.toSet(); + + final nodesToDelete = dbSet.difference(listSet); + final nodesToAdd = listSet.difference(dbSet); + + for (final node in nodesToDelete) await node.delete(); + for (final node in nodesToAdd) await node.save(); +} + +class Node { Node({ + this.id = 0, this.label, this.login, this.password, this.useSSL, + this.isPow = false, this.trusted = false, this.socksProxyAddress, this.path = '', this.isEnabledForAutoSwitching = false, + this.isOfficial = false, + this.isBuiltin = false, + this.isDefault = false, String? uri, WalletType? type, }) { @@ -50,67 +77,204 @@ class Node extends HiveObject with Keyable { trusted: $trusted, socksProxyAddress: $socksProxyAddress, isEnabledForAutoSwitching: $isEnabledForAutoSwitching, + isOfficial: $isOfficial, + isBuiltin: $isBuiltin, + isDefault: $isDefault })"""; } Node.fromMap(Map map) - : uriRaw = map['uri'] as String? ?? '', + : id = (map[selfIdColumn] ?? 0) as int, + uriRaw = map['uri'] as String? ?? '', path = map['path'] as String? ?? '', login = map['login'] as String?, label = map['label'] as String?, password = map['password'] as String?, - useSSL = map['useSSL'] as bool?, - trusted = map['trusted'] as bool? ?? false, - socksProxyAddress = map['socksProxyPort'] as String?, - isEnabledForAutoSwitching = map['isEnabledForAutoSwitching'] as bool? ?? false; + isPow = _getBoolFromDB(map['isPow']), + useSSL = _getBoolFromDB(map['useSSL']), + typeRaw = (map["typeRaw"] ?? 0) as int, + trusted = _getBoolFromDB(map['trusted']), + socksProxyAddress = map['socksProxyAddress'] as String?, + isEnabledForAutoSwitching = _getBoolFromDB(map['isEnabledForAutoSwitching']), + isOfficial = _getBoolFromDB(map['isOfficial']), + isBuiltin = _getBoolFromDB(map['isBuiltin']), + isDefault = _getBoolFromDB(map['isDefault']); - static const typeId = NODE_TYPE_ID; - static const boxName = 'Nodes'; + static bool _getBoolFromDB(value, {bool? defaultValue}) { + if (value is bool) { + return value; + } else if (value is int) { + return value == 1; + } else { + return defaultValue ?? false; + } + } - @HiveField(0, defaultValue: '') + Map toMap() { + return { + selfIdColumn: id, + 'uri': uriRaw, + 'path': path, + 'login': login, + "label": label, + 'password': password, + "isPow": isPow ? 1 : 0, + 'useSSL': (useSSL ?? false) ? 1 : 0, + "typeRaw": typeRaw, + 'trusted': trusted ? 1 : 0, + 'socksProxyAddress': socksProxyAddress, + 'isEnabledForAutoSwitching': isEnabledForAutoSwitching ? 1 : 0, + "isOfficial": isOfficial ? 1 : 0, + "isBuiltin": isBuiltin ? 1 : 0, + "isDefault": isDefault ? 1 : 0 + }; + } + + Node.fromUri(Uri uri, WalletType type) + : id = 0, + uriRaw = + "${uri.host}${uri.hasPort ? ':${uri.port}' : (uri.queryParameters['port']?.isNotEmpty == true ? ':${uri.queryParameters['port']}' : '')}", + path = uri.path, + login = uri.userInfo.contains(':') + ? uri.userInfo.split(':')[0] + : uri.queryParameters['username'], + password = uri.userInfo.contains(':') + ? uri.userInfo.split(':')[1] + : uri.queryParameters['password'], + useSSL = uri.queryParameters['protocol'] == 'https', + trusted = uri.queryParameters['trusted'] == 'true', + isPow = false, + isEnabledForAutoSwitching = false, + isOfficial = false, + isBuiltin = false, + isDefault = false, + typeRaw = serializeToInt(type); + + Future delete() async { + return await db!.delete(tableName, where: '${selfIdColumn} = ?', whereArgs: [id]); + } + + static Future deleteAll() async { + return await db!.delete(tableName, where: "isPow = ?", whereArgs: [0]); + } + + static Future deleteAllPow() async { + return await db!.delete(tableName, where: "isPow = ?", whereArgs: [1]); + } + + Future save() async { + final json = toMap(); + if (json[selfIdColumn] == 0) { + json[selfIdColumn] = null; + } + id = await db!.insert(tableName, json, conflictAlgorithm: ConflictAlgorithm.replace); + return id; + } + + static Future> selectList(String where, List whereArgs, + {String? orderBy}) async { + if (orderBy == null) { + orderBy = selfIdColumn; + } + final list = await db!.query( + tableName, + where: where.isNotEmpty ? where : "1 = 1", + whereArgs: whereArgs.isNotEmpty ? whereArgs : null, + orderBy: orderBy, + ); + return List.generate(list.length, (index) => Node.fromMap(list[index])); + } + + static Future select(String where, List whereArgs, {String? orderBy}) async { + if (orderBy == null) { + orderBy = selfIdColumn; + } + final list = await db!.query( + tableName, + where: where.isNotEmpty ? where : "1 = 1", + whereArgs: whereArgs.isNotEmpty ? whereArgs : null, + orderBy: orderBy, + ); + return list.isEmpty ? null : Node.fromMap(list.first); + } + + static Future> getAll() async { + return selectList("isPow = ?", [0]); + } + + static Future> getAllBuiltin() async { + return selectList("isPow = ? AND isBuiltin = ?", [0, 1]); + } + + static Future> getAllPowBuiltin() async { + return selectList("isPow = ? AND isBuiltin = ?", [1, 1]); + } + + static Future> getAllForWalletType(WalletType type) async { + return selectList("typeRaw = ? AND isPow = ?", [serializeToInt(type), 0]); + } + + static Future getDefaultForWalletType(WalletType type) async { + return (await selectList( + "typeRaw = ? AND isPow = ? AND isDefault = ?", [serializeToInt(type), 0, 1])) + .firstOrNull; + } + + static Future getDefaultPowForWalletType(WalletType type) async { + return (await selectList( + "typeRaw = ? AND isPow = ? AND isDefault = ?", [serializeToInt(type), 1, 1])) + .firstOrNull; + } + + static Future> getAllForWalletTypePow(WalletType type) async { + return selectList("typeRaw = ? AND isPow = ?", [serializeToInt(type), 1]); + } + + static Future> getAllPow() async { + return selectList("isPow = ?", [1]); + } + + static Future get(int id) async { + return select("${selfIdColumn} = ?", [id]); + } + + int id; late String uriRaw; - - @HiveField(1) String? login; - - @HiveField(2) String? password; - - @HiveField(3, defaultValue: 0) late int typeRaw; - - @HiveField(4) bool? useSSL; - - @HiveField(5, defaultValue: false) bool trusted; - - @HiveField(6) + bool isPow; String? socksProxyAddress; - - @HiveField(7, defaultValue: '') String? path; - - @HiveField(8) bool? isElectrs; - - @HiveField(9) bool? supportsSilentPayments; - - @HiveField(10) bool? supportsMweb; - - @HiveField(11, defaultValue: false) bool isEnabledForAutoSwitching; + bool isOfficial; + bool isBuiltin; + bool isDefault; - @HiveField(12, defaultValue: '') String? label; + static String get tableName => "Node"; + static String get selfIdColumn => "${tableName}Id"; + bool get isSSL => useSSL ?? false; bool get useSocksProxy => socksProxyAddress == null ? false : socksProxyAddress!.isNotEmpty; Uri get uri { + try { + return _uri; + } catch (e) { + printV(e); + return Uri(); + } + } + + Uri get _uri { switch (type) { case WalletType.monero: case WalletType.wownero: @@ -161,18 +325,10 @@ class Node extends HiveObject with Keyable { socksProxyAddress.hashCode ^ path.hashCode; - @override - dynamic get keyIndex { - _keyIndex ??= key; - return _keyIndex; - } - WalletType get type => deserializeFromInt(typeRaw); set type(WalletType type) => typeRaw = serializeToInt(type); - dynamic _keyIndex; - Future requestNode() async { try { switch (type) { @@ -210,15 +366,13 @@ class Node extends HiveObject with Keyable { final body = {'jsonrpc': '2.0', 'id': '0', 'method': methodName}; try { - final client = ProxyWrapper().getHttpIOClient(); - final jsonBody = json.encode(body); - final response = await client.post( - rpcUri, - headers: {'Content-Type': 'application/json'}, - body: jsonBody, - ); + final response = await ProxyWrapper().post( + clearnetUri: rpcUri, + headers: {'Content-Type': 'application/json'}, + body: jsonBody, + allowMitmMoneroBypassSSLCheck: true); // Check if we received a 401 Unauthorized response if (response.statusCode == 401) { final daemonRpc = DaemonRpc( diff --git a/cw_core/lib/node_legacy.dart b/cw_core/lib/node_legacy.dart new file mode 100644 index 000000000..26e4f5757 --- /dev/null +++ b/cw_core/lib/node_legacy.dart @@ -0,0 +1,230 @@ +import 'dart:io'; +import 'package:cw_core/keyable.dart'; +import 'package:cw_core/node_list.dart'; +import 'package:cw_core/utils/proxy_socket/abstract.dart'; +import 'package:cw_core/utils/proxy_wrapper.dart'; +import 'package:cw_core/utils/print_verbose.dart'; +import 'dart:convert'; +import 'package:hive/hive.dart'; +import 'package:cw_core/hive_type_ids.dart'; +import 'package:cw_core/wallet_type.dart'; +import 'dart:math' as math; +import 'package:convert/convert.dart'; + +import 'package:crypto/crypto.dart'; + +import 'cake_hive.dart'; +import 'node.dart' as node_new; + +part 'node_legacy.part.dart'; + +Future performNodeHiveMigration() async { + if (!CakeHive.isAdapterRegistered(Node.typeId)) { + CakeHive.registerAdapter(NodeAdapter()); + } + + final nodeBox = await CakeHive.openBox(Node.boxName); + final powNodeBox = await CakeHive.openBox(Node.boxName + "pow"); + final builtinNodes = await loadAllDefaultNodes(); + final builtinPowNodes = await loadDefaultNanoPowNodes(); + await Node.migrateAllToSqlite(nodeBox, powNodeBox, builtinNodes, builtinPowNodes); +} + +Uri createUriFromElectrumAddress(String address, String path) => + Uri.tryParse('tcp://$address$path')!; + +@HiveType(typeId: Node.typeId) +class Node extends HiveObject with Keyable { + Node({ + this.label, + this.login, + this.password, + this.useSSL, + this.trusted = false, + this.socksProxyAddress, + this.path = '', + this.isEnabledForAutoSwitching = false, + String? uri, + WalletType? type, + }) { + if (uri != null) { + uriRaw = uri; + } + if (type != null) { + this.type = type; + } + } + + static Future migrateAllToSqlite(Box nodeBox, Box powNodeBox, + List defaultNodes, List defaultPowNodes) async { + final list = nodeBox.values.toList(); + final powList = powNodeBox.values.toList(); + for (final node in list) { + if (defaultNodes.any((item) => item.uri == node.uri)) { + // default nodes will be added by validateBuiltinNodes(), no need to migrate them. + await node.delete(); + continue; + } + await node.migrateToSqlite(isPow: false, isBuiltin: false, isOfficial: false); + await node.delete(); + } + for (final node in powList) { + if (defaultPowNodes.any((item) => item.uri == node.uri)) { + await node.delete(); + continue; + } + await node.migrateToSqlite(isPow: true, isBuiltin: false, isOfficial: false); + await node.delete(); + } + } + + Future migrateToSqlite( + {required bool isPow, required bool isBuiltin, required bool isOfficial}) async { + final newNode = node_new.Node( + id: key as int, + login: login, + label: label, + password: password, + type: type, + useSSL: useSSL, + trusted: trusted, + socksProxyAddress: socksProxyAddress, + isPow: isPow, + path: path, + uri: uriRaw, + isEnabledForAutoSwitching: isEnabledForAutoSwitching, + isOfficial: isOfficial, + isBuiltin: isBuiltin, + ); + await newNode.save(); + } + + Node.fromMap(Map map) + : uriRaw = map['uri'] as String? ?? '', + path = map['path'] as String? ?? '', + login = map['login'] as String?, + label = map['label'] as String?, + password = map['password'] as String?, + useSSL = map['useSSL'] as bool?, + trusted = map['trusted'] as bool? ?? false, + socksProxyAddress = map['socksProxyPort'] as String?, + isEnabledForAutoSwitching = map['isEnabledForAutoSwitching'] as bool? ?? false; + + static const typeId = NODE_TYPE_ID; + static const boxName = 'Nodes'; + + @HiveField(0, defaultValue: '') + late String uriRaw; + + @HiveField(1) + String? login; + + @HiveField(2) + String? password; + + @HiveField(3, defaultValue: 0) + late int typeRaw; + + @HiveField(4) + bool? useSSL; + + @HiveField(5, defaultValue: false) + bool trusted; + + @HiveField(6) + String? socksProxyAddress; + + @HiveField(7, defaultValue: '') + String? path; + + @HiveField(8) + bool? isElectrs; + + @HiveField(9) + bool? supportsSilentPayments; + + @HiveField(10) + bool? supportsMweb; + + @HiveField(11, defaultValue: false) + bool isEnabledForAutoSwitching; + + @HiveField(12, defaultValue: '') + String? label; + + bool get isSSL => useSSL ?? false; + + bool get useSocksProxy => socksProxyAddress == null ? false : socksProxyAddress!.isNotEmpty; + + Uri get uri { + try { + return _uri; + } catch (e) { + printV(e); + return Uri(); + } + } + + Uri get _uri { + switch (type) { + case WalletType.monero: + case WalletType.wownero: + return Uri.http(uriRaw, ''); + case WalletType.bitcoin: + case WalletType.litecoin: + case WalletType.bitcoinCash: + case WalletType.dogecoin: + return createUriFromElectrumAddress(uriRaw, path!); + case WalletType.nano: + case WalletType.banano: + case WalletType.ethereum: + case WalletType.polygon: + case WalletType.base: + case WalletType.bsc: + case WalletType.arbitrum: + return Uri.parse( + "http${isSSL ? "s" : ""}://$uriRaw${path!.startsWith("/") || path!.isEmpty ? path : "/$path"}"); + case WalletType.none: + throw Exception('Unexpected type ${type.toString()} for Node uri'); + } + } + + bool get isValidProxyAddress => socksProxyAddress?.contains(':') ?? false; + + @override + bool operator ==(other) => + other is Node && + (other.uriRaw == uriRaw && + other.login == login && + other.label == label && + other.password == password && + other.typeRaw == typeRaw && + other.useSSL == useSSL && + other.trusted == trusted && + other.socksProxyAddress == socksProxyAddress && + other.path == path); + + @override + int get hashCode => + uriRaw.hashCode ^ + login.hashCode ^ + label.hashCode ^ + password.hashCode ^ + typeRaw.hashCode ^ + useSSL.hashCode ^ + trusted.hashCode ^ + socksProxyAddress.hashCode ^ + path.hashCode; + + @override + dynamic get keyIndex { + _keyIndex ??= key; + return _keyIndex; + } + + WalletType get type => deserializeFromInt(typeRaw); + + set type(WalletType type) => typeRaw = serializeToInt(type); + + dynamic _keyIndex; +} diff --git a/cw_core/lib/node_legacy.part.dart b/cw_core/lib/node_legacy.part.dart new file mode 100644 index 000000000..305312530 --- /dev/null +++ b/cw_core/lib/node_legacy.part.dart @@ -0,0 +1,75 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'node_legacy.dart'; + +// ************************************************************************** +// TypeAdapterGenerator +// ************************************************************************** + +class NodeAdapter extends TypeAdapter { + @override + final int typeId = 1; + + @override + Node read(BinaryReader reader) { + final numOfFields = reader.readByte(); + final fields = { + for (int i = 0; i < numOfFields; i++) reader.readByte(): reader.read(), + }; + return Node( + label: fields[12] == null ? '' : fields[12] as String?, + login: fields[1] as String?, + password: fields[2] as String?, + useSSL: fields[4] as bool?, + trusted: fields[5] == null ? false : fields[5] as bool, + socksProxyAddress: fields[6] as String?, + path: fields[7] == null ? '' : fields[7] as String?, + isEnabledForAutoSwitching: fields[11] == null ? false : fields[11] as bool, + ) + ..uriRaw = fields[0] == null ? '' : fields[0] as String + ..typeRaw = fields[3] == null ? 0 : fields[3] as int + ..isElectrs = fields[8] as bool? + ..supportsSilentPayments = fields[9] as bool? + ..supportsMweb = fields[10] as bool?; + } + + @override + void write(BinaryWriter writer, Node obj) { + writer + ..writeByte(13) + ..writeByte(0) + ..write(obj.uriRaw) + ..writeByte(1) + ..write(obj.login) + ..writeByte(2) + ..write(obj.password) + ..writeByte(3) + ..write(obj.typeRaw) + ..writeByte(4) + ..write(obj.useSSL) + ..writeByte(5) + ..write(obj.trusted) + ..writeByte(6) + ..write(obj.socksProxyAddress) + ..writeByte(7) + ..write(obj.path) + ..writeByte(8) + ..write(obj.isElectrs) + ..writeByte(9) + ..write(obj.supportsSilentPayments) + ..writeByte(10) + ..write(obj.supportsMweb) + ..writeByte(11) + ..write(obj.isEnabledForAutoSwitching) + ..writeByte(12) + ..write(obj.label); + } + + @override + int get hashCode => typeId.hashCode; + + @override + bool operator ==(Object other) => + identical(this, other) || + other is NodeAdapter && runtimeType == other.runtimeType && typeId == other.typeId; +} diff --git a/cw_core/lib/node_list.dart b/cw_core/lib/node_list.dart new file mode 100644 index 000000000..c0d99b2a7 --- /dev/null +++ b/cw_core/lib/node_list.dart @@ -0,0 +1,159 @@ +import 'package:flutter/services.dart'; +import "package:yaml/yaml.dart"; +import 'package:cw_core/node.dart'; +import 'package:cw_core/wallet_type.dart'; + +Future> loadDefaultNodes(WalletType type) async { + String path; + switch (type) { + case WalletType.monero: + path = 'assets/node_list.yml'; + break; + case WalletType.bitcoin: + path = 'assets/bitcoin_electrum_server_list.yml'; + break; + case WalletType.litecoin: + path = 'assets/litecoin_electrum_server_list.yml'; + break; + // TODO: (refactoring) each wallet would have its path, so `wallet.nodePath` would be decided based on chain id in Evm wallet + case WalletType.ethereum: + path = 'assets/ethereum_server_list.yml'; + break; + case WalletType.nano: + path = 'assets/nano_node_list.yml'; + break; + case WalletType.bitcoinCash: + path = 'assets/bitcoin_cash_electrum_server_list.yml'; + break; + case WalletType.polygon: + path = 'assets/polygon_node_list.yml'; + break; + case WalletType.wownero: + path = 'assets/wownero_node_list.yml'; + break; + case WalletType.dogecoin: + path = 'assets/dogecoin_electrum_server_list.yml'; + break; + case WalletType.base: + path = 'assets/base_node_list.yml'; + break; + case WalletType.arbitrum: + path = 'assets/arbitrum_node_list.yml'; + break; + case WalletType.bsc: + path = 'assets/bsc_node_list.yml'; + break; + case WalletType.banano: + case WalletType.none: + path = ''; + break; + } + + final nodesRaw = await rootBundle.loadString(path); + final loadedNodes = loadYaml(nodesRaw) as YamlList; + final nodes = []; + + for (final raw in loadedNodes) { + if (raw is Map) { + final map = Map.from(raw); + map["isBuiltin"] = 1; + map["isOfficial"] = ((map["isOfficial"] as bool? ?? false) ? 1 : 0); + map["isDefault"] = ((map["isDefault"] as bool? ?? false) ? 1 : 0); + + final node = Node.fromMap(Map.from(map)); + node.type = type; + nodes.add(node); + } + } + + return nodes; +} + +Future> loadDefaultNanoPowNodes() async { + final powNodesRaw = await rootBundle.loadString('assets/nano_pow_node_list.yml'); + final loadedPowNodes = loadYaml(powNodesRaw) as YamlList; + final nodes = []; + + for (final raw in loadedPowNodes) { + if (raw is Map) { + final map = Map.from(raw); + map["isBuiltin"] = 1; + map["isOfficial"] = ((map["isOfficial"] as bool? ?? false) ? 1 : 0); + map["isDefault"] = ((map["isDefault"] as bool? ?? false) ? 1 : 0); + final node = Node.fromMap(Map.from(map)); + node.isPow = true; + node.type = WalletType.nano; + nodes.add(node); + } + } + + return nodes; +} + +Future> loadAllDefaultNodes() async { + final wowneroNodes = await loadDefaultNodes(WalletType.wownero); + final moneroNodes = await loadDefaultNodes(WalletType.monero); + final bitcoinElectrumServerList = await loadDefaultNodes(WalletType.bitcoin); + final litecoinElectrumServerList = await loadDefaultNodes(WalletType.litecoin); + final bitcoinCashElectrumServerList = await loadDefaultNodes(WalletType.bitcoinCash); + final ethereumNodes = await loadDefaultNodes(WalletType.ethereum); + final nanoNodes = await loadDefaultNodes(WalletType.nano); + final polygonNodes = await loadDefaultNodes(WalletType.polygon); + final dogecoinElectrumServerList = await loadDefaultNodes(WalletType.dogecoin); + final baseNodes = await loadDefaultNodes(WalletType.base); + final arbitrumNodes = await loadDefaultNodes(WalletType.arbitrum); + final bscNodes = await loadDefaultNodes(WalletType.bsc); + + return wowneroNodes + + moneroNodes + + bitcoinElectrumServerList + + litecoinElectrumServerList + + ethereumNodes + + bitcoinCashElectrumServerList + + nanoNodes + + polygonNodes + + dogecoinElectrumServerList + + baseNodes + + arbitrumNodes + + bscNodes; +} + +Future resetToDefault() async { + final nodes = await loadAllDefaultNodes(); + + final currentNodes = await Node.getAll(); + + // after reset, nodes should retain the id to correspond with the ones in shared prefs. + // alternative would be nuking the shared prefs on reset + for (final node in nodes) { + try { + final curr = currentNodes.firstWhere( + (item) => item.uri == node.uri && item.typeRaw == node.typeRaw, + ); + node.id = curr.id; + } catch (e) {} + } + + await Node.deleteAll(); + for (final node in nodes) { + await node.save(); + } +} + +Future resetPowToDefault() async { + final nanoPowNodes = await loadDefaultNanoPowNodes(); + await Node.deleteAllPow(); + for (final node in nanoPowNodes) { + node.save(); + } +} + +Future getDefaultNodeFromFiles(WalletType type, {bool isPow = false}) async { + final nodes = isPow ? await loadDefaultNanoPowNodes() : await loadDefaultNodes(type); + try { + return nodes.firstWhere((item) => item.isDefault); + } catch (e) { + // ideally the yamls would always have a default, but this is better than nothing + return nodes.first; + } +} diff --git a/cw_core/lib/output_info.dart b/cw_core/lib/output_info.dart index 24f9e62be..ef53099f9 100644 --- a/cw_core/lib/output_info.dart +++ b/cw_core/lib/output_info.dart @@ -1,24 +1,25 @@ +import 'package:cw_core/amount/money.dart'; + class OutputInfo { - const OutputInfo( - {required this.address, - required this.sendAll, - required this.isParsedAddress, - this.cryptoAmount, - this.formattedCryptoAmount, - this.fiatAmount, - this.note, - this.extractedAddress, - this.memo, - this.extra = const {}}); + const OutputInfo({ + required this.address, + required this.sendAll, + required this.isParsedAddress, + required this.cryptoAmount, + this.fiatAmount, + this.note, + this.extractedAddress, + this.memo, + this.extra = const {}, + }); final String? fiatAmount; - final String? cryptoAmount; + final Money cryptoAmount; final String address; final String? note; final String? extractedAddress; final bool sendAll; final bool isParsedAddress; - final int? formattedCryptoAmount; final String? memo; final Map extra; } diff --git a/cw_core/lib/parse_fixed.dart b/cw_core/lib/parse_fixed.dart index 695bad60f..35d2e263c 100644 --- a/cw_core/lib/parse_fixed.dart +++ b/cw_core/lib/parse_fixed.dart @@ -29,32 +29,49 @@ BigInt? tryParseFixed(String value, int decimals) { BigInt parseFixed(String value, int decimals) { final multiplier = getMultiplier(decimals); + /// handle weird cases where users enter spaces and currency after the amount + /// This should be handled from UI field to prevent non numerical values + /// but will be in the refactoring + if (value.contains(" ")) { + value = value.split(" ").first; + } + final negative = value.startsWith("-"); - if (negative) value = value.substring(1); + if (negative) { + value = value.substring(1); + } - if (value == ".") throw FormatException("missing value, value, $value"); + if (value == ".") { + throw FormatException("missing value, value, $value"); + } - if (value.startsWith(".")) value = "0$value"; + if (value.startsWith(".")) { + value = "0$value"; + } final comps = value.split("."); if (comps.length > 2) { throw FormatException("too many decimal points, value, $value"); } - var whole = comps.isNotEmpty ? comps[0] : "0"; - var fraction = (comps.length == 2 ? comps[1] : "0").padRight(decimals, "0"); + final whole = comps.isNotEmpty ? comps[0] : "0"; + final fraction = (comps.length == 2 ? comps[1] : "").padRight(decimals, "0"); if (fraction.length > multiplier.length - 1) { - throw FormatException("fractional component exceeds decimals, underflow, parseFixed"); + throw FormatException( + "fractional component(${fraction.length}) exceeds decimals(${decimals}), underflow, parseFixed", + ); } final wholeValue = BigInt.parse(whole); - final fractionValue = BigInt.parse(fraction); + final fractionValue = fraction.isEmpty ? BigInt.zero : BigInt.parse(fraction); final multiplierValue = BigInt.parse(multiplier); var wei = (wholeValue * multiplierValue) + fractionValue; - if (negative) wei *= BigInt.from(-1); + if (negative) { + wei *= BigInt.from(-1); + } return wei; } diff --git a/cw_core/lib/pathForWallet.dart b/cw_core/lib/pathForWallet.dart index 49052fb92..10cbb28af 100644 --- a/cw_core/lib/pathForWallet.dart +++ b/cw_core/lib/pathForWallet.dart @@ -1,6 +1,7 @@ import 'dart:io'; import 'package:cw_core/root_dir.dart'; import 'package:cw_core/wallet_type.dart'; +import 'package:path/path.dart' as p; Future pathForWalletTypeDir({required WalletType type}) async { final root = await getAppDir(); @@ -35,3 +36,43 @@ Future outdatedAndroidPathForWalletDir({required String name}) async { return pathDir; } + +Future copyWalletFilesTo({ + required String fromName, + required String toName, + required WalletType type, +}) async { + if (fromName == toName) return; + + final typeRoot = await pathForWalletTypeDir(type: type); + final sourceDir = Directory(p.join(typeRoot, fromName)); + if (!sourceDir.existsSync()) { + throw "Source wallet not found: $fromName $type"; + } + + if (Directory(p.join(typeRoot, toName)).existsSync()) { + throw Exception('Cannot rename wallet: "$toName" already exists'); + } + + final destinationDir = Directory(p.join(typeRoot, toName)); + await _copyDirectory(sourceDir, destinationDir); + + for (final suffix in const ['', '.keys', '.keys.backup']) { + final file = File(p.join(destinationDir.path, '$fromName$suffix')); + if (file.existsSync()) { + await file.rename(p.join(destinationDir.path, '$toName$suffix')); + } + } +} + +Future _copyDirectory(Directory source, Directory destination) async { + await destination.create(recursive: true); + await for (final entity in source.list(followLinks: false)) { + final name = p.basename(entity.path); + if (entity is File) { + await entity.copy(p.join(destination.path, name)); + } else if (entity is Directory) { + await _copyDirectory(entity, Directory(p.join(destination.path, name))); + } + } +} diff --git a/cw_core/lib/payjoin_session.dart b/cw_core/lib/payjoin_session.dart index 8dd0f43f1..c68902d2a 100644 --- a/cw_core/lib/payjoin_session.dart +++ b/cw_core/lib/payjoin_session.dart @@ -1,9 +1,9 @@ import 'package:cw_core/hive_type_ids.dart'; import 'package:hive/hive.dart'; -part 'payjoin_session.g.dart'; +part 'payjoin_session.part.dart'; -@HiveType(typeId: PAYJOIN_SESSION_TYPE_ID) +// @HiveType(typeId: PAYJOIN_SESSION_TYPE_ID) class PayjoinSession extends HiveObject { PayjoinSession({ required this.walletId, @@ -25,31 +25,31 @@ class PayjoinSession extends HiveObject { static const typeId = PAYJOIN_SESSION_TYPE_ID; static const boxName = 'PayjoinSessions'; - @HiveField(0) + // @HiveField(0) final String walletId; - @HiveField(1) + // @HiveField(1) final String? sender; - @HiveField(2) + // @HiveField(2) final String? receiver; - @HiveField(3) + // @HiveField(3) final String? pjUri; - @HiveField(4) + // @HiveField(4) String status; - @HiveField(5) + // @HiveField(5) DateTime? inProgressSince; - @HiveField(6) + // @HiveField(6) String? txId; - @HiveField(7) + // @HiveField(7) String? rawAmount; - @HiveField(8) + // @HiveField(8) String? error; bool get isSenderSession => sender != null; diff --git a/cw_core/lib/payjoin_session.part.dart b/cw_core/lib/payjoin_session.part.dart new file mode 100644 index 000000000..aa796c07e --- /dev/null +++ b/cw_core/lib/payjoin_session.part.dart @@ -0,0 +1,63 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'payjoin_session.dart'; + +// ************************************************************************** +// TypeAdapterGenerator +// ************************************************************************** + +class PayjoinSessionAdapter extends TypeAdapter { + @override + final int typeId = 23; + + @override + PayjoinSession read(BinaryReader reader) { + final numOfFields = reader.readByte(); + final fields = { + for (int i = 0; i < numOfFields; i++) reader.readByte(): reader.read(), + }; + return PayjoinSession( + walletId: fields[0] as String, + receiver: fields[2] as String?, + sender: fields[1] as String?, + pjUri: fields[3] as String?, + status: fields[4] as String, + inProgressSince: fields[5] as DateTime?, + rawAmount: fields[7] as String?, + ) + ..txId = fields[6] as String? + ..error = fields[8] as String?; + } + + @override + void write(BinaryWriter writer, PayjoinSession obj) { + writer + ..writeByte(9) + ..writeByte(0) + ..write(obj.walletId) + ..writeByte(1) + ..write(obj.sender) + ..writeByte(2) + ..write(obj.receiver) + ..writeByte(3) + ..write(obj.pjUri) + ..writeByte(4) + ..write(obj.status) + ..writeByte(5) + ..write(obj.inProgressSince) + ..writeByte(6) + ..write(obj.txId) + ..writeByte(7) + ..write(obj.rawAmount) + ..writeByte(8) + ..write(obj.error); + } + + @override + int get hashCode => typeId.hashCode; + + @override + bool operator ==(Object other) => + identical(this, other) || + other is PayjoinSessionAdapter && runtimeType == other.runtimeType && typeId == other.typeId; +} diff --git a/cw_core/lib/payment_uris.dart b/cw_core/lib/payment_uris.dart index 5e56fc722..effeb13a8 100644 --- a/cw_core/lib/payment_uris.dart +++ b/cw_core/lib/payment_uris.dart @@ -1,4 +1,4 @@ -import 'package:cw_core/format_fixed.dart'; +import "package:cw_core/format_fixed.dart"; abstract class PaymentURI { PaymentURI({required this.amount, required this.address}); @@ -7,12 +7,19 @@ abstract class PaymentURI { final String address; } +class ExternalAddressURI extends PaymentURI { + ExternalAddressURI({required super.amount, required super.address}); + + @override + String toString() => address; +} + class MoneroURI extends PaymentURI { MoneroURI({required super.amount, required super.address}); @override String toString() { - var base = 'monero:$address'; + var base = "monero:$address"; if (amount.isNotEmpty) { base += '?tx_amount=${amount.replaceAll(',', '.')}'; @@ -27,7 +34,7 @@ class HavenURI extends PaymentURI { @override String toString() { - var base = 'haven:$address'; + var base = "haven:$address"; if (amount.isNotEmpty) { base += '?tx_amount=${amount.replaceAll(',', '.')}'; @@ -38,7 +45,7 @@ class HavenURI extends PaymentURI { } class BitcoinURI extends PaymentURI { - BitcoinURI({required super.amount, required super.address, this.pjUri = ''}); + BitcoinURI({required super.amount, required super.address, this.pjUri = ""}); final String pjUri; @@ -46,13 +53,16 @@ class BitcoinURI extends PaymentURI { String toString() { final qp = {}; - if (amount.isNotEmpty) qp['amount'] = amount.replaceAll(',', '.'); - if (pjUri.isNotEmpty && !address.startsWith("sp")) { - qp['pjos'] = '0'; - qp['pj'] = pjUri; + if (amount.isNotEmpty) { + qp["amount"] = amount.replaceAll(",", "."); } - return Uri(scheme: 'bitcoin', path: address, queryParameters: qp).toString(); + if (pjUri.isNotEmpty && !address.startsWith("sp")) { + qp["pjos"] = "0"; + qp["pj"] = pjUri; + } + + return Uri(scheme: "bitcoin", path: address, queryParameters: qp).toString(); } } @@ -61,7 +71,7 @@ class LitecoinURI extends PaymentURI { @override String toString() { - var base = 'litecoin:$address'; + var base = "litecoin:$address"; if (amount.isNotEmpty) { base += '?amount=${amount.replaceAll(',', '.')}'; @@ -76,52 +86,7 @@ class EthereumURI extends PaymentURI { @override String toString() { - var base = 'ethereum:$address'; - - if (amount.isNotEmpty) { - base += '?amount=${amount.replaceAll(',', '.')}'; - } - - return base; - } -} - -class BaseURI extends PaymentURI { - BaseURI({required super.amount, required super.address}); - - @override - String toString() { - var base = 'base:$address'; - - if (amount.isNotEmpty) { - base += '?amount=${amount.replaceAll(',', '.')}'; - } - - return base; - } -} - -class ArbitrumURI extends PaymentURI { - ArbitrumURI({required super.amount, required super.address}); - - @override - String toString() { - var base = 'arbitrum:$address'; - - if (amount.isNotEmpty) { - base += '?amount=${amount.replaceAll(',', '.')}'; - } - - return base; - } -} - -class BSCURI extends PaymentURI { - BSCURI({required super.amount, required super.address}); - - @override - String toString() { - var base = 'bsc:$address'; + var base = "ethereum:$address"; if (amount.isNotEmpty) { base += '?amount=${amount.replaceAll(',', '.')}'; @@ -151,22 +116,7 @@ class NanoURI extends PaymentURI { @override String toString() { - var base = 'nano:$address'; - if (amount.isNotEmpty) { - base += '?amount=${amount.replaceAll(',', '.')}'; - } - - return base; - } -} - -class PolygonURI extends PaymentURI { - PolygonURI({required super.amount, required super.address}); - - @override - String toString() { - var base = 'polygon:$address'; - + var base = "nano:$address"; if (amount.isNotEmpty) { base += '?amount=${amount.replaceAll(',', '.')}'; } @@ -176,14 +126,23 @@ class PolygonURI extends PaymentURI { } class SolanaURI extends PaymentURI { - SolanaURI({required super.amount, required super.address}); + SolanaURI({required super.amount, required super.address, this.contractAddress}); + + final String? contractAddress; @override String toString() { - var base = 'solana:$address'; + var base = "solana:$address"; + final params = []; if (amount.isNotEmpty) { - base += '?amount=${amount.replaceAll(',', '.')}'; + params.add('amount=${amount.replaceAll(',', '.')}'); + } + if (contractAddress != null && contractAddress!.isNotEmpty) { + params.add("spl-token=$contractAddress"); + } + if (params.isNotEmpty) { + base += '?${params.join('&')}'; } return base; @@ -191,14 +150,23 @@ class SolanaURI extends PaymentURI { } class TronURI extends PaymentURI { - TronURI({required super.amount, required super.address}); + TronURI({required super.amount, required super.address, this.contractAddress}); + + final String? contractAddress; @override String toString() { - var base = 'tron:$address'; + var base = "tron:$address"; + final params = []; if (amount.isNotEmpty) { - base += '?amount=${amount.replaceAll(',', '.')}'; + params.add('amount=${amount.replaceAll(',', '.')}'); + } + if (contractAddress != null && contractAddress!.isNotEmpty) { + params.add("token=$contractAddress"); + } + if (params.isNotEmpty) { + base += '?${params.join('&')}'; } return base; @@ -210,7 +178,7 @@ class WowneroURI extends PaymentURI { @override String toString() { - var base = 'wownero:$address'; + var base = "wownero:$address"; if (amount.isNotEmpty) { base += '?tx_amount=${amount.replaceAll(',', '.')}'; @@ -221,12 +189,11 @@ class WowneroURI extends PaymentURI { } class ZanoURI extends PaymentURI { - ZanoURI({required String amount, required String address}) - : super(amount: amount, address: address); + ZanoURI({required super.amount, required super.address}); @override String toString() { - var base = 'zano:' + address; + var base = "zano:$address"; if (amount.isNotEmpty) { base += '?amount=${amount.replaceAll(',', '.')}'; @@ -237,12 +204,11 @@ class ZanoURI extends PaymentURI { } class DecredURI extends PaymentURI { - DecredURI({required String amount, required String address}) - : super(amount: amount, address: address); + DecredURI({required super.amount, required super.address}); @override String toString() { - var base = 'decred:' + address; + var base = "decred:$address"; if (amount.isNotEmpty) { base += '?amount=${amount.replaceAll(',', '.')}'; @@ -253,12 +219,11 @@ class DecredURI extends PaymentURI { } class DogeURI extends PaymentURI { - DogeURI({required String amount, required String address}) - : super(amount: amount, address: address); + DogeURI({required super.amount, required super.address}); @override String toString() { - var base = 'doge:' + address; + var base = "doge:$address"; if (amount.isNotEmpty) { base += '?amount=${amount.replaceAll(',', '.')}'; @@ -273,7 +238,7 @@ class ZcashURI extends PaymentURI { @override String toString() { - var base = 'zcash:$address'; + var base = "zcash:$address"; if (amount.isNotEmpty) { base += '?amount=${amount.replaceAll(',', '.')}'; @@ -284,93 +249,45 @@ class ZcashURI extends PaymentURI { } class ERC681URI extends PaymentURI { - final int chainId; - final String? contractAddress; - ERC681URI({ required this.chainId, required super.address, required super.amount, required this.contractAddress, + this.tokenDecimals = 18, + this.rawTokenAmount, }); - @override - String toString() { - var uri = 'ethereum:'; - - final targetAddress = contractAddress ?? address; - uri += targetAddress; - - if (chainId != 1) { - uri += '@$chainId'; - } - - if (contractAddress != null) { - uri += '/transfer'; - } - - final params = {}; - - if (contractAddress != null) { - params['address'] = address; - if (amount.isNotEmpty) { - params['uint256'] = _formatAmountForERC20(amount); - } - } else { - if (amount.isNotEmpty) { - params['value'] = _formatAmountForNative(amount); - } - } - - if (params.isNotEmpty) { - uri += '?'; - uri += params.entries.map((e) => '${e.key}=${e.value}').join('&'); - } - - return uri; - } - - /// Formats amount for ERC-20 token transfers (in atomic units) - String _formatAmountForERC20(String amount) { - try { - // Convert decimal amount to BigInt (assuming 18 decimals) - final amountDouble = double.parse(amount.replaceAll(',', '.')); - final amountBigInt = BigInt.from(amountDouble * 1e18); - return amountBigInt.toString(); - } catch (e) { - // Fallback to original amount if parsing fails - return amount.replaceAll(',', '.'); - } - } - - /// Formats amount for native ETH payments (in wei using scientific notation) - String _formatAmountForNative(String amount) { - try { - // Convert decimal amount to double for scientific notation - final amountDouble = double.parse(amount.replaceAll(',', '.')); - - // Use scientific notation as recommended by ERC-681 - return '${amountDouble}e18'; - } catch (e) { - // Fallback to original amount if parsing fails - return amount.replaceAll(',', '.'); - } - } - factory ERC681URI.fromUri(Uri uri) { final (isContract, targetAddress) = _getTargetAddress(uri.path); final chainId = _getChainID(uri.path); - final address = isContract ? uri.queryParameters["address"] ?? '' : targetAddress; - final amountParam = isContract ? uri.queryParameters["uint256"] : uri.queryParameters["value"]; + final address = isContract ? uri.queryParameters["address"] ?? "" : targetAddress; var formatedAmount = ""; + String? rawTokenAmount; - if (amountParam != null) { - final normalized = _normalizeToIntegerWei(amountParam); - formatedAmount = formatFixed(BigInt.parse(normalized), 18); - } else { + if (isContract) { formatedAmount = uri.queryParameters["amount"] ?? ""; + final uint256Param = uri.queryParameters["uint256"]; + if (uint256Param != null) { + final raw = uint256Param.replaceAll(",", ".").trim(); + if (!_scientificPattern.hasMatch(raw) && raw.contains(".")) { + if (formatedAmount.isEmpty) { + formatedAmount = raw; + } + } else { + rawTokenAmount = _normalizeToIntegerWei(raw); + } + } + } else { + final valueParam = uri.queryParameters["value"]; + if (valueParam != null) { + final normalized = BigInt.tryParse(_normalizeToIntegerWei(valueParam)); + formatedAmount = normalized != null ? formatFixed(normalized, 18) : ""; + } else { + formatedAmount = uri.queryParameters["amount"] ?? ""; + } } return ERC681URI( @@ -378,20 +295,85 @@ class ERC681URI extends PaymentURI { address: address, amount: formatedAmount, contractAddress: isContract ? targetAddress : null, + rawTokenAmount: rawTokenAmount, ); } - static int _getChainID(String path) { - return int.parse(RegExp( - r'@\d*', - ).firstMatch(path)?.group(0)?.replaceAll("@", "") ?? - "1"); + final int chainId; + final String? contractAddress; + final int tokenDecimals; + final String? rawTokenAmount; + + static final RegExp _scientificPattern = RegExp(r"^[+-]?(\d+\.?\d*|\d*\.?\d+)[eE][+-]?\d+$"); + + @override + String toString() { + var uri = "ethereum:"; + + final targetAddress = contractAddress ?? address; + uri += targetAddress; + + uri += "@$chainId"; + + if (contractAddress != null) { + uri += "/transfer"; + } + + final params = {}; + + if (contractAddress != null) { + params["address"] = address; + // only emit an atomic uint256 when it is unambiguous, either stored raw or 18 decimals, + // old app versions divide any uint256 by 1e18 so other tokens rely on the amount param + final uint256 = rawTokenAmount ?? + (tokenDecimals == 18 && amount.isNotEmpty + ? _expandDecimal(amount.replaceAll(",", "."), 18) + : null); + if (uint256 != null && uint256.isNotEmpty) { + params["uint256"] = uint256; + } + if (amount.isNotEmpty) { + params["amount"] = amount.replaceAll(",", "."); + } + } else { + if (amount.isNotEmpty) { + params["value"] = _formatAmountForNative(amount); + } + } + + if (params.isNotEmpty) { + uri += "?${params.entries.map((e) => "${e.key}=${e.value}").join("&")}"; + } + + return uri; } + /// Formats amount for native ETH payments (in wei using scientific notation) + String _formatAmountForNative(String amount) { + try { + // Convert decimal amount to double for scientific notation + final amountDouble = double.parse(amount.replaceAll(",", ".")); + + // Use scientific notation as recommended by ERC-681 + return "${amountDouble}e18"; + } catch (e) { + // Fallback to original amount if parsing fails + return amount.replaceAll(",", "."); + } + } + + static int _getChainID(String path) => + int.tryParse( + RegExp(r"@\d*").firstMatch(path)?.group(0)?.replaceAll("@", "") ?? "1", + ) ?? + 1; + static (bool, String) _getTargetAddress(String path) { - final targetAddress = - RegExp(r'^(0x)?[0-9a-f]{40}', caseSensitive: false).firstMatch(path)!.group(0)!; - return (path.contains("/"), targetAddress); + // I saw in the schema (thanks Konsti) that EIP-681 allows an optional "pay-" prefix before the target address, so adding a check for it here + final cleaned = path.startsWith("pay-") ? path.substring(4) : path; + final match = RegExp(r"^(0x)?[0-9a-f]{40}", caseSensitive: false).firstMatch(cleaned); + final targetAddress = match?.group(0) ?? cleaned.split("@").first.split("/").first; + return (cleaned.contains("/"), targetAddress); } /// Normalize an input amount into an integer wei string. @@ -401,25 +383,26 @@ class ERC681URI extends PaymentURI { /// - Scientific notation: "0.123e18", "1e6" → expanded to integer /// - Decimal ETH: "0.123456" → shifted by 18 decimals static String _normalizeToIntegerWei(String input) { - final raw = input.replaceAll(',', '.').trim(); + final raw = input.replaceAll(",", ".").trim(); // First we check if it's already a plain integer (basically just a number with no dot, no exponent) try { - final isPlainInteger = RegExp(r'^[+-]?\d+$').hasMatch(raw) && - !raw.contains('.') && - !raw.toLowerCase().contains('e'); - if (isPlainInteger) return raw.replaceFirst(RegExp(r'^\+'), ''); + final isPlainInteger = RegExp(r"^[+-]?\d+$").hasMatch(raw) && + !raw.contains(".") && + !raw.toLowerCase().contains("e"); + if (isPlainInteger) { + return raw.replaceFirst(RegExp(r"^\+"), ""); + } // Then we check if it's a scientific notation - final sci = RegExp(r'^[+-]?(\d+\.?\d*|\d*\.?\d+)[eE][+-]?\d+$'); - if (sci.hasMatch(raw)) { - final mantissaStr = raw.toLowerCase().split('e')[0]; - final exp = int.parse(raw.toLowerCase().split('e')[1]); + if (_scientificPattern.hasMatch(raw)) { + final mantissaStr = raw.toLowerCase().split("e")[0]; + final exp = int.parse(raw.toLowerCase().split("e")[1]); return _expandDecimal(mantissaStr, exp); } // Lastly, we check if it's a fixed decimal ETH amount, here we shift by 18 to get wei for the amount - if (raw.contains('.')) { + if (raw.contains(".")) { return _expandDecimal(raw, 18); } return raw; @@ -437,34 +420,34 @@ class ERC681URI extends PaymentURI { /// _expandDecimal('1.2', 3) -> '1200' static String _expandDecimal(String decimalStr, int expShift) { var s = decimalStr.trim(); - var sign = ''; - if (s.startsWith('-') || s.startsWith('+')) { - sign = s[0] == '-' ? '-' : ''; + var sign = ""; + if (s.startsWith("-") || s.startsWith("+")) { + sign = s[0] == "-" ? "-" : ""; s = s.substring(1); } // First we split the integer and fractional parts - final parts = s.split('.'); - final intPart = parts[0].isEmpty ? '0' : parts[0]; - final fracPart = parts.length > 1 ? parts[1] : ''; - final digits = (intPart + fracPart).replaceFirst(RegExp(r'^0+'), ''); + final parts = s.split("."); + final intPart = parts[0].isEmpty ? "0" : parts[0]; + final fracPart = parts.length > 1 ? parts[1] : ""; + final digits = (intPart + fracPart).replaceFirst(RegExp(r"^0+"), ""); final fracLen = fracPart.length; // Then we calculate the effective shift = desired shift minus existing fractional digits final shift = expShift - fracLen; if (shift >= 0) { - final head = digits.isEmpty ? '0' : digits; - final zeros = List.filled(shift, '0').join(); + final head = digits.isEmpty ? "0" : digits; + final zeros = List.filled(shift, "0").join(); final res = head + zeros; - return sign + (res.isEmpty ? '0' : res); + return sign + (res.isEmpty ? "0" : res); } else { // Need to insert a decimal point within digits; return integer by truncating final cut = digits.length + shift; if (cut <= 0) { - return '0'; + return "0"; } final res = digits.substring(0, cut); - return sign + (res.isEmpty ? '0' : res); + return sign + (res.isEmpty ? "0" : res); } } } diff --git a/cw_core/lib/pending_transaction.dart b/cw_core/lib/pending_transaction.dart index 89bd75922..fb4031936 100644 --- a/cw_core/lib/pending_transaction.dart +++ b/cw_core/lib/pending_transaction.dart @@ -1,3 +1,5 @@ +import 'package:cw_core/amount/money.dart'; + class PendingChange { final String address; final BigInt amount; @@ -7,22 +9,20 @@ class PendingChange { mixin PendingTransaction { String get id; + + Money get amount; + Money get fee; + Money? get additionalCost => null; + String get amountFormatted; - String get feeFormatted; - String get feeFormattedValue; + String get feeFormatted => fee.toStringWithSymbol(fractionalDigits: 8); + String get feeFormattedValue => fee.toStringWithPrecision(fractionalDigits: 8); String? feeRate; String get hex; String? get evmTxHashFromRawHex => null; int? get outputCount => null; PendingChange? change; - String get roundedAmountFormatted { - if (amountFormatted.split(".").last.length <= 8) { - return amountFormatted; - } - return double.parse(amountFormatted).toStringAsFixed(8); - } - bool shouldCommitUR() => false; Future commit(); diff --git a/cw_core/lib/receive_page_option.dart b/cw_core/lib/receive_page_option.dart index aacbe4918..5a560575c 100644 --- a/cw_core/lib/receive_page_option.dart +++ b/cw_core/lib/receive_page_option.dart @@ -3,8 +3,6 @@ import 'package:cw_core/enumerate.dart'; class ReceivePageOption implements Enumerate { static const mainnet = ReceivePageOption._('mainnet'); static const testnet = ReceivePageOption._('testnet'); - static const anonPayInvoice = ReceivePageOption._('anonPayInvoice'); - static const anonPayDonationLink = ReceivePageOption._('anonPayDonationLink'); const ReceivePageOption._(this.value, {this.iconPath, this.description, this.isCommon = false, this.addAddressWord = false}); @@ -22,7 +20,4 @@ class ReceivePageOption implements Enumerate { const ReceivePageOptions = [ ReceivePageOption.mainnet, - // FIXME anonpay disabed for new ui beta. restore after screens are ready - // ReceivePageOption.anonPayInvoice, - // ReceivePageOption.anonPayDonationLink ]; diff --git a/cw_core/lib/spl_token.dart b/cw_core/lib/spl_token.dart index 88323e175..2c2285b67 100644 --- a/cw_core/lib/spl_token.dart +++ b/cw_core/lib/spl_token.dart @@ -1,42 +1,8 @@ -import 'package:cw_core/crypto_currency.dart'; -import 'package:cw_core/hive_type_ids.dart'; -import 'package:hive/hive.dart'; - -part 'spl_token.g.dart'; - -@HiveType(typeId: SPLToken.typeId) -class SPLToken extends CryptoCurrency with HiveObjectMixin { - @override - @HiveField(0) - final String name; - - @HiveField(1) - final String symbol; - - @HiveField(2) - final String mintAddress; - - @HiveField(3) - final int decimal; - - @HiveField(4, defaultValue: true) - bool _enabled; - - @HiveField(5) - final String mint; - - @override - @HiveField(6) - final String? iconPath; - - @override - @HiveField(7) - final String? tag; - - @override - @HiveField(8, defaultValue: false) - bool isPotentialScam; +import "package:cw_core/crypto_currency.dart"; +import "package:cw_core/db/sqlite.dart"; +import "package:sqflite/sqflite.dart"; +class SPLToken extends CryptoCurrency { SPLToken({ required this.name, required this.symbol, @@ -44,9 +10,12 @@ class SPLToken extends CryptoCurrency with HiveObjectMixin { required this.decimal, required this.mint, this.iconPath, - this.tag = 'SOL', + this.tag = "SOL", bool enabled = true, this.isPotentialScam = false, + super.groups, + this.id = 0, + this.walletName, }) : _enabled = enabled, super( name: mint.toLowerCase(), @@ -63,27 +32,21 @@ class SPLToken extends CryptoCurrency with HiveObjectMixin { required String mint, required String symbol, required String mintAddress, + required int decimal, String? iconPath, bool isPotentialScam = false, - }) { - return SPLToken( - name: name, - symbol: symbol, - mintAddress: mintAddress, - decimal: 0, - mint: mint, - iconPath: iconPath, - isPotentialScam: isPotentialScam, - ); - } + }) => + SPLToken( + name: name, + symbol: symbol, + mintAddress: mintAddress, + decimal: decimal, + mint: mint, + iconPath: iconPath, + isPotentialScam: isPotentialScam, + ); - @override - bool get enabled => _enabled; - - @override - set enabled(bool value) => _enabled = value; - - SPLToken.copyWith(SPLToken other, {String? icon, String? tag, bool? enabled}) + SPLToken.copyWith(SPLToken other, {String? icon, String? tag, bool? enabled, String? walletName}) : name = other.name, symbol = other.symbol, mintAddress = other.mintAddress, @@ -93,6 +56,8 @@ class SPLToken extends CryptoCurrency with HiveObjectMixin { tag = tag ?? other.tag, iconPath = icon ?? other.iconPath, isPotentialScam = other.isPotentialScam, + id = 0, + walletName = walletName ?? other.walletName, super( title: other.symbol.toUpperCase(), name: other.symbol.toLowerCase(), @@ -101,10 +66,129 @@ class SPLToken extends CryptoCurrency with HiveObjectMixin { tag: other.tag, iconPath: icon, isPotentialScam: other.isPotentialScam, + groups: other.groups, ); - static const typeId = SPL_TOKEN_TYPE_ID; - static const boxName = 'SPLTokens'; + SPLToken.fromMap(Map map) + : this( + name: map["name"] as String? ?? "", + symbol: map["symbol"] as String? ?? "", + mintAddress: map["mintAddress"] as String? ?? "", + decimal: (map["decimal"] ?? 0) as int, + mint: map["mint"] as String? ?? "", + enabled: _getBoolFromDB(map["enabled"], defaultValue: true), + iconPath: map["iconPath"] as String?, + tag: map["tag"] as String?, + isPotentialScam: _getBoolFromDB(map["isPotentialScam"]), + id: (map[selfIdColumn] ?? 0) as int, + walletName: map["walletName"] as String?, + ); + @override + final String name; + + @override + final String symbol; + + final String mintAddress; + + final int decimal; + + bool _enabled; + + final String mint; + + @override + final String? iconPath; + + @override + final String? tag; + + @override + bool isPotentialScam; + + int id; + String? walletName; + + @override + bool get enabled => _enabled; + + @override + set enabled(bool value) => _enabled = value; + + static bool _getBoolFromDB(value, {bool? defaultValue}) { + if (value is bool) { + return value; + } else if (value is int) { + return value == 1; + } else { + return defaultValue ?? false; + } + } + + Map toMap() => { + selfIdColumn: id, + "walletName": walletName, + "name": name, + "symbol": symbol, + "mintAddress": mintAddress, + "decimal": decimal, + "mint": mint, + "enabled": _enabled ? 1 : 0, + "iconPath": iconPath, + "tag": tag, + "isPotentialScam": isPotentialScam ? 1 : 0, + }; + + static String get tableName => "SPLToken"; + static String get selfIdColumn => "${tableName}Id"; + + Future save() async { + if (walletName == null) { + throw StateError("SPLToken.save() requires walletName to be set"); + } + + final json = toMap(); + if (json[selfIdColumn] == 0) { + json[selfIdColumn] = null; + } + id = await db!.insert(tableName, json, conflictAlgorithm: ConflictAlgorithm.replace); + return id; + } + + static Future> selectList(String where, List whereArgs, + {String? orderBy}) async { + orderBy ??= selfIdColumn; + final list = await db!.query( + tableName, + where: where.isNotEmpty ? where : "1 = 1", + whereArgs: whereArgs.isNotEmpty ? whereArgs : null, + orderBy: orderBy, + ); + return List.generate(list.length, (index) => SPLToken.fromMap(list[index])); + } + + static Future> getAllForWallet(String walletName) async => + selectList("walletName = ?", [walletName]); + + static Future getByMint(String walletName, String mintAddress) async { + final list = await selectList("walletName = ? AND mintAddress = ?", [walletName, mintAddress]); + return list.isEmpty ? null : list.first; + } + + static Future deleteForWallet(String walletName, String mintAddress) => db!.delete( + tableName, + where: "walletName = ? AND mintAddress = ?", + whereArgs: [walletName, mintAddress], + ); + + static Future deleteAllForWallet(String walletName) => + db!.delete(tableName, where: "walletName = ?", whereArgs: [walletName]); + + static Future renameWallet(String oldName, String newName) async { + await db!.delete(tableName, where: "walletName = ?", whereArgs: [newName]); + await db! + .update(tableName, {"walletName": newName}, where: "walletName = ?", whereArgs: [oldName]); + } @override bool operator ==(other) => @@ -113,4 +197,7 @@ class SPLToken extends CryptoCurrency with HiveObjectMixin { @override int get hashCode => mintAddress.hashCode; + + @override + String get apiString => "sol.$mintAddress"; } diff --git a/cw_core/lib/spl_token_legacy.dart b/cw_core/lib/spl_token_legacy.dart new file mode 100644 index 000000000..df1c8bca7 --- /dev/null +++ b/cw_core/lib/spl_token_legacy.dart @@ -0,0 +1,133 @@ +import "package:cw_core/cake_hive.dart"; +import "package:cw_core/db/sqlite.dart"; +import "package:cw_core/hive_type_ids.dart"; +import "package:cw_core/spl_token.dart" as spl_new; +import "package:cw_core/utils/print_verbose.dart"; +import "package:cw_core/wallet_info.dart"; +import "package:cw_core/wallet_type.dart"; +import "package:hive/hive.dart"; +import "package:sqflite/sqflite.dart"; + +part "spl_token_legacy.part.dart"; + +Future performSplTokenHiveMigration() async { + try { + if (!CakeHive.isAdapterRegistered(SPLToken.typeId)) { + CakeHive.registerAdapter(SPLTokenAdapter()); + } + + final wallets = await WalletInfo.getAll(); + await SPLToken.migrateAllToSqlite(wallets); + } catch (e) { + printV("Error performing SPLToken Hive migration: $e, continuing anyway"); + } +} + +// @HiveType(typeId: SPLToken.typeId) +class SPLToken extends HiveObject { + SPLToken({ + required this.name, + required this.symbol, + required this.mintAddress, + required this.decimal, + required this.mint, + this.iconPath, + this.tag = "SOL", + bool enabled = true, + this.isPotentialScam = false, + }) : _enabled = enabled; + // @HiveField(0) + final String name; + + // @HiveField(1) + final String symbol; + + // @HiveField(2) + final String mintAddress; + + // @HiveField(3) + final int decimal; + + // @HiveField(4, defaultValue: true) + bool _enabled; + + // @HiveField(5) + final String mint; + + // @HiveField(6) + final String? iconPath; + + // @HiveField(7) + final String? tag; + + // @HiveField(8, defaultValue: false) + bool isPotentialScam; + + bool get enabled => _enabled; + + set enabled(bool value) => _enabled = value; + + static const typeId = SPL_TOKEN_TYPE_ID; + static const boxName = "SPLTokens"; + + static Future migrateAllToSqlite(List wallets) async { + final sanitizedToRawNames = >{}; + for (final wallet in wallets) { + // Hash Bags: solana is removed, so no wallet has tokens to migrate. + continue; + + sanitizedToRawNames + .putIfAbsent(wallet.name.replaceAll(" ", "_"), () => {}) + .add(wallet.name); + } + + for (final entry in sanitizedToRawNames.entries) { + final tokenBoxName = "${entry.key}_$boxName"; + try { + if (!await CakeHive.boxExists(tokenBoxName)) { + continue; + } + + final box = await CakeHive.openBox(tokenBoxName); + + for (final key in box.keys.toList()) { + final token = box.get(key); + if (token == null) { + continue; + } + + for (final rawName in entry.value) { + await token.migrateToSqlite(walletName: rawName); + } + await box.delete(key); + } + + await box.deleteFromDisk(); + } catch (e) { + printV("Error migrating spl token box $tokenBoxName: $e, continuing anyway"); + } + } + } + + Future migrateToSqlite({required String walletName}) async { + final row = spl_new.SPLToken( + name: name, + symbol: symbol, + mintAddress: mintAddress, + decimal: decimal, + mint: mint, + enabled: _enabled, + iconPath: iconPath, + tag: tag, + isPotentialScam: isPotentialScam, + walletName: walletName, + ).toMap(); + row[spl_new.SPLToken.selfIdColumn] = null; + + await db!.insert( + spl_new.SPLToken.tableName, + row, + conflictAlgorithm: ConflictAlgorithm.replace, + ); + } +} diff --git a/cw_core/lib/spl_token_legacy.part.dart b/cw_core/lib/spl_token_legacy.part.dart new file mode 100644 index 000000000..ca228a65e --- /dev/null +++ b/cw_core/lib/spl_token_legacy.part.dart @@ -0,0 +1,62 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of "spl_token_legacy.dart"; + +// ************************************************************************** +// TypeAdapterGenerator +// ************************************************************************** + +class SPLTokenAdapter extends TypeAdapter { + @override + final int typeId = 16; + + @override + SPLToken read(BinaryReader reader) { + final numOfFields = reader.readByte(); + final fields = { + for (int i = 0; i < numOfFields; i++) reader.readByte(): reader.read(), + }; + return SPLToken( + name: fields[0] as String, + symbol: fields[1] as String, + mintAddress: fields[2] as String, + decimal: fields[3] as int, + mint: fields[5] as String, + iconPath: fields[6] as String?, + tag: fields[7] as String?, + isPotentialScam: fields[8] == null ? false : fields[8] as bool, + ).._enabled = fields[4] == null ? true : fields[4] as bool; + } + + @override + void write(BinaryWriter writer, SPLToken obj) { + writer + ..writeByte(9) + ..writeByte(0) + ..write(obj.name) + ..writeByte(1) + ..write(obj.symbol) + ..writeByte(2) + ..write(obj.mintAddress) + ..writeByte(3) + ..write(obj.decimal) + ..writeByte(4) + ..write(obj._enabled) + ..writeByte(5) + ..write(obj.mint) + ..writeByte(6) + ..write(obj.iconPath) + ..writeByte(7) + ..write(obj.tag) + ..writeByte(8) + ..write(obj.isPotentialScam); + } + + @override + int get hashCode => typeId.hashCode; + + @override + bool operator ==(Object other) => + identical(this, other) || + other is SPLTokenAdapter && runtimeType == other.runtimeType && typeId == other.typeId; +} diff --git a/cw_core/lib/transaction_info.dart b/cw_core/lib/transaction_info.dart index e3453747a..a8743e0f8 100644 --- a/cw_core/lib/transaction_info.dart +++ b/cw_core/lib/transaction_info.dart @@ -1,20 +1,18 @@ +import 'package:cw_core/amount/money.dart'; +import 'package:cw_core/format_amount.dart'; import 'package:cw_core/transaction_direction.dart'; import 'package:cw_core/keyable.dart'; abstract class TransactionInfo extends Object with Keyable { late String id; late String txHash = id; - late int amount; - int? fee; + late Money amount; + Money? fee; late TransactionDirection direction; late bool isPending; late DateTime date; int? height; int confirmations = 0; - String amountFormatted(); - String fiatAmount(); - String? feeFormatted(); - void changeFiatAmount(String amount); String? to; String? from; String? evmSignatureName; @@ -26,4 +24,8 @@ abstract class TransactionInfo extends Object with Keyable { dynamic get keyIndex => id; Map additionalInfo = {}; + + String? _fiatAmount; + String fiatAmount() => _fiatAmount ?? ''; + void changeFiatAmount(String amount) => _fiatAmount = formatAmount(amount); } diff --git a/cw_core/lib/tron_token.dart b/cw_core/lib/tron_token.dart index 3d891bea7..c2220d00d 100644 --- a/cw_core/lib/tron_token.dart +++ b/cw_core/lib/tron_token.dart @@ -1,41 +1,8 @@ -// ignore_for_file: annotate_overrides, overridden_fields - -import 'package:cw_core/crypto_currency.dart'; -import 'package:cw_core/hive_type_ids.dart'; -import 'package:hive/hive.dart'; - -part 'tron_token.g.dart'; - -@HiveType(typeId: TronToken.typeId) -class TronToken extends CryptoCurrency with HiveObjectMixin { - @HiveField(0) - final String name; - - @HiveField(1) - final String symbol; - - @HiveField(2) - final String contractAddress; - - @HiveField(3) - final int decimal; - - @HiveField(4, defaultValue: true) - bool _enabled; - - @HiveField(5) - final String? iconPath; - - @HiveField(6) - final String? tag; - - @HiveField(7, defaultValue: false) - final bool isPotentialScam; - - bool get enabled => _enabled; - - set enabled(bool value) => _enabled = value; +import "package:cw_core/crypto_currency.dart"; +import "package:cw_core/db/sqlite.dart"; +import "package:sqflite/sqflite.dart"; +class TronToken extends CryptoCurrency { TronToken({ required this.name, required this.symbol, @@ -43,8 +10,10 @@ class TronToken extends CryptoCurrency with HiveObjectMixin { required this.decimal, bool enabled = true, this.iconPath, - this.tag = 'TRX', + this.tag = "TRX", this.isPotentialScam = false, + this.id = 0, + this.walletName, }) : _enabled = enabled, super( name: symbol.toLowerCase(), @@ -55,9 +24,27 @@ class TronToken extends CryptoCurrency with HiveObjectMixin { decimals: decimal, isPotentialScam: isPotentialScam, ); + TronToken.fromMap(Map map) + : this( + name: map["name"] as String? ?? "", + symbol: map["symbol"] as String? ?? "", + contractAddress: map["contractAddress"] as String? ?? "", + decimal: (map["decimal"] ?? 0) as int, + enabled: _getBoolFromDB(map["enabled"], defaultValue: true), + iconPath: map["iconPath"] as String?, + tag: map["tag"] as String?, + isPotentialScam: _getBoolFromDB(map["isPotentialScam"]), + id: (map[selfIdColumn] ?? 0) as int, + walletName: map["walletName"] as String?, + ); - TronToken.copyWith(TronToken other, {String? icon, String? tag, bool? enabled}) - : name = other.name, + TronToken.copyWith( + TronToken other, { + String? icon, + String? tag, + bool? enabled, + String? walletName, + }) : name = other.name, symbol = other.symbol, contractAddress = other.contractAddress, decimal = other.decimal, @@ -65,6 +52,8 @@ class TronToken extends CryptoCurrency with HiveObjectMixin { tag = tag ?? other.tag, iconPath = icon ?? other.iconPath, isPotentialScam = other.isPotentialScam, + id = 0, + walletName = walletName ?? other.walletName, super( name: other.name, title: other.symbol.toUpperCase(), @@ -74,9 +63,110 @@ class TronToken extends CryptoCurrency with HiveObjectMixin { decimals: other.decimal, isPotentialScam: other.isPotentialScam, ); + @override + final String name; - static const typeId = TRON_TOKEN_TYPE_ID; - static const boxName = 'TronTokens'; + @override + final String symbol; + + @override + final String? iconPath; + + @override + final String? tag; + + @override + final bool isPotentialScam; + + @override + bool get enabled => _enabled; + + @override + set enabled(bool value) => _enabled = value; + + int id; + bool _enabled; + final int decimal; + String? walletName; + final String contractAddress; + + static bool _getBoolFromDB(value, {bool? defaultValue}) { + if (value is bool) { + return value; + } else if (value is int) { + return value == 1; + } else { + return defaultValue ?? false; + } + } + + Map toMap() => { + selfIdColumn: id, + "walletName": walletName, + "name": name, + "symbol": symbol, + "contractAddress": contractAddress, + "decimal": decimal, + "enabled": _enabled ? 1 : 0, + "iconPath": iconPath, + "tag": tag, + "isPotentialScam": isPotentialScam ? 1 : 0, + }; + + static String get tableName => "TronToken"; + static String get selfIdColumn => "${tableName}Id"; + + Future save() async { + if (walletName == null) { + throw StateError("TronToken.save() requires walletName to be set"); + } + + final json = toMap(); + if (json[selfIdColumn] == 0) { + json[selfIdColumn] = null; + } + id = await db!.insert(tableName, json, conflictAlgorithm: ConflictAlgorithm.replace); + return id; + } + + static Future> selectList( + String where, + List whereArgs, { + String? orderBy, + }) async { + orderBy ??= selfIdColumn; + final list = await db!.query( + tableName, + where: where.isNotEmpty ? where : "1 = 1", + whereArgs: whereArgs.isNotEmpty ? whereArgs : null, + orderBy: orderBy, + ); + return List.generate(list.length, (index) => TronToken.fromMap(list[index])); + } + + static Future> getAllForWallet(String walletName) => + selectList("walletName = ?", [walletName]); + + static Future getByContract(String walletName, String contractAddress) async { + final list = + await selectList("walletName = ? AND contractAddress = ?", [walletName, contractAddress]); + return list.isEmpty ? null : list.first; + } + + static Future deleteForWallet(String walletName, String contractAddress) => db!.delete( + tableName, + where: "walletName = ? AND contractAddress = ?", + whereArgs: [walletName, contractAddress], + ); + + static Future deleteAllForWallet(String walletName) => + db!.delete(tableName, where: "walletName = ?", whereArgs: [walletName]); + + static Future renameWallet(String oldName, String newName) async { + await db!.delete(tableName, where: "walletName = ?", whereArgs: [newName]); + await db! + .update(tableName, {"walletName": newName}, where: "walletName = ?", whereArgs: [oldName]); + } @override bool operator ==(other) => @@ -85,4 +175,7 @@ class TronToken extends CryptoCurrency with HiveObjectMixin { @override int get hashCode => contractAddress.hashCode; + + @override + String get apiString => "trx.$contractAddress"; } diff --git a/cw_core/lib/tron_token_legacy.dart b/cw_core/lib/tron_token_legacy.dart new file mode 100644 index 000000000..77804b89f --- /dev/null +++ b/cw_core/lib/tron_token_legacy.dart @@ -0,0 +1,128 @@ +import "package:cw_core/cake_hive.dart"; +import "package:cw_core/db/sqlite.dart"; +import "package:cw_core/hive_type_ids.dart"; +import "package:cw_core/tron_token.dart" as tron_new; +import "package:cw_core/utils/print_verbose.dart"; +import "package:cw_core/wallet_info.dart"; +import "package:cw_core/wallet_type.dart"; +import "package:hive/hive.dart"; +import "package:sqflite/sqflite.dart"; + +part "tron_token_legacy.part.dart"; + +Future performTronTokenHiveMigration() async { + try { + if (!CakeHive.isAdapterRegistered(TronToken.typeId)) { + CakeHive.registerAdapter(TronTokenAdapter()); + } + + final wallets = await WalletInfo.getAll(); + await TronToken.migrateAllToSqlite(wallets); + } catch (e) { + printV("Error performing TronToken Hive migration: $e, continuing anyway"); + } +} + +// @HiveType(typeId: TronToken.typeId) +class TronToken extends HiveObject { + TronToken({ + required this.name, + required this.symbol, + required this.contractAddress, + required this.decimal, + bool enabled = true, + this.iconPath, + this.tag = "TRX", + this.isPotentialScam = false, + }) : _enabled = enabled; + // @HiveField(0) + final String name; + + // @HiveField(1) + final String symbol; + + // @HiveField(2) + final String contractAddress; + + // @HiveField(3) + final int decimal; + + // @HiveField(4, defaultValue: true) + bool _enabled; + + // @HiveField(5) + final String? iconPath; + + // @HiveField(6) + final String? tag; + + // @HiveField(7, defaultValue: false) + final bool isPotentialScam; + + bool get enabled => _enabled; + + set enabled(bool value) => _enabled = value; + + static const typeId = TRON_TOKEN_TYPE_ID; + static const boxName = "TronTokens"; + + static Future migrateAllToSqlite(List wallets) async { + final sanitizedToRawNames = >{}; + for (final wallet in wallets) { + // Hash Bags: tron is removed, so no wallet has tokens to migrate. + continue; + + sanitizedToRawNames + .putIfAbsent(wallet.name.replaceAll(" ", "_"), () => {}) + .add(wallet.name); + } + + for (final entry in sanitizedToRawNames.entries) { + final tokenBoxName = "${entry.key}_$boxName"; + try { + if (!await CakeHive.boxExists(tokenBoxName)) { + continue; + } + + final box = await CakeHive.openBox(tokenBoxName); + + for (final key in box.keys.toList()) { + final token = box.get(key); + if (token == null) { + continue; + } + + for (final rawName in entry.value) { + await token.migrateToSqlite(walletName: rawName); + } + await box.delete(key); + } + + await box.deleteFromDisk(); + } catch (e) { + printV("Error migrating tron token box $tokenBoxName: $e, continuing anyway"); + } + } + } + + Future migrateToSqlite({required String walletName}) async { + final row = tron_new.TronToken( + name: name, + symbol: symbol, + contractAddress: contractAddress, + decimal: decimal, + enabled: _enabled, + iconPath: iconPath, + tag: tag, + isPotentialScam: isPotentialScam, + walletName: walletName, + ).toMap(); + row[tron_new.TronToken.selfIdColumn] = null; + + await db!.insert( + tron_new.TronToken.tableName, + row, + conflictAlgorithm: ConflictAlgorithm.replace, + ); + } +} diff --git a/cw_core/lib/tron_token_legacy.part.dart b/cw_core/lib/tron_token_legacy.part.dart new file mode 100644 index 000000000..0884d9184 --- /dev/null +++ b/cw_core/lib/tron_token_legacy.part.dart @@ -0,0 +1,59 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of "tron_token_legacy.dart"; + +// ************************************************************************** +// TypeAdapterGenerator +// ************************************************************************** + +class TronTokenAdapter extends TypeAdapter { + @override + final int typeId = 18; + + @override + TronToken read(BinaryReader reader) { + final numOfFields = reader.readByte(); + final fields = { + for (int i = 0; i < numOfFields; i++) reader.readByte(): reader.read(), + }; + return TronToken( + name: fields[0] as String, + symbol: fields[1] as String, + contractAddress: fields[2] as String, + decimal: fields[3] as int, + iconPath: fields[5] as String?, + tag: fields[6] as String?, + isPotentialScam: fields[7] == null ? false : fields[7] as bool, + ).._enabled = fields[4] == null ? true : fields[4] as bool; + } + + @override + void write(BinaryWriter writer, TronToken obj) { + writer + ..writeByte(8) + ..writeByte(0) + ..write(obj.name) + ..writeByte(1) + ..write(obj.symbol) + ..writeByte(2) + ..write(obj.contractAddress) + ..writeByte(3) + ..write(obj.decimal) + ..writeByte(4) + ..write(obj._enabled) + ..writeByte(5) + ..write(obj.iconPath) + ..writeByte(6) + ..write(obj.tag) + ..writeByte(7) + ..write(obj.isPotentialScam); + } + + @override + int get hashCode => typeId.hashCode; + + @override + bool operator ==(Object other) => + identical(this, other) || + other is TronTokenAdapter && runtimeType == other.runtimeType && typeId == other.typeId; +} diff --git a/cw_core/lib/unspent_coins_info.dart b/cw_core/lib/unspent_coins_info.dart index a60feb634..8e0ec7883 100644 --- a/cw_core/lib/unspent_coins_info.dart +++ b/cw_core/lib/unspent_coins_info.dart @@ -2,9 +2,9 @@ import 'package:cw_core/hive_type_ids.dart'; import 'package:cw_core/unspent_comparable_mixin.dart'; import 'package:hive/hive.dart'; -part 'unspent_coins_info.g.dart'; +part 'unspent_coins_info.part.dart'; -@HiveType(typeId: UnspentCoinsInfo.typeId) +// @HiveType(typeId: UnspentCoinsInfo.typeId) class UnspentCoinsInfo extends HiveObject with UnspentComparable { UnspentCoinsInfo({ required this.walletId, @@ -25,40 +25,40 @@ class UnspentCoinsInfo extends HiveObject with UnspentComparable { static const boxName = 'Unspent'; static const boxKey = 'unspentBoxKey'; - @HiveField(0, defaultValue: '') + // @HiveField(0, defaultValue: '') String walletId; - @HiveField(1, defaultValue: '') + // @HiveField(1, defaultValue: '') String hash; - @HiveField(2, defaultValue: false) + // @HiveField(2, defaultValue: false) bool isFrozen; - @HiveField(3, defaultValue: false) + // @HiveField(3, defaultValue: false) bool isSending; - @HiveField(4) + // @HiveField(4) String? noteRaw; - @HiveField(5, defaultValue: '') + // @HiveField(5, defaultValue: '') String address; - @HiveField(6, defaultValue: 0) + // @HiveField(6, defaultValue: 0) int value; - @HiveField(7, defaultValue: 0) + // @HiveField(7, defaultValue: 0) int vout; - @HiveField(8, defaultValue: null) + // @HiveField(8, defaultValue: null) String? keyImage; - @HiveField(9, defaultValue: false) + // @HiveField(9, defaultValue: false) bool isChange; - @HiveField(10, defaultValue: 0) + // @HiveField(10, defaultValue: 0) int accountIndex; - @HiveField(11, defaultValue: false) + // @HiveField(11, defaultValue: false) bool? isSilentPayment; String get note => noteRaw ?? ''; diff --git a/cw_core/lib/unspent_coins_info.part.dart b/cw_core/lib/unspent_coins_info.part.dart new file mode 100644 index 000000000..658bb1e6f --- /dev/null +++ b/cw_core/lib/unspent_coins_info.part.dart @@ -0,0 +1,74 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'unspent_coins_info.dart'; + +// ************************************************************************** +// TypeAdapterGenerator +// ************************************************************************** + +class UnspentCoinsInfoAdapter extends TypeAdapter { + @override + final int typeId = 9; + + @override + UnspentCoinsInfo read(BinaryReader reader) { + final numOfFields = reader.readByte(); + final fields = { + for (int i = 0; i < numOfFields; i++) reader.readByte(): reader.read(), + }; + return UnspentCoinsInfo( + walletId: fields[0] == null ? '' : fields[0] as String, + hash: fields[1] == null ? '' : fields[1] as String, + isFrozen: fields[2] == null ? false : fields[2] as bool, + isSending: fields[3] == null ? false : fields[3] as bool, + noteRaw: fields[4] as String?, + address: fields[5] == null ? '' : fields[5] as String, + vout: fields[7] == null ? 0 : fields[7] as int, + value: fields[6] == null ? 0 : fields[6] as int, + keyImage: fields[8] as String?, + isChange: fields[9] == null ? false : fields[9] as bool, + accountIndex: fields[10] == null ? 0 : fields[10] as int, + isSilentPayment: fields[11] == null ? false : fields[11] as bool?, + ); + } + + @override + void write(BinaryWriter writer, UnspentCoinsInfo obj) { + writer + ..writeByte(12) + ..writeByte(0) + ..write(obj.walletId) + ..writeByte(1) + ..write(obj.hash) + ..writeByte(2) + ..write(obj.isFrozen) + ..writeByte(3) + ..write(obj.isSending) + ..writeByte(4) + ..write(obj.noteRaw) + ..writeByte(5) + ..write(obj.address) + ..writeByte(6) + ..write(obj.value) + ..writeByte(7) + ..write(obj.vout) + ..writeByte(8) + ..write(obj.keyImage) + ..writeByte(9) + ..write(obj.isChange) + ..writeByte(10) + ..write(obj.accountIndex) + ..writeByte(11) + ..write(obj.isSilentPayment); + } + + @override + int get hashCode => typeId.hashCode; + + @override + bool operator ==(Object other) => + identical(this, other) || + other is UnspentCoinsInfoAdapter && + runtimeType == other.runtimeType && + typeId == other.typeId; +} diff --git a/cw_core/lib/utils/file.dart b/cw_core/lib/utils/file.dart index 72fc94859..3ad3165d6 100644 --- a/cw_core/lib/utils/file.dart +++ b/cw_core/lib/utils/file.dart @@ -1,21 +1,84 @@ +import 'dart:convert'; import 'dart:io'; -import 'package:cw_core/key.dart'; +import 'dart:typed_data'; + +import 'package:cake_backup/backup.dart' as cwb; import 'package:encrypt/encrypt.dart' as encrypt; -Future write({required String path, required String password, required String data}) async => - writeData(path: path, password: password, data: data); +const _ivEncodedStringLength = 12; -Future writeData( - {required String path, required String password, required String data}) async { - final keys = extractKeys(password); - final key = encrypt.Key.fromBase64(keys.first); - final iv = encrypt.IV.fromBase64(keys.last); - final encrypted = await encode(key: key, iv: iv, data: data); - final f = File(path); - f.writeAsStringSync(encrypted); +Future write({ + required String path, + required String password, + required String data, + bool? highEntropyPassphrase, +}) async { + await _writeXChaCha20( + path: path, + password: password, + data: data, + highEntropyPassphrase: highEntropyPassphrase ?? !Platform.isLinux, + ); } -Future read({required String path, required String password}) async { +Future read({ + required String path, + required String password, + bool? highEntropyPassphrase, +}) async { + final useHighEntropy = highEntropyPassphrase ?? !Platform.isLinux; + try { + return await _readXChaCha20(path: path, password: password); + } catch (e) { + final encrypted = await File(path).readAsBytes(); + if (encrypted.isNotEmpty && + (encrypted[0] == cwb.lowEntropyVersion || encrypted[0] == cwb.highEntropyVersion)) { + rethrow; + } + + final String data; + try { + data = await _readLegacy(path: path, password: password); + } catch (_) { + throw Exception('Failed to decrypt legacy file: invalid password or corrupted data'); + } + if (data.isEmpty) { + throw Exception('Failed to read data'); + } + + // Salsa20 is unauthenticated, so a wrong password decrypts to garbage instead of + // failing. Every payload written here is a JSON object with at least one key, so a + // correctly decrypted file always starts with '{"' and anything else is a bad + // password. + // There's 1 in 2^16 chance that this will be a false positive, but check later + // prevents any damage to the file caused by re-encryption. + if (!data.startsWith('{"')) { + throw Exception('Failed to decrypt legacy file: invalid password or corrupted data'); + } + + if (_isJson(data)) { + await _writeXChaCha20( + path: path, + password: password, + data: data, + highEntropyPassphrase: useHighEntropy, + ); + } + + return data; + } +} + +bool _isJson(String data) { + try { + json.decode(data); + return true; + } catch (_) { + return false; + } +} + +Future _readLegacy({required String path, required String password}) async { final file = File(path); if (!file.existsSync()) { @@ -24,5 +87,57 @@ Future read({required String path, required String password}) async { final encrypted = file.readAsStringSync(); - return decode(password: password, data: encrypted); + return _decode(password: password, data: encrypted); +} + +Future _writeXChaCha20({ + required String path, + required String password, + required String data, + required bool highEntropyPassphrase, +}) async { + final encrypted = await cwb.encrypt( + password, + Uint8List.fromList(utf8.encode(data)), + highEntropyPassphrase: highEntropyPassphrase, + ); + + final tmpFile = File('$path.tmp'); + tmpFile.writeAsBytesSync(encrypted, flush: true); + tmpFile.renameSync(path); +} + +Future _readXChaCha20({ + required String path, + required String password, +}) async { + final encrypted = await File(path).readAsBytes(); + final bytes = await cwb.decrypt(password, encrypted); + return _decodeUtf8(bytes); +} + +// Linux wallets written by the old XChaCha20EncryptionFileUtils stored one byte per +// UTF-16 code unit, so bytes above 0x7F in those files are Latin-1 rather than UTF-8. +String _decodeUtf8(Uint8List bytes) { + try { + return utf8.decode(bytes); + } on FormatException { + return String.fromCharCodes(bytes); + } +} + +List _extractKeys(String key) { + final k = key.substring(0, key.length - _ivEncodedStringLength); + final iv = key.substring(key.length - _ivEncodedStringLength); + + return [k, iv]; +} + +Future _decode({required String password, required String data}) async { + final keys = _extractKeys(password); + final key = encrypt.Key.fromBase64(keys.first); + final iv = encrypt.IV.fromBase64(keys.last); + final encrypter = encrypt.Encrypter(encrypt.Salsa20(key)); + + return encrypter.decrypt64(data, iv: iv); } diff --git a/cw_core/lib/utils/ipfs_url.dart b/cw_core/lib/utils/ipfs_url.dart new file mode 100644 index 000000000..f5c320aea --- /dev/null +++ b/cw_core/lib/utils/ipfs_url.dart @@ -0,0 +1,25 @@ +const _ipfsGateway = "https://ipfs.io/ipfs/"; +const _ipfsScheme = "ipfs://"; + +String? tryNormalizeIpfsUrl(String? url) { + if (url == null) { + return null; + } + + if (!url.toLowerCase().startsWith(_ipfsScheme)) { + return url.toLowerCase().startsWith("https://") ? url : null; + } + + var cid = url.substring(_ipfsScheme.length); + + // Some minters write ipfs://ipfs/, which would double the gateway path. + if (cid.toLowerCase().startsWith("ipfs/")) { + cid = cid.substring(5); + } + + if (cid.isEmpty) { + return null; + } + + return "$_ipfsGateway$cid"; +} diff --git a/cw_core/lib/utils/nft_text.dart b/cw_core/lib/utils/nft_text.dart new file mode 100644 index 000000000..1cf0f6f8d --- /dev/null +++ b/cw_core/lib/utils/nft_text.dart @@ -0,0 +1,25 @@ +// Control characters and bidi overrides let a mint's own metadata fake extra +// lines or reverse the text around it wherever the name is shown. +final _unsafeDisplayCharacters = RegExp( + "[\u0000-\u001F\u007F\u00AD\u034F\u061C\u115F\u1160\u180E\u200B\u200E\u200F" + "\u2028\u2029\u202A-\u202E\u2060-\u2064\u2066-\u2069\u3164\uFEFF\uFFA0]" + "|[\u{E0000}-\u{E007F}]", + unicode: true, +); + +const _maxDisplayTextLength = 512; + +String? sanitizeNFTText(String? text) { + if (text == null) { + return null; + } + + final cleaned = text.replaceAll(_unsafeDisplayCharacters, " ").trim(); + final runes = cleaned.runes; + + if (runes.length <= _maxDisplayTextLength) { + return cleaned; + } + + return "${String.fromCharCodes(runes.take(_maxDisplayTextLength))}\u2026"; +} diff --git a/cw_core/lib/wallet_addresses.dart b/cw_core/lib/wallet_addresses.dart index ccbb8aa34..765afaa58 100644 --- a/cw_core/lib/wallet_addresses.dart +++ b/cw_core/lib/wallet_addresses.dart @@ -71,11 +71,12 @@ abstract class WalletAddresses { Future saveAddressesInBox() async { try { walletInfo.address = address; - walletInfo.setAddresses(addressesMap); - walletInfo.setAddressInfos(addressInfos); - walletInfo.setUsedAddresses(usedAddresses.toList()); - walletInfo.setHiddenAddresses(hiddenAddresses.toList()); - walletInfo.setManualAddresses(manualAddresses.toList()); + // TODO: check if it will affect the performance of each wallet + await walletInfo.setAddresses(addressesMap); + await walletInfo.setAddressInfos(addressInfos); + await walletInfo.setUsedAddresses(usedAddresses.toList()); + await walletInfo.setHiddenAddresses(hiddenAddresses.toList()); + await walletInfo.setManualAddresses(manualAddresses.toList()); await walletInfo.save(); } catch (e) { diff --git a/cw_core/lib/wallet_base.dart b/cw_core/lib/wallet_base.dart index c7ed4eccb..03c438a45 100644 --- a/cw_core/lib/wallet_base.dart +++ b/cw_core/lib/wallet_base.dart @@ -1,3 +1,4 @@ +import "package:cw_core/receive_page_option.dart"; import 'package:mobx/mobx.dart'; import 'package:cw_core/balance.dart'; import 'package:cw_core/transaction_info.dart'; @@ -12,6 +13,7 @@ import 'package:cw_core/crypto_currency.dart'; import 'package:cw_core/sync_status.dart'; import 'package:cw_core/node.dart'; import 'package:cw_core/wallet_type.dart'; +import 'package:cw_core/pathForWallet.dart'; abstract class WalletBase { @@ -43,8 +45,6 @@ abstract class WalletBase get balance; - String formatCryptoAmount(String amount) => amount; - SyncStatus get syncStatus; set syncStatus(SyncStatus status); @@ -117,7 +117,8 @@ abstract class WalletBase null; - Future renameWalletFiles(String newWalletName); + Future renameWalletFiles(String newWalletName) => + copyWalletFilesTo(fromName: walletInfo.name, toName: newWalletName, type: type); Future signMessage(String message, {String? address = null}); @@ -136,4 +137,19 @@ abstract class WalletBase checkNodeHealth(); + + bool get hasPayjoinSupport => false; + bool get hasLightningSupport => false; + bool get hasSilentPaymentsScanning => false; + + bool get hasCoinControl => false; + bool get hasAccountsSupport => false; + bool get hasNativeAccounts => false; + bool get canToggleMultiAccounts => hasAccountsSupport && !hasNativeAccounts; + + // hardware wallet - bitbox, ledger, trezor. + // we also have airgap wallets but those can't sign messages + bool get canSignMessages => walletInfo.hardwareWalletType == null || walletInfo.isHardwareWallet; + + bool receiveOptionAvailable(ReceivePageOption option) => true; } diff --git a/cw_core/lib/wallet_info.dart b/cw_core/lib/wallet_info.dart index 5fb2cff70..9c2149eab 100644 --- a/cw_core/lib/wallet_info.dart +++ b/cw_core/lib/wallet_info.dart @@ -44,7 +44,7 @@ enum HardwareWalletType { coldcard, seedsigner, keystone, - trezor, + trezor; } enum WalletInfoAddressType { @@ -244,6 +244,68 @@ class WalletInfoAddress { } } +class WalletInfoAccount { + WalletInfoAccount({ + required this.walletInfoId, + required this.accountIndex, + required this.label, + }); + + factory WalletInfoAccount.fromJson(Map json) => WalletInfoAccount( + walletInfoId: json["walletInfoId"] as int, + accountIndex: json["accountIndex"] as int, + label: json["label"] as String, + ); + + int walletInfoId; + int accountIndex; + String label; + + static String get tableName => "walletInfoAccount"; + + static Future> selectList(int walletInfoId) async { + final query = await db!.query( + tableName, + where: "walletInfoId = ?", + whereArgs: [walletInfoId], + orderBy: "accountIndex ASC", + ); + + return List.generate(query.length, (index) => WalletInfoAccount.fromJson(query[index])); + } + + static Future deleteByWalletInfoId(int walletInfoId) async => + await db!.delete(tableName, where: "walletInfoId = ?", whereArgs: [walletInfoId]); + + static Future insertOrUpdate({ + required int walletInfoId, + required int accountIndex, + required String label, + }) async => + await db!.transaction((txn) async { + final updated = await txn.update( + tableName, + {"label": label}, + where: "walletInfoId = ? AND accountIndex = ?", + whereArgs: [walletInfoId, accountIndex], + ); + + if (updated > 0) return updated; + + return await txn.insert(tableName, { + "walletInfoId": walletInfoId, + "accountIndex": accountIndex, + "label": label, + }); + }); + + Map toJson() => { + "walletInfoId": walletInfoId, + "accountIndex": accountIndex, + "label": label, + }; +} + class DerivationInfo { DerivationInfo({ this.id = 0, @@ -346,58 +408,73 @@ class WalletInfo { this.hashedWalletIdentifier, this.isNonSeedWallet, this.sortOrder, + this.currentAccountIndex, this.addressPageType, this.receiveInfoboxDismissed, this.showCombinedBalance, - this.favoriteTokenAddress) + this.favoriteTokenAddress, + this.showSeedBackupReminder) : _yatLastUsedAddressController = StreamController.broadcast(); - factory WalletInfo.external( - {required String id, - required String name, - required WalletType type, - required bool isRecovery, - required int restoreHeight, - required DateTime date, - required String dirPath, - required String path, - required String address, - bool? showIntroCakePayCard, - String yatEid = '', - String yatLastUsedAddressRaw = '', - int? derivationInfoId, - HardwareWalletType? hardwareWalletType, - String? parentAddress, - String? hashedWalletIdentifier, - bool? isNonSeedWallet, - int? sortOrder, - bool? receiveInfoboxDismissed, - bool? showCombinedBalance, - String? favoriteTokenAddress}) { - return WalletInfo( - 0, - id, - name, - type, - isRecovery, - restoreHeight, - date.millisecondsSinceEpoch, - dirPath, - path, - address, - yatEid, - yatLastUsedAddressRaw, - showIntroCakePayCard, - derivationInfoId ?? -1, - hardwareWalletType, - parentAddress, - hashedWalletIdentifier, - isNonSeedWallet ?? false, - sortOrder ?? 0, - null, - receiveInfoboxDismissed ?? false, - showCombinedBalance ?? true, - favoriteTokenAddress); + factory WalletInfo.external({ + required String id, + required String name, + required WalletType type, + required bool isRecovery, + required int restoreHeight, + required DateTime date, + required String dirPath, + required String path, + required String address, + bool? showIntroCakePayCard, + String yatEid = "", + String yatLastUsedAddressRaw = "", + int? derivationInfoId, + HardwareWalletType? hardwareWalletType, + String? parentAddress, + String? hashedWalletIdentifier, + bool? isNonSeedWallet, + int? sortOrder, + int currentAccountIndex = 0, + bool? receiveInfoboxDismissed, + bool? showCombinedBalance, + String? favoriteTokenAddress, + }) { + final wi = WalletInfo( + 0, + id, + name, + type, + isRecovery, + restoreHeight, + date.millisecondsSinceEpoch, + dirPath, + path, + address, + yatEid.isNotEmpty ? yatEid : null, + yatLastUsedAddressRaw.isNotEmpty ? yatLastUsedAddressRaw : null, + showIntroCakePayCard ?? false, + derivationInfoId ?? -1, + hardwareWalletType, + parentAddress, + hashedWalletIdentifier, + isNonSeedWallet ?? false, + sortOrder ?? 0, + currentAccountIndex, + null, + receiveInfoboxDismissed ?? false, + showCombinedBalance ?? true, + favoriteTokenAddress?.isNotEmpty == true ? favoriteTokenAddress : null, + false, + ); + + if (type == WalletType.bitcoin) { + wi.isMultiAccountsEnabled = false; + } else if (type == WalletType.monero || type == WalletType.wownero) { + wi.isMultiAccountsEnabled = true; + } + + return wi; } static String get tableName => 'walletInfo'; @@ -406,6 +483,8 @@ class WalletInfo { int internalId; + int currentAccountIndex; + String id; String name; WalletType type; @@ -418,6 +497,7 @@ class WalletInfo { bool receiveInfoboxDismissed; bool showCombinedBalance; String? favoriteTokenAddress; + bool showSeedBackupReminder; Future> getAddresses() async { final list = await WalletInfoAddressMap.selectList(internalId); @@ -426,11 +506,9 @@ class WalletInfo { Future setAddresses(Map addresses) async { await WalletInfoAddressMap.deleteByWalletInfoId(internalId); - final keys = addresses.keys.toList(); - final values = addresses.values.toList(); - // ToDo: check why the addresses list gets changed half way through - for (int i = 0; i < keys.length; i++) { - await WalletInfoAddressMap.insert(internalId, keys[i], values[i]); + final entries = addresses.entries.toList(); + for (final entry in entries) { + await WalletInfoAddressMap.insert(internalId, entry.key, entry.value); } } @@ -505,8 +583,76 @@ class WalletInfo { await WalletInfoAddress.insert(internalId, type, address); } + Future> getAccounts() async { + var accounts = await WalletInfoAccount.selectList(internalId); + + if (accounts.isEmpty) { + await WalletInfoAccount.insertOrUpdate( + walletInfoId: internalId, + accountIndex: 0, + label: "Primary account", + ); + + accounts = await WalletInfoAccount.selectList(internalId); + } + + if (!accounts.any((a) => a.accountIndex == currentAccountIndex)) { + currentAccountIndex = accounts.first.accountIndex; + await save(); + } + + return accounts; + } + + Future setSelectedAccount(int accountIndex) async { + currentAccountIndex = accountIndex; + await save(); + } + + Future setAccounts(List accounts) async { + await WalletInfoAccount.deleteByWalletInfoId(internalId); + + for (final account in accounts) { + await WalletInfoAccount.insertOrUpdate( + walletInfoId: internalId, + accountIndex: account.accountIndex, + label: account.label, + ); + } + + if (accounts.isNotEmpty && !accounts.any((a) => a.accountIndex == currentAccountIndex)) { + currentAccountIndex = accounts.first.accountIndex; + await save(); + } + } + + Future addAccount({ + required int accountIndex, + required String label, + }) async { + await WalletInfoAccount.insertOrUpdate( + walletInfoId: internalId, + accountIndex: accountIndex, + label: label, + ); + } + + Future renameAccount({ + required int accountIndex, + required String label, + }) async { + await WalletInfoAccount.insertOrUpdate( + walletInfoId: internalId, + accountIndex: accountIndex, + label: label, + ); + } + String? addressPageType; String? network; + int? accountDiscoveryLimit; + bool? isMultiAccountsEnabled; + int derivationInfoId; DerivationInfo? _derivationInfo; @@ -582,14 +728,20 @@ class WalletInfo { "hashedWalletIdentifier": hashedWalletIdentifier, "isNonSeedWallet": isNonSeedWallet ? 1 : 0, "sortOrder": sortOrder, + "currentAccountIndex": currentAccountIndex, "addressPageType": addressPageType, "receiveInfoboxDismissed": receiveInfoboxDismissed ? 1 : 0, "showCombinedBalance": showCombinedBalance ? 1 : 0, - "favoriteTokenAddress": favoriteTokenAddress + "favoriteTokenAddress": favoriteTokenAddress, + "showSeedBackupReminder": showSeedBackupReminder ? 1 : 0, + "network": network, + "accountDiscoveryLimit": accountDiscoveryLimit, + "isMultiAccountsEnabled": + isMultiAccountsEnabled == null ? null : (isMultiAccountsEnabled! ? 1 : 0), }; factory WalletInfo.fromJson(Map json) { - return WalletInfo( + final info = WalletInfo( json[selfIdColumn] as int, json['id'] as String, json['name'] as String, @@ -611,10 +763,18 @@ class WalletInfo { json['hashedWalletIdentifier'] as String?, (json['isNonSeedWallet'] as int) == 1, json['sortOrder'] as int? ?? 0, + json['currentAccountIndex'] as int? ?? 0, json['addressPageType'] as String? ?? null, json['receiveInfoboxDismissed'] != 0, json["showCombinedBalance"] != 0, - json["favoriteTokenAddress"] as String? ?? null); + json["favoriteTokenAddress"] as String? ?? null, + json["showSeedBackupReminder"] == 1); + info.network = json['network'] as String?; + info.accountDiscoveryLimit = json['accountDiscoveryLimit'] as int?; + final rawIsMultiAccountsEnabled = json['isMultiAccountsEnabled']; + info.isMultiAccountsEnabled = + rawIsMultiAccountsEnabled == null ? null : (rawIsMultiAccountsEnabled as int) == 1; + return info; } Future save() async { @@ -630,7 +790,8 @@ class WalletInfo { } static Future delete(WalletInfo walletInfo) async { - return await db!.delete(tableName, where: 'id = ?', whereArgs: [walletInfo.id]); + final deleted = await db!.delete(tableName, where: 'id = ?', whereArgs: [walletInfo.id]); + return deleted; } static Future> selectList(String where, List whereArgs, @@ -660,4 +821,21 @@ class WalletInfo { restoreHeight = height; await save(); } + + Future setAccountDiscoveryLimit(int limit) async { + accountDiscoveryLimit = limit; + await save(); + } + + Future clearSeedBackupReminder() async { + final previous = showSeedBackupReminder; + showSeedBackupReminder = false; + + try { + await save(); + } catch (e) { + showSeedBackupReminder = previous; + printV("Failed to save the seed backup reminder flag: $e"); + } + } } diff --git a/cw_core/lib/wallet_info_legacy.dart b/cw_core/lib/wallet_info_legacy.dart index bb199eeb1..a150b90c2 100644 --- a/cw_core/lib/wallet_info_legacy.dart +++ b/cw_core/lib/wallet_info_legacy.dart @@ -268,10 +268,13 @@ class WalletInfo extends HiveObject { hashedWalletIdentifier, isNonSeedWallet, 0, + 0, addressPageType, false, true, - null); + null, + false) + ..network = network; final wiId = await walletInfo.save(); for (final address in usedAddresses ?? []) { await newWi.WalletInfoAddress.insert(wiId, newWi.WalletInfoAddressType.used, address); diff --git a/cw_core/lib/wallet_service.dart b/cw_core/lib/wallet_service.dart index 016f3cc68..f4d6ca6aa 100644 --- a/cw_core/lib/wallet_service.dart +++ b/cw_core/lib/wallet_service.dart @@ -1,11 +1,16 @@ -import 'dart:convert'; -import 'dart:io'; +import "dart:convert"; +import "dart:io"; +import 'package:cw_core/encryption_file_utils.dart'; +import 'package:cw_core/imported_nft.dart'; import 'package:cw_core/pathForWallet.dart'; -import 'package:cw_core/utils/file.dart'; +import 'package:cw_core/utils/print_verbose.dart'; +import 'package:cw_core/wallet_keys_file.dart'; import 'package:cw_core/wallet_base.dart'; import 'package:cw_core/wallet_credentials.dart'; +import 'package:cw_core/wallet_info.dart'; import 'package:cw_core/wallet_type.dart'; +import 'package:path/path.dart' as p; abstract class WalletService { @@ -25,7 +30,38 @@ abstract class WalletService remove(String wallet); - Future rename(String currentName, String password, String newName); + Future rename(String currentName, String password, String newName) async { + if (currentName == newName) { + return; + } + + final currentWalletInfo = await WalletInfo.get(currentName, getType()); + if (currentWalletInfo == null) { + throw Exception("Wallet not found"); + } + + await copyWalletFilesTo(fromName: currentName, toName: newName, type: getType()); + await saveBackup(newName); + + currentWalletInfo.id = WalletBase.idFor(newName, getType()); + currentWalletInfo.name = newName; + await currentWalletInfo.save(); + + await _renameTokenRows(currentName, newName); + + final oldDir = Directory(p.join(await pathForWalletTypeDir(type: getType()), currentName)); + if (oldDir.existsSync()) { + try { + await oldDir.delete(recursive: true); + } catch (e) { + printV('rename: failed to delete old wallet dir "$currentName": $e'); + } + } + } + + Future _renameTokenRows(String currentName, String newName) async { + + } Future restoreWalletFilesFromBackup(String name) async { final backupWalletDirPath = await pathForWalletDir(name: "$name.backup", type: getType()); @@ -47,18 +83,25 @@ abstract class WalletService getSeeds(String name, String password, WalletType type) async { try { + final encryption = encryptionFileUtilsFor(Platform.isLinux); + + if (await WalletKeysFile.hasKeysFile(name, type)) { + final keysData = await WalletKeysFile.readKeysFile(name, type, password, encryption); + return keysData.mnemonic ?? keysData.altMnemonic ?? keysData.privateKey ?? ''; + } + final path = await pathForWallet(name: name, type: type); - final jsonSource = await read(path: path, password: password); + final jsonSource = await encryption.read(path: path, password: password); try { final data = json.decode(jsonSource) as Map; - return data['mnemonic'] as String? ?? ''; + return data["mnemonic"] as String? ?? ""; } catch (_) { // if not a valid json return jsonSource.substring(0, 200); } } catch (_) { // if the file couldn't be opened or read - return ''; + return ""; } } diff --git a/cw_core/lib/wallet_type.dart b/cw_core/lib/wallet_type.dart index fca4ff91f..69bffa1de 100644 --- a/cw_core/lib/wallet_type.dart +++ b/cw_core/lib/wallet_type.dart @@ -1,8 +1,9 @@ import 'package:cw_core/crypto_currency.dart'; import 'package:cw_core/hive_type_ids.dart'; +import 'package:cw_core/utils/print_verbose.dart'; import 'package:hive/hive.dart'; -part 'wallet_type.g.dart'; +part 'wallet_type.part.dart'; const walletTypes = [ WalletType.monero, @@ -35,52 +36,56 @@ const evmWalletTypes = [ WalletType.bsc ]; -// HiveField IDs are pinned — never renumber. Existing user wallets persist -// type via the int ID, so changing assignments here would re-interpret saved -// wallet types on next launch. To remove a type, drop its enum entry; do not -// re-use its old ID. -@HiveType(typeId: WALLET_TYPE_TYPE_ID) +// Two encodings are persisted; never change either. +// - The HiveField IDs are the bytes WalletTypeAdapter (wallet_type.part.dart) reads when legacy +// Hive data is migrated. To remove a type, drop it; never reuse its ID. +// - WalletType.index is what the SQLite wallet table stores (wallet_info.dart), so the enum +// ORDER is persisted as well: only ever append. Our order differs from upstream's, so a merge +// must never take upstream's enum. +// hive_generator no longer runs (upstream froze the adapters into *.part.dart); the annotations +// below are kept as the record of the IDs. +// @HiveType(typeId: WALLET_TYPE_TYPE_ID) enum WalletType { - @HiveField(0) + // @HiveField(0) monero, - @HiveField(1) + // @HiveField(1) none, - @HiveField(2) + // @HiveField(2) bitcoin, - @HiveField(3) + // @HiveField(3) litecoin, - @HiveField(5) + // @HiveField(5) ethereum, - @HiveField(6) + // @HiveField(6) nano, - @HiveField(7) + // @HiveField(7) banano, - @HiveField(8) + // @HiveField(8) bitcoinCash, - @HiveField(9) + // @HiveField(9) polygon, - @HiveField(12) + // @HiveField(12) wownero, - @HiveField(15) + // @HiveField(15) dogecoin, - @HiveField(16) + // @HiveField(16) base, - @HiveField(17) + // @HiveField(17) arbitrum, - @HiveField(19) + // @HiveField(19) bsc, } @@ -183,38 +188,39 @@ String walletTypeToString(WalletType type) { } } -String walletTypeToDisplayName(WalletType type) { - switch (type) { - case WalletType.monero: - return 'Monero (XMR)'; - case WalletType.bitcoin: - return 'Bitcoin (BTC)'; - case WalletType.litecoin: - return 'Litecoin (LTC)'; - case WalletType.ethereum: - return 'Ethereum (ETH)'; - case WalletType.bitcoinCash: - return 'Bitcoin Cash (BCH)'; - case WalletType.nano: - return 'Nano (XNO)'; - case WalletType.banano: - return 'Banano (BAN)'; - case WalletType.polygon: - return 'Polygon (POL)'; - case WalletType.wownero: - return 'Wownero (WOW)'; - case WalletType.dogecoin: - return 'Dogecoin (DOGE)'; - case WalletType.base: - return 'Base'; - case WalletType.arbitrum: - return 'Arbitrum (ARB)'; - case WalletType.bsc: - return 'BNB Smart Chain (BNB)'; - case WalletType.none: - return ''; - } -} +String walletTypeToDisplayName(WalletType type) => switch (type) { + WalletType.monero => 'Monero', + WalletType.bitcoin => 'Bitcoin', + WalletType.litecoin => 'Litecoin', + WalletType.ethereum => 'Ethereum', + WalletType.bitcoinCash => 'Bitcoin Cash', + WalletType.nano => 'Nano', + WalletType.banano => 'Banano', + WalletType.polygon => 'Polygon', + WalletType.wownero => 'Wownero', + WalletType.dogecoin => 'Dogecoin', + WalletType.base => 'Base', + WalletType.arbitrum => 'Arbitrum', + WalletType.bsc => 'BNB Smart Chain', + WalletType.none => '' + }; + +String walletTypeToDisplayTicker(WalletType type) => switch (type) { + WalletType.monero => 'XMR', + WalletType.bitcoin => 'BTC', + WalletType.litecoin => 'LTC', + WalletType.ethereum => 'ETH', + WalletType.bitcoinCash => 'BCH', + WalletType.nano => 'XNO', + WalletType.banano => 'BAN', + WalletType.polygon => 'POL', + WalletType.wownero => 'WOW', + WalletType.dogecoin => 'DOGE', + WalletType.base => '', + WalletType.arbitrum => 'ARB', + WalletType.bsc => 'BNB', + WalletType.none => '' + }; WalletType? _cryptoCurrencyToWalletType(CryptoCurrency type) { switch (type) { diff --git a/cw_core/lib/wallet_type.part.dart b/cw_core/lib/wallet_type.part.dart new file mode 100644 index 000000000..b6a4d30a3 --- /dev/null +++ b/cw_core/lib/wallet_type.part.dart @@ -0,0 +1,104 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'wallet_type.dart'; + +// ************************************************************************** +// TypeAdapterGenerator +// ************************************************************************** + +class WalletTypeAdapter extends TypeAdapter { + @override + final int typeId = 5; + + @override + WalletType read(BinaryReader reader) { + switch (reader.readByte()) { + case 0: + return WalletType.monero; + case 1: + return WalletType.none; + case 2: + return WalletType.bitcoin; + case 3: + return WalletType.litecoin; + case 5: + return WalletType.ethereum; + case 6: + return WalletType.nano; + case 7: + return WalletType.banano; + case 8: + return WalletType.bitcoinCash; + case 9: + return WalletType.polygon; + case 12: + return WalletType.wownero; + case 15: + return WalletType.dogecoin; + case 16: + return WalletType.base; + case 17: + return WalletType.arbitrum; + case 19: + return WalletType.bsc; + default: + return WalletType.monero; + } + } + + @override + void write(BinaryWriter writer, WalletType obj) { + switch (obj) { + case WalletType.monero: + writer.writeByte(0); + break; + case WalletType.none: + writer.writeByte(1); + break; + case WalletType.bitcoin: + writer.writeByte(2); + break; + case WalletType.litecoin: + writer.writeByte(3); + break; + case WalletType.ethereum: + writer.writeByte(5); + break; + case WalletType.nano: + writer.writeByte(6); + break; + case WalletType.banano: + writer.writeByte(7); + break; + case WalletType.bitcoinCash: + writer.writeByte(8); + break; + case WalletType.polygon: + writer.writeByte(9); + break; + case WalletType.wownero: + writer.writeByte(12); + break; + case WalletType.dogecoin: + writer.writeByte(15); + break; + case WalletType.base: + writer.writeByte(16); + break; + case WalletType.arbitrum: + writer.writeByte(17); + break; + case WalletType.bsc: + writer.writeByte(19); + break; + } + } + + @override + int get hashCode => typeId.hashCode; + + @override + bool operator ==(Object other) => + identical(this, other) || + other is WalletTypeAdapter && runtimeType == other.runtimeType && typeId == other.typeId; +} diff --git a/cw_core/lib/wownero_balance.dart b/cw_core/lib/wownero_balance.dart deleted file mode 100644 index de6518023..000000000 --- a/cw_core/lib/wownero_balance.dart +++ /dev/null @@ -1,17 +0,0 @@ -import 'package:cw_core/balance.dart'; -import 'package:cw_core/wownero_amount_format.dart'; - -class WowneroBalance extends Balance { - WowneroBalance({required this.fullBalance, required this.unlockedBalance, this.frozenBalance = 0}) - : formattedFrozenBalance = wowneroAmountToString(amount: frozenBalance), - super.fromInt(unlockedBalance, fullBalance - unlockedBalance, frozen: frozenBalance); - - final int fullBalance; - final int unlockedBalance; - final int frozenBalance; - final String formattedFrozenBalance; - - @override - String get formattedUnAvailableBalance => - formattedFrozenBalance == '0.0' ? '' : formattedFrozenBalance; -} diff --git a/cw_core/lib/zano_asset.dart b/cw_core/lib/zano_asset.dart index 0332f5972..d619e7ae5 100644 --- a/cw_core/lib/zano_asset.dart +++ b/cw_core/lib/zano_asset.dart @@ -4,38 +4,38 @@ import 'package:cw_core/crypto_currency.dart'; import 'package:cw_core/hive_type_ids.dart'; import 'package:hive/hive.dart'; -part 'zano_asset.g.dart'; +part 'zano_asset.part.dart'; -@HiveType(typeId: ZanoAsset.typeId) +// @HiveType(typeId: ZanoAsset.typeId) class ZanoAsset extends CryptoCurrency with HiveObjectMixin { - @HiveField(0) + // @HiveField(0) final String fullName; - @HiveField(1) + // @HiveField(1) final String ticker; - @HiveField(2) + // @HiveField(2) final String assetId; - @HiveField(3) + // @HiveField(3) final int decimalPoint; - @HiveField(4, defaultValue: true) + // @HiveField(4, defaultValue: true) bool _enabled; - @HiveField(5) + // @HiveField(5) final String? iconPath; // @HiveField(6) // final String? tag; - @HiveField(6) + // @HiveField(6) final String owner; - @HiveField(7) + // @HiveField(7) final String metaInfo; - @HiveField(8) + // @HiveField(8) final BigInt currentSupply; - @HiveField(9) + // @HiveField(9) final bool hiddenSupply; - @HiveField(10) + // @HiveField(10) final BigInt totalMaxSupply; - @HiveField(11) + // @HiveField(11) final bool isInGlobalWhitelist; - @HiveField(12, defaultValue: null) + // @HiveField(12, defaultValue: null) final Map? info; bool get enabled => _enabled; diff --git a/cw_core/lib/zano_asset.part.dart b/cw_core/lib/zano_asset.part.dart new file mode 100644 index 000000000..f0fad3143 --- /dev/null +++ b/cw_core/lib/zano_asset.part.dart @@ -0,0 +1,74 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'zano_asset.dart'; + +// ************************************************************************** +// TypeAdapterGenerator +// ************************************************************************** + +class ZanoAssetAdapter extends TypeAdapter { + @override + final int typeId = 22; + + @override + ZanoAsset read(BinaryReader reader) { + final numOfFields = reader.readByte(); + final fields = { + for (int i = 0; i < numOfFields; i++) reader.readByte(): reader.read(), + }; + return ZanoAsset( + fullName: fields[0] as String, + ticker: fields[1] as String, + assetId: fields[2] as String, + decimalPoint: fields[3] as int, + iconPath: fields[5] as String?, + owner: fields[6] as String, + metaInfo: fields[7] as String, + currentSupply: fields[8] as BigInt, + hiddenSupply: fields[9] as bool, + totalMaxSupply: fields[10] as BigInt, + isInGlobalWhitelist: fields[11] as bool, + info: (fields[12] as Map?)?.cast(), + ).._enabled = fields[4] == null ? true : fields[4] as bool; + } + + @override + void write(BinaryWriter writer, ZanoAsset obj) { + writer + ..writeByte(13) + ..writeByte(0) + ..write(obj.fullName) + ..writeByte(1) + ..write(obj.ticker) + ..writeByte(2) + ..write(obj.assetId) + ..writeByte(3) + ..write(obj.decimalPoint) + ..writeByte(4) + ..write(obj._enabled) + ..writeByte(5) + ..write(obj.iconPath) + ..writeByte(6) + ..write(obj.owner) + ..writeByte(7) + ..write(obj.metaInfo) + ..writeByte(8) + ..write(obj.currentSupply) + ..writeByte(9) + ..write(obj.hiddenSupply) + ..writeByte(10) + ..write(obj.totalMaxSupply) + ..writeByte(11) + ..write(obj.isInGlobalWhitelist) + ..writeByte(12) + ..write(obj.info); + } + + @override + int get hashCode => typeId.hashCode; + + @override + bool operator ==(Object other) => + identical(this, other) || + other is ZanoAssetAdapter && runtimeType == other.runtimeType && typeId == other.typeId; +} diff --git a/cw_core/pubspec.lock b/cw_core/pubspec.lock deleted file mode 100644 index 15eee658f..000000000 --- a/cw_core/pubspec.lock +++ /dev/null @@ -1,911 +0,0 @@ -# Generated by pub -# See https://dart.dev/tools/pub/glossary#lockfile -packages: - _fe_analyzer_shared: - dependency: transitive - description: - name: _fe_analyzer_shared - sha256: "16e298750b6d0af7ce8a3ba7c18c69c3785d11b15ec83f6dcd0ad2a0009b3cab" - url: "https://pub.dev" - source: hosted - version: "76.0.0" - _macros: - dependency: transitive - description: dart - source: sdk - version: "0.3.3" - analyzer: - dependency: transitive - description: - name: analyzer - sha256: "1f14db053a8c23e260789e9b0980fa27f2680dd640932cae5e1137cce0e46e1e" - url: "https://pub.dev" - source: hosted - version: "6.11.0" - args: - dependency: transitive - description: - name: args - sha256: d0481093c50b1da8910eb0bb301626d4d8eb7284aa739614d2b394ee09e3ea04 - url: "https://pub.dev" - source: hosted - version: "2.7.0" - asn1lib: - dependency: transitive - description: - name: asn1lib - sha256: "1c296cd268f486cabcc3930e9b93a8133169305f18d722916e675959a88f6d2c" - url: "https://pub.dev" - source: hosted - version: "1.5.9" - async: - dependency: transitive - description: - name: async - sha256: "758e6d74e971c3e5aceb4110bfd6698efc7f501675bcfe0c775459a8140750eb" - url: "https://pub.dev" - source: hosted - version: "2.13.0" - bech32: - dependency: "direct main" - description: - path: "." - ref: "05755063b593aa6cca0a4820a318e0ce17de6192" - resolved-ref: "05755063b593aa6cca0a4820a318e0ce17de6192" - url: "https://github.com/cake-tech/bech32.git" - source: git - version: "0.2.2" - blockchain_utils: - dependency: "direct main" - description: - path: "." - ref: cake-update-v2 - resolved-ref: "59fdf29d72068e0522a96a8953ed7272833a9f57" - url: "https://github.com/cake-tech/blockchain_utils" - source: git - version: "3.3.0" - boolean_selector: - dependency: transitive - description: - name: boolean_selector - sha256: "8aab1771e1243a5063b8b0ff68042d67334e3feab9e95b9490f9a6ebf73b42ea" - url: "https://pub.dev" - source: hosted - version: "2.1.2" - build: - dependency: transitive - description: - name: build - sha256: cef23f1eda9b57566c81e2133d196f8e3df48f244b317368d65c5943d91148f0 - url: "https://pub.dev" - source: hosted - version: "2.4.2" - build_config: - dependency: transitive - description: - name: build_config - sha256: "4ae2de3e1e67ea270081eaee972e1bd8f027d459f249e0f1186730784c2e7e33" - url: "https://pub.dev" - source: hosted - version: "1.1.2" - build_daemon: - dependency: transitive - description: - name: build_daemon - sha256: "8e928697a82be082206edb0b9c99c5a4ad6bc31c9e9b8b2f291ae65cd4a25daa" - url: "https://pub.dev" - source: hosted - version: "4.0.4" - build_resolvers: - dependency: "direct dev" - description: - name: build_resolvers - sha256: b9e4fda21d846e192628e7a4f6deda6888c36b5b69ba02ff291a01fd529140f0 - url: "https://pub.dev" - source: hosted - version: "2.4.4" - build_runner: - dependency: "direct dev" - description: - name: build_runner - sha256: "058fe9dce1de7d69c4b84fada934df3e0153dd000758c4d65964d0166779aa99" - url: "https://pub.dev" - source: hosted - version: "2.4.15" - build_runner_core: - dependency: transitive - description: - name: build_runner_core - sha256: "22e3aa1c80e0ada3722fe5b63fd43d9c8990759d0a2cf489c8c5d7b2bdebc021" - url: "https://pub.dev" - source: hosted - version: "8.0.0" - built_collection: - dependency: transitive - description: - name: built_collection - sha256: "376e3dd27b51ea877c28d525560790aee2e6fbb5f20e2f85d5081027d94e2100" - url: "https://pub.dev" - source: hosted - version: "5.1.1" - built_value: - dependency: transitive - description: - name: built_value - sha256: "082001b5c3dc495d4a42f1d5789990505df20d8547d42507c29050af6933ee27" - url: "https://pub.dev" - source: hosted - version: "8.10.1" - cake_backup: - dependency: "direct main" - description: - path: "." - ref: "3aba867dcab6737f6707782f5db15d71f303db38" - resolved-ref: "3aba867dcab6737f6707782f5db15d71f303db38" - url: "https://github.com/cake-tech/cake_backup.git" - source: git - version: "1.0.0+1" - characters: - dependency: transitive - description: - name: characters - sha256: f71061c654a3380576a52b451dd5532377954cf9dbd272a78fc8479606670803 - url: "https://pub.dev" - source: hosted - version: "1.4.0" - checked_yaml: - dependency: transitive - description: - name: checked_yaml - sha256: feb6bed21949061731a7a75fc5d2aa727cf160b91af9a3e464c5e3a32e28b5ff - url: "https://pub.dev" - source: hosted - version: "2.0.3" - clock: - dependency: transitive - description: - name: clock - sha256: fddb70d9b5277016c77a80201021d40a2247104d9f4aa7bab7157b7e3f05b84b - url: "https://pub.dev" - source: hosted - version: "1.1.2" - code_builder: - dependency: transitive - description: - name: code_builder - sha256: "0ec10bf4a89e4c613960bf1e8b42c64127021740fb21640c29c909826a5eea3e" - url: "https://pub.dev" - source: hosted - version: "4.10.1" - collection: - dependency: transitive - description: - name: collection - sha256: "2f5709ae4d3d59dd8f7cd309b4e023046b57d8a6c82130785d2b0e5868084e76" - url: "https://pub.dev" - source: hosted - version: "1.19.1" - convert: - dependency: transitive - description: - name: convert - sha256: b30acd5944035672bc15c6b7a8b47d773e41e2f17de064350988c5d02adb1c68 - url: "https://pub.dev" - source: hosted - version: "3.1.2" - crypto: - dependency: transitive - description: - name: crypto - sha256: "1e445881f28f22d6140f181e07737b22f1e099a5e1ff94b0af2f9e4a463f4855" - url: "https://pub.dev" - source: hosted - version: "3.0.6" - cryptography: - dependency: transitive - description: - name: cryptography - sha256: d146b76d33d94548cf035233fbc2f4338c1242fa119013bead807d033fc4ae05 - url: "https://pub.dev" - source: hosted - version: "2.7.0" - cupertino_icons: - dependency: transitive - description: - name: cupertino_icons - sha256: ba631d1c7f7bef6b729a622b7b752645a2d076dba9976925b8f25725a30e1ee6 - url: "https://pub.dev" - source: hosted - version: "1.0.8" - dart_style: - dependency: transitive - description: - name: dart_style - sha256: "7306ab8a2359a48d22310ad823521d723acfed60ee1f7e37388e8986853b6820" - url: "https://pub.dev" - source: hosted - version: "2.3.8" - decimal: - dependency: "direct main" - description: - name: decimal - sha256: "24a261d5d5c87e86c7651c417a5dbdf8bcd7080dd592533910e8d0505a279f21" - url: "https://pub.dev" - source: hosted - version: "2.3.3" - encrypt: - dependency: "direct main" - description: - name: encrypt - sha256: "62d9aa4670cc2a8798bab89b39fc71b6dfbacf615de6cf5001fb39f7e4a996a2" - url: "https://pub.dev" - source: hosted - version: "5.0.3" - fake_async: - dependency: transitive - description: - name: fake_async - sha256: "5368f224a74523e8d2e7399ea1638b37aecfca824a3cc4dfdf77bf1fa905ac44" - url: "https://pub.dev" - source: hosted - version: "1.3.3" - ffi: - dependency: transitive - description: - name: ffi - sha256: "16ed7b077ef01ad6170a3d0c57caa4a112a38d7a2ed5602e0aca9ca6f3d98da6" - url: "https://pub.dev" - source: hosted - version: "2.1.3" - file: - dependency: "direct main" - description: - name: file - sha256: a3b4f84adafef897088c160faf7dfffb7696046cb13ae90b508c2cbc95d3b8d4 - url: "https://pub.dev" - source: hosted - version: "7.0.1" - fixnum: - dependency: transitive - description: - name: fixnum - sha256: b6dc7065e46c974bc7c5f143080a6764ec7a4be6da1285ececdc37be96de53be - url: "https://pub.dev" - source: hosted - version: "1.1.1" - flutter: - dependency: "direct main" - description: flutter - source: sdk - version: "0.0.0" - flutter_mobx: - dependency: "direct main" - description: - name: flutter_mobx - sha256: ba5e93467866a2991259dc51cffd41ef45f695c667c2b8e7b087bf24118b50fe - url: "https://pub.dev" - source: hosted - version: "2.3.0" - flutter_test: - dependency: "direct dev" - description: flutter - source: sdk - version: "0.0.0" - frontend_server_client: - dependency: transitive - description: - name: frontend_server_client - sha256: f64a0333a82f30b0cca061bc3d143813a486dc086b574bfb233b7c1372427694 - url: "https://pub.dev" - source: hosted - version: "4.0.0" - glob: - dependency: transitive - description: - name: glob - sha256: c3f1ee72c96f8f78935e18aa8cecced9ab132419e8625dc187e1c2408efc20de - url: "https://pub.dev" - source: hosted - version: "2.1.3" - graphs: - dependency: transitive - description: - name: graphs - sha256: "741bbf84165310a68ff28fe9e727332eef1407342fca52759cb21ad8177bb8d0" - url: "https://pub.dev" - source: hosted - version: "2.3.2" - hive: - dependency: transitive - description: - name: hive - sha256: "8dcf6db979d7933da8217edcec84e9df1bdb4e4edc7fc77dbd5aa74356d6d941" - url: "https://pub.dev" - source: hosted - version: "2.2.3" - hive_generator: - dependency: "direct dev" - description: - name: hive_generator - sha256: "06cb8f58ace74de61f63500564931f9505368f45f98958bd7a6c35ba24159db4" - url: "https://pub.dev" - source: hosted - version: "2.0.1" - http: - dependency: "direct main" - description: - name: http - sha256: "2c11f3f94c687ee9bad77c171151672986360b2b001d109814ee7140b2cf261b" - url: "https://pub.dev" - source: hosted - version: "1.4.0" - http_multi_server: - dependency: transitive - description: - name: http_multi_server - sha256: aa6199f908078bb1c5efb8d8638d4ae191aac11b311132c3ef48ce352fb52ef8 - url: "https://pub.dev" - source: hosted - version: "3.2.2" - http_parser: - dependency: transitive - description: - name: http_parser - sha256: "178d74305e7866013777bab2c3d8726205dc5a4dd935297175b19a23a2e66571" - url: "https://pub.dev" - source: hosted - version: "4.1.2" - intl: - dependency: "direct main" - description: - name: intl - sha256: d6f56758b7d3014a48af9701c085700aac781a92a87a62b1333b46d8879661cf - url: "https://pub.dev" - source: hosted - version: "0.19.0" - io: - dependency: transitive - description: - name: io - sha256: dfd5a80599cf0165756e3181807ed3e77daf6dd4137caaad72d0b7931597650b - url: "https://pub.dev" - source: hosted - version: "1.0.5" - js: - dependency: transitive - description: - name: js - sha256: f2c445dce49627136094980615a031419f7f3eb393237e4ecd97ac15dea343f3 - url: "https://pub.dev" - source: hosted - version: "0.6.7" - json_annotation: - dependency: transitive - description: - name: json_annotation - sha256: "1ce844379ca14835a50d2f019a3099f419082cfdd231cd86a142af94dd5c6bb1" - url: "https://pub.dev" - source: hosted - version: "4.9.0" - leak_tracker: - dependency: transitive - description: - name: leak_tracker - sha256: "6bb818ecbdffe216e81182c2f0714a2e62b593f4a4f13098713ff1685dfb6ab0" - url: "https://pub.dev" - source: hosted - version: "10.0.9" - leak_tracker_flutter_testing: - dependency: transitive - description: - name: leak_tracker_flutter_testing - sha256: f8b613e7e6a13ec79cfdc0e97638fddb3ab848452eff057653abd3edba760573 - url: "https://pub.dev" - source: hosted - version: "3.0.9" - leak_tracker_testing: - dependency: transitive - description: - name: leak_tracker_testing - sha256: "6ba465d5d76e67ddf503e1161d1f4a6bc42306f9d66ca1e8f079a47290fb06d3" - url: "https://pub.dev" - source: hosted - version: "3.0.1" - logging: - dependency: transitive - description: - name: logging - sha256: c8245ada5f1717ed44271ed1c26b8ce85ca3228fd2ffdb75468ab01979309d61 - url: "https://pub.dev" - source: hosted - version: "1.3.0" - macros: - dependency: transitive - description: - name: macros - sha256: "1d9e801cd66f7ea3663c45fc708450db1fa57f988142c64289142c9b7ee80656" - url: "https://pub.dev" - source: hosted - version: "0.1.3-main.0" - matcher: - dependency: transitive - description: - name: matcher - sha256: dc58c723c3c24bf8d3e2d3ad3f2f9d7bd9cf43ec6feaa64181775e60190153f2 - url: "https://pub.dev" - source: hosted - version: "0.12.17" - material_color_utilities: - dependency: transitive - description: - name: material_color_utilities - sha256: f7142bb1154231d7ea5f96bc7bde4bda2a0945d2806bb11670e30b850d56bdec - url: "https://pub.dev" - source: hosted - version: "0.11.1" - meta: - dependency: transitive - description: - name: meta - sha256: e3641ec5d63ebf0d9b41bd43201a66e3fc79a65db5f61fc181f04cd27aab950c - url: "https://pub.dev" - source: hosted - version: "1.16.0" - mime: - dependency: transitive - description: - name: mime - sha256: "41a20518f0cb1256669420fdba0cd90d21561e560ac240f26ef8322e45bb7ed6" - url: "https://pub.dev" - source: hosted - version: "2.0.0" - mobx: - dependency: "direct main" - description: - name: mobx - sha256: bf1a90e5bcfd2851fc6984e20eef69557c65d9e4d0a88f5be4cf72c9819ce6b0 - url: "https://pub.dev" - source: hosted - version: "2.5.0" - mobx_codegen: - dependency: "direct dev" - description: - name: mobx_codegen - sha256: e0abbbc651a69550440f6b65c99ec222a1e2a4afd7baec8ba0f3088c7ca582a8 - url: "https://pub.dev" - source: hosted - version: "2.7.1" - nested: - dependency: transitive - description: - name: nested - sha256: "03bac4c528c64c95c722ec99280375a6f2fc708eec17c7b3f07253b626cd2a20" - url: "https://pub.dev" - source: hosted - version: "1.0.0" - on_chain: - dependency: "direct main" - description: - path: "." - ref: "096865a8c6b89c260beadfec04f7e184c40a3273" - resolved-ref: "096865a8c6b89c260beadfec04f7e184c40a3273" - url: "https://github.com/cake-tech/on_chain.git" - source: git - version: "3.7.0" - package_config: - dependency: transitive - description: - name: package_config - sha256: f096c55ebb7deb7e384101542bfba8c52696c1b56fca2eb62827989ef2353bbc - url: "https://pub.dev" - source: hosted - version: "2.2.0" - path: - dependency: transitive - description: - name: path - sha256: "75cca69d1490965be98c73ceaea117e8a04dd21217b37b292c9ddbec0d955bc5" - url: "https://pub.dev" - source: hosted - version: "1.9.1" - path_provider: - dependency: "direct main" - description: - name: path_provider - sha256: "50c5dd5b6e1aaf6fb3a78b33f6aa3afca52bf903a8a5298f53101fdaee55bbcd" - url: "https://pub.dev" - source: hosted - version: "2.1.5" - path_provider_android: - dependency: transitive - description: - name: path_provider_android - sha256: d0d310befe2c8ab9e7f393288ccbb11b60c019c6b5afc21973eeee4dda2b35e9 - url: "https://pub.dev" - source: hosted - version: "2.2.17" - path_provider_foundation: - dependency: transitive - description: - name: path_provider_foundation - sha256: "4843174df4d288f5e29185bd6e72a6fbdf5a4a4602717eed565497429f179942" - url: "https://pub.dev" - source: hosted - version: "2.4.1" - path_provider_linux: - dependency: transitive - description: - name: path_provider_linux - sha256: f7a1fe3a634fe7734c8d3f2766ad746ae2a2884abe22e241a8b301bf5cac3279 - url: "https://pub.dev" - source: hosted - version: "2.2.1" - path_provider_platform_interface: - dependency: transitive - description: - name: path_provider_platform_interface - sha256: "88f5779f72ba699763fa3a3b06aa4bf6de76c8e5de842cf6f29e2e06476c2334" - url: "https://pub.dev" - source: hosted - version: "2.1.2" - path_provider_windows: - dependency: transitive - description: - name: path_provider_windows - sha256: bd6f00dbd873bfb70d0761682da2b3a2c2fccc2b9e84c495821639601d81afe7 - url: "https://pub.dev" - source: hosted - version: "2.3.0" - platform: - dependency: transitive - description: - name: platform - sha256: "5d6b1b0036a5f331ebc77c850ebc8506cbc1e9416c27e59b439f917a902a4984" - url: "https://pub.dev" - source: hosted - version: "3.1.6" - plugin_platform_interface: - dependency: transitive - description: - name: plugin_platform_interface - sha256: "4820fbfdb9478b1ebae27888254d445073732dae3d6ea81f0b7e06d5dedc3f02" - url: "https://pub.dev" - source: hosted - version: "2.1.8" - pointycastle: - dependency: transitive - description: - name: pointycastle - sha256: "4be0097fcf3fd3e8449e53730c631200ebc7b88016acecab2b0da2f0149222fe" - url: "https://pub.dev" - source: hosted - version: "3.9.1" - pool: - dependency: transitive - description: - name: pool - sha256: "20fe868b6314b322ea036ba325e6fc0711a22948856475e2c2b6306e8ab39c2a" - url: "https://pub.dev" - source: hosted - version: "1.5.1" - provider: - dependency: transitive - description: - name: provider - sha256: "4abbd070a04e9ddc287673bf5a030c7ca8b685ff70218720abab8b092f53dd84" - url: "https://pub.dev" - source: hosted - version: "6.1.5" - pub_semver: - dependency: transitive - description: - name: pub_semver - sha256: "5bfcf68ca79ef689f8990d1160781b4bad40a3bd5e5218ad4076ddb7f4081585" - url: "https://pub.dev" - source: hosted - version: "2.2.0" - pubspec_parse: - dependency: transitive - description: - name: pubspec_parse - sha256: "0560ba233314abbed0a48a2956f7f022cce7c3e1e73df540277da7544cad4082" - url: "https://pub.dev" - source: hosted - version: "1.5.0" - rational: - dependency: transitive - description: - name: rational - sha256: cb808fb6f1a839e6fc5f7d8cb3b0a10e1db48b3be102de73938c627f0b636336 - url: "https://pub.dev" - source: hosted - version: "2.2.3" - shelf: - dependency: transitive - description: - name: shelf - sha256: e7dd780a7ffb623c57850b33f43309312fc863fb6aa3d276a754bb299839ef12 - url: "https://pub.dev" - source: hosted - version: "1.4.2" - shelf_web_socket: - dependency: transitive - description: - name: shelf_web_socket - sha256: "3632775c8e90d6c9712f883e633716432a27758216dfb61bd86a8321c0580925" - url: "https://pub.dev" - source: hosted - version: "3.0.0" - sky_engine: - dependency: transitive - description: flutter - source: sdk - version: "0.0.0" - socks5_proxy: - dependency: "direct main" - description: - path: "." - ref: "27ad7c2efae8d7460325c74b90f660085cbd0685" - resolved-ref: "27ad7c2efae8d7460325c74b90f660085cbd0685" - url: "https://github.com/LacticWhale/socks_dart" - source: git - version: "2.1.0" - socks_socket: - dependency: "direct main" - description: - path: "." - ref: e6232c53c1595469931ababa878759a067c02e94 - resolved-ref: e6232c53c1595469931ababa878759a067c02e94 - url: "https://github.com/sneurlax/socks_socket" - source: git - version: "1.1.1" - source_gen: - dependency: transitive - description: - name: source_gen - sha256: "14658ba5f669685cd3d63701d01b31ea748310f7ab854e471962670abcf57832" - url: "https://pub.dev" - source: hosted - version: "1.5.0" - source_helper: - dependency: transitive - description: - name: source_helper - sha256: "86d247119aedce8e63f4751bd9626fc9613255935558447569ad42f9f5b48b3c" - url: "https://pub.dev" - source: hosted - version: "1.3.5" - source_span: - dependency: transitive - description: - name: source_span - sha256: "254ee5351d6cb365c859e20ee823c3bb479bf4a293c22d17a9f1bf144ce86f7c" - url: "https://pub.dev" - source: hosted - version: "1.10.1" - sqflite: - dependency: "direct main" - description: - name: sqflite - sha256: "2d7299468485dca85efeeadf5d38986909c5eb0cd71fd3db2c2f000e6c9454bb" - url: "https://pub.dev" - source: hosted - version: "2.4.1" - sqflite_android: - dependency: transitive - description: - name: sqflite_android - sha256: "78f489aab276260cdd26676d2169446c7ecd3484bbd5fead4ca14f3ed4dd9ee3" - url: "https://pub.dev" - source: hosted - version: "2.4.0" - sqflite_common: - dependency: transitive - description: - name: sqflite_common - sha256: "761b9740ecbd4d3e66b8916d784e581861fd3c3553eda85e167bc49fdb68f709" - url: "https://pub.dev" - source: hosted - version: "2.5.4+6" - sqflite_common_ffi: - dependency: "direct main" - description: - name: sqflite_common_ffi - sha256: "883dd810b2b49e6e8c3b980df1829ef550a94e3f87deab5d864917d27ca6bf36" - url: "https://pub.dev" - source: hosted - version: "2.3.4+4" - sqflite_darwin: - dependency: transitive - description: - name: sqflite_darwin - sha256: "22adfd9a2c7d634041e96d6241e6e1c8138ca6817018afc5d443fef91dcefa9c" - url: "https://pub.dev" - source: hosted - version: "2.4.1+1" - sqflite_platform_interface: - dependency: transitive - description: - name: sqflite_platform_interface - sha256: "8dd4515c7bdcae0a785b0062859336de775e8c65db81ae33dd5445f35be61920" - url: "https://pub.dev" - source: hosted - version: "2.4.0" - sqlite3: - dependency: transitive - description: - name: sqlite3 - sha256: f393d92c71bdcc118d6203d07c991b9be0f84b1a6f89dd4f7eed348131329924 - url: "https://pub.dev" - source: hosted - version: "2.9.0" - sqlite3_flutter_libs: - dependency: "direct main" - description: - name: sqlite3_flutter_libs - sha256: "69c80d812ef2500202ebd22002cbfc1b6565e9ff56b2f971e757fac5d42294df" - url: "https://pub.dev" - source: hosted - version: "0.5.40" - stack_trace: - dependency: transitive - description: - name: stack_trace - sha256: "8b27215b45d22309b5cddda1aa2b19bdfec9df0e765f2de506401c071d38d1b1" - url: "https://pub.dev" - source: hosted - version: "1.12.1" - stream_channel: - dependency: transitive - description: - name: stream_channel - sha256: "969e04c80b8bcdf826f8f16579c7b14d780458bd97f56d107d3950fdbeef059d" - url: "https://pub.dev" - source: hosted - version: "2.1.4" - stream_transform: - dependency: transitive - description: - name: stream_transform - sha256: ad47125e588cfd37a9a7f86c7d6356dde8dfe89d071d293f80ca9e9273a33871 - url: "https://pub.dev" - source: hosted - version: "2.1.1" - string_scanner: - dependency: transitive - description: - name: string_scanner - sha256: "921cd31725b72fe181906c6a94d987c78e3b98c2e205b397ea399d4054872b43" - url: "https://pub.dev" - source: hosted - version: "1.4.1" - synchronized: - dependency: transitive - description: - name: synchronized - sha256: "69fe30f3a8b04a0be0c15ae6490fc859a78ef4c43ae2dd5e8a623d45bfcf9225" - url: "https://pub.dev" - source: hosted - version: "3.3.0+3" - term_glyph: - dependency: transitive - description: - name: term_glyph - sha256: "7f554798625ea768a7518313e58f83891c7f5024f88e46e7182a4558850a4b8e" - url: "https://pub.dev" - source: hosted - version: "1.2.2" - test_api: - dependency: transitive - description: - name: test_api - sha256: fb31f383e2ee25fbbfe06b40fe21e1e458d14080e3c67e7ba0acfde4df4e0bbd - url: "https://pub.dev" - source: hosted - version: "0.7.4" - timing: - dependency: transitive - description: - name: timing - sha256: "70a3b636575d4163c477e6de42f247a23b315ae20e86442bebe32d3cabf61c32" - url: "https://pub.dev" - source: hosted - version: "1.0.1" - torch_dart: - dependency: "direct main" - description: - path: "../scripts/torch_dart" - relative: true - source: path - version: "0.0.1" - tuple: - dependency: transitive - description: - name: tuple - sha256: a97ce2013f240b2f3807bcbaf218765b6f301c3eff91092bcfa23a039e7dd151 - url: "https://pub.dev" - source: hosted - version: "2.0.2" - typed_data: - dependency: transitive - description: - name: typed_data - sha256: f9049c039ebfeb4cf7a7104a675823cd72dba8297f264b6637062516699fa006 - url: "https://pub.dev" - source: hosted - version: "1.4.0" - unorm_dart: - dependency: "direct main" - description: - name: unorm_dart - sha256: "23d8bf65605401a6a32cff99435fed66ef3dab3ddcad3454059165df46496a3b" - url: "https://pub.dev" - source: hosted - version: "0.3.0" - vector_math: - dependency: transitive - description: - name: vector_math - sha256: "80b3257d1492ce4d091729e3a67a60407d227c27241d6927be0130c98e741803" - url: "https://pub.dev" - source: hosted - version: "2.1.4" - vm_service: - dependency: transitive - description: - name: vm_service - sha256: ddfa8d30d89985b96407efce8acbdd124701f96741f2d981ca860662f1c0dc02 - url: "https://pub.dev" - source: hosted - version: "15.0.0" - watcher: - dependency: "direct overridden" - description: - name: watcher - sha256: "69da27e49efa56a15f8afe8f4438c4ec02eff0a117df1b22ea4aad194fe1c104" - url: "https://pub.dev" - source: hosted - version: "1.1.1" - web: - dependency: transitive - description: - name: web - sha256: "868d88a33d8a87b18ffc05f9f030ba328ffefba92d6c127917a2ba740f9cfe4a" - url: "https://pub.dev" - source: hosted - version: "1.1.1" - web_socket: - dependency: transitive - description: - name: web_socket - sha256: "34d64019aa8e36bf9842ac014bb5d2f5586ca73df5e4d9bf5c936975cae6982c" - url: "https://pub.dev" - source: hosted - version: "1.0.1" - web_socket_channel: - dependency: transitive - description: - name: web_socket_channel - sha256: d645757fb0f4773d602444000a8131ff5d48c9e47adfe9772652dd1a4f2d45c8 - url: "https://pub.dev" - source: hosted - version: "3.0.3" - xdg_directories: - dependency: transitive - description: - name: xdg_directories - sha256: "7a3f37b05d989967cdddcbb571f1ea834867ae2faa29725fd085180e0883aa15" - url: "https://pub.dev" - source: hosted - version: "1.1.0" - yaml: - dependency: transitive - description: - name: yaml - sha256: b9da305ac7c39faa3f030eccd175340f968459dae4af175130b3fc47e40d76ce - url: "https://pub.dev" - source: hosted - version: "3.1.3" -sdks: - dart: ">=3.7.0-0 <4.0.0" - flutter: ">=3.27.0" diff --git a/cw_core/pubspec.yaml b/cw_core/pubspec.yaml index d78cc38ce..7ea8378bf 100644 --- a/cw_core/pubspec.yaml +++ b/cw_core/pubspec.yaml @@ -14,16 +14,15 @@ dependencies: http: ^1.1.0 file: ^7.0.0 path_provider: ^2.1.5 - mobx: ^2.0.7+4 - flutter_mobx: ^2.0.6+1 + mobx: ^2.6.0 + flutter_mobx: ^2.3.0 intl: any encrypt: ^5.0.1 decimal: ^2.3.3 cake_backup: git: url: https://github.com/cake-tech/cake_backup.git - ref: 3aba867dcab6737f6707782f5db15d71f303db38 - version: 1.0.0 + ref: b5d86a2a21e1c186cd0baa4df1f3a4f3f9413056 socks5_proxy: git: url: https://github.com/LacticWhale/socks_dart @@ -42,7 +41,7 @@ dependencies: blockchain_utils: git: url: https://github.com/cake-tech/blockchain_utils - ref: cake-update-v2 + ref: 59fdf29d72068e0522a96a8953ed7272833a9f57 sqflite: ^2.4.1 sqflite_common_ffi: ^2.3.4+4 bech32: @@ -54,10 +53,10 @@ dependencies: dev_dependencies: flutter_test: sdk: flutter - build_runner: ^2.4.15 - build_resolvers: ^2.4.4 - mobx_codegen: ^2.0.7 - hive_generator: ^2.0.1 + build_runner: ^2.15.0 + mobx_codegen: ^2.7.7 + hive: ^2.2.3 +# hive_generator: ^2.0.1 dependency_overrides: watcher: ^1.1.0 diff --git a/cw_core/pubspec_overrides.yaml b/cw_core/pubspec_overrides.yaml new file mode 120000 index 000000000..d957432d3 --- /dev/null +++ b/cw_core/pubspec_overrides.yaml @@ -0,0 +1 @@ +../pubspec_overrides.yaml \ No newline at end of file diff --git a/cw_core/test/amount/amount_sanitizer_test.dart b/cw_core/test/amount/amount_sanitizer_test.dart new file mode 100644 index 000000000..fc70ac8de --- /dev/null +++ b/cw_core/test/amount/amount_sanitizer_test.dart @@ -0,0 +1,31 @@ +import 'package:cw_core/amount/amount_sanitizer.dart'; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + group('smartAmountSanitizer', () { + test('sanitize decimal dot', () { + final amount = "1,000.00".sanitized(); + expect(amount, "1000.00"); + }); + + test('sanitize decimal comma', () { + final amount = "1.000,00".sanitized(); + expect(amount, "1000.00"); + }); + + test('general case', () { + final amount = "1000.00".sanitized(); + expect(amount, "1000.00"); + }); + + test('no decimals', () { + final amount = "1,000".sanitized(); + expect(amount, "1.000"); + }); + + test('no decimals', () { + final amount = "1.000".sanitized(); + expect(amount, "1.000"); + }); + }); +} diff --git a/cw_core/test/amount/exchange_rate_test.dart b/cw_core/test/amount/exchange_rate_test.dart new file mode 100644 index 000000000..e7932d8d5 --- /dev/null +++ b/cw_core/test/amount/exchange_rate_test.dart @@ -0,0 +1,315 @@ +import 'package:cw_core/amount/exchange_rate.dart'; +import 'package:cw_core/amount/money.dart'; +import 'package:cw_core/crypto_currency.dart'; +import 'package:flutter_test/flutter_test.dart'; + +import 'utils.dart'; + +void main() { + group('ExchangeRate', () { + final rate = ExchangeRate( + base: CryptoCurrency.btc, + quote: Money.parse("60000", EUR), + ); + + test('convert base currency to quote currency: 1 BTC to 60.000 EUR', () { + final result = rate.convert(Money.parse('1', CryptoCurrency.btc)); + expect(result, Money.parse("60000.00", EUR)); + }); + + test('convert base currency to quote currency: 0.5 BTC to 30.000 EUR', () { + final result = rate.convert(Money.parse('0.5', CryptoCurrency.btc)); + expect(result, Money.parse("30000.00", EUR)); + }); + + test('convert quote currency to base currency: 60.000 EUR to 1 BTC', () { + final result = rate.convert(Money.parse("60000.00", EUR)); + expect(result, Money.parse('1', CryptoCurrency.btc)); + }); + + test('convert quote currency to base currency: 30 EUR to 0.0005 BTC', () { + final result = rate.convert(Money.parse("30.00", EUR)); + expect(result, Money.parse('0.0005', CryptoCurrency.btc)); + }); + }); + + group('Edge Cases', () { + final rate = ExchangeRate( + base: CryptoCurrency.btc, + quote: Money.parse("60000", EUR), + ); + + test('Converting zero turns into zero', () { + expect(rate.convert(Money.zero(CryptoCurrency.btc)), Money.zero(EUR)); + expect(rate.convert(Money.zero(EUR)), Money.zero(CryptoCurrency.btc)); + }); + + test('truncate to smallest unit', () { + final result = rate.convert(Money(BigInt.one, CryptoCurrency.btc)); // 1 sat + expect(result, Money.zero(EUR)); + }); + + test('Truncate base unit of base currency', () { + final result = rate.convert(Money(BigInt.one, EUR)); + expect(result, Money.fromInt(16, CryptoCurrency.btc)); + }); + + test('No int overflow with large numbers', () { + final allBtc = Money.parse('21000000', CryptoCurrency.btc); + final result = rate.convert(allBtc); + expect(result, Money(BigInt.from(126000000000000), EUR)); + }); + + test('truncation for negative amounts', () { + final result = rate.convert(Money.fromInt(-1, EUR)); + expect(result, Money.fromInt(-16, CryptoCurrency.btc)); + }); + + test('Base currency without decimals places: JPY', () { + final rate = ExchangeRate( + base: JPY, + quote: Money.fromInt(1, EUR), + ); + + expect(rate.convert(Money.fromInt(500, JPY)), Money.fromInt(500, EUR)); + expect(rate.convert(Money.fromInt(500, EUR)), Money.fromInt(500, JPY)); + }); + + test('Base currency with 18 decimals places: ETH', () { + final rate = ExchangeRate( + base: CryptoCurrency.eth, + quote: Money.fromInt(300000, EUR), + ); + + expect(rate.convert(Money.parse('1', CryptoCurrency.eth)), Money.fromInt(300000, EUR)); + expect(rate.convert(Money.fromInt(3000, EUR)), Money.parse('0.01', CryptoCurrency.eth)); + }); + + test('Smalles base unit is less than smalles base unit of quote', () { + final rate = ExchangeRate( + base: CryptoCurrency.eth, + quote: Money.fromInt(300000, EUR), + ); + expect(rate.convert(Money(BigInt.one, CryptoCurrency.eth)), Money(BigInt.zero, EUR)); + }); + + test('Cannot convert currency outside of pair', () { + expect(() => rate.convert(Money.parse("60000", USD)), throwsArgumentError); + }); + + test('handle quote being 0 correctly', () { + final zeroRate = ExchangeRate( + base: CryptoCurrency.btc, + quote: Money(BigInt.zero, EUR, EUR.decimals), + ); + expect(zeroRate.convert(Money.fromInt(100, CryptoCurrency.btc)), Money(BigInt.zero, EUR)); + expect(zeroRate.convert(Money.fromInt(100, EUR)), Money(BigInt.zero, CryptoCurrency.btc)); + }); + + test('handle super low quote correctly', () { + final zeroRate = ExchangeRate( + base: CryptoCurrency.shib, + quote: Money.parse("0.00000444", EUR, strictParsing: false), + ); + + expect( + zeroRate.convert(Money.parse("1", CryptoCurrency.shib)), + Money.parse("0.00000444", EUR, strictParsing: false), + ); + expect( + zeroRate.convert(Money.fromInt(10000, EUR)), + Money.parse("22522522.522522522522522522", CryptoCurrency.shib), + ); + + expect( + zeroRate.convert(Money.parse("200", CryptoCurrency.shib)), + Money.parse("0.000888", EUR, strictParsing: false), + ); + }); + + group('Precision and scale', () { + final rate = ExchangeRate(base: CryptoCurrency.btc, quote: Money.parse("60000", EUR)); + + test('convert is insensitive to the input scale (forward)', () { + // Same value, three different internal scales. + final coarse = Money(BigInt.one, CryptoCurrency.btc, 0); + final normal = Money.parse("1", CryptoCurrency.btc); + final fine = Money.parse("1.000000000000000000", CryptoCurrency.btc, strictParsing: false); + + expect(rate.convert(coarse), Money.parse("60000.00", EUR)); + expect(rate.convert(normal), Money.parse("60000.00", EUR)); + expect(rate.convert(fine), Money.parse("60000.00", EUR)); + }); + + test('convert is insensitive to the input scale (reverse)', () { + expect( + rate.convert(Money(BigInt.one, EUR, 0)), // €1 at scale 0 + rate.convert(Money.parse("1", EUR)), // €1 at scale 2 + ); + }); + + test('sub-unit precision in the input does not leak into the result', () { + // ad = 18 here, well past BTC's 8. The divisor must cancel the *amount's* + // scale, not the base currency's. + final overPrecise = + Money.parse("1.000000000000000001", CryptoCurrency.btc, strictParsing: false); + + expect(rate.convert(overPrecise), Money.parse("60000.00", EUR)); + }); + + test('the result carries the quote scale, not the currency default', () { + final preciseRate = ExchangeRate( + base: CryptoCurrency.btc, + quote: Money.parse("60000.123456", EUR, strictParsing: false), + ); + + final result = preciseRate.convert(Money.parse("1", CryptoCurrency.btc)); + + expect(result.decimals, 6); + expect(result, Money.parse("60000.123456", EUR, strictParsing: false)); + }); + + test('a quote scale finer than the currency survives the round trip', () { + final preciseRate = ExchangeRate( + base: CryptoCurrency.btc, + quote: Money.parse("60000.50", EUR, strictParsing: false), + ); + + expect( + preciseRate.convert(preciseRate.convert(Money.parse("1", CryptoCurrency.btc))), + Money.parse("1", CryptoCurrency.btc), + ); + }); + }); + + group('Truncation', () { + final rate = ExchangeRate(base: CryptoCurrency.btc, quote: Money.parse("60000", EUR)); + + test('truncates rather than rounding, even past the halfway point', () { + // 9 sats = 0.54 cents. Rounding would give 1. + expect(rate.convert(Money(BigInt.from(9), CryptoCurrency.btc)), Money.zero(EUR)); + // 1 cent = 16.66 sats. Rounding would give 17. + expect(rate.convert(Money(BigInt.one, EUR)), Money.fromInt(16, CryptoCurrency.btc)); + }); + + test('truncates toward zero for negatives, not downward', () { + // -0.54 cents must be 0, not -1. + expect(rate.convert(Money(BigInt.from(-9), CryptoCurrency.btc)), Money.zero(EUR)); + expect(rate.convert(Money(BigInt.from(-1), EUR)), Money.fromInt(-16, CryptoCurrency.btc)); + }); + + test('truncation is symmetric around zero', () { + final positive = rate.convert(Money(BigInt.from(7), EUR)); + final negative = rate.convert(Money(BigInt.from(-7), EUR)); + + expect(negative, -positive); + }); + + test('round trip loses less than one quote base unit', () { + final original = Money.parse("0.12345678", CryptoCurrency.btc); + final back = rate.convert(rate.convert(original)); + final oneQuoteUnitInBaseUnits = + BigInt.from(10).pow(CryptoCurrency.btc.decimals) ~/ rate.quote.amount; + + expect( + (original - back).amount.abs(), + lessThanOrEqualTo(oneQuoteUnitInBaseUnits + BigInt.one), + ); + }); + + test('round trip never inflates the amount', () { + // Both truncations round toward zero, so the result can only shrink. + for (final source in ["0.12345678", "1", "0.5", "21000000"]) { + final original = Money.parse(source, CryptoCurrency.btc); + expect( + rate.convert(rate.convert(original)) <= original, + isTrue, + reason: "round trip grew $source", + ); + } + }); + + test('a value below one base unit of the quote collapses to zero', () { + final back = rate.convert(rate.convert(Money(BigInt.one, CryptoCurrency.btc))); + + expect(back.isZero, isTrue); + }); + }); + + group('Extreme decimal spreads', () { + test('30 decimals against 0 decimals: NANO/JPY', () { + final rate = ExchangeRate(base: CryptoCurrency.nano, quote: Money.parse("150", JPY)); + + expect(rate.convert(Money.parse("1", CryptoCurrency.nano)), Money.fromInt(150, JPY)); + expect(rate.convert(Money.fromInt(150, JPY)), Money.parse("1", CryptoCurrency.nano)); + expect(rate.convert(Money.fromInt(1, JPY)).decimals, 30); + }); + + test('0 decimals against 18 decimals: JPY/SHIB', () { + final rate = ExchangeRate(base: JPY, quote: Money.parse("2500", CryptoCurrency.shib)); + + expect(rate.convert(Money.fromInt(2, JPY)), Money.parse("5000", CryptoCurrency.shib)); + expect(rate.convert(Money.parse("5000", CryptoCurrency.shib)), Money.fromInt(2, JPY)); + }); + + test('no overflow at the extremes', () { + final rate = ExchangeRate( + base: CryptoCurrency.shib, + quote: Money.parse("0.00000444", EUR, strictParsing: false), + ); + final totalSupply = Money.parse("589000000000000", CryptoCurrency.shib); + + expect(rate.convert(totalSupply).isNegative, isFalse); + expect(rate.convert(rate.convert(totalSupply)) <= totalSupply, isTrue); + }); + }); + + group('Degenerate pairs', () { + test('identity pair returns the amount untouched', () { + final identity = ExchangeRate(base: EUR, quote: Money.parse("1", EUR)); + + expect(identity.convert(Money.parse("42.42", EUR)), Money.parse("42.42", EUR)); + expect(identity.convert(Money.zero(EUR)), Money.zero(EUR)); + }); + + test('a same-currency pair silently ignores its own rate', () { + final bogus = ExchangeRate(base: EUR, quote: Money.parse("2", EUR)); + expect(bogus.convert(Money.parse("10", EUR)), Money.parse("10", EUR)); + }); + + test('a rate of exactly one is a no-op in both directions', () { + final rate = ExchangeRate(base: JPY, quote: Money.fromInt(100, EUR)); + + expect(rate.convert(Money.fromInt(7, JPY)), Money.fromInt(700, EUR)); + expect(rate.convert(Money.fromInt(700, EUR)), Money.fromInt(7, JPY)); + }); + + test('negative quotes propagate their sign', () { + final inverted = ExchangeRate(base: CryptoCurrency.btc, quote: Money.parse("-60000", EUR)); + + expect( + inverted.convert(Money.parse("1", CryptoCurrency.btc)), + Money.parse("-60000.00", EUR), + ); + expect( + inverted.convert(Money.parse("-60000.00", EUR)), + Money.parse("1", CryptoCurrency.btc), + ); + }); + + test('zero amount with a zero quote', () { + final zeroRate = ExchangeRate(base: CryptoCurrency.btc, quote: Money.zero(EUR)); + + expect(zeroRate.convert(Money.zero(CryptoCurrency.btc)), Money.zero(EUR)); + expect(zeroRate.convert(Money.zero(EUR)), Money.zero(CryptoCurrency.btc)); + }); + + test('rejects a foreign currency in both slots', () { + final rate = ExchangeRate(base: CryptoCurrency.btc, quote: Money.parse("60000", EUR)); + + expect(() => rate.convert(Money.parse("1", CryptoCurrency.xmr)), throwsArgumentError); + expect(() => rate.convert(Money.parse("1", USD)), throwsArgumentError); + }); + }); + }); +} diff --git a/cw_core/test/amount/money_local_test.dart b/cw_core/test/amount/money_local_test.dart new file mode 100644 index 000000000..15aeaea0e --- /dev/null +++ b/cw_core/test/amount/money_local_test.dart @@ -0,0 +1,132 @@ +import "package:cw_core/amount/money.dart"; +import "package:cw_core/amount/money_local.dart"; +import "package:cw_core/crypto_currency.dart"; +import "package:flutter_test/flutter_test.dart"; +import "package:intl/intl.dart"; + +final _btc012 = Money.fromInt(12345678, CryptoCurrency.btc); // 0.12345678 BTC +final _btcTrailing = Money.fromInt(12000000, CryptoCurrency.btc); // 0.12 BTC +final _btcGrouping = Money.fromInt(123450000000, CryptoCurrency.btc); // 1234.5 BTC +final _xmrHalf = Money.fromInt(500000000000, CryptoCurrency.xmr); // 0.5 XMR + +void main() { + group("toLocalStringWithPrecision", () { + test("localizes grouping (en_US)", () { + expect(_btcGrouping.toLocalStringWithPrecision(locale: "en_US"), "1,234.5"); + }); + + test("no grouping for sub-1 values", () { + expect(_btc012.toLocalStringWithPrecision(locale: "en_US"), "0.12345678"); + }); + + test("swaps grouping/decimal separators (de_DE)", () { + expect(_btcGrouping.toLocalStringWithPrecision(locale: "de_DE"), "1.234,5"); + }); + + test("formats as English when intl has no number data for the locale (yo)", () { + expect(_btcGrouping.toLocalStringWithPrecision(locale: "yo"), "1,234.5"); + }); + + test("base unit renders integer sats with grouping", () { + expect( + _btc012.toLocalStringWithPrecision(useBaseUnit: true, locale: "en_US"), + "12,345,678", + ); + }); + + test("fractionalDigits truncates (does not round)", () { + // 0.12345678 -> 0.12 (NOT 0.13). + expect(_btc012.toLocalStringWithPrecision(fractionalDigits: 2, locale: "en_US"), "0.12"); + }); + + test("trimZeros:false keeps trailing zeros", () { + expect( + _btcTrailing.toLocalStringWithPrecision(trimZeros: false, locale: "en_US"), + "0.12000000", + ); + }); + + test("respects a higher-precision currency (XMR)", () { + expect(_xmrHalf.toLocalStringWithPrecision(locale: "en_US"), "0.5"); + }); + + test("falls back to the ambient Intl locale when locale is null", () { + final previous = Intl.defaultLocale; + addTearDown(() => Intl.defaultLocale = previous); + Intl.defaultLocale = "de_DE"; + + // Proves the extension passes null through to NumberFormat rather than + // hardcoding a locale. + expect(_btcGrouping.toLocalStringWithPrecision(), "1.234,5"); + }); + }); + + group("toLocalStringWithSymbol", () { + test("suffixes the symbol by default", () { + expect(_btcGrouping.toLocalStringWithSymbol(locale: "en_US"), "1,234.5 BTC"); + }); + + test("prefixes the symbol when withSymbolPrefix is true", () { + expect( + _btcGrouping.toLocalStringWithSymbol(withSymbolPrefix: true, locale: "en_US"), + "BTC 1,234.5", + ); + }); + + test("suffixes the base-unit ticker", () { + expect( + _btc012.toLocalStringWithSymbol(useBaseUnit: true, locale: "en_US"), + "12,345,678 sats", + ); + }); + + test("prefixes the base-unit ticker", () { + expect( + _btc012.toLocalStringWithSymbol( + useBaseUnit: true, + withSymbolPrefix: true, + locale: "en_US", + ), + "sats 12,345,678", + ); + }); + + test("prefix respects de_DE separators", () { + expect( + _btcGrouping.toLocalStringWithSymbol(withSymbolPrefix: true, locale: "de_DE"), + "BTC 1.234,5", + ); + }); + + test("uses the currency symbol for non-BTC", () { + expect(_xmrHalf.toLocalStringWithSymbol(locale: "en_US"), "0.5 XMR"); + }); + + test("forwards fractionalDigits to the precision string", () { + expect(_btc012.toLocalStringWithSymbol(fractionalDigits: 2, locale: "en_US"), "0.12 BTC"); + }); + + test("forwards trimZeros to the precision string", () { + expect( + _btcTrailing.toLocalStringWithSymbol(trimZeros: false, locale: "en_US"), + "0.12000000 BTC", + ); + }); + }); + + group("negative amounts", () { + test("keeps the sign when the integer part is non-zero", () { + expect((-_btcGrouping).toLocalStringWithSymbol(locale: "en_US"), "-1,234.5 BTC"); + }); + + // NOTE: currently fails. For |amount| < 1 the sign is dropped: + // formatFixed yields "-0.5", but _withLocalSeparator re-parses the integer + // part and int.tryParse("-0") == 0, so the "-" is lost -> "0.5 XMR". + // Fix in _withLocalSeparator by capturing the sign before parsing, e.g. + // final negative = amount.startsWith("-"); + // ... then re-apply it to the formatted result. + test("keeps the sign for sub-1 amounts", () { + expect((-_xmrHalf).toLocalStringWithSymbol(locale: "en_US"), "-0.5 XMR"); + }); + }); +} diff --git a/cw_core/test/amount/money_test.dart b/cw_core/test/amount/money_test.dart new file mode 100644 index 000000000..6a91f206f --- /dev/null +++ b/cw_core/test/amount/money_test.dart @@ -0,0 +1,496 @@ +import "package:cw_core/amount/money.dart"; +import "package:cw_core/crypto_currency.dart"; +import "package:flutter_test/flutter_test.dart"; + +import "utils.dart"; + +void main() { + group("Money", () { + test("parse", () { + var money = Money.parse("1", CryptoCurrency.btc); + expect(money.amount, BigInt.from(100000000)); + + money = Money.parse("-1", CryptoCurrency.btc); + expect(money.amount, BigInt.from(-100000000)); + + money = Money.parse("1.11", CryptoCurrency.btc); + expect(money.amount, BigInt.from(111000000)); + + money = Money.parse("-1.11", CryptoCurrency.btc); + expect(money.amount, BigInt.from(-111000000)); + + money = Money.parse("-.11", CryptoCurrency.btc); + expect(money.amount, BigInt.from(-11000000)); + + money = Money.parse(".11", CryptoCurrency.btc); + expect(money.amount, BigInt.from(11000000)); + + // Invalid representation + expect(() => Money.parse("1,11", CryptoCurrency.btc), throwsFormatException); + + // To many decimals + expect(() => Money.parse("-1.0000000000000001", CryptoCurrency.btc), throwsFormatException); + + money = Money.parse("1", CryptoCurrency.btc, isBaseUnit: true); + expect(money.amount, BigInt.from(1)); + + money = Money.parse("0", CryptoCurrency.btc, isBaseUnit: true); + expect(money.amount, BigInt.from(0)); + + money = Money.parse("-1", CryptoCurrency.btc, isBaseUnit: true); + expect(money.amount, BigInt.from(-1)); + + // canonical representation when expecting base units + expect(() => Money.parse("1.0", CryptoCurrency.btc, isBaseUnit: true), throwsFormatException); + }); + + test("tryParse", () { + var money = Money.tryParse("1", CryptoCurrency.btc); + expect(money?.amount, BigInt.from(100000000)); + + money = Money.tryParse("-1", CryptoCurrency.btc); + expect(money?.amount, BigInt.from(-100000000)); + + money = Money.tryParse("1.11", CryptoCurrency.btc); + expect(money?.amount, BigInt.from(111000000)); + + money = Money.tryParse("-1.11", CryptoCurrency.btc); + expect(money?.amount, BigInt.from(-111000000)); + + money = Money.tryParse("-.11", CryptoCurrency.btc); + expect(money?.amount, BigInt.from(-11000000)); + + money = Money.tryParse(".11", CryptoCurrency.btc); + expect(money?.amount, BigInt.from(11000000)); + + // Invalid representation + money = Money.tryParse("1,11", CryptoCurrency.btc); + expect(money?.amount, isNull); + + // To many decimals + money = Money.tryParse("-1.0000000000000001", CryptoCurrency.btc); + expect(money?.amount, isNull); + + money = Money.tryParse("1", CryptoCurrency.btc, isBaseUnit: true); + expect(money?.amount, BigInt.from(1)); + + money = Money.tryParse("0", CryptoCurrency.btc, isBaseUnit: true); + expect(money?.amount, BigInt.from(0)); + + money = Money.tryParse("-1", CryptoCurrency.btc, isBaseUnit: true); + expect(money?.amount, BigInt.from(-1)); + + // canonical representation when expecting base units + money = Money.tryParse("1.0", CryptoCurrency.btc, isBaseUnit: true); + expect(money?.amount, isNull); + }); + + test("sign", () { + expect(Money(BigInt.from(0), CryptoCurrency.btc).sign, 0); + + expect(Money(BigInt.from(1), CryptoCurrency.btc).sign, 1); + + expect(Money(BigInt.from(10), CryptoCurrency.btc).sign, 1); + + expect(Money(BigInt.from(-1), CryptoCurrency.btc).sign, -1); + + expect(Money(BigInt.from(-10), CryptoCurrency.btc).sign, -1); + }); + + test("isZero", () { + expect(Money(BigInt.from(0), CryptoCurrency.btc).isZero, isTrue); + + expect(Money(BigInt.from(1), CryptoCurrency.btc).isZero, isFalse); + + expect(Money(BigInt.from(-1), CryptoCurrency.btc).isZero, isFalse); + }); + + test("isNegative", () { + expect(Money(BigInt.from(0), CryptoCurrency.btc).isNegative, isFalse); + + expect(Money(BigInt.from(1), CryptoCurrency.btc).isNegative, isFalse); + + expect(Money(BigInt.from(-1), CryptoCurrency.btc).isNegative, isTrue); + }); + + group("Comparison", () { + final fourBitcoin = Money(BigInt.from(400000000), CryptoCurrency.btc); + final fiveBitcoin = Money(BigInt.from(500000000), CryptoCurrency.btc); + final sixBitcoin = Money(BigInt.from(600000000), CryptoCurrency.btc); + + final fiveMonero = Money.parse("5", CryptoCurrency.xmr); + + test("==", () { + expect(fiveBitcoin, equals(Money(BigInt.from(500000000), CryptoCurrency.btc))); + expect(fiveBitcoin, isNot(equals(fourBitcoin))); + expect(fiveBitcoin, isNot(equals(sixBitcoin))); + expect(fiveBitcoin, isNot(equals(fiveMonero))); + // intentional check of incompatible types. + // ignore: unrelated_type_equality_checks + expect(fiveBitcoin == "Not money", equals(false)); + }); + + test("<", () { + expect(fiveBitcoin < sixBitcoin, isTrue); + expect(fiveBitcoin < fiveBitcoin, isFalse); + expect(fiveBitcoin < fourBitcoin, isFalse); + + // Cannot compare money in different currencies: + expect(() => fiveBitcoin < fiveMonero, throwsArgumentError); + }); + + test("<=", () { + expect(fiveBitcoin <= sixBitcoin, isTrue); + expect(fiveBitcoin <= fiveBitcoin, isTrue); + expect(fiveBitcoin <= fourBitcoin, isFalse); + + // Cannot compare money in different currencies: + expect(() => fiveBitcoin <= fiveMonero, throwsArgumentError); + }); + + test(">", () { + expect(fiveBitcoin > fourBitcoin, isTrue); + expect(fiveBitcoin > fiveBitcoin, isFalse); + expect(fiveBitcoin > sixBitcoin, isFalse); + + // Cannot compare money in different currencies: + expect(() => fiveBitcoin > fiveMonero, throwsArgumentError); + }); + + test(">=", () { + expect(fiveBitcoin >= fourBitcoin, isTrue); + expect(fiveBitcoin >= fiveBitcoin, isTrue); + expect(fiveBitcoin >= sixBitcoin, isFalse); + + // Cannot compare money in different currencies: + expect(() => fiveBitcoin >= fiveMonero, throwsArgumentError); + }); + + test("conformance to Comparable", () { + expect(fiveBitcoin, isA>()); + + expect(fiveBitcoin.compareTo(fiveBitcoin), isZero); + expect(fiveBitcoin.compareTo(fourBitcoin), isPositive); + expect(fiveBitcoin.compareTo(sixBitcoin), isNegative); + expect(() => fiveBitcoin.compareTo(fiveMonero), throwsArgumentError); + }); + }); + + group("Math", () { + test("+", () { + final oneBitcoin = Money.parse("1", CryptoCurrency.btc); + final twoBitcoin = Money.parse("2", CryptoCurrency.btc); + final threeBitcoin = Money.parse("3", CryptoCurrency.btc); + + expect(oneBitcoin + twoBitcoin, equals(threeBitcoin)); + }); + + test("cannot add different currencies", () { + final oneBitcoin = Money(BigInt.one, CryptoCurrency.btc); + final oneMonero = Money(BigInt.one, CryptoCurrency.xmr); + + expect(() => oneBitcoin + oneMonero, throwsArgumentError); + }); + + test("invert money", () { + final oneSatoshi = Money(BigInt.one, CryptoCurrency.btc); + final negativeOneSatoshi = Money(BigInt.from(-1), CryptoCurrency.btc); + + expect(-oneSatoshi, equals(negativeOneSatoshi)); + expect(-negativeOneSatoshi, equals(oneSatoshi)); + }); + + test("-", () { + final oneSatoshi = Money(BigInt.one, CryptoCurrency.btc); + final twoSatoshi = Money(BigInt.two, CryptoCurrency.btc); + final threeSatoshi = Money(BigInt.from(3), CryptoCurrency.btc); + + expect(threeSatoshi - oneSatoshi, equals(twoSatoshi)); + }); + + test("cannot subtract different currencies", () { + final oneSatoshi = Money(BigInt.one, CryptoCurrency.btc); + final oneMonero = Money(BigInt.one, CryptoCurrency.xmr); + + expect(() => oneSatoshi - oneMonero, throwsArgumentError); + }); + + test("*", () { + final zeroSatoshi = Money(BigInt.zero, CryptoCurrency.btc); + final oneSatoshi = Money(BigInt.one, CryptoCurrency.btc); + final twoSatoshi = Money(BigInt.two, CryptoCurrency.btc); + + expect(oneSatoshi * BigInt.zero, equals(zeroSatoshi)); + expect(oneSatoshi * BigInt.two, equals(twoSatoshi)); + expect(oneSatoshi * BigInt.from(-2), equals(-twoSatoshi)); + }); + + group("/", () { + final threeBI = BigInt.from(3); + final fourBI = BigInt.from(4); + + final negThreeBI = BigInt.from(-3); + final negFourBI = BigInt.from(-4); + + test("rounds down when fractional part is < 0.5", () { + expect((Money(BigInt.from(10), CryptoCurrency.btc) / threeBI).amount, threeBI); + expect((Money(BigInt.from(-10), CryptoCurrency.btc) / threeBI).amount, negThreeBI); + expect((Money(BigInt.from(10), CryptoCurrency.btc) / negThreeBI).amount, negThreeBI); + expect((Money(BigInt.from(-10), CryptoCurrency.btc) / negThreeBI).amount, threeBI); + }); + + test("rounds up when fractional part is > 0.5", () { + expect((Money(BigInt.from(11), CryptoCurrency.btc) / threeBI).amount, fourBI); // 3.66... + expect((Money(BigInt.from(-11), CryptoCurrency.btc) / threeBI).amount, negFourBI); + expect((Money(BigInt.from(11), CryptoCurrency.btc) / negThreeBI).amount, negFourBI); + expect((Money(BigInt.from(-11), CryptoCurrency.btc) / negThreeBI).amount, fourBI); + }); + + test("rounds AWAY from zero when fractional part is exactly 0.5 (Ties)", () { + // 5 / 2 = 2.5 -> 3 + expect((Money(BigInt.from(5), CryptoCurrency.btc) / BigInt.two).amount, threeBI); + // -5 / 2 = -2.5 -> -3 + expect((Money(BigInt.from(-5), CryptoCurrency.btc) / BigInt.two).amount, negThreeBI); + // 5 / -2 = -2.5 -> -3 + expect((Money(BigInt.from(5), CryptoCurrency.btc) / -BigInt.two).amount, negThreeBI); + // -5 / -2 = 2.5 -> 3 + expect((Money(BigInt.from(-5), CryptoCurrency.btc) / -BigInt.two).amount, threeBI); + }); + + test("handles zero numerator correctly", () { + expect((Money(BigInt.zero, CryptoCurrency.btc) / BigInt.from(5)).amount, BigInt.zero); + expect((Money(BigInt.zero, CryptoCurrency.btc) / BigInt.from(-5)).amount, BigInt.zero); + }); + + test("throws Exception on division by zero", () { + expect( + () => Money(BigInt.from(5), CryptoCurrency.btc) / BigInt.zero, + throwsA(isA()), + ); + }); + + test("handles very large BigInts without precision loss", () { + // A number that would lose precision if converted to a standard double + final hugeA = BigInt.parse("10000000000000000000000000000000000005"); + final hugeB = BigInt.parse("10"); + // 1000...005 / 10 = 1000...000.5 -> Should round up to 1000...001 + final expected = BigInt.parse("1000000000000000000000000000000000001"); + + expect((Money(hugeA, CryptoCurrency.btc) / hugeB).amount, expected); + }); + + test("handles exact division perfectly", () { + expect( + (Money(BigInt.from(100), CryptoCurrency.btc) / BigInt.from(20)).amount, + BigInt.from(5), + ); + expect( + (Money(BigInt.from(-100), CryptoCurrency.btc) / BigInt.from(20)).amount, + BigInt.from(-5), + ); + }); + }); + }); + + group("copyWith", () { + final money = Money.parse("1", CryptoCurrency.btc); + + test("currency", () { + final copy = money.copyWith(currency: CryptoCurrency.eth); + expect(copy.amount, equals(BigInt.parse("1000000000000000000"))); + expect(copy.currency.decimals, equals(18)); + expect(copy.toString(), "1"); + }); + + test("currency nano", () { + final copy = money.copyWith(currency: CryptoCurrency.nano); + expect(copy.amount, equals(BigInt.parse("1000000000000000000000000000000"))); + expect(copy.currency.decimals, equals(30)); + expect(copy.toString(), "1"); + }); + + test("amount", () { + final copy = money.copyWith(amount: BigInt.one); + expect(copy.amount, BigInt.one); + expect(copy.currency.decimals, 8); + }); + + test("amount and currency", () { + final copy = money.copyWith(amount: BigInt.one); + expect(copy.amount, BigInt.one); + expect(copy.currency.decimals, 8); + }); + }); + + group("toStringWithPrecision", () { + test("with fractionalDigits and padded with zeros", () { + final money = Money.parse("1", CryptoCurrency.btc); + expect(money.amount, equals(BigInt.parse("100000000"))); + expect(money.currency.decimals, equals(8)); + expect(money.toStringWithPrecision(fractionalDigits: 5, trimZeros: false), "1.00000"); + }); + + test("using base unit sats", () { + final money = Money.parse("1", CryptoCurrency.btc); + expect(money.amount, equals(BigInt.parse("100000000"))); + expect(money.currency.decimals, equals(8)); + expect(money.toStringWithPrecision(useBaseUnit: true), "100000000"); + }); + }); + + group("toStringWithSymbol", () { + test("with fractionalDigits and padded with zeros", () { + final money = Money.parse("1", CryptoCurrency.btc); + expect(money.amount, equals(BigInt.parse("100000000"))); + expect(money.currency.decimals, equals(8)); + expect(money.toStringWithSymbol(fractionalDigits: 5, trimZeros: false), "1.00000 BTC"); + }); + + test("using base unit sats", () { + final money = Money.parse("1", CryptoCurrency.btc); + expect(money.amount, equals(BigInt.parse("100000000"))); + expect(money.currency.decimals, equals(8)); + expect(money.toStringWithSymbol(useBaseUnit: true), "100000000 sats"); + }); + + group("withSymbolPrefix", () { + test("with fractionalDigits and padded with zeros", () { + final money = Money.parse("1", CryptoCurrency.btc); + expect(money.amount, equals(BigInt.parse("100000000"))); + expect(money.currency.decimals, equals(8)); + expect( + money.toStringWithSymbol(fractionalDigits: 5, trimZeros: false, withSymbolPrefix: true), + "BTC 1.00000", + ); + }); + + test("using base unit sats", () { + final money = Money.parse("1", CryptoCurrency.btc); + expect(money.amount, equals(BigInt.parse("100000000"))); + expect(money.currency.decimals, equals(8)); + expect( + money.toStringWithSymbol(useBaseUnit: true, withSymbolPrefix: true), + "sats 100000000", + ); + }); + }); + }); + + group("different scales", () { + final coarseOne = Money(BigInt.one, CryptoCurrency.btc, 0); + final fineOne = Money(BigInt.from(100000000), CryptoCurrency.btc, 8); + + test("== across scales", () { + expect(coarseOne, equals(fineOne)); + expect(coarseOne, isNot(equals(Money(BigInt.two, CryptoCurrency.btc, 0)))); + }); + + test("equal values across scales hash equally", () { + expect(coarseOne.hashCode, equals(fineOne.hashCode)); + + expect( + Money(BigInt.from(-11), CryptoCurrency.btc, 1).hashCode, + equals(Money(BigInt.from(-110000000), CryptoCurrency.btc, 8).hashCode), + ); + }); + + test("zero hashes equally at every scale", () { + final hashes = [0, 1, 8, 18, 30] + .map((s) => Money(BigInt.zero, CryptoCurrency.btc, s).hashCode) + .toSet(); + + expect(hashes, hasLength(1)); + }); + + test("whole units are not confused with their digits", () { + // (100, scale 0) is one hundred, not one: stripping must stop at 0. + final oneHundred = Money(BigInt.from(100), CryptoCurrency.btc, 0); + + expect(oneHundred, isNot(equals(coarseOne))); + expect(oneHundred.hashCode, isNot(equals(coarseOne.hashCode))); + }); + + test("Set deduplicates across scales", () { + expect( + { + Money(BigInt.from(11), CryptoCurrency.btc, 1), + Money(BigInt.from(110000000), CryptoCurrency.btc, 8), + Money(BigInt.from(1100), CryptoCurrency.btc, 3), + }, + hasLength(1), + ); + }); + + test("+ aligns operands", () { + expect(coarseOne + fineOne, equals(Money(BigInt.two, CryptoCurrency.btc, 0))); + expect(coarseOne + fineOne, equals(fineOne + coarseOne)); + }); + + test("- aligns operands", () { + // Regression: this used to subtract the raw amounts, i.e. 1 - 100000000. + expect((coarseOne - fineOne).isZero, isTrue); + expect((fineOne - coarseOne).isZero, isTrue); + + final threeCoarse = Money(BigInt.from(3), CryptoCurrency.btc, 0); + expect(threeCoarse - fineOne, equals(Money(BigInt.two, CryptoCurrency.btc, 0))); + expect(threeCoarse - fineOne, equals(-(fineOne - threeCoarse))); + }); + + test("the result keeps the finer scale", () { + expect((coarseOne + fineOne).decimals, greaterThanOrEqualTo(fineOne.decimals)); + }); + + test("sub-unit precision survives a coarse operand", () { + final oneSatoshi = Money(BigInt.one, CryptoCurrency.btc, 8); + + expect( + coarseOne + oneSatoshi, + equals(Money(BigInt.from(100000001), CryptoCurrency.btc, 8)), + ); + }); + + test("comparison operators align", () { + expect(coarseOne < fineOne, isFalse); + expect(coarseOne <= fineOne, isTrue); + expect(coarseOne >= fineOne, isTrue); + expect(coarseOne > fineOne, isFalse); + expect(Money(BigInt.two, CryptoCurrency.btc, 0) > fineOne, isTrue); + }); + + test("compareTo aligns and agrees with ==", () { + expect(coarseOne.compareTo(fineOne), isZero); + expect(coarseOne.compareTo(fineOne) == 0, equals(coarseOne == fineOne)); + expect(Money(BigInt.two, CryptoCurrency.btc, 0).compareTo(fineOne), isPositive); + expect(Money(BigInt.zero, CryptoCurrency.btc, 0).compareTo(fineOne), isNegative); + }); + + test("toStringWithPrecision", () { + expect( + Money.parse("60000.123456", USD, strictParsing: false).toStringWithPrecision(), + "60000.12", + ); + expect( + Money.parse("1.23456789", USD, strictParsing: false).toStringWithPrecision(), + "1.23", + ); + expect(Money(BigInt.one, CryptoCurrency.btc, 0).toStringWithPrecision(), "1"); + expect( + Money(BigInt.one, CryptoCurrency.btc, 0).toStringWithPrecision(useBaseUnit: true), + "100000000", + ); + }); + + test("toString", () { + expect( + Money.parse("60000.123456", CryptoCurrency.btc, strictParsing: false).toString(), + "60000.123456", + ); + expect( + Money.parse("1.23456789", CryptoCurrency.btc, strictParsing: false).toString(), + "1.23456789", + ); + expect(Money(BigInt.one, CryptoCurrency.btc, 0).toString(), "1"); + }); + }); + }); +} diff --git a/cw_core/test/amount/non_finite_amount_test.dart b/cw_core/test/amount/non_finite_amount_test.dart new file mode 100644 index 000000000..4498e2f9e --- /dev/null +++ b/cw_core/test/amount/non_finite_amount_test.dart @@ -0,0 +1,127 @@ +import "package:cw_core/amount/money.dart"; +import "package:cw_core/amount/money_double.dart"; +import "package:cw_core/crypto_currency.dart"; +import "package:cw_core/parse_fixed.dart"; +import "package:flutter_test/flutter_test.dart"; + +/// Every non-finite spelling that `double.tryParse` accepts. +/// +/// These matter because a `double`-based amount guard cannot reject them: +/// `Infinity <= 0` is false and *every* comparison against `NaN` is false, so a +/// non-finite amount sails through an `if (amount <= 0) throw` check and then +/// collapses to zero once it reaches Money/BigInt -- i.e. a zero-amount +/// transaction that passed validation. The Money layer is the enforcement point +/// for that invariant, so pin it here. +const nonFiniteSpellings = [ + "Infinity", + "+Infinity", + "-Infinity", + "NaN", + "1e999", // overflows to Infinity as a double + "1E400", + " Infinity ", +]; + +void main() { + group("non-finite amounts", () { + test("double.tryParse accepts them (this is why Money must reject them)", () { + for (final spelling in nonFiniteSpellings) { + final parsed = double.tryParse(spelling); + expect(parsed, isNotNull, reason: "double.tryParse rejected $spelling"); + expect(parsed!.isFinite, isFalse, reason: "$spelling parsed as finite"); + // The point of the whole exercise: a `<= 0` guard does not stop these. + if (!parsed.isNegative) { + expect(parsed <= 0, isFalse, reason: "$spelling was caught by a <= 0 guard"); + } + } + }); + + test("Money.tryParse returns null for every non-finite spelling", () { + for (final spelling in nonFiniteSpellings) { + expect( + Money.tryParse(spelling, CryptoCurrency.btc), + isNull, + reason: "Money.tryParse accepted $spelling", + ); + expect( + Money.tryParse(spelling, CryptoCurrency.btc, strictParsing: false), + isNull, + reason: "Money.tryParse(strictParsing: false) accepted $spelling", + ); + } + }); + + test("Money.parse throws for every non-finite spelling", () { + for (final spelling in nonFiniteSpellings) { + expect( + () => Money.parse(spelling, CryptoCurrency.btc), + throwsA(isA()), + reason: "Money.parse accepted $spelling", + ); + } + }); + + test("CryptoCurrency.tryParseAmount returns null, parseAmount throws", () { + for (final spelling in nonFiniteSpellings) { + expect(CryptoCurrency.btc.tryParseAmount(spelling), isNull, reason: spelling); + expect( + () => CryptoCurrency.btc.parseAmount(spelling), + throwsA(isA()), + reason: spelling, + ); + } + }); + + test("tryParseFixed returns null / parseFixed throws (the underlying gate)", () { + for (final spelling in nonFiniteSpellings) { + expect(tryParseFixed(spelling, 8), isNull, reason: spelling); + expect( + () => parseFixed(spelling, 8), + throwsA(isA()), + reason: spelling, + ); + } + }); + + test("double.tryToMoney returns null for non-finite doubles", () { + // Relied on by ExchangeViewModel.changeReceiveAmount/changeDepositAmount, which + // divide by `bestRate` and can produce Infinity when the rate is still 0. + expect(double.infinity.tryToMoney(CryptoCurrency.btc), isNull); + expect(double.negativeInfinity.tryToMoney(CryptoCurrency.btc), isNull); + expect(double.nan.tryToMoney(CryptoCurrency.btc), isNull); + }); + + test("a rejected amount funnels to Money.zero, which is the sweep-all sentinel", () { + // Why the cw_monero/cw_wownero guards exist. MONERO_Wallet_createTransaction: + // Monero::optional optAmount; + // if (amount != 0) { optAmount = amount; } + // so amount == 0 means "no amount given" and wallet2 sweeps the whole account. + // Output.cryptoAmountMoney falls back to Money.zero on any parse failure, and a + // zero Money stringifies to an all-zeros decimal -- exactly the value the native + // layer reads as "send everything". Nothing between them may treat 0 as an amount. + for (final spelling in nonFiniteSpellings) { + final fallback = + CryptoCurrency.btc.tryParseAmount(spelling) ?? Money.zero(CryptoCurrency.xmr); + expect(fallback.amount, BigInt.zero, reason: spelling); + } + + final zeroXmr = Money.zero(CryptoCurrency.xmr); + expect(zeroXmr.amount, BigInt.zero); + expect(double.tryParse(zeroXmr.toString()), 0.0, + reason: "a zero Money must stringify to a numeric zero, i.e. the sweep-all sentinel"); + expect(RegExp(r"^0(\.0+)?$").hasMatch(zeroXmr.toString()), isTrue, + reason: "unexpected zero rendering: ${zeroXmr.toString()}"); + + // One piconero is NOT zero -- the guards must not reject genuine dust. + expect(Money(BigInt.one, CryptoCurrency.xmr).sign, 1); + expect(double.parse(Money(BigInt.one, CryptoCurrency.xmr).toString()) > 0, isTrue); + }); + + test("finite amounts still round-trip (guard did not over-reject)", () { + expect(Money.tryParse("0", CryptoCurrency.btc)!.amount, BigInt.zero); + expect(Money.tryParse("1", CryptoCurrency.btc)!.amount, BigInt.from(100000000)); + expect(Money.tryParse("0.00000001", CryptoCurrency.btc)!.amount, BigInt.one); + expect(1.5.tryToMoney(CryptoCurrency.btc)!.amount, BigInt.from(150000000)); + }); + }); +} diff --git a/cw_core/test/amount/utils.dart b/cw_core/test/amount/utils.dart new file mode 100644 index 000000000..4fbe1c6df --- /dev/null +++ b/cw_core/test/amount/utils.dart @@ -0,0 +1,44 @@ +import 'package:cw_core/amount/money.dart'; +import 'package:cw_core/currency.dart'; + +class FiatCurrency implements Currency { + const FiatCurrency({ + required this.symbol, + required this.countryCode, + required this.fullName, + this.decimals = 2, + }); + + final String countryCode; + + @override + final String fullName; + + @override + final int decimals; + + @override + final String symbol; + + @override + String? get iconPath => throw UnimplementedError(); + + @override + String get name => throw UnimplementedError(); + + @override + String? get tag => throw UnimplementedError(); + + @override + String get apiString => "fiat.$symbol"; + + @override + Money parseAmount(String value) => Money.parse(value, this); + + @override + Money? tryParseAmount(String value) => Money.tryParse(value, this); +} + +const EUR = FiatCurrency(symbol: 'EUR', countryCode: "eur", fullName: "Euro"); +const USD = FiatCurrency(symbol: 'USD', countryCode: "usd", fullName: "US Dollar"); +const JPY = FiatCurrency(symbol: 'JPY', countryCode: "jpn", fullName: "Japanese Yen", decimals: 0); diff --git a/cw_core/test/crypto_amount_format.dart b/cw_core/test/crypto_amount_format.dart index c611ad7d8..fa0458bde 100644 --- a/cw_core/test/crypto_amount_format.dart +++ b/cw_core/test/crypto_amount_format.dart @@ -108,6 +108,15 @@ void main() { }); }); + group("Locale without intl number data", () { + test("should format as English instead of throwing (ha)", () { + const input = "1123.4567"; + final result = input.withLocalSeperator("ha"); + + expect(result, equals("1,123.4567")); + }); + }); + group('With XMR Suffix', () { const locale = 'de_CH'; diff --git a/cw_core/test/encryption_file_utils_test.dart b/cw_core/test/encryption_file_utils_test.dart new file mode 100644 index 000000000..bfd840f01 --- /dev/null +++ b/cw_core/test/encryption_file_utils_test.dart @@ -0,0 +1,380 @@ +import 'dart:convert'; +import 'dart:io'; +import 'dart:typed_data'; + +import 'package:cake_backup/backup.dart' as cwb; +import 'package:cw_core/encryption_file_utils.dart'; +import 'package:cw_core/key.dart'; +import 'package:cw_core/utils/file.dart' as encrypted_file; +import 'package:cw_core/wallet_keys_file.dart'; +import 'package:encrypt/encrypt.dart' as encrypt; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + late Directory tmpDir; + + setUp(() { + tmpDir = Directory.systemTemp.createTempSync('cw_enc_'); + }); + + tearDown(() { + if (tmpDir.existsSync()) { + tmpDir.deleteSync(recursive: true); + } + }); + + String path([String name = 'wallet.json']) => '${tmpDir.path}/$name'; + + const walletJson = '{"mnemonic":"abandon ability able","privateKey":null}'; + + group('XChaCha20 round-trip', () { + test('writes and reads JSON with a generated high-entropy password', () async { + final password = generateKey(); + await encrypted_file.write( + path: path(), + password: password, + data: walletJson, + highEntropyPassphrase: true, + ); + + expect(await encrypted_file.read(path: path(), password: password), walletJson); + expect(File('${path()}.tmp').existsSync(), isFalse); + }); + + test('uses high-entropy (v3) vs low-entropy (v2) version bytes', () async { + final password = generateKey(); + + await encrypted_file.write( + path: path('high'), + password: password, + data: walletJson, + highEntropyPassphrase: true, + ); + expect(File(path('high')).readAsBytesSync().first, cwb.highEntropyVersion); + + await encrypted_file.write( + path: path('low'), + password: password, + data: walletJson, + highEntropyPassphrase: false, + ); + expect(File(path('low')).readAsBytesSync().first, cwb.lowEntropyVersion); + + expect(await encrypted_file.read(path: path('high'), password: password), walletJson); + expect(await encrypted_file.read(path: path('low'), password: password), walletJson); + }); + + test('read ignores highEntropyPassphrase because version is in the file', () async { + final password = generateKey(); + await encrypted_file.write( + path: path(), + password: password, + data: walletJson, + highEntropyPassphrase: true, + ); + + expect( + await encrypted_file.read( + path: path(), + password: password, + highEntropyPassphrase: false, + ), + walletJson, + ); + }); + + test('round-trips UTF-8 JSON with non-ASCII characters', () async { + final password = generateKey(); + const data = '{"name":"Café","mnemonic":"abandon"}'; + await encrypted_file.write( + path: path(), + password: password, + data: data, + highEntropyPassphrase: true, + ); + + expect(await encrypted_file.read(path: path(), password: password), data); + }); + + test('wrong password does not fall back to Salsa20 or rewrite the file', () async { + final password = generateKey(); + await encrypted_file.write( + path: path(), + password: password, + data: walletJson, + highEntropyPassphrase: true, + ); + final before = File(path()).readAsBytesSync(); + + await expectLater( + encrypted_file.read(path: path(), password: generateKey()), + throwsA(anything), + ); + expect(File(path()).readAsBytesSync(), before); + }); + + test('corrupt XChaCha20 blob is not treated as a legacy Salsa20 file', () async { + final password = generateKey(); + await encrypted_file.write( + path: path(), + password: password, + data: walletJson, + highEntropyPassphrase: true, + ); + final bytes = File(path()).readAsBytesSync(); + bytes[bytes.length - 1] = bytes[bytes.length - 1] ^ 0xFF; + File(path()).writeAsBytesSync(bytes); + + await expectLater( + encrypted_file.read(path: path(), password: password), + throwsA(anything), + ); + expect(File(path()).readAsBytesSync().first, cwb.highEntropyVersion); + }); + }); + + group('encryptionFileUtilsFor', () { + test('isDirect=false (generated password) writes high-entropy v3', () async { + final password = generateKey(); + final encryption = encryptionFileUtilsFor(false); + await encryption.write(path: path(), password: password, data: walletJson); + + expect(File(path()).readAsBytesSync().first, cwb.highEntropyVersion); + expect(await encryption.read(path: path(), password: password), walletJson); + }); + + test('isDirect=true (user password) writes low-entropy v2', () async { + const password = 'user-chosen-passphrase'; + final encryption = encryptionFileUtilsFor(true); + await encryption.write(path: path(), password: password, data: walletJson); + + expect(File(path()).readAsBytesSync().first, cwb.lowEntropyVersion); + expect(await encryption.read(path: path(), password: password), walletJson); + }); + + test('WalletKeysData JSON survives a write/read used by getSeeds', () async { + final password = generateKey(); + final keys = WalletKeysData( + mnemonic: 'abandon ability able about above absent absorb abstract', + privateKey: 'deadbeef', + ); + final encryption = encryptionFileUtilsFor(false); + await encryption.write(path: path(), password: password, data: keys.toJSON()); + + final decoded = json.decode(await encryption.read(path: path(), password: password)) + as Map; + final restored = WalletKeysData.fromJSON(decoded); + expect(restored.mnemonic, keys.mnemonic); + expect(restored.privateKey, keys.privateKey); + }); + }); + + group('legacy Salsa20 migration', () { + test('reads a Salsa20 wallet file and re-encrypts it as XChaCha20', () async { + final password = generateKey(); + File(path()).writeAsStringSync(_salsa20Encrypt(password, walletJson)); + expect(File(path()).readAsBytesSync().first, isNot(cwb.lowEntropyVersion)); + expect(File(path()).readAsBytesSync().first, isNot(cwb.highEntropyVersion)); + + final result = await encrypted_file.read( + path: path(), + password: password, + highEntropyPassphrase: true, + ); + + expect(result, walletJson); + expect(File(path()).readAsBytesSync().first, cwb.highEntropyVersion); + expect(await encrypted_file.read(path: path(), password: password), walletJson); + }); + + test('migrated file can be opened with EncryptionFileUtils', () async { + final password = generateKey(); + File(path()).writeAsStringSync(_salsa20Encrypt(password, walletJson)); + + final encryption = encryptionFileUtilsFor(false); + expect(await encryption.read(path: path(), password: password), walletJson); + expect(await encryption.read(path: path(), password: password), walletJson); + }); + + test('wrong password does not rewrite the Salsa20 file', () async { + final password = generateKey(); + final salsa = _salsa20Encrypt(password, walletJson); + File(path()).writeAsStringSync(salsa); + + await expectLater( + encrypted_file.read(path: path(), password: generateKey()), + throwsLegacyDecryptError, + ); + expect(File(path()).readAsStringSync(), salsa); + }); + + test('short or malformed password does not leak _readLegacy errors', () async { + final password = generateKey(); + final salsa = _salsa20Encrypt(password, walletJson); + File(path()).writeAsStringSync(salsa); + + await expectLater( + encrypted_file.read(path: path(), password: 'user-pass'), + throwsLegacyDecryptError, + ); + expect(File(path()).readAsStringSync(), salsa); + }); + + test('corrupt non-base64 file does not leak _readLegacy errors', () async { + File(path()).writeAsStringSync('not-valid-base64!!!'); + + await expectLater( + encrypted_file.read(path: path(), password: generateKey()), + throwsLegacyDecryptError, + ); + expect(File(path()).readAsStringSync(), 'not-valid-base64!!!'); + }); + + test('invalid UTF-8 legacy bytes do not leak _readLegacy errors', () async { + File(path()).writeAsBytesSync(const [0x00, 0xFF, 0xFE, 0x01]); + + await expectLater( + encrypted_file.read(path: path(), password: generateKey()), + throwsLegacyDecryptError, + ); + expect(File(path()).readAsBytesSync(), const [0x00, 0xFF, 0xFE, 0x01]); + }); + + test('rejects Salsa20 plaintext that does not start with {"', () async { + final password = generateKey(); + File(path()).writeAsStringSync(_salsa20Encrypt(password, 'not-json-payload')); + + await expectLater( + encrypted_file.read(path: path(), password: password), + throwsLegacyDecryptError, + ); + expect(File(path()).readAsBytesSync().first, isNot(cwb.highEntropyVersion)); + expect(File(path()).readAsBytesSync().first, isNot(cwb.lowEntropyVersion)); + }); + + test('does not re-encrypt Salsa20 data that starts with {" but is not JSON', () async { + final password = generateKey(); + const garbage = '{"not valid json'; + File(path()).writeAsStringSync(_salsa20Encrypt(password, garbage)); + + expect(await encrypted_file.read(path: path(), password: password), garbage); + expect(File(path()).readAsBytesSync().first, isNot(cwb.highEntropyVersion)); + expect(File(path()).readAsBytesSync().first, isNot(cwb.lowEntropyVersion)); + }); + + test( + 'wrong password that decrypts to {" does not rewrite the file', + () async { + final password = generateKey(); + final salsa = _salsa20Encrypt(password, walletJson); + File(path()).writeAsStringSync(salsa); + + final collision = _findWrongPasswordWithJsonObjectPrefix(salsa); + expect(collision, isNot(password)); + expect(_salsa20Decrypt(collision, salsa).startsWith('{"'), isTrue); + expect(() => json.decode(_salsa20Decrypt(collision, salsa)), throwsA(anything)); + + final result = await encrypted_file.read(path: path(), password: collision); + + expect(result.startsWith('{"'), isTrue); + expect(result, isNot(walletJson)); + expect(File(path()).readAsStringSync(), salsa); + }, + ); + }); + + group('legacy Linux XChaCha20 Latin-1 payloads', () { + test('decodes files stored as one byte per UTF-16 code unit', () async { + final password = generateKey(); + const data = '{"name":"Café","mnemonic":"abandon"}'; + // Old XChaCha20EncryptionFileUtils used String.codeUnits instead of utf8.encode. + final encrypted = await cwb.encrypt( + password, + Uint8List.fromList(data.codeUnits), + highEntropyPassphrase: true, + ); + File(path()).writeAsBytesSync(encrypted); + + expect(await encrypted_file.read(path: path(), password: password), data); + }); + }); + + group('generateKey', () { + test('produces unique passwords that still encrypt and decrypt', () async { + final a = generateKey(); + final b = generateKey(); + expect(a, isNot(b)); + expect(a.length, greaterThan(12)); + + await encrypted_file.write( + path: path(), + password: a, + data: walletJson, + highEntropyPassphrase: true, + ); + expect(await encrypted_file.read(path: path(), password: a), walletJson); + }); + }); +} + +final throwsLegacyDecryptError = throwsA( + isA().having( + (e) => e.toString(), + 'message', + contains('Failed to decrypt legacy file'), + ), +); + +({encrypt.Key key, encrypt.IV iv}) _salsa20Keys(String password) { + const ivEncodedStringLength = 12; + return ( + key: encrypt.Key.fromBase64(password.substring(0, password.length - ivEncodedStringLength)), + iv: encrypt.IV.fromBase64(password.substring(password.length - ivEncodedStringLength)), + ); +} + +encrypt.Encrypter _salsa20(String password) { + final keys = _salsa20Keys(password); + return encrypt.Encrypter(encrypt.Salsa20(keys.key)); +} + +String _salsa20Encrypt(String password, String data) { + final keys = _salsa20Keys(password); + return _salsa20(password).encrypt(data, iv: keys.iv).base64; +} + +String _salsa20Decrypt(String password, String data) { + final keys = _salsa20Keys(password); + return _salsa20(password).decrypt64(data, iv: keys.iv); +} + +List _salsa20DecryptBytes(String password, String data) { + final keys = _salsa20Keys(password); + return _salsa20(password).decryptBytes(encrypt.Encrypted.fromBase64(data), iv: keys.iv); +} + +String _passwordFromNonce(int nonce) { + final ivBytes = Uint8List(8); + ivBytes.buffer.asByteData().setUint64(0, nonce, Endian.little); + return encrypt.Key(Uint8List(32)).base64 + encrypt.IV(ivBytes).base64; +} + +/// Salsa20 has no MAC, so a wrong key decrypts to garbage. The first two bytes +/// are `{"` about once in 2^16 tries; keep going until that happens, then skip +/// the much rarer case where the garbage is also valid JSON (that would rewrite). +String _findWrongPasswordWithJsonObjectPrefix(String ciphertext) { + const maxAttempts = 1 << 20; + for (var nonce = 0; nonce < maxAttempts; nonce++) { + final candidate = _passwordFromNonce(nonce); + final bytes = _salsa20DecryptBytes(candidate, ciphertext); + if (bytes.length < 2 || bytes[0] != 0x7b || bytes[1] != 0x22) { + continue; + } + try { + json.decode(_salsa20Decrypt(candidate, ciphertext)); + } catch (_) { + return candidate; + } + } + fail('no Salsa20 {" prefix collision in $maxAttempts attempts'); +} diff --git a/cw_core/test/imported_nft_test.dart b/cw_core/test/imported_nft_test.dart new file mode 100644 index 000000000..2bc14b546 --- /dev/null +++ b/cw_core/test/imported_nft_test.dart @@ -0,0 +1,176 @@ +import "dart:io"; + +import "package:cw_core/db/sqlite.dart"; +import "package:cw_core/imported_nft.dart"; +import "package:flutter_test/flutter_test.dart"; +import "package:path_provider_platform_interface/path_provider_platform_interface.dart"; +import "package:sqflite_common_ffi/sqflite_ffi.dart"; + +// Faking the documents dir keeps getAppDir() off the platform channel, so the +// test runs with a plain `flutter test` on any host and in CI. +class _FakePathProviderPlatform extends PathProviderPlatform { + _FakePathProviderPlatform(this.root); + + final String root; + + @override + Future getApplicationDocumentsPath() async => root; + + @override + Future getApplicationSupportPath() async => root; +} + +Future main() async { + final dataRoot = Directory("./test/data/imported_nft"); + + Future insert(String walletName, String chain, String identifier) => + ImportedNFT(walletName: walletName, chain: chain, identifier: identifier, name: identifier) + .save(); + + Future> identifiersFor(String walletName, [String? chain]) async => + (await ImportedNFT.getAllForWallet(walletName, chain)).map((nft) => nft.identifier).toList(); + + setUpAll(() async { + if (dataRoot.existsSync()) { + dataRoot.deleteSync(recursive: true); + } + dataRoot.createSync(recursive: true); + Directory("${dataRoot.path}/cake_wallet").createSync(recursive: true); + + PathProviderPlatform.instance = _FakePathProviderPlatform(dataRoot.absolute.path); + sqfliteFfiInit(); + databaseFactory = databaseFactoryFfi; + await initDb(); + }); + + setUp(() async { + await db!.delete(ImportedNFT.tableName); + }); + + tearDownAll(() { + if (dataRoot.existsSync()) { + dataRoot.deleteSync(recursive: true); + } + }); + + group("ImportedNFT wallet lifecycle", () { + test("reading without a chain returns every chain the wallet imported on", () async { + await insert("Main", ImportedNFT.solanaChain, "SolMint"); + await insert("Main", "eth", "0xcontract:1"); + await insert("Main", "polygon", "0xother:7"); + + expect(await identifiersFor("Main", "eth"), ["0xcontract:1"]); + expect( + (await identifiersFor("Main"))..sort(), + ["0xcontract:1", "0xother:7", "SolMint"], + ); + }); + + test("rename moves every chain and leaves other wallets alone", () async { + await insert("Main", ImportedNFT.solanaChain, "SolMint"); + await insert("Main", "eth", "0xcontract:1"); + await insert("Sidekick", "eth", "0xkeep:9"); + + await ImportedNFT.renameWallet("Main", "Renamed"); + + expect(await identifiersFor("Main"), isEmpty); + expect((await identifiersFor("Renamed"))..sort(), ["0xcontract:1", "SolMint"]); + expect(await identifiersFor("Sidekick"), ["0xkeep:9"]); + }); + + test("renaming into a name that still has orphan rows does not trip the unique index", + () async { + await insert("Orphaned", "eth", "0xcontract:1"); + await insert("Orphaned", "polygon", "0xsurvivor:5"); + await insert("Main", "eth", "0xcontract:1"); + await insert("Main", "eth", "0xsecond:2"); + + await ImportedNFT.renameWallet("Main", "Orphaned", chains: const ["eth"]); + + // Only the colliding chain is cleared. A row the destination holds on + // another chain is not ours to delete. + expect( + (await identifiersFor("Orphaned"))..sort(), + ["0xcontract:1", "0xsecond:2", "0xsurvivor:5"], + ); + }); + + test("renaming a solana wallet leaves a same-named evm wallet's NFTs alone", () async { + await insert("Sol", ImportedNFT.solanaChain, "SolMint"); + await insert("Vault", "eth", "0xEthNFT:1"); + + await ImportedNFT.renameWallet( + "Sol", + "Vault", + chains: const [ImportedNFT.solanaChain], + ); + + expect((await identifiersFor("Vault"))..sort(), ["0xEthNFT:1", "SolMint"]); + }); + + test("deleting a solana wallet leaves a same-named evm wallet's NFTs alone", () async { + await insert("Vault", ImportedNFT.solanaChain, "SolMint"); + await insert("Vault", "eth", "0xEthNFT:1"); + + await ImportedNFT.deleteAllForWallet( + "Vault", + chains: const [ImportedNFT.solanaChain], + ); + + expect(await identifiersFor("Vault"), ["0xEthNFT:1"]); + }); + + test("an empty chain scope deletes nothing rather than everything", () async { + await insert("Main", "eth", "0xcontract:1"); + + await ImportedNFT.deleteAllForWallet("Main", chains: const []); + + expect(await identifiersFor("Main"), ["0xcontract:1"]); + }); + + test("delete removes every chain for that wallet only", () async { + await insert("Main", ImportedNFT.solanaChain, "SolMint"); + await insert("Main", "eth", "0xcontract:1"); + await insert("Sidekick", ImportedNFT.solanaChain, "OtherMint"); + + await ImportedNFT.deleteAllForWallet("Main"); + + expect(await identifiersFor("Main"), isEmpty); + expect(await identifiersFor("Sidekick"), ["OtherMint"]); + }); + + test("deleting one leaves the wallet's other imports in place", () async { + await insert("Main", "eth", "0xcontract:1"); + await insert("Main", "eth", "0xsecond:2"); + await insert("Main", "polygon", "0xcontract:1"); + + await ImportedNFT.deleteOne("Main", "eth", "0xcontract:1"); + + expect(await identifiersFor("Main", "eth"), ["0xsecond:2"]); + expect(await identifiersFor("Main", "polygon"), ["0xcontract:1"]); + }); + + test("copying to a new name keeps the rows under the old one", () async { + await insert("Main", ImportedNFT.solanaChain, "SolMint"); + await insert("Main", "eth", "0xcontract:1"); + + for (final nft in await ImportedNFT.getAllForWallet("Main")) { + await ImportedNFT.copyWith(nft, walletName: "Copy").save(); + } + + expect((await identifiersFor("Main"))..sort(), ["0xcontract:1", "SolMint"]); + expect((await identifiersFor("Copy"))..sort(), ["0xcontract:1", "SolMint"]); + }); + + test("a copy is a new row, so saving it does not overwrite the original", () async { + await insert("Main", "eth", "0xcontract:1"); + + final original = (await ImportedNFT.getAllForWallet("Main")).single; + final copy = ImportedNFT.copyWith(original, walletName: "Copy"); + await copy.save(); + + expect(copy.id, isNot(original.id)); + expect((await ImportedNFT.getAllForWallet("Main")).single.id, original.id); + }); + }); +} diff --git a/cw_core/test/ipfs_url_test.dart b/cw_core/test/ipfs_url_test.dart new file mode 100644 index 000000000..14ab8439c --- /dev/null +++ b/cw_core/test/ipfs_url_test.dart @@ -0,0 +1,55 @@ +import "package:cw_core/utils/ipfs_url.dart"; +import "package:flutter_test/flutter_test.dart"; + +void main() { + group("tryNormalizeIpfsUrl", () { + test("rewrites an ipfs uri to the gateway", () { + expect(tryNormalizeIpfsUrl("ipfs://QmAbC123"), "https://ipfs.io/ipfs/QmAbC123"); + }); + + test("does not double the gateway path when the uri already says ipfs/", () { + expect(tryNormalizeIpfsUrl("ipfs://ipfs/QmAbC123"), "https://ipfs.io/ipfs/QmAbC123"); + }); + + test("matches the scheme whatever its case", () { + expect(tryNormalizeIpfsUrl("IPFS://QmAbC123"), "https://ipfs.io/ipfs/QmAbC123"); + expect(tryNormalizeIpfsUrl("Ipfs://QmAbC123"), "https://ipfs.io/ipfs/QmAbC123"); + }); + + test("returns null for an ipfs uri with no cid, rather than a bare gateway url", () { + expect(tryNormalizeIpfsUrl("ipfs://"), isNull); + expect(tryNormalizeIpfsUrl("ipfs://ipfs/"), isNull); + }); + + test("passes through an https url that is not ipfs", () { + expect(tryNormalizeIpfsUrl("https://arweave.net/abc"), "https://arweave.net/abc"); + }); + + test("drops a scheme nothing can render, rather than handing it to the image widget", () { + expect(tryNormalizeIpfsUrl("ar://abc123"), isNull); + expect(tryNormalizeIpfsUrl(""), isNull); + }); + + test("returns null for null", () { + expect(tryNormalizeIpfsUrl(null), isNull); + }); + + test("is idempotent, so a value read back from storage stays stable", () { + final once = tryNormalizeIpfsUrl("ipfs://QmAbC123"); + expect(tryNormalizeIpfsUrl(once), once); + }); + + test("drops any scheme that is not https", () { + // These render through Image.network, which does not go through the proxy. + expect(tryNormalizeIpfsUrl("http://127.0.0.1:8080/x.png"), isNull); + expect(tryNormalizeIpfsUrl("file:///etc/passwd"), isNull); + expect(tryNormalizeIpfsUrl("javascript:alert(1)"), isNull); + expect(tryNormalizeIpfsUrl("data:text/html,x"), isNull); + }); + + test("keeps an https url whatever its casing", () { + expect(tryNormalizeIpfsUrl("https://ok.example/a.png"), "https://ok.example/a.png"); + expect(tryNormalizeIpfsUrl("HTTPS://Up.example/a.png"), "HTTPS://Up.example/a.png"); + }); + }); +} diff --git a/cw_core/test/lnurl_test.dart b/cw_core/test/lnurl_test.dart index 5b369dcc3..a09dbd12d 100644 --- a/cw_core/test/lnurl_test.dart +++ b/cw_core/test/lnurl_test.dart @@ -1,3 +1,5 @@ +import 'package:cw_core/amount/money.dart'; +import 'package:cw_core/crypto_currency.dart'; import 'package:cw_core/lnurl.dart'; import 'package:cw_core/utils/proxy_wrapper.dart'; import 'package:cw_core/utils/tor/abstract.dart'; @@ -11,13 +13,14 @@ void main() { expect(content, Uri.parse("https://cake.cash/.well-known/lnurlp/konsti")); }); - test('get amount', () async { - CakeTor.instance = await CakeTorInstance.getInstance(); - - final content = await LNURL.getPayRequestAmount( - "LNURL1DP68GURN8GHJ7CTSWQHXY6T5WFJHZAT9WD6ZU6T09AUZ7URJDAU8JTMKXYHKCM308A5N6MNHVVNXJEPAXGMNWWPEX3JRGDTPXVNXZ0FCX5CRQVPSXQNX602VFE24YNP9XGC8W6T5DQJNYVRPD4HH2MN5GTRNLZ"); - expect(content, 8500); - }); + // This tests includes a rest request to an URL that expired and due to the lack of an alternative URL we ignore this test + // test('get amount', () async { + // CakeTor.instance = await CakeTorInstance.getInstance(); + // + // final content = await LNURL.getPayRequestAmount( + // "LNURL1DP68GURN8GHJ7CTSWQHXY6T5WFJHZAT9WD6ZU6T09AUZ7URJDAU8JTMKXYHKCM308A5N6MNHVVNXJEPAXGMNWWPEX3JRGDTPXVNXZ0FCX5CRQVPSXQNX602VFE24YNP9XGC8W6T5DQJNYVRPD4HH2MN5GTRNLZ"); + // expect(content, Money.fromInt(8500, CryptoCurrency.btcln)); + // }); test('get no amount', () async { CakeTor.instance = await CakeTorInstance.getInstance(); @@ -70,25 +73,25 @@ void main() { test("should get the amount 1 BTC encoded as bitcoin", () { final invoice = "lnbc11p5exaxspp5u6e6lx90rw2vqr2e5axt8ur3v98qnmqfsmh8r0l0mcm7ry70vyvsdqqcqzzsxqrrsssp5eqydmrufk39dwhuxzajwx4xyffn0mlcu3qa8vjwry6qev5gsepkq9qxpqysgqt0zldve9uxt3txjt8wp0gev52lquljgugy26nneue85pjwk5yak4hs2d8533tus07pq5ka6zrnd0ruaeepwres8p7ws5mx63xlwff5sqlxcy8a"; - expect(getBolt11Amount(invoice), 100000000); + expect(getBolt11Amount(invoice), Money.fromInt(100000000, CryptoCurrency.btcln)); }); test("should get the amount 0,1 BTC encoded as millibitcoin", () { final invoice = "lnbc100m1p5exagmpp5n4c5tml3h8h7k4gthc9papkyeymceqqvcldkng4nvxwzj7nu9jnsdqqcqzzsxqrrsssp58kgzrh7y3pevantduc4yg33aa6r79ed6xgmhr0dy40zc8pxrz5ms9qxpqysgqcq27km3v3wcz6pntmsfnz259zy6tff0uwjz9kamdrvmxmdv09gp44mh0ygn7gweqf4zdev4x8e67p5kcvahethchdkkjexhtk7emuqcpasz0lp"; - expect(getBolt11Amount(invoice), 10000000); + expect(getBolt11Amount(invoice), Money.fromInt(10000000, CryptoCurrency.btcln)); }); test("should get the amount 1000 SATS encoded as microbitcoin", () { final invoice = "lnbc10u1p5exfgvpp5n3s9dsw9ddax5c3h8437ya7y4g582uyufyna626yr9vqdfjrhwzqdqqcqzzsxqrrsssp5mmkyp5h35fpaxm4063f3kc7d4nmgd4gd8fv8086wxd784m76kmpq9qxpqysgqye9r0vahpjy9l2je6vakxzk3cjnsyx76r29c4amkz4cmapq5tqt5l7rl0emkd003jjvz2d8jqrw6wc4cduvapuyeseh7a855l82wutcpeq30kg"; - expect(getBolt11Amount(invoice), 1000); + expect(getBolt11Amount(invoice), Money.fromInt(1000, CryptoCurrency.btcln)); }); test("should get the amount 1 SAT encoded as nanobitcoin", () { final invoice = "lnbc10n1p5exazppp5szh273r6nzvwydzsdlg0ws0vhxwnc6nqtqt4sjt0vu6x6l8t5csqdqqcqzzsxqrrsssp5nkup6uht7xlfqaj8lxpe5kx0ejum9p4pvv3nlnndr9ykzge3393s9qxpqysgqufh05a7qs0ezeckvmh7tjkr9ngng96nq274twg20z45ysp7afpdrttm2ky73cpjwpjqsmtzzd79w6lyvszgmkanwn8mj0jcfunrwv3qprg3z3j"; - expect(getBolt11Amount(invoice), 1); + expect(getBolt11Amount(invoice), Money.fromInt(1, CryptoCurrency.btcln)); }); }); } diff --git a/cw_core/test/nft_text_test.dart b/cw_core/test/nft_text_test.dart new file mode 100644 index 000000000..420562e45 --- /dev/null +++ b/cw_core/test/nft_text_test.dart @@ -0,0 +1,89 @@ +import "package:cw_core/utils/nft_text.dart"; +import "package:flutter_test/flutter_test.dart"; + +void main() { + group("sanitizeNFTText", () { + test("removes the separators that would fake extra lines", () { + expect(sanitizeNFTText("Ape\u2028Address: 9xQeW"), "Ape Address: 9xQeW"); + expect(sanitizeNFTText("Ape\u2029Address: 9xQeW"), "Ape Address: 9xQeW"); + expect(sanitizeNFTText("Ape\nAddress: 9xQeW"), "Ape Address: 9xQeW"); + }); + + test("removes bidi overrides and isolates that reorder what follows", () { + expect(sanitizeNFTText("evil\u202Etxen"), "evil txen"); + expect(sanitizeNFTText("a\u2066b\u2069c"), "a b c"); + }); + + test("removes invisibles that hide or reorder characters", () { + expect(sanitizeNFTText("A\u200BB"), "A B"); + expect(sanitizeNFTText("A\u200FB"), "A B"); + expect(sanitizeNFTText("A\u061CB"), "A B"); + expect(sanitizeNFTText("A\u2060B"), "A B"); + }); + + test("removes control characters and delete", () { + expect(sanitizeNFTText("A\u0000B"), "A B"); + expect(sanitizeNFTText("A\u001FB"), "A B"); + expect(sanitizeNFTText("A\u007FB"), "A B"); + }); + + test("leaves ordinary text alone", () { + expect(sanitizeNFTText("DeGod #1234"), "DeGod #1234"); + expect(sanitizeNFTText(" padded "), "padded"); + }); + + test("returns null for null", () { + expect(sanitizeNFTText(null), isNull); + }); + + test("counts the cap in characters, so emoji are not cut in half", () { + final long = sanitizeNFTText("\u{1F600}" * 600)!; + + expect(long.runes.length, 513); + expect(long.runes.last, 0x2026); + + final units = long.codeUnits; + final endsOnHighSurrogate = units.isNotEmpty && units.last >= 0xD800 && units.last <= 0xDBFF; + expect(endsOnHighSurrogate, isFalse); + }); + + test("does not truncate a name that is long in code units but short in characters", () { + expect(sanitizeNFTText("\u{1F600}" * 400)!.runes.length, 400); + }); + + test("is idempotent, so re-reading a saved value does not change it", () { + const raw = "Ape\u2028Name\u202E with \u{1F600}"; + final once = sanitizeNFTText(raw); + + expect(sanitizeNFTText(once), once); + }); + + test("keeps the joiners that carry meaning in persian and emoji", () { + // Replacing these put a visible space inside a Persian word and split + // a two-person emoji into two glyphs. + expect(sanitizeNFTText("\u{1F468}\u200D\u{1F4BB}"), "\u{1F468}\u200D\u{1F4BB}"); + expect( + sanitizeNFTText("\u0645\u06CC\u200C\u062E\u0648\u0627\u0647\u0645"), + "\u0645\u06CC\u200C\u062E\u0648\u0627\u0647\u0645", + ); + }); + + test("strips the invisibles a mint can hide text behind", () { + for (final invisible in [ + "\u00AD", // soft hyphen + "\u034F", // combining grapheme joiner + "\u180E", // mongolian vowel separator + "\u3164", // hangul filler + "\uFEFF", // zero width no-break space + "\uFFA0", // halfwidth hangul filler + "\u{E0041}", // tag latin capital A + ]) { + expect( + sanitizeNFTText("A${invisible}B"), + "A B", + reason: "U+${invisible.runes.first.toRadixString(16).toUpperCase()} survived", + ); + } + }); + }); +} diff --git a/cw_core/test/parse_fixed_test.dart b/cw_core/test/parse_fixed_test.dart index c102618e1..b9e905208 100644 --- a/cw_core/test/parse_fixed_test.dart +++ b/cw_core/test/parse_fixed_test.dart @@ -45,6 +45,19 @@ void main() { test('should fail to parse `.`, missing value', () => expect(() => parseFixed(".", 6), throwsFormatException)); }); + + group("parseFixed, zero decimal currency", () { + test("should parse 5 as 5", () => expect(parseFixed("5", 0), BigInt.from(5))); + + test("should parse 0 as 0", () => expect(parseFixed("0", 0), BigInt.from(0))); + + test("should parse 1. as 1", () => expect(parseFixed("1.", 0), BigInt.from(1))); + + test("should parse -3 as -3", () => expect(parseFixed("-3", 0), BigInt.from(-3))); + + test("should fail to parse 5.5, fractional component exceeds decimals", + () => expect(() => parseFixed("5.5", 0), throwsFormatException)); + }); }); group('tryParseFixed', () { diff --git a/cw_core/test/payment_uris_test.dart b/cw_core/test/payment_uris_test.dart new file mode 100644 index 000000000..a61bab1cf --- /dev/null +++ b/cw_core/test/payment_uris_test.dart @@ -0,0 +1,237 @@ +import "package:cw_core/payment_uris.dart"; +import "package:flutter_test/flutter_test.dart"; + +void main() { + group("TronURI", () { + const address = "TNPeeaaFB7K9cmo4uQpcU32zGK8G1NYqeL"; + const contract = "TR7NHqjeKQxGTCi8q8ZY4pL8otSzgjLj6t"; + + test("includes the TRC20 contract as the token param", () { + final uri = TronURI(amount: "1.5", address: address, contractAddress: contract); + + expect(uri.toString(), "tron:$address?amount=1.5&token=$contract"); + }); + + test("omits the token param for native TRX", () { + final uri = TronURI(amount: "1.5", address: address); + + expect(uri.toString(), "tron:$address?amount=1.5"); + expect(uri.toString().contains("token="), false); + }); + + test("includes the token param when there is no amount", () { + final uri = TronURI(amount: "", address: address, contractAddress: contract); + + expect(uri.toString(), "tron:$address?token=$contract"); + }); + + test("converts a comma decimal separator in the amount", () { + final uri = TronURI(amount: "1,5", address: address); + + expect(uri.toString(), "tron:$address?amount=1.5"); + }); + + test("emits a bare address without amount or contract", () { + final uri = TronURI(amount: "", address: address); + + expect(uri.toString(), "tron:$address"); + }); + }); + + group("SolanaURI", () { + const address = "4Nd1mYvNQyJ8BDVXLgkvSGpVdQMZ3hxwVFkfwXNq6Wgk"; + const mint = "Es9vMFrzaCERmJfrF4H2FYD4KCoNkY11McCe8BenwNYB"; + + test("includes the SPL mint as the spl-token param", () { + final uri = SolanaURI(amount: "2", address: address, contractAddress: mint); + + expect(uri.toString(), "solana:$address?amount=2&spl-token=$mint"); + }); + + test("omits the spl-token param for native SOL", () { + final uri = SolanaURI(amount: "2", address: address); + + expect(uri.toString(), "solana:$address?amount=2"); + expect(uri.toString().contains("spl-token="), false); + }); + + test("includes the spl-token param when there is no amount", () { + final uri = SolanaURI(amount: "", address: address, contractAddress: mint); + + expect(uri.toString(), "solana:$address?spl-token=$mint"); + }); + + test("converts a comma decimal separator in the amount", () { + final uri = SolanaURI(amount: "0,5", address: address); + + expect(uri.toString(), "solana:$address?amount=0.5"); + }); + + test("emits a bare address without amount or mint", () { + final uri = SolanaURI(amount: "", address: address); + + expect(uri.toString(), "solana:$address"); + }); + }); + + group("ERC681URI", () { + const recipient = "0x742d35Cc6634C0532925a3b8D4C9db96C4b4d8b6"; + const contract = "0xdAC17F958D2ee523a2206206994597C13D831ec7"; + + test("includes the mainnet chainId on a plain native transfer", () { + final uri = ERC681URI(chainId: 1, address: recipient, amount: "", contractAddress: null); + + expect(uri.toString(), "ethereum:$recipient@1"); + }); + + test("appends the chainId for native transfers", () { + final uri = ERC681URI(chainId: 137, address: recipient, amount: "", contractAddress: null); + + expect(uri.toString(), "ethereum:$recipient@137"); + }); + + test("emits the native amount in ERC-681 scientific notation", () { + final uri = ERC681URI(chainId: 1, address: recipient, amount: "1", contractAddress: null); + + expect(uri.toString(), "ethereum:$recipient@1?value=1.0e18"); + }); + + test("includes the mainnet chainId in the transfer form", () { + final uri = ERC681URI( + chainId: 1, + address: recipient, + amount: "200.172148", + contractAddress: contract, + tokenDecimals: 6, + ); + + expect( + uri.toString(), + "ethereum:$contract@1/transfer?address=$recipient&amount=200.172148", + ); + }); + + test("builds the transfer form for a token", () { + final uri = + ERC681URI(chainId: 137, address: recipient, amount: "", contractAddress: contract); + + expect(uri.toString(), "ethereum:$contract@137/transfer?address=$recipient"); + }); + + test("parses the transfer form back into recipient, contract and chainId", () { + final parsed = ERC681URI.fromUri( + Uri.parse("ethereum:$contract@1/transfer?address=$recipient&uint256=1000000"), + ); + + expect(parsed.address, recipient); + expect(parsed.contractAddress, contract); + expect(parsed.chainId, 1); + expect(parsed.rawTokenAmount, "1000000"); + expect(parsed.amount, ""); + }); + + test("keeps the uint256 raw so the caller can apply the token decimals", () { + final parsed = ERC681URI.fromUri( + Uri.parse( + "ethereum:0xdAC17F958D2ee523a2206206994597C13D831ec7@1/transfer?address=$recipient&uint256=1000000", + ), + ); + + expect(parsed.rawTokenAmount, "1000000"); + expect(parsed.amount, ""); + }); + + test("prefers the amount param over the uint256 when both are present", () { + final parsed = ERC681URI.fromUri( + Uri.parse( + "ethereum:$contract@1/transfer?address=$recipient&uint256=1000000000000000000&amount=1", + ), + ); + + expect(parsed.amount, "1"); + expect(parsed.rawTokenAmount, "1000000000000000000"); + }); + + test("treats a uint256 with a decimal point as a display amount", () { + final parsed = ERC681URI.fromUri( + Uri.parse("ethereum:$contract@1/transfer?address=$recipient&uint256=1.5"), + ); + + expect(parsed.amount, "1.5"); + expect(parsed.rawTokenAmount, null); + }); + + test("parses a native transfer with chainId", () { + final parsed = ERC681URI.fromUri( + Uri.parse("ethereum:$recipient@137?value=2000000000000000000"), + ); + + expect(parsed.address, recipient); + expect(parsed.contractAddress, null); + expect(parsed.chainId, 137); + expect(parsed.amount, "2"); + }); + + test("emits the uint256 and the amount param for an 18 decimal token", () { + final uri = + ERC681URI(chainId: 137, address: recipient, amount: "1", contractAddress: contract); + + expect( + uri.toString(), + "ethereum:$contract@137/transfer?address=$recipient&uint256=1000000000000000000&amount=1", + ); + }); + + test("omits the uint256 for tokens that do not use 18 decimals", () { + final uri = ERC681URI( + chainId: 137, + address: recipient, + amount: "1.5", + contractAddress: contract, + tokenDecimals: 6, + ); + + expect(uri.toString(), "ethereum:$contract@137/transfer?address=$recipient&amount=1.5"); + }); + + test("emits a stored raw token amount verbatim", () { + final uri = ERC681URI( + chainId: 1, + address: recipient, + amount: "", + contractAddress: contract, + tokenDecimals: 6, + rawTokenAmount: "1000000", + ); + + expect(uri.toString(), "ethereum:$contract@1/transfer?address=$recipient&uint256=1000000"); + }); + + test("parses a scientific notation value", () { + final parsed = ERC681URI.fromUri( + Uri.parse("ethereum:$recipient@1?value=2.014e18"), + ); + + expect(parsed.amount, "2.014"); + }); + + test("parses a Cake style amount param", () { + final parsed = ERC681URI.fromUri( + Uri.parse("ethereum:$recipient?amount=1.5"), + ); + + expect(parsed.address, recipient); + expect(parsed.amount, "1.5"); + expect(parsed.chainId, 1); + }); + + test("defaults to mainnet when the chainId is absent", () { + final parsed = ERC681URI.fromUri(Uri.parse("ethereum:$recipient")); + + expect(parsed.address, recipient); + expect(parsed.contractAddress, null); + expect(parsed.chainId, 1); + expect(parsed.amount, ""); + }); + }); +} diff --git a/cw_core/test/spl_token_amount_test.dart b/cw_core/test/spl_token_amount_test.dart new file mode 100644 index 000000000..d686b8c46 --- /dev/null +++ b/cw_core/test/spl_token_amount_test.dart @@ -0,0 +1,104 @@ +import "package:cw_core/amount/money.dart"; +import "package:cw_core/spl_token.dart"; +import "package:flutter_test/flutter_test.dart"; + +void main() { + group("SPL token raw amounts", () { + test("6 decimal token renders raw base units", () { + final jup = SPLToken( + name: "Jupiter", + symbol: "JUP", + mint: "jup", + mintAddress: "JUPyiwrYJFskUPiHa7hkeR8VUtAeFoSYbKedZNsDvCN", + decimal: 6, + ); + + expect(Money(BigInt.from(100000), jup).toStringWithPrecision(), "0.1"); + expect(Money(BigInt.from(931614), jup).toStringWithPrecision(), "0.931614"); + }); + + test("8 decimal token renders raw base units", () { + final xstock = SPLToken( + name: "Amazon xStock", + symbol: "AMZNX", + mint: "amznx", + mintAddress: "mintAddressWithEightDecimals", + decimal: 8, + ); + + expect(Money(BigInt.from(341694), xstock).toStringWithPrecision(), "0.00341694"); + }); + + test("the same raw amount means different values per decimals", () { + final raw = BigInt.from(100000); + + final six = SPLToken( + name: "Six", + symbol: "SIX", + mint: "six", + mintAddress: "mintAddressWithSixDecimals", + decimal: 6, + ); + + final nine = SPLToken( + name: "Nine", + symbol: "NINE", + mint: "nine", + mintAddress: "mintAddressWithNineDecimals", + decimal: 9, + ); + + final zero = SPLToken( + name: "Zero", + symbol: "ZERO", + mint: "zero", + mintAddress: "mintAddressWithZeroDecimals", + decimal: 0, + ); + + expect(Money(raw, six).toStringWithPrecision(), "0.1"); + expect(Money(raw, nine).toStringWithPrecision(), "0.0001"); + expect(Money(raw, zero).toStringWithPrecision(), "100000"); + }); + }); + + group("SPL token amount parsing", () { + test("parses whole amounts for a zero decimal token", () { + final zero = SPLToken( + name: "Zero", + symbol: "ZERO", + mint: "zero", + mintAddress: "mintAddressWithZeroDecimals", + decimal: 0, + ); + + expect(Money.parse("5", zero).amount, BigInt.from(5)); + expect(Money.parse("0", zero).amount, BigInt.zero); + }); + + test("parses fractional amounts for a 6 decimal token", () { + final jup = SPLToken( + name: "Jupiter", + symbol: "JUP", + mint: "jup", + mintAddress: "JUPyiwrYJFskUPiHa7hkeR8VUtAeFoSYbKedZNsDvCN", + decimal: 6, + ); + + expect(Money.parse("0.1", jup).amount, BigInt.from(100000)); + expect(Money.parse("10.339089", jup).amount, BigInt.from(10339089)); + }); + + test("rejects more fractional digits than the token has decimals", () { + final zero = SPLToken( + name: "Zero", + symbol: "ZERO", + mint: "zero", + mintAddress: "mintAddressWithZeroDecimals", + decimal: 0, + ); + + expect(() => Money.parse("5.5", zero), throwsFormatException); + }); + }); +} diff --git a/cw_custom_lints/lib/http_force_proxy/http_force_proxy_rule.dart b/cw_custom_lints/lib/http_force_proxy/http_force_proxy_rule.dart new file mode 100644 index 000000000..aaea81542 --- /dev/null +++ b/cw_custom_lints/lib/http_force_proxy/http_force_proxy_rule.dart @@ -0,0 +1,61 @@ +import "package:analyzer/analysis_rule/analysis_rule.dart"; +import "package:analyzer/analysis_rule/rule_context.dart"; +import "package:analyzer/analysis_rule/rule_visitor_registry.dart"; +import "package:analyzer/dart/ast/ast.dart"; +import "package:analyzer/dart/ast/visitor.dart"; +import "package:analyzer/error/error.dart"; + +class HttpForceProxyRule extends AnalysisRule { + HttpForceProxyRule() + : super( + name: "no_http_imports", + description: + "Using the http package breaks proxy integration. Please use ProxyWrapper or alias it as \"very_insecure_http_do_not_use\".", + ); + + static const LintCode code = LintCode( + "no_http_imports", + "Using the http package breaks proxy integration. Please use ProxyWrapper or alias it as \"very_insecure_http_do_not_use\".", + severity: DiagnosticSeverity.WARNING, + ); + + @override + LintCode get diagnosticCode => code; + + @override + void registerNodeProcessors( + RuleVisitorRegistry registry, + RuleContext context, + ) { + final filePath = context.definingUnit.file.path; + + if (filePath.endsWith("proxy_wrapper.dart")) { + return; + } + + registry.addImportDirective(this, _Visitor(this)); + } +} + +class _Visitor extends SimpleAstVisitor { + _Visitor(this.rule); + + final AnalysisRule rule; + + @override + void visitImportDirective(ImportDirective node) { + final uriString = node.uri.stringValue; + if (uriString == null) { + return; + } + + if (uriString.startsWith("package:http")) { + final prefixNode = node.prefix; + if (prefixNode != null && prefixNode.name == "very_insecure_http_do_not_use") { + return; + } + + rule.reportAtNode(node); + } + } +} diff --git a/cw_custom_lints/lib/main.dart b/cw_custom_lints/lib/main.dart new file mode 100644 index 000000000..1bf70f6aa --- /dev/null +++ b/cw_custom_lints/lib/main.dart @@ -0,0 +1,34 @@ +import "package:analysis_server_plugin/plugin.dart"; +import "package:analysis_server_plugin/registry.dart"; +import "package:cw_custom_lints/http_force_proxy/http_force_proxy_rule.dart"; +import "package:cw_custom_lints/modal_top_bar_semantics/modal_top_bar_semantics_rule.dart"; +import "package:cw_custom_lints/modern_button_semantics/modern_button_semantics_rule.dart"; +import "package:cw_custom_lints/money_amount_to_string/money_amount_to_string_fix.dart"; +import "package:cw_custom_lints/money_amount_to_string/money_amount_to_string_rule.dart"; +import "package:cw_custom_lints/print_verbose/print_verbose_fix.dart"; +import "package:cw_custom_lints/print_verbose/print_verbose_rule.dart"; +import "package:cw_custom_lints/restricted_imports/restricted_imports_rule.dart"; + +final plugin = CwCustomLintsPlugin(); + +class CwCustomLintsPlugin extends Plugin { + @override + String get name => "cw_custom_lints"; + + @override + void register(PluginRegistry registry) { + registry.registerWarningRule(PrintVerboseRule()); + registry.registerWarningRule(RestrictedImportsRule()); + registry.registerWarningRule(HttpForceProxyRule()); + registry.registerWarningRule(ModernButtonSemanticsRule()); + registry.registerWarningRule(ModalTopBarSemanticsRule()); + registry.registerWarningRule(MoneyAmountToStringRule()); + + registry.registerFixForRule(PrintVerboseRule.code, ReplaceWithPrintV.new); + registry.registerFixForRule(MoneyAmountToStringRule.code, ReplaceWithMoneyToString.new); + registry.registerFixForRule( + MoneyAmountToStringRule.code, + ReplaceWithMoneyToStringInBaseUnit.new, + ); + } +} diff --git a/cw_custom_lints/lib/modal_top_bar_semantics/modal_top_bar_semantics_rule.dart b/cw_custom_lints/lib/modal_top_bar_semantics/modal_top_bar_semantics_rule.dart new file mode 100644 index 000000000..69eccaffe --- /dev/null +++ b/cw_custom_lints/lib/modal_top_bar_semantics/modal_top_bar_semantics_rule.dart @@ -0,0 +1,69 @@ +import "package:analyzer/analysis_rule/analysis_rule.dart"; +import "package:analyzer/analysis_rule/rule_context.dart"; +import "package:analyzer/analysis_rule/rule_visitor_registry.dart"; +import "package:analyzer/dart/ast/ast.dart"; +import "package:analyzer/dart/ast/visitor.dart"; +import "package:analyzer/error/error.dart"; +import "package:cw_custom_lints/utils/widget_arguments.dart"; + +class ModalTopBarSemanticsRule extends AnalysisRule { + ModalTopBarSemanticsRule() + : super( + name: "require_modal_top_bar_semantics", + description: + "ModalTopBar icons need a semantic label, because the icon alone does not say whether it closes the modal, goes back or does something else.", + ); + + static const LintCode code = LintCode( + "require_modal_top_bar_semantics", + "ModalTopBar was given a {0} without a {1}, so screen readers have nothing to announce.", + correctionMessage: "Pass a localized {1} next to the {0}.", + severity: DiagnosticSeverity.WARNING, + ); + + static const _iconSemanticLabels = { + "leadingIcon": "leadingSemanticLabel", + "trailingIcon": "trailingSemanticLabel", + }; + + @override + LintCode get diagnosticCode => code; + + @override + void registerNodeProcessors(RuleVisitorRegistry registry, RuleContext context) => + registry.addInstanceCreationExpression(this, _Visitor(this)); +} + +class _Visitor extends SimpleAstVisitor { + _Visitor(this.rule); + + final AnalysisRule rule; + + @override + void visitInstanceCreationExpression(InstanceCreationExpression node) { + if (!isCakeWalletWidget(node.constructorName, "ModalTopBar")) { + return; + } + + for (final slot in ModalTopBarSemanticsRule._iconSemanticLabels.entries) { + _checkSlot(node.argumentList, iconName: slot.key, semanticLabelName: slot.value); + } + } + + void _checkSlot( + ArgumentList argumentList, { + required String iconName, + required String semanticLabelName, + }) { + final icon = namedArgument(argumentList, iconName); + if (icon == null || icon.argumentExpression is NullLiteral) { + return; + } + + if (!isMissingOrEmptyText(namedArgumentExpression(argumentList, semanticLabelName))) { + return; + } + + rule.reportAtToken(icon.name, arguments: [iconName, semanticLabelName]); + } +} diff --git a/cw_custom_lints/lib/modern_button_semantics/modern_button_semantics_rule.dart b/cw_custom_lints/lib/modern_button_semantics/modern_button_semantics_rule.dart new file mode 100644 index 000000000..7824fced4 --- /dev/null +++ b/cw_custom_lints/lib/modern_button_semantics/modern_button_semantics_rule.dart @@ -0,0 +1,56 @@ +import "package:analyzer/analysis_rule/analysis_rule.dart"; +import "package:analyzer/analysis_rule/rule_context.dart"; +import "package:analyzer/analysis_rule/rule_visitor_registry.dart"; +import "package:analyzer/dart/ast/ast.dart"; +import "package:analyzer/dart/ast/visitor.dart"; +import "package:analyzer/error/error.dart"; +import "package:cw_custom_lints/utils/widget_arguments.dart"; + +class ModernButtonSemanticsRule extends AnalysisRule { + ModernButtonSemanticsRule() + : super( + name: "require_modern_button_semantics", + description: + "ModernButton needs a semanticLabel when it has no visible label, otherwise screen readers announce an unnamed button.", + ); + + static const LintCode code = LintCode( + "require_modern_button_semantics", + "ModernButton has no visible label, so screen readers have nothing to announce.", + correctionMessage: "Pass a localized semanticLabel, or a non-empty label.", + severity: DiagnosticSeverity.WARNING, + ); + + @override + LintCode get diagnosticCode => code; + + @override + void registerNodeProcessors(RuleVisitorRegistry registry, RuleContext context) => + registry.addInstanceCreationExpression(this, _Visitor(this)); +} + +class _Visitor extends SimpleAstVisitor { + _Visitor(this.rule); + + final AnalysisRule rule; + + @override + void visitInstanceCreationExpression(InstanceCreationExpression node) { + final constructorName = node.constructorName; + if (!isCakeWalletWidget(constructorName, "ModernButton")) { + return; + } + + final argumentList = node.argumentList; + final hasLabel = !isMissingOrEmptyText(namedArgumentExpression(argumentList, "label")); + final hasSemanticLabel = !isMissingOrEmptyText( + namedArgumentExpression(argumentList, "semanticLabel"), + ); + + if (hasLabel || hasSemanticLabel) { + return; + } + + rule.reportAtNode(constructorName); + } +} diff --git a/cw_custom_lints/lib/money_amount_to_string/money_amount_to_string_fix.dart b/cw_custom_lints/lib/money_amount_to_string/money_amount_to_string_fix.dart new file mode 100644 index 000000000..3aad8152a --- /dev/null +++ b/cw_custom_lints/lib/money_amount_to_string/money_amount_to_string_fix.dart @@ -0,0 +1,66 @@ +import "package:analysis_server_plugin/edit/dart/correction_producer.dart"; +import "package:analysis_server_plugin/edit/dart/dart_fix_kind_priority.dart"; +import "package:analyzer/dart/ast/ast.dart"; +import "package:analyzer_plugin/utilities/change_builder/change_builder_core.dart"; +import "package:analyzer_plugin/utilities/fixes/fixes.dart"; +import "package:analyzer_plugin/utilities/range_factory.dart"; +import "package:cw_custom_lints/money_amount_to_string/money_amount_to_string_rule.dart"; + +abstract class _ReplaceMoneyAmountToString extends ResolvedCorrectionProducer { + _ReplaceMoneyAmountToString({required super.context}); + + String get replacement; + + @override + CorrectionApplicability get applicability => CorrectionApplicability.singleLocation; + + @override + Future compute(ChangeBuilder builder) async { + final invocation = node.thisOrAncestorOfType(); + if (invocation == null) { + return; + } + + final amount = moneyAmountReference(invocation.realTarget); + if (amount == null) { + return; + } + + await builder.addDartFileEdit( + file, + (builder) => builder.addSimpleReplacement(range.startEnd(amount, invocation), replacement), + ); + } +} + +class ReplaceWithMoneyToString extends _ReplaceMoneyAmountToString { + ReplaceWithMoneyToString({required super.context}); + + static const _fixKind = FixKind( + "dart.fix.replaceWithMoneyToString", + DartFixKindPriority.standard, + "Replace with toString() for a decimal amount", + ); + + @override + FixKind get fixKind => _fixKind; + + @override + String get replacement => "toString()"; +} + +class ReplaceWithMoneyToStringInBaseUnit extends _ReplaceMoneyAmountToString { + ReplaceWithMoneyToStringInBaseUnit({required super.context}); + + static const _fixKind = FixKind( + "dart.fix.replaceWithMoneyToStringInBaseUnit", + DartFixKindPriority.standard, + "Replace with toStringWithPrecision(useBaseUnit: true) for base units", + ); + + @override + FixKind get fixKind => _fixKind; + + @override + String get replacement => "toStringWithPrecision(useBaseUnit: true)"; +} diff --git a/cw_custom_lints/lib/money_amount_to_string/money_amount_to_string_rule.dart b/cw_custom_lints/lib/money_amount_to_string/money_amount_to_string_rule.dart new file mode 100644 index 000000000..3a7e58917 --- /dev/null +++ b/cw_custom_lints/lib/money_amount_to_string/money_amount_to_string_rule.dart @@ -0,0 +1,85 @@ +import "package:analyzer/analysis_rule/analysis_rule.dart"; +import "package:analyzer/analysis_rule/rule_context.dart"; +import "package:analyzer/analysis_rule/rule_visitor_registry.dart"; +import "package:analyzer/dart/ast/ast.dart"; +import "package:analyzer/dart/ast/visitor.dart"; +import "package:analyzer/dart/element/element.dart"; +import "package:analyzer/error/error.dart"; + +class MoneyAmountToStringRule extends AnalysisRule { + MoneyAmountToStringRule() + : super( + name: "no_money_amount_to_string", + description: + "Money.amount is the internal BigInt, so stringifying it drops the decimal point.", + ); + + static const LintCode code = LintCode( + "no_money_amount_to_string", + "This stringifies the internal BigInt of Money instead of the Money itself, so the decimal point is dropped and the amount reads far larger than it is.", + correctionMessage: + "Call toString() on the Money for a decimal amount, or toStringWithPrecision(useBaseUnit: true) for base units.", + severity: DiagnosticSeverity.WARNING, + ); + + @override + LintCode get diagnosticCode => code; + + @override + void registerNodeProcessors(RuleVisitorRegistry registry, RuleContext context) => + registry.addMethodInvocation(this, _Visitor(this)); +} + + +SimpleIdentifier? moneyAmountReference(Expression? expression) { + final identifier = switch (expression) { + PrefixedIdentifier() => expression.identifier, + PropertyAccess() => expression.propertyName, + SimpleIdentifier() => expression, + _ => null, + }; + + if (identifier == null || identifier.name != "amount") { + return null; + } + + return _isDeclaredByMoney(identifier.element) ? identifier : null; +} + +bool _isDeclaredByMoney(Element? element) { + if (element == null) { + return false; + } + + final enclosing = element.enclosingElement; + if (enclosing is! InterfaceElement || enclosing.name != "Money") { + return false; + } + + final libraryUri = element.library?.uri; + if (libraryUri == null || libraryUri.scheme != "package") { + return false; + } + + final segments = libraryUri.pathSegments; + return segments.isNotEmpty && segments.first == "cw_core"; +} + +class _Visitor extends SimpleAstVisitor { + _Visitor(this.rule); + + final AnalysisRule rule; + + @override + void visitMethodInvocation(MethodInvocation node) { + if (node.methodName.name != "toString" || node.argumentList.arguments.isNotEmpty) { + return; + } + + if (moneyAmountReference(node.realTarget) == null) { + return; + } + + rule.reportAtNode(node); + } +} diff --git a/cw_custom_lints/lib/no_bare_catch/no_bare_catch_rule.dart b/cw_custom_lints/lib/no_bare_catch/no_bare_catch_rule.dart new file mode 100644 index 000000000..611ca6bfe --- /dev/null +++ b/cw_custom_lints/lib/no_bare_catch/no_bare_catch_rule.dart @@ -0,0 +1,51 @@ +import "package:analyzer/analysis_rule/analysis_rule.dart"; +import "package:analyzer/analysis_rule/rule_context.dart"; +import "package:analyzer/analysis_rule/rule_visitor_registry.dart"; +import "package:analyzer/dart/ast/ast.dart"; +import "package:analyzer/dart/ast/visitor.dart"; +import "package:analyzer/error/error.dart"; + +class NoBareCatchRule extends AnalysisRule { + NoBareCatchRule() + : super( + name: "no_bare_catch", + description: "please specify the exception class to catch (on SomeException catch (e))", + ); + + static const LintCode code = LintCode( + "no_bare_catch", + "please specify the exception class to catch (on SomeException catch (e))", + severity: DiagnosticSeverity.WARNING, + ); + + @override + LintCode get diagnosticCode => code; + + @override + void registerNodeProcessors(RuleVisitorRegistry registry, RuleContext context) { + registry.addCatchClause(this, _Visitor(this)); + } +} + +class _Visitor extends SimpleAstVisitor { + _Visitor(this.rule); + + final AnalysisRule rule; + + @override + void visitCatchClause(CatchClause node) { + if (node.exceptionType != null) { + return; + } + + final tryStatement = node.parent; + if (tryStatement is TryStatement) { + final precedingClauses = tryStatement.catchClauses.takeWhile((clause) => clause != node); + if (precedingClauses.any((clause) => clause.exceptionType != null)) { + return; + } + } + + rule.reportAtNode(node); + } +} diff --git a/cw_custom_lints/lib/print_verbose/print_verbose_fix.dart b/cw_custom_lints/lib/print_verbose/print_verbose_fix.dart new file mode 100644 index 000000000..f33af29da --- /dev/null +++ b/cw_custom_lints/lib/print_verbose/print_verbose_fix.dart @@ -0,0 +1,49 @@ +import "package:analysis_server_plugin/edit/dart/correction_producer.dart"; +import "package:analysis_server_plugin/edit/dart/dart_fix_kind_priority.dart"; +import "package:analyzer/dart/ast/ast.dart"; +import "package:analyzer_plugin/utilities/change_builder/change_builder_core.dart"; +import "package:analyzer_plugin/utilities/fixes/fixes.dart"; +import "package:analyzer_plugin/utilities/range_factory.dart"; + +class ReplaceWithPrintV extends ResolvedCorrectionProducer { + ReplaceWithPrintV({required super.context}); + + static const _importString = "package:cw_core/utils/print_verbose.dart"; + + static const _fixKind = FixKind( + "dart.fix.replaceWithPrintV", + DartFixKindPriority.standard, + "Replace with printV()", + ); + + @override + CorrectionApplicability get applicability => CorrectionApplicability.singleLocation; + + @override + FixKind get fixKind => _fixKind; + + @override + Future compute(ChangeBuilder builder) async { + final invocation = node.thisOrAncestorOfType(); + if (invocation == null) { + return; + } + + final root = invocation.root; + final hasImport = root is CompilationUnit && + root.directives.whereType().any( + (directive) => directive.uri.stringValue == _importString, + ); + + await builder.addDartFileEdit(file, (builder) { + builder.addSimpleReplacement( + range.node(invocation.methodName), + "printV", + ); + + if (!hasImport) { + builder.importLibrary(Uri.parse(_importString)); + } + }); + } +} diff --git a/cw_custom_lints/lib/print_verbose/print_verbose_rule.dart b/cw_custom_lints/lib/print_verbose/print_verbose_rule.dart new file mode 100644 index 000000000..0fee39bc6 --- /dev/null +++ b/cw_custom_lints/lib/print_verbose/print_verbose_rule.dart @@ -0,0 +1,52 @@ +import "package:analyzer/analysis_rule/analysis_rule.dart"; +import "package:analyzer/analysis_rule/rule_context.dart"; +import "package:analyzer/analysis_rule/rule_visitor_registry.dart"; +import "package:analyzer/dart/ast/ast.dart"; +import "package:analyzer/dart/ast/visitor.dart"; +import "package:analyzer/error/error.dart"; + +class PrintVerboseRule extends AnalysisRule { + PrintVerboseRule() + : super( + name: "use_print_v", + description: "Use printV() from cw_core instead of print().", + ); + + static const LintCode code = LintCode( + "use_print_v", + "Use printV() from cw_core instead", + correctionMessage: "Replace print with printV", + severity: DiagnosticSeverity.WARNING, + ); + + @override + LintCode get diagnosticCode => code; + + @override + void registerNodeProcessors( + RuleVisitorRegistry registry, + RuleContext context, + ) { + final filePath = context.definingUnit.file.path; + + if (filePath.contains("/tool/") || filePath.contains("print_verbose.dart")) { + // tool/ is allowed to use print as it never makes its way into the app + return; + } + + registry.addMethodInvocation(this, _Visitor(this)); + } +} + +class _Visitor extends SimpleAstVisitor { + _Visitor(this.rule); + + final AnalysisRule rule; + + @override + void visitMethodInvocation(MethodInvocation node) { + if (node.methodName.name == "print" && node.target == null) { + rule.reportAtNode(node); + } + } +} diff --git a/cw_custom_lints/lib/restricted_imports/restricted_imports_rule.dart b/cw_custom_lints/lib/restricted_imports/restricted_imports_rule.dart new file mode 100644 index 000000000..80680af87 --- /dev/null +++ b/cw_custom_lints/lib/restricted_imports/restricted_imports_rule.dart @@ -0,0 +1,79 @@ +import "package:analyzer/analysis_rule/analysis_rule.dart"; +import "package:analyzer/analysis_rule/rule_context.dart"; +import "package:analyzer/analysis_rule/rule_visitor_registry.dart"; +import "package:analyzer/dart/ast/ast.dart"; +import "package:analyzer/dart/ast/visitor.dart"; +import "package:analyzer/error/error.dart"; + +class RestrictedImportsRule extends AnalysisRule { + RestrictedImportsRule() + : super( + name: "no_restricted_imports_in_lib", + description: + "Do not import from coin-specific packages. If necessary, wire your logic through configure.dart", + ); + + static const LintCode code = LintCode( + "no_restricted_imports_in_lib", + "Do not import from coin-specific packages. If necessary, wire your logic through configure.dart", + severity: DiagnosticSeverity.ERROR, + ); + + static const _restrictedPackages = { + "cw_bitcoin", + "cw_bitcoin_cash", + "cw_electrum", + "cw_litecoin", + "cw_evm", + "cw_mweb", + "cw_nano", + "cw_solana", + "cw_tron", + "cw_zano", + }; + + @override + LintCode get diagnosticCode => code; + + @override + void registerNodeProcessors( + RuleVisitorRegistry registry, + RuleContext context, + ) { + final filePath = context.definingUnit.file.path; + final fileName = filePath.split("/").last; + + // ignore cw_something folders + if (filePath.contains("cw_")) { + return; + } + + // allow ex. cw_bitcoin in bitcoin.dart + if (_restrictedPackages.contains("cw_" + fileName.replaceAll(".dart", ""))) { + return; + } + + registry.addImportDirective(this, _Visitor(this)); + } +} + +class _Visitor extends SimpleAstVisitor { + _Visitor(this.rule); + + final AnalysisRule rule; + + @override + void visitImportDirective(ImportDirective node) { + final uriString = node.uri.stringValue; + if (uriString == null || !uriString.startsWith("package:")) { + return; + } + + final pathWithoutScheme = uriString.replaceFirst("package:", ""); + final packageName = pathWithoutScheme.split("/").first; + + if (RestrictedImportsRule._restrictedPackages.contains(packageName)) { + rule.reportAtNode(node); + } + } +} diff --git a/cw_custom_lints/lib/utils/widget_arguments.dart b/cw_custom_lints/lib/utils/widget_arguments.dart new file mode 100644 index 000000000..cd6bb22d3 --- /dev/null +++ b/cw_custom_lints/lib/utils/widget_arguments.dart @@ -0,0 +1,39 @@ +import "package:analyzer/dart/ast/ast.dart"; + +bool isCakeWalletWidget(ConstructorName constructorName, String className) { + final element = constructorName.type.element; + if (element == null || element.name != className) { + return false; + } + + final libraryUri = element.library?.uri; + if (libraryUri == null || libraryUri.scheme != "package") { + return false; + } + + final segments = libraryUri.pathSegments; + return segments.isNotEmpty && segments.first == "cake_wallet"; +} + +NamedArgument? namedArgument(ArgumentList argumentList, String name) { + for (final argument in argumentList.arguments) { + if (argument is NamedArgument && argument.name.lexeme == name) { + return argument; + } + } + + return null; +} + +Expression? namedArgumentExpression(ArgumentList argumentList, String name) => + namedArgument(argumentList, name)?.argumentExpression; + +// passed null or "" +// does NOT work if a runtime expression evaluates to an empty string or null +bool isMissingOrEmptyText(Expression? expression) { + if (expression == null || expression is NullLiteral) { + return true; + } + + return expression is StringLiteral && (expression.stringValue?.isEmpty ?? false); +} diff --git a/cw_custom_lints/pubspec.lock b/cw_custom_lints/pubspec.lock new file mode 100644 index 000000000..cd831e497 --- /dev/null +++ b/cw_custom_lints/pubspec.lock @@ -0,0 +1,429 @@ +# Generated by pub +# See https://dart.dev/tools/pub/glossary#lockfile +packages: + _fe_analyzer_shared: + dependency: transitive + description: + name: _fe_analyzer_shared + sha256: cd6add6f846f35fb79f3c315296703c1a24f3cfd7f4739d91a74961c1c7e9f1b + url: "https://pub.dev" + source: hosted + version: "100.0.0" + analysis_server_plugin: + dependency: "direct main" + description: + name: analysis_server_plugin + sha256: c4021359e7a673ecd9c6db5f11fea12fabb55e0ba96fa08c78920ae81936f1d1 + url: "https://pub.dev" + source: hosted + version: "0.3.15" + analyzer: + dependency: "direct main" + description: + name: analyzer + sha256: "6ba98576948803398b69e3a444df24eacdbe12ed699c7014e120ea38552debbf" + url: "https://pub.dev" + source: hosted + version: "13.0.0" + analyzer_plugin: + dependency: "direct main" + description: + name: analyzer_plugin + sha256: "9a81dc36f7f71890d7cf83960a3a7df1e6c9a24035f98eb848e91ad48b1d21c6" + url: "https://pub.dev" + source: hosted + version: "0.14.9" + analyzer_testing: + dependency: "direct dev" + description: + name: analyzer_testing + sha256: da27ae9bd4bf3ce4ebab6295cf922ca21a2b14ea8fcacc859342aca796a48a2d + url: "https://pub.dev" + source: hosted + version: "0.2.6" + args: + dependency: transitive + description: + name: args + sha256: d0481093c50b1da8910eb0bb301626d4d8eb7284aa739614d2b394ee09e3ea04 + url: "https://pub.dev" + source: hosted + version: "2.7.0" + async: + dependency: transitive + description: + name: async + sha256: e2eb0491ba5ddb6177742d2da23904574082139b07c1e33b8503b9f46f3e1a37 + url: "https://pub.dev" + source: hosted + version: "2.13.1" + boolean_selector: + dependency: transitive + description: + name: boolean_selector + sha256: "8aab1771e1243a5063b8b0ff68042d67334e3feab9e95b9490f9a6ebf73b42ea" + url: "https://pub.dev" + source: hosted + version: "2.1.2" + cli_config: + dependency: transitive + description: + name: cli_config + sha256: ac20a183a07002b700f0c25e61b7ee46b23c309d76ab7b7640a028f18e4d99ec + url: "https://pub.dev" + source: hosted + version: "0.2.0" + collection: + dependency: transitive + description: + name: collection + sha256: "2f5709ae4d3d59dd8f7cd309b4e023046b57d8a6c82130785d2b0e5868084e76" + url: "https://pub.dev" + source: hosted + version: "1.19.1" + convert: + dependency: transitive + description: + name: convert + sha256: b30acd5944035672bc15c6b7a8b47d773e41e2f17de064350988c5d02adb1c68 + url: "https://pub.dev" + source: hosted + version: "3.1.2" + coverage: + dependency: transitive + description: + name: coverage + sha256: "956a3de0725ca232ad353565a8290d3357592bf4250f6f298a185e2d949c5d3d" + url: "https://pub.dev" + source: hosted + version: "1.15.1" + crypto: + dependency: transitive + description: + name: crypto + sha256: c8ea0233063ba03258fbcf2ca4d6dadfefe14f02fab57702265467a19f27fadf + url: "https://pub.dev" + source: hosted + version: "3.0.7" + dart_style: + dependency: transitive + description: + name: dart_style + sha256: "59d53ef8eaed9d288ed9767618e2b31c4fa0383a127db59d5eb2e737a7638a60" + url: "https://pub.dev" + source: hosted + version: "3.1.9" + file: + dependency: transitive + description: + name: file + sha256: a3b4f84adafef897088c160faf7dfffb7696046cb13ae90b508c2cbc95d3b8d4 + url: "https://pub.dev" + source: hosted + version: "7.0.1" + frontend_server_client: + dependency: transitive + description: + name: frontend_server_client + sha256: f64a0333a82f30b0cca061bc3d143813a486dc086b574bfb233b7c1372427694 + url: "https://pub.dev" + source: hosted + version: "4.0.0" + glob: + dependency: transitive + description: + name: glob + sha256: c3f1ee72c96f8f78935e18aa8cecced9ab132419e8625dc187e1c2408efc20de + url: "https://pub.dev" + source: hosted + version: "2.1.3" + http_multi_server: + dependency: transitive + description: + name: http_multi_server + sha256: aa6199f908078bb1c5efb8d8638d4ae191aac11b311132c3ef48ce352fb52ef8 + url: "https://pub.dev" + source: hosted + version: "3.2.2" + http_parser: + dependency: transitive + description: + name: http_parser + sha256: "178d74305e7866013777bab2c3d8726205dc5a4dd935297175b19a23a2e66571" + url: "https://pub.dev" + source: hosted + version: "4.1.2" + io: + dependency: transitive + description: + name: io + sha256: dfd5a80599cf0165756e3181807ed3e77daf6dd4137caaad72d0b7931597650b + url: "https://pub.dev" + source: hosted + version: "1.0.5" + logging: + dependency: transitive + description: + name: logging + sha256: c8245ada5f1717ed44271ed1c26b8ce85ca3228fd2ffdb75468ab01979309d61 + url: "https://pub.dev" + source: hosted + version: "1.3.0" + matcher: + dependency: transitive + description: + name: matcher + sha256: "31bd099b47c10cd1aeb55146a2d46ce0277630ecef3f7dae54ad7873f36696cd" + url: "https://pub.dev" + source: hosted + version: "0.12.20" + meta: + dependency: transitive + description: + name: meta + sha256: "307249ce4ff29d58a18e97f6345f539382eb9c9c29ecda628900f31de0443dd9" + url: "https://pub.dev" + source: hosted + version: "1.19.0" + mime: + dependency: transitive + description: + name: mime + sha256: "41a20518f0cb1256669420fdba0cd90d21561e560ac240f26ef8322e45bb7ed6" + url: "https://pub.dev" + source: hosted + version: "2.0.0" + node_preamble: + dependency: transitive + description: + name: node_preamble + sha256: "6e7eac89047ab8a8d26cf16127b5ed26de65209847630400f9aefd7cd5c730db" + url: "https://pub.dev" + source: hosted + version: "2.0.2" + package_config: + dependency: transitive + description: + name: package_config + sha256: f096c55ebb7deb7e384101542bfba8c52696c1b56fca2eb62827989ef2353bbc + url: "https://pub.dev" + source: hosted + version: "2.2.0" + path: + dependency: transitive + description: + name: path + sha256: "75cca69d1490965be98c73ceaea117e8a04dd21217b37b292c9ddbec0d955bc5" + url: "https://pub.dev" + source: hosted + version: "1.9.1" + pool: + dependency: transitive + description: + name: pool + sha256: "978783255c543aa3586a1b3c21f6e9d720eb315376a915872c61ef8b5c20177d" + url: "https://pub.dev" + source: hosted + version: "1.5.2" + pub_semver: + dependency: transitive + description: + name: pub_semver + sha256: "5bfcf68ca79ef689f8990d1160781b4bad40a3bd5e5218ad4076ddb7f4081585" + url: "https://pub.dev" + source: hosted + version: "2.2.0" + shelf: + dependency: transitive + description: + name: shelf + sha256: e7dd780a7ffb623c57850b33f43309312fc863fb6aa3d276a754bb299839ef12 + url: "https://pub.dev" + source: hosted + version: "1.4.2" + shelf_packages_handler: + dependency: transitive + description: + name: shelf_packages_handler + sha256: "89f967eca29607c933ba9571d838be31d67f53f6e4ee15147d5dc2934fee1b1e" + url: "https://pub.dev" + source: hosted + version: "3.0.2" + shelf_static: + dependency: transitive + description: + name: shelf_static + sha256: c87c3875f91262785dade62d135760c2c69cb217ac759485334c5857ad89f6e3 + url: "https://pub.dev" + source: hosted + version: "1.1.3" + shelf_web_socket: + dependency: transitive + description: + name: shelf_web_socket + sha256: "3632775c8e90d6c9712f883e633716432a27758216dfb61bd86a8321c0580925" + url: "https://pub.dev" + source: hosted + version: "3.0.0" + source_map_stack_trace: + dependency: transitive + description: + name: source_map_stack_trace + sha256: c0713a43e323c3302c2abe2a1cc89aa057a387101ebd280371d6a6c9fa68516b + url: "https://pub.dev" + source: hosted + version: "2.1.2" + source_maps: + dependency: transitive + description: + name: source_maps + sha256: "190222579a448b03896e0ca6eca5998fa810fda630c1d65e2f78b3f638f54812" + url: "https://pub.dev" + source: hosted + version: "0.10.13" + source_span: + dependency: transitive + description: + name: source_span + sha256: "56a02f1f4cd1a2d96303c0144c93bd6d909eea6bee6bf5a0e0b685edbd4c47ab" + url: "https://pub.dev" + source: hosted + version: "1.10.2" + stack_trace: + dependency: transitive + description: + name: stack_trace + sha256: "8b27215b45d22309b5cddda1aa2b19bdfec9df0e765f2de506401c071d38d1b1" + url: "https://pub.dev" + source: hosted + version: "1.12.1" + stream_channel: + dependency: transitive + description: + name: stream_channel + sha256: "969e04c80b8bcdf826f8f16579c7b14d780458bd97f56d107d3950fdbeef059d" + url: "https://pub.dev" + source: hosted + version: "2.1.4" + string_scanner: + dependency: transitive + description: + name: string_scanner + sha256: "921cd31725b72fe181906c6a94d987c78e3b98c2e205b397ea399d4054872b43" + url: "https://pub.dev" + source: hosted + version: "1.4.1" + term_glyph: + dependency: transitive + description: + name: term_glyph + sha256: "7f554798625ea768a7518313e58f83891c7f5024f88e46e7182a4558850a4b8e" + url: "https://pub.dev" + source: hosted + version: "1.2.2" + test: + dependency: "direct dev" + description: + name: test + sha256: "0d5ba5602ec3baa28c8ce365e1efc5575969c765f45c554a3e167dc7945b9c30" + url: "https://pub.dev" + source: hosted + version: "1.31.2" + test_api: + dependency: transitive + description: + name: test_api + sha256: "475610b2aa23c19687cce2961e44b0cc57cafe220f67c2b80201231b2a07fbe7" + url: "https://pub.dev" + source: hosted + version: "0.7.13" + test_core: + dependency: transitive + description: + name: test_core + sha256: a39c204a4fc7a7ccb04a2b985e359fda3cc37e45e0b8ac61c3fb1a05aa832132 + url: "https://pub.dev" + source: hosted + version: "0.6.19" + test_reflective_loader: + dependency: "direct dev" + description: + name: test_reflective_loader + sha256: d828d5ca15179aaac2aaf8f510cf0a52ec28e0031681b044ec5e581a4b8002e7 + url: "https://pub.dev" + source: hosted + version: "0.4.0" + typed_data: + dependency: transitive + description: + name: typed_data + sha256: f9049c039ebfeb4cf7a7104a675823cd72dba8297f264b6637062516699fa006 + url: "https://pub.dev" + source: hosted + version: "1.4.0" + vm_service: + dependency: transitive + description: + name: vm_service + sha256: "0016aef94fc66495ac78af5859181e3f3bf2026bd8eecc72b9565601e19ab360" + url: "https://pub.dev" + source: hosted + version: "15.2.0" + watcher: + dependency: transitive + description: + name: watcher + sha256: "1398c9f081a753f9226febe8900fce8f7d0a67163334e1c94a2438339d79d635" + url: "https://pub.dev" + source: hosted + version: "1.2.1" + web: + dependency: transitive + description: + name: web + sha256: "868d88a33d8a87b18ffc05f9f030ba328ffefba92d6c127917a2ba740f9cfe4a" + url: "https://pub.dev" + source: hosted + version: "1.1.1" + web_socket: + dependency: transitive + description: + name: web_socket + sha256: "34d64019aa8e36bf9842ac014bb5d2f5586ca73df5e4d9bf5c936975cae6982c" + url: "https://pub.dev" + source: hosted + version: "1.0.1" + web_socket_channel: + dependency: transitive + description: + name: web_socket_channel + sha256: d645757fb0f4773d602444000a8131ff5d48c9e47adfe9772652dd1a4f2d45c8 + url: "https://pub.dev" + source: hosted + version: "3.0.3" + webkit_inspection_protocol: + dependency: transitive + description: + name: webkit_inspection_protocol + sha256: "87d3f2333bb240704cd3f1c6b5b7acd8a10e7f0bc28c28dcf14e782014f4a572" + url: "https://pub.dev" + source: hosted + version: "1.2.1" + yaml: + dependency: transitive + description: + name: yaml + sha256: b9da305ac7c39faa3f030eccd175340f968459dae4af175130b3fc47e40d76ce + url: "https://pub.dev" + source: hosted + version: "3.1.3" + yaml_edit: + dependency: transitive + description: + name: yaml_edit + sha256: "07c9e63ba42519745182b88ca12264a7ba2484d8239958778dfe4d44fe760488" + url: "https://pub.dev" + source: hosted + version: "2.2.4" +sdks: + dart: ">=3.11.0 <4.0.0" diff --git a/cw_custom_lints/pubspec.yaml b/cw_custom_lints/pubspec.yaml new file mode 100644 index 000000000..399fa59c6 --- /dev/null +++ b/cw_custom_lints/pubspec.yaml @@ -0,0 +1,15 @@ +name: cw_custom_lints +version: 0.0.1 +environment: + sdk: '>=3.9.0 <4.0.0' + + +dependencies: + analysis_server_plugin: 0.3.15 + analyzer: 13.0.0 + analyzer_plugin: 0.14.9 + +dev_dependencies: + analyzer_testing: 0.2.6 + test_reflective_loader: 0.4.0 + test: 1.31.2 diff --git a/cw_dogecoin/.gitignore b/cw_dogecoin/.gitignore index eb6c05cd3..b050f03cf 100644 --- a/cw_dogecoin/.gitignore +++ b/cw_dogecoin/.gitignore @@ -22,8 +22,6 @@ migrate_working_dir/ #.vscode/ # Flutter/Dart/Pub related -# Libraries should not include pubspec.lock, per https://dart.dev/guides/libraries/private-files#pubspeclock. -/pubspec.lock **/doc/api/ .dart_tool/ .flutter-plugins diff --git a/cw_dogecoin/analysis_options.yaml b/cw_dogecoin/analysis_options.yaml deleted file mode 100644 index a5744c1cf..000000000 --- a/cw_dogecoin/analysis_options.yaml +++ /dev/null @@ -1,4 +0,0 @@ -include: package:flutter_lints/flutter.yaml - -# Additional information about this file can be found at -# https://dart.dev/guides/language/analysis-options diff --git a/cw_dogecoin/lib/src/dogecoin_wallet.dart b/cw_dogecoin/lib/src/dogecoin_wallet.dart index 4ac06848e..41dd4047a 100644 --- a/cw_dogecoin/lib/src/dogecoin_wallet.dart +++ b/cw_dogecoin/lib/src/dogecoin_wallet.dart @@ -54,8 +54,10 @@ abstract class DogeCoinWalletBase extends ElectrumWallet with Store { initialAddresses: initialAddresses, initialRegularAddressIndex: initialRegularAddressIndex, initialChangeAddressIndex: initialChangeAddressIndex, - mainHdByType: mainHdByType, - sideHdByType: sideHdByType, + mainHdByTypeAndAccount: mainHdByTypeAndAccount, + sideHdByTypeAndAccount: sideHdByTypeAndAccount, + accountIndexes: [currentAccountIndex], + currentAccountIndex: currentAccountIndex, legacyMainHd: mainHd, legacySideHd: sideHd, network: network, @@ -68,7 +70,7 @@ abstract class DogeCoinWalletBase extends ElectrumWallet with Store { } @override - int get networkDustAmount => 100000000; // 1 DOGE = 1e8 koinu + BigInt get networkDustAmount => BigInt.from(100000000); // 1 DOGE = 1e8 koinu static int estimatedDogeCoinTransactionSize(int inputsCount, int outputsCounts) => inputsCount * 180 + outputsCounts * 34 + 10; diff --git a/cw_dogecoin/lib/src/dogecoin_wallet_addresses.dart b/cw_dogecoin/lib/src/dogecoin_wallet_addresses.dart index 5fdd5ac17..55ceb6b22 100644 --- a/cw_dogecoin/lib/src/dogecoin_wallet_addresses.dart +++ b/cw_dogecoin/lib/src/dogecoin_wallet_addresses.dart @@ -13,8 +13,10 @@ class DogeCoinWalletAddresses = DogeCoinWalletAddressesBase with _$DogeCoinWalle abstract class DogeCoinWalletAddressesBase extends ElectrumWalletAddresses with Store { DogeCoinWalletAddressesBase( WalletInfo walletInfo, { - required super.mainHdByType, - required super.sideHdByType, + required super.mainHdByTypeAndAccount, + required super.sideHdByTypeAndAccount, + required super.accountIndexes, + required super.currentAccountIndex, required super.legacyMainHd, required super.legacySideHd, required super.network, diff --git a/cw_dogecoin/lib/src/dogecoin_wallet_service.dart b/cw_dogecoin/lib/src/dogecoin_wallet_service.dart index 988abd2e7..1a78e14ff 100644 --- a/cw_dogecoin/lib/src/dogecoin_wallet_service.dart +++ b/cw_dogecoin/lib/src/dogecoin_wallet_service.dart @@ -1,12 +1,10 @@ import 'dart:io'; import 'package:bip39/bip39.dart'; -import 'package:collection/collection.dart'; import 'package:cw_bitcoin/bitcoin_mnemonics_bip39.dart'; import 'package:cw_core/encryption_file_utils.dart'; import 'package:cw_core/pathForWallet.dart'; import 'package:cw_core/unspent_coins_info.dart'; -import 'package:cw_core/wallet_base.dart'; import 'package:cw_core/wallet_info.dart'; import 'package:cw_core/wallet_service.dart'; import 'package:cw_core/wallet_type.dart'; @@ -100,29 +98,6 @@ class DogeCoinWalletService extends WalletService< } } - @override - Future rename(String currentName, String password, String newName) async { - final currentWalletInfo = await WalletInfo.get(currentName, getType()); - if (currentWalletInfo == null) { - throw Exception('Wallet not found'); - } - final currentWallet = await DogeCoinWalletBase.open( - password: password, - name: currentName, - walletInfo: currentWalletInfo, - unspentCoinsInfo: unspentCoinsInfoSource, - encryptionFileUtils: encryptionFileUtilsFor(isDirect)); - - await currentWallet.renameWalletFiles(newName); - await saveBackup(newName); - - final newWalletInfo = currentWalletInfo; - newWalletInfo.id = WalletBase.idFor(newName, getType()); - newWalletInfo.name = newName; - - await newWalletInfo.save(); - } - @override Future restoreFromHardwareWallet(DogeCoinNewWalletCredentials credentials) { throw UnimplementedError( diff --git a/cw_dogecoin/pubspec.yaml b/cw_dogecoin/pubspec.yaml index d28f90b15..48d19db62 100644 --- a/cw_dogecoin/pubspec.yaml +++ b/cw_dogecoin/pubspec.yaml @@ -15,8 +15,8 @@ dependencies: bip39: ^1.0.6 bip32: ^2.0.0 path_provider: ^2.0.11 - mobx: ^2.0.7+4 - flutter_mobx: ^2.0.6+1 + mobx: ^2.6.0 + flutter_mobx: ^2.3.0 cw_core: path: ../cw_core cw_bitcoin: @@ -25,21 +25,22 @@ dependencies: blockchain_utils: git: url: https://github.com/cake-tech/blockchain_utils - ref: cake-update-v2 + ref: 59fdf29d72068e0522a96a8953ed7272833a9f57 dev_dependencies: flutter_test: sdk: flutter - build_runner: ^2.4.15 - mobx_codegen: ^2.0.7 - hive_generator: ^2.0.1 + build_runner: ^2.15.0 + mobx_codegen: ^2.7.7 +# hive_generator: ^2.0.1 + hive: 2.0.1 dependency_overrides: watcher: ^1.1.0 bitcoin_base: git: url: https://github.com/cake-tech/bitcoin_base - ref: cake-update-v11 + ref: 4e41f96f4838139895c65c3f49109d05af5d46aa # For information on the generic Dart part of this file, see the diff --git a/cw_dogecoin/pubspec_overrides.yaml b/cw_dogecoin/pubspec_overrides.yaml new file mode 120000 index 000000000..d957432d3 --- /dev/null +++ b/cw_dogecoin/pubspec_overrides.yaml @@ -0,0 +1 @@ +../pubspec_overrides.yaml \ No newline at end of file diff --git a/cw_evm/.gitignore b/cw_evm/.gitignore index 96486fd93..32aee4b25 100644 --- a/cw_evm/.gitignore +++ b/cw_evm/.gitignore @@ -22,8 +22,6 @@ migrate_working_dir/ #.vscode/ # Flutter/Dart/Pub related -# Libraries should not include pubspec.lock, per https://dart.dev/guides/libraries/private-files#pubspeclock. -/pubspec.lock **/doc/api/ .dart_tool/ .packages diff --git a/cw_evm/analysis_options.yaml b/cw_evm/analysis_options.yaml deleted file mode 100644 index a5744c1cf..000000000 --- a/cw_evm/analysis_options.yaml +++ /dev/null @@ -1,4 +0,0 @@ -include: package:flutter_lints/flutter.yaml - -# Additional information about this file can be found at -# https://dart.dev/guides/language/analysis-options diff --git a/cw_evm/build.yaml b/cw_evm/build.yaml new file mode 100644 index 000000000..81a9c96df --- /dev/null +++ b/cw_evm/build.yaml @@ -0,0 +1,5 @@ +targets: + $default: + sources: + exclude: + - "**/.secrets.g.dart" diff --git a/cw_evm/lib/clients/evm_chain_client.dart b/cw_evm/lib/clients/evm_chain_client.dart index 0d51e8f17..02641a00a 100644 --- a/cw_evm/lib/clients/evm_chain_client.dart +++ b/cw_evm/lib/clients/evm_chain_client.dart @@ -2,6 +2,7 @@ import 'dart:async'; import 'dart:convert'; import 'dart:developer'; +import 'package:cw_core/amount/money.dart'; import 'package:cw_core/crypto_currency.dart'; import 'package:cw_core/erc20_token.dart'; import 'package:cw_core/node.dart'; @@ -56,7 +57,7 @@ class EVMChainClient { } if (response.statusCode >= 200 && response.statusCode < 300 && jsonResponse['status'] != 0) { - final res = (jsonResponse['result'] as List); + final res = jsonResponse["result"] as List; res.removeWhere((e) => e['value'] == '0'); // Filter out spam native transactions below 0.00001 ETH (10000000000000 wei) @@ -144,7 +145,10 @@ class EVMChainClient { final jsonResponse = json.decode(response.body) as Map; - if (response.statusCode >= 200 && response.statusCode < 300 && jsonResponse['status'] != 0) { + if (response.statusCode >= 200 && + response.statusCode < 300 && + jsonResponse['status'] != 0 && + jsonResponse['result'] is List) { final symbol = EVMChainUtils.getFeeCurrency(chainId); return (jsonResponse['result'] as List) @@ -171,17 +175,31 @@ class EVMChainClient { try { Uri? rpcUri; bool isModifiedNodeUri = false; + final nodeHost = Uri.parse("https://${node.uriRaw}").host; + final pathSegments = + (node.path ?? "").split("/").where((segment) => segment.isNotEmpty).toList(); - if (node.uriRaw.contains('nownodes.io')) { + if (nodeHost.endsWith(".nownodes.io") && pathSegments.isEmpty) { isModifiedNodeUri = true; String nowNodeApiKey = secrets.nowNodesApiKey; if (nowNodeApiKey.isEmpty) { - printV('NowNodes API key is empty, cannot connect to ${node.uriRaw}'); + printV("NowNodes API key is empty, cannot connect to ${node.uriRaw}"); return false; } rpcUri = Uri.https(node.uriRaw, '/$nowNodeApiKey'); + } else if (nodeHost.endsWith(".g.alchemy.com") && + (pathSegments.isEmpty || (pathSegments.length == 1 && pathSegments.first == "v2"))) { + isModifiedNodeUri = true; + String alchemyApiKey = ""; + + if (alchemyApiKey.isEmpty) { + printV("Alchemy API key is empty, cannot connect to ${node.uriRaw}"); + return false; + } + + rpcUri = Uri.https(node.uriRaw, "/v2/$alchemyApiKey"); } _client = Web3Client(isModifiedNodeUri ? rpcUri!.toString() : node.uri.toString(), client); @@ -297,14 +315,13 @@ class EVMChainClient { Future signTransaction({ required Credentials privateKey, required String toAddress, - required BigInt amount, - required BigInt gasFee, + required Money amount, + required Money gasFee, required int estimatedGasUnits, required int maxFeePerGas, required EVMChainTransactionPriority? priority, required CryptoCurrency currency, required String feeCurrency, - required int exponent, String? contractAddress, String? data, int? gasPrice, @@ -315,13 +332,17 @@ class EVMChainClient { currency == CryptoCurrency.baseEth || currency == CryptoCurrency.arbEth || currency == CryptoCurrency.bnb || + currency == CryptoCurrency.robEth || contractAddress != null); - bool isNativeToken = currency == CryptoCurrency.eth || - currency == CryptoCurrency.maticpoly || - currency == CryptoCurrency.baseEth || - currency == CryptoCurrency.arbEth || - currency == CryptoCurrency.bnb; + final isNativeToken = [ + CryptoCurrency.eth, + CryptoCurrency.maticpoly, + CryptoCurrency.baseEth, + CryptoCurrency.arbEth, + CryptoCurrency.bnb, + CryptoCurrency.robEth + ].contains(currency); // Get nonce with "pending" block tag to include pending transactions // This prevents "Nonce too low" errors when sending multiple transactions quickly @@ -335,7 +356,7 @@ class EVMChainClient { to: EthereumAddress.fromHex(toAddress), maxPriorityFeePerGas: priority != null ? EtherAmount.fromInt(EtherUnit.gwei, priority.tip) : null, - amount: isNativeToken ? EtherAmount.inWei(amount) : EtherAmount.zero(), + amount: isNativeToken ? EtherAmount.inWei(amount.amount) : EtherAmount.zero(), data: data != null ? hexToBytes(data) : null, maxGas: estimatedGasUnits, maxFeePerGas: EtherAmount.fromInt(EtherUnit.wei, maxFeePerGas), @@ -358,7 +379,7 @@ class EVMChainClient { signedTransaction = await erc20.transfer( EthereumAddress.fromHex(toAddress), - amount, + amount.amount, credentials: privateKey, transaction: transaction, ); @@ -369,24 +390,20 @@ class EVMChainClient { return PendingEVMChainTransaction( signedTransaction: prepareSignedTransactionForSending(signedTransaction), - amount: amount.toString(), + amount: amount, fee: gasFee, - feeCurrency: feeCurrency, sendTransaction: _sendTransaction, - exponent: exponent, ); } Future signApprovalTransaction({ required Credentials privateKey, required String spender, - required BigInt amount, - required BigInt gasFee, - required String feeCurrency, + required Money amount, + required Money gasFee, required int estimatedGasUnits, required int maxFeePerGas, required EVMChainTransactionPriority? priority, - required int exponent, required String contractAddress, int? gasPrice, bool useBlinkProtection = true, @@ -416,20 +433,18 @@ class EVMChainClient { final signedTransaction = await erc20.approve( EthereumAddress.fromHex(spender), - amount, + amount.amount, credentials: privateKey, transaction: transaction, ); return PendingEVMChainTransaction( signedTransaction: prepareSignedTransactionForSending(signedTransaction), - amount: amount.toString(), + amount: amount, fee: gasFee, - feeCurrency: feeCurrency, sendTransaction: () => sendTransaction(signedTransaction, useBlinkProtection: useBlinkProtection), - exponent: exponent, - isInfiniteApproval: amount.toRadixString(16) == + isInfiniteApproval: amount.amount.toRadixString(16) == 'ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff', ); } @@ -524,36 +539,37 @@ class EVMChainClient { } Future fetchERC20Balances( - EthereumAddress userAddress, String contractAddress) async { + EthereumAddress userAddress, Erc20Token token) async { try { - final erc20 = ERC20(address: EthereumAddress.fromHex(contractAddress), client: _client!); + final erc20 = + ERC20(address: EthereumAddress.fromHex(token.contractAddress), client: _client!); final balance = await erc20.balanceOf(userAddress); - int exponent = (await erc20.decimals()).toInt(); - - return EVMChainERC20Balance(balance, exponent: exponent); + return EVMChainERC20Balance(Money(balance, token)); } on RangeError catch (_) { throw Exception('Invalid token contract for this network.'); } catch (e) { if (e.toString().contains("hostUnreachable")) { - return EVMChainERC20Balance(BigInt.zero); + return EVMChainERC20Balance(Money.zero(token)); } throw Exception('Could not fetch balances: ${e.toString()}'); } } - Future getErc20Token(String contractAddress, String chainName) async { + Future getErc20Token(String contractAddress, String? chainName) async { try { - final token = await getErc20TokenFromMoralis(contractAddress, chainName); + if (chainName != null) { + final token = await getErc20TokenFromMoralis(contractAddress, chainName); - if (token == null || token.name.isEmpty || token.symbol.isEmpty) { - return await getErcTokenInfoFromNode(contractAddress, chainName); + if (token != null && token.name.isNotEmpty && token.symbol.isNotEmpty) { + return token; + } } - return token; + return await getErcTokenInfoFromNode(contractAddress); } catch (e) { try { - return await getErcTokenInfoFromNode(contractAddress, chainName); + return await getErcTokenInfoFromNode(contractAddress); } catch (e) { return null; } @@ -600,7 +616,7 @@ class EVMChainClient { ); } - Future getErcTokenInfoFromNode(String contractAddress, String chainName) async { + Future getErcTokenInfoFromNode(String contractAddress) async { final erc20 = ERC20(address: EthereumAddress.fromHex(contractAddress), client: _client!); final name = await erc20.name(); final symbol = await erc20.symbol(); @@ -624,74 +640,116 @@ class EVMChainClient { return []; } - final uri = Uri.https( - 'deep-index.moralis.io', - '/api/v2.2/$address/erc20', - { - "chain": chainName, - }, - ); - - final response = await client.get( - uri, - headers: { - "Accept": "application/json", - "X-API-Key": secrets.moralisApiKey, - }, - ); - - if (response.statusCode < 200 || response.statusCode >= 300) { - printV('Moralis API returned invalid status code: ${response.statusCode}'); - return []; - } - - final decodedResponse = jsonDecode(response.body) as List; - + const maxPages = 3; + String? cursor; + int pageCount = 0; final List tokens = []; - for (final item in decodedResponse) { - final tokenData = item as Map; + do { + final params = { + "chain": chainName, + if (cursor != null && cursor.isNotEmpty) "cursor": cursor, + }; - final balanceStr = tokenData['balance'] as String? ?? '0'; - final balanceWei = BigInt.tryParse(balanceStr) ?? BigInt.zero; - if (balanceWei == BigInt.zero) continue; - - final contractAddress = (tokenData['token_address'] as String? ?? '').toLowerCase(); - final name = (tokenData['name'] as String? ?? '').toString(); - final symbol = (tokenData['symbol'] as String? ?? '').toString(); - final symbolFiltered = symbol.replaceFirst(RegExp('^\\\$'), ''); - - final decimalsRaw = tokenData['decimals']; - final decimals = - decimalsRaw is int ? decimalsRaw : int.tryParse(decimalsRaw.toString()) ?? 18; - - final logo = tokenData['logo'] as String?; - final thumbnail = tokenData['thumbnail'] as String?; - final iconUrl = logo ?? thumbnail; - - final possibleSpamRaw = tokenData['possible_spam']; - final possibleSpam = possibleSpamRaw is bool - ? possibleSpamRaw - : (possibleSpamRaw.toString().toLowerCase() == 'true'); - - final verifiedContractRaw = tokenData['verified_contract']; - final verifiedContract = verifiedContractRaw is bool - ? verifiedContractRaw - : (verifiedContractRaw.toString().toLowerCase() == 'true'); - - tokens.add( - MoralisWalletTokenBalance( - contractAddress: contractAddress, - name: name, - symbol: symbolFiltered, - decimals: decimals, - iconUrl: iconUrl, - balanceWei: balanceWei, - possibleSpam: possibleSpam, - verifiedContract: verifiedContract, - ), + final uri = Uri.https( + 'deep-index.moralis.io', + '/api/v2.2/wallets/$address/tokens', + params, ); - } + + final response = await client.get( + uri, + headers: { + "Accept": "application/json", + "X-API-Key": secrets.moralisApiKey, + }, + ); + + if (response.statusCode < 200 || response.statusCode >= 300) { + printV('Moralis API returned invalid status code: ${response.statusCode}'); + return tokens; + } + + final decoded = jsonDecode(response.body); + if (decoded is! Map) return tokens; + + final result = decoded['result']; + if (result is! List) return tokens; + + for (final item in result) { + if (item is! Map) continue; + final tokenData = item; + + final nativeRaw = tokenData['native_token']; + final nativeToken = + nativeRaw is bool ? nativeRaw : (nativeRaw?.toString().toLowerCase() == 'true'); + if (nativeToken) continue; + + final balanceStr = tokenData['balance'] as String? ?? '0'; + final balanceWei = BigInt.tryParse(balanceStr) ?? BigInt.zero; + if (balanceWei == BigInt.zero) continue; + + final contractAddress = (tokenData['token_address'] as String? ?? '').toLowerCase(); + final name = (tokenData['name'] as String? ?? '').toString(); + final symbol = (tokenData['symbol'] as String? ?? '').toString(); + final symbolFiltered = symbol.replaceFirst(RegExp('^\\\$'), ''); + + final decimalsRaw = tokenData['decimals']; + final decimals = + decimalsRaw is int ? decimalsRaw : int.tryParse(decimalsRaw.toString()) ?? 18; + + final logo = tokenData['logo'] as String?; + final thumbnail = tokenData['thumbnail'] as String?; + final iconUrl = logo ?? thumbnail; + + final possibleSpamRaw = tokenData['possible_spam']; + final possibleSpam = possibleSpamRaw is bool + ? possibleSpamRaw + : (possibleSpamRaw?.toString().toLowerCase() == 'true'); + + final verifiedContractRaw = tokenData['verified_contract']; + final verifiedContract = verifiedContractRaw is bool + ? verifiedContractRaw + : (verifiedContractRaw?.toString().toLowerCase() == 'true'); + + final usdPriceRaw = tokenData['usd_price']; + final double? usdPrice = usdPriceRaw is num + ? usdPriceRaw.toDouble() + : (usdPriceRaw is String ? double.tryParse(usdPriceRaw) : null); + + final usdValueRaw = tokenData['usd_value']; + final double? usdValue = usdValueRaw is num + ? usdValueRaw.toDouble() + : (usdValueRaw is String ? double.tryParse(usdValueRaw) : null); + + final securityRaw = tokenData['security_score']; + final int? securityScore = securityRaw is int + ? securityRaw + : (securityRaw is num + ? securityRaw.toInt() + : (securityRaw is String ? int.tryParse(securityRaw) : null)); + + tokens.add( + MoralisWalletTokenBalance( + contractAddress: contractAddress, + name: name, + symbol: symbolFiltered, + decimals: decimals, + iconUrl: iconUrl, + balanceWei: balanceWei, + possibleSpam: possibleSpam, + verifiedContract: verifiedContract, + usdPrice: usdPrice, + usdValue: usdValue, + securityScore: securityScore, + ), + ); + } + + final nextCursor = decoded['cursor']; + cursor = nextCursor is String && nextCursor.isNotEmpty ? nextCursor : null; + pageCount++; + } while (cursor != null && pageCount < maxPages); return tokens; } catch (e, stackTrace) { @@ -743,6 +801,9 @@ class MoralisWalletTokenBalance { final BigInt balanceWei; final bool possibleSpam; final bool verifiedContract; + final double? usdPrice; + final double? usdValue; + final int? securityScore; MoralisWalletTokenBalance({ required this.contractAddress, @@ -753,5 +814,8 @@ class MoralisWalletTokenBalance { required this.balanceWei, required this.possibleSpam, required this.verifiedContract, + this.usdPrice, + this.usdValue, + this.securityScore, }); } diff --git a/cw_evm/lib/clients/robinhood_client.dart b/cw_evm/lib/clients/robinhood_client.dart new file mode 100644 index 000000000..fe4215198 --- /dev/null +++ b/cw_evm/lib/clients/robinhood_client.dart @@ -0,0 +1,42 @@ +import "package:cw_evm/clients/evm_chain_client.dart"; +import "package:flutter/foundation.dart"; +import "package:web3dart/web3dart.dart"; + +class RobinhoodClient extends EVMChainClient { + RobinhoodClient() : super(chainId: 4663); + + @override + Transaction createTransaction({ + required EthereumAddress from, + required EthereumAddress to, + required EtherAmount amount, + EtherAmount? maxPriorityFeePerGas, + Uint8List? data, + int? maxGas, + EtherAmount? gasPrice, + EtherAmount? maxFeePerGas, + int? nonce, + }) { + EtherAmount? finalGasPrice = gasPrice; + + if (gasPrice == null && maxFeePerGas != null) { + finalGasPrice = maxFeePerGas; + } + + return Transaction( + from: from, + to: to, + value: amount, + data: data, + maxGas: maxGas, + gasPrice: finalGasPrice, + nonce: nonce, + ); + } + + @override + Uint8List prepareSignedTransactionForSending(Uint8List signedTransaction) => signedTransaction; + + @override + int get chainId => 4663; +} diff --git a/cw_evm/lib/deuro/deuro_savings.dart b/cw_evm/lib/deuro/deuro_savings.dart index c81c82ce9..53c8393a2 100644 --- a/cw_evm/lib/deuro/deuro_savings.dart +++ b/cw_evm/lib/deuro/deuro_savings.dart @@ -1,5 +1,7 @@ +import 'package:cw_core/amount/money.dart'; import 'package:cw_core/crypto_currency.dart'; -import 'package:cw_evm/deuro/deuro_savings_contract.dart'; +import 'package:cw_evm/deuro/deuro_savings_gateway_contract.dart' as v1; +import 'package:cw_evm/deuro/deuro_savings_contract.dart' as v2; import 'package:cw_evm/evm_chain_wallet.dart'; import 'package:cw_evm/contract/erc20.dart'; import 'package:cw_evm/evm_chain_exceptions.dart'; @@ -9,21 +11,27 @@ import 'package:web3dart/crypto.dart'; import 'package:web3dart/web3dart.dart'; const String savingsGatewayAddress = "0x073493d73258C4BEb6542e8dd3e1b2891C972303"; +const String savingsV2Address = "0x760233b90e45d186A9A98E911B115F7F4B90d3D9"; const String dEuroAddress = "0xbA3f535bbCcCcA2A154b573Ca6c5A49BAAE0a3ea"; const String frontendCode = "0x00000000000000000000000000000000000000000043616b652057616c6c6574"; class DEuro { - final SavingsGateway _savingsGateway; + final v2.Savings _savings; final ERC20 _dEuro; final EVMChainWallet _wallet; DEuro(EVMChainWallet wallet) : _wallet = wallet, - _savingsGateway = _getSavingsGateway(wallet.getWeb3Client()!), + _savings = _getSavings(wallet.getWeb3Client()!), _dEuro = _getDEuroToken(wallet.getWeb3Client()!); - static SavingsGateway _getSavingsGateway(Web3Client client) => SavingsGateway( + static v2.Savings _getSavings(Web3Client client) => v2.Savings( + address: EthereumAddress.fromHex(savingsV2Address), + client: client, + ); + + static v1.SavingsGateway _getSavingsGateway(Web3Client client) => v1.SavingsGateway( address: EthereumAddress.fromHex(savingsGatewayAddress), client: client, ); @@ -35,26 +43,31 @@ class DEuro { EthereumAddress get _address => EthereumAddress.fromHex(_wallet.walletAddresses.primaryAddress); - Future get savingsBalance async => - (await _savingsGateway.savings(accountOwner: _address)).saved; + Future get savingsBalance async => + Money((await _savings.savings(accountOwner: _address)).saved, CryptoCurrency.deuro); - Future get accruedInterest => _savingsGateway.accruedInterest(accountOwner: _address); + Future get savingsBalanceV1 async => Money( + (await _getSavingsGateway(_wallet.getWeb3Client()!).savings(accountOwner: _address)).saved, + CryptoCurrency.deuro); - Future get interestRate => _savingsGateway.currentRatePPM(); + Future get accruedInterest async => + Money(await _savings.accruedInterest(accountOwner: _address), CryptoCurrency.deuro); - Future get approvedBalance => _dEuro.allowance(_address, _savingsGateway.self.address); + Future get interestRate => _savings.currentRatePPM(); + + Future get approvedBalance => _dEuro.allowance(_address, _savings.self.address); Future _checkEthBalanceForGasFees(EVMChainTransactionPriority priority) async { final ethBalance = await _wallet.getWeb3Client()!.getBalance(_address); final currentBalance = ethBalance.getInWei; + final savings = _getSavingsGateway(_wallet.getWeb3Client()!); final gasFeesModel = await _wallet.calculateActualEstimatedFeeForCreateTransaction( - amount: BigInt.zero, - contractAddress: _savingsGateway.self.address.hexEip55, - receivingAddressHex: _savingsGateway.self.address.hexEip55, + amount: Money.zero(_wallet.currency), + contractAddress: savings.self.address.hexEip55, + receivingAddressHex: savings.self.address.hexEip55, priority: priority, - data: _savingsGateway.self.abi.functions[17] - .encodeCall([BigInt.zero, hexToBytes(frontendCode)]), + data: savings.self.abi.functions[17].encodeCall([BigInt.zero, hexToBytes(frontendCode)]), ); final estimatedGasFee = BigInt.from(gasFeesModel.estimatedGasFee); @@ -73,28 +86,26 @@ class DEuro { try { await _checkEthBalanceForGasFees(priority); - final signedTransaction = await _savingsGateway.save( - (amount: amount, frontendCode: hexToBytes(frontendCode)), + final signedTransaction = await _savings.save( + (amount: amount, compound: true), credentials: _wallet.evmChainPrivateKey, ); final fee = await _wallet.calculateActualEstimatedFeeForCreateTransaction( - amount: amount, - contractAddress: _savingsGateway.self.address.hexEip55, - receivingAddressHex: _savingsGateway.self.address.hexEip55, + amount: Money.zero(_wallet.currency), + contractAddress: _savings.self.address.hexEip55, + receivingAddressHex: _savings.self.address.hexEip55, priority: priority, - data: _savingsGateway.self.abi.functions[17].encodeCall([amount, hexToBytes(frontendCode)]), + data: _savings.self.abi.functions[18].encodeCall([amount, true]), ); - final sendTransaction = () => _wallet.getWeb3Client()!.sendRawTransaction(signedTransaction); + sendTransaction() => _wallet.getWeb3Client()!.sendRawTransaction(signedTransaction); return PendingEVMChainTransaction( - feeCurrency: "ETH", sendTransaction: sendTransaction, signedTransaction: signedTransaction, - fee: BigInt.from(fee.estimatedGasFee), - amount: amount.toString(), - exponent: 18, + fee: Money(BigInt.from(fee.estimatedGasFee), CryptoCurrency.eth), + amount: Money(amount, CryptoCurrency.deuro), ); } catch (e) { if (e.toString().contains('insufficient funds for gas')) { @@ -110,29 +121,66 @@ class DEuro { try { await _checkEthBalanceForGasFees(priority); - final signedTransaction = await _savingsGateway.withdraw( + final signedTransaction = await _savings.withdraw( + (target: _address, amount: amount), + credentials: _wallet.evmChainPrivateKey, + ); + + final fee = await _wallet.calculateActualEstimatedFeeForCreateTransaction( + amount: Money.zero(_wallet.currency), + contractAddress: _savings.self.address.hexEip55, + receivingAddressHex: _savings.self.address.hexEip55, + priority: priority, + data: _savings.self.abi.functions[23].encodeCall([_address, amount]), + ); + + sendTransaction() => _wallet.getWeb3Client()!.sendRawTransaction(signedTransaction); + + return PendingEVMChainTransaction( + sendTransaction: sendTransaction, + signedTransaction: signedTransaction, + fee: Money(BigInt.from(fee.estimatedGasFee), CryptoCurrency.eth), + amount: Money(amount, CryptoCurrency.deuro), + ); + } catch (e) { + if (e.toString().contains('insufficient funds for gas')) { + final ethBalance = await _wallet.getWeb3Client()!.getBalance(_address); + throw InsufficientGasFeeException(currentBalance: ethBalance.getInWei); + } + rethrow; + } + } + + Future withdrawSavingsV1(EVMChainTransactionPriority priority) async { + try { + await _checkEthBalanceForGasFees(priority); + final withdrawAmount = await savingsBalanceV1; + + // Withdraw at least a million to overflow and close the savings position + final amount = BigInt.parse("1000000000000000000000000"); + final savings = _getSavingsGateway(_wallet.getWeb3Client()!); + final signedTransaction = await savings.withdraw( (target: _address, amount: amount, frontendCode: hexToBytes(frontendCode)), credentials: _wallet.evmChainPrivateKey, ); final fee = await _wallet.calculateActualEstimatedFeeForCreateTransaction( - amount: amount, - contractAddress: _savingsGateway.self.address.hexEip55, - receivingAddressHex: _savingsGateway.self.address.hexEip55, + amount: Money.zero(_wallet.currency), + contractAddress: savings.self.address.hexEip55, + receivingAddressHex: savings.self.address.hexEip55, priority: priority, - data: _savingsGateway.self.abi.functions[24] - .encodeCall([_address, amount, hexToBytes(frontendCode)]), + data: + savings.self.abi.functions[24].encodeCall([_address, amount, hexToBytes(frontendCode)]), ); - final sendTransaction = () => _wallet.getWeb3Client()!.sendRawTransaction(signedTransaction); + sendTransaction() => _wallet.getWeb3Client()!.sendRawTransaction(signedTransaction); return PendingEVMChainTransaction( - sendTransaction: sendTransaction, - signedTransaction: signedTransaction, - fee: BigInt.from(fee.estimatedGasFee), - amount: amount.toString(), - feeCurrency: "ETH", - exponent: 18); + sendTransaction: sendTransaction, + signedTransaction: signedTransaction, + fee: Money(BigInt.from(fee.estimatedGasFee), CryptoCurrency.eth), + amount: withdrawAmount, + ); } catch (e) { if (e.toString().contains('insufficient funds for gas')) { final ethBalance = await _wallet.getWeb3Client()!.getBalance(_address); @@ -146,28 +194,26 @@ class DEuro { try { await _checkEthBalanceForGasFees(priority); - final signedTransaction = await _savingsGateway.refreshBalance( + final signedTransaction = await _savings.refreshBalance( (owner: _address), credentials: _wallet.evmChainPrivateKey, ); final fee = await _wallet.calculateActualEstimatedFeeForCreateTransaction( - amount: BigInt.zero, - contractAddress: _savingsGateway.self.address.hexEip55, - receivingAddressHex: _savingsGateway.self.address.hexEip55, + amount: Money.zero(_wallet.currency), + contractAddress: _savings.self.address.hexEip55, + receivingAddressHex: _savings.self.address.hexEip55, priority: priority, - data: _savingsGateway.self.abi.functions[15].encodeCall([_address]), + data: _savings.self.abi.functions[16].encodeCall([_address]), ); - final sendTransaction = () => _wallet.getWeb3Client()!.sendRawTransaction(signedTransaction); + sendTransaction() => _wallet.getWeb3Client()!.sendRawTransaction(signedTransaction); return PendingEVMChainTransaction( - feeCurrency: "ETH", sendTransaction: sendTransaction, signedTransaction: signedTransaction, - fee: BigInt.from(fee.estimatedGasFee), - amount: 0.toString(), - exponent: 18, + fee: Money(BigInt.from(fee.estimatedGasFee), CryptoCurrency.eth), + amount: Money.zero(CryptoCurrency.deuro), ); } catch (e) { if (e.toString().contains('insufficient funds for gas')) { @@ -184,14 +230,14 @@ class DEuro { await _checkEthBalanceForGasFees(priority); return (await _wallet.createApprovalTransaction( - BigInt.parse( - 'ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff', - radix: 16, - ), - _savingsGateway.self.address.hexEip55, - CryptoCurrency.deuro, + Money( + BigInt.parse( + 'ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff', + radix: 16, + ), + CryptoCurrency.deuro), + _savings.self.address.hexEip55, priority, - "ETH", )) as PendingEVMChainTransaction; } catch (e) { if (e.toString().contains('insufficient funds for gas')) { diff --git a/cw_evm/lib/deuro/deuro_savings_contract.dart b/cw_evm/lib/deuro/deuro_savings_contract.dart index bd8807385..cd17f4047 100644 --- a/cw_evm/lib/deuro/deuro_savings_contract.dart +++ b/cw_evm/lib/deuro/deuro_savings_contract.dart @@ -1,16 +1,20 @@ +// @dart=3.0 +// coverage:ignore-file +// GENERATED CODE - DO NOT MODIFY BY HAND // ignore_for_file: type=lint // ignore_for_file: unused_local_variable, unused_element, deprecated_member_use, deprecated_member_use_from_same_package, use_function_type_syntax_for_parameters, unnecessary_const, avoid_init_to_null, invalid_override_different_default_values_named, prefer_expression_function_bodies, annotate_overrides, invalid_annotation_target, unnecessary_question_mark // ignore_for_file: no_leading_underscores_for_library_prefixes -import 'package:web3dart/web3dart.dart' as _i1; import 'dart:typed_data' as _i2; +import 'package:web3dart/web3dart.dart' as _i1; + final _contractAbi = _i1.ContractAbi.fromJson( - '[{"inputs":[{"internalType":"contract IDecentralizedEURO","name":"deuro_","type":"address"},{"internalType":"uint24","name":"initialRatePPM","type":"uint24"},{"internalType":"address","name":"gateway_","type":"address"}],"stateMutability":"nonpayable","type":"constructor"},{"inputs":[],"name":"ChangeNotReady","type":"error"},{"inputs":[],"name":"ModuleDisabled","type":"error"},{"inputs":[],"name":"NoPendingChange","type":"error"},{"anonymous":false,"inputs":[{"indexed":true,"internalType":"address","name":"account","type":"address"},{"indexed":false,"internalType":"uint256","name":"interest","type":"uint256"}],"name":"InterestCollected","type":"event"},{"anonymous":false,"inputs":[{"indexed":false,"internalType":"uint24","name":"newRate","type":"uint24"}],"name":"RateChanged","type":"event"},{"anonymous":false,"inputs":[{"indexed":false,"internalType":"address","name":"who","type":"address"},{"indexed":false,"internalType":"uint24","name":"nextRate","type":"uint24"},{"indexed":false,"internalType":"uint40","name":"nextChange","type":"uint40"}],"name":"RateProposed","type":"event"},{"anonymous":false,"inputs":[{"indexed":true,"internalType":"address","name":"account","type":"address"},{"indexed":false,"internalType":"uint192","name":"amount","type":"uint192"}],"name":"Saved","type":"event"},{"anonymous":false,"inputs":[{"indexed":true,"internalType":"address","name":"account","type":"address"},{"indexed":false,"internalType":"uint192","name":"amount","type":"uint192"}],"name":"Withdrawn","type":"event"},{"inputs":[],"name":"GATEWAY","outputs":[{"internalType":"contract IFrontendGateway","name":"","type":"address"}],"stateMutability":"view","type":"function"},{"inputs":[{"internalType":"address","name":"accountOwner","type":"address"}],"name":"accruedInterest","outputs":[{"internalType":"uint192","name":"","type":"uint192"}],"stateMutability":"view","type":"function"},{"inputs":[{"internalType":"address","name":"accountOwner","type":"address"},{"internalType":"uint256","name":"timestamp","type":"uint256"}],"name":"accruedInterest","outputs":[{"internalType":"uint192","name":"","type":"uint192"}],"stateMutability":"view","type":"function"},{"inputs":[{"internalType":"uint192","name":"targetAmount","type":"uint192"},{"internalType":"bytes32","name":"frontendCode","type":"bytes32"}],"name":"adjust","outputs":[],"stateMutability":"nonpayable","type":"function"},{"inputs":[{"internalType":"uint192","name":"targetAmount","type":"uint192"}],"name":"adjust","outputs":[],"stateMutability":"nonpayable","type":"function"},{"inputs":[],"name":"applyChange","outputs":[],"stateMutability":"nonpayable","type":"function"},{"inputs":[{"components":[{"internalType":"uint192","name":"saved","type":"uint192"},{"internalType":"uint64","name":"ticks","type":"uint64"}],"internalType":"struct Savings.Account","name":"account","type":"tuple"},{"internalType":"uint64","name":"ticks","type":"uint64"}],"name":"calculateInterest","outputs":[{"internalType":"uint192","name":"","type":"uint192"}],"stateMutability":"view","type":"function"},{"inputs":[],"name":"currentRatePPM","outputs":[{"internalType":"uint24","name":"","type":"uint24"}],"stateMutability":"view","type":"function"},{"inputs":[],"name":"currentTicks","outputs":[{"internalType":"uint64","name":"","type":"uint64"}],"stateMutability":"view","type":"function"},{"inputs":[],"name":"deuro","outputs":[{"internalType":"contract IERC20","name":"","type":"address"}],"stateMutability":"view","type":"function"},{"inputs":[],"name":"equity","outputs":[{"internalType":"contract IReserve","name":"","type":"address"}],"stateMutability":"view","type":"function"},{"inputs":[],"name":"nextChange","outputs":[{"internalType":"uint40","name":"","type":"uint40"}],"stateMutability":"view","type":"function"},{"inputs":[],"name":"nextRatePPM","outputs":[{"internalType":"uint24","name":"","type":"uint24"}],"stateMutability":"view","type":"function"},{"inputs":[{"internalType":"uint24","name":"newRatePPM_","type":"uint24"},{"internalType":"address[]","name":"helpers","type":"address[]"}],"name":"proposeChange","outputs":[],"stateMutability":"nonpayable","type":"function"},{"inputs":[{"internalType":"address","name":"owner","type":"address"}],"name":"refreshBalance","outputs":[{"internalType":"uint192","name":"","type":"uint192"}],"stateMutability":"nonpayable","type":"function"},{"inputs":[],"name":"refreshMyBalance","outputs":[{"internalType":"uint192","name":"","type":"uint192"}],"stateMutability":"nonpayable","type":"function"},{"inputs":[{"internalType":"uint192","name":"amount","type":"uint192"},{"internalType":"bytes32","name":"frontendCode","type":"bytes32"}],"name":"save","outputs":[],"stateMutability":"nonpayable","type":"function"},{"inputs":[{"internalType":"address","name":"owner","type":"address"},{"internalType":"uint192","name":"amount","type":"uint192"}],"name":"save","outputs":[],"stateMutability":"nonpayable","type":"function"},{"inputs":[{"internalType":"address","name":"owner","type":"address"},{"internalType":"uint192","name":"amount","type":"uint192"},{"internalType":"bytes32","name":"frontendCode","type":"bytes32"}],"name":"save","outputs":[],"stateMutability":"nonpayable","type":"function"},{"inputs":[{"internalType":"uint192","name":"amount","type":"uint192"}],"name":"save","outputs":[],"stateMutability":"nonpayable","type":"function"},{"inputs":[{"internalType":"address","name":"","type":"address"}],"name":"savings","outputs":[{"internalType":"uint192","name":"saved","type":"uint192"},{"internalType":"uint64","name":"ticks","type":"uint64"}],"stateMutability":"view","type":"function"},{"inputs":[{"internalType":"uint256","name":"timestamp","type":"uint256"}],"name":"ticks","outputs":[{"internalType":"uint64","name":"","type":"uint64"}],"stateMutability":"view","type":"function"},{"inputs":[{"internalType":"address","name":"target","type":"address"},{"internalType":"uint192","name":"amount","type":"uint192"}],"name":"withdraw","outputs":[{"internalType":"uint256","name":"","type":"uint256"}],"stateMutability":"nonpayable","type":"function"},{"inputs":[{"internalType":"address","name":"target","type":"address"},{"internalType":"uint192","name":"amount","type":"uint192"},{"internalType":"bytes32","name":"frontendCode","type":"bytes32"}],"name":"withdraw","outputs":[{"internalType":"uint256","name":"","type":"uint256"}],"stateMutability":"nonpayable","type":"function"}]', - 'SavingsGateway', + '[{"inputs":[{"internalType":"contract IDecentralizedEURO","name":"deuro_","type":"address"},{"internalType":"uint24","name":"initialRatePPM","type":"uint24"}],"stateMutability":"nonpayable","type":"constructor"},{"inputs":[],"name":"ChangeNotReady","type":"error"},{"inputs":[],"name":"ModuleDisabled","type":"error"},{"inputs":[],"name":"NoPendingChange","type":"error"},{"anonymous":false,"inputs":[{"indexed":true,"internalType":"address","name":"account","type":"address"},{"indexed":false,"internalType":"uint192","name":"amount","type":"uint192"}],"name":"InterestClaimed","type":"event"},{"anonymous":false,"inputs":[{"indexed":true,"internalType":"address","name":"account","type":"address"},{"indexed":false,"internalType":"uint256","name":"interest","type":"uint256"},{"indexed":false,"internalType":"bool","name":"compounded","type":"bool"}],"name":"InterestCollected","type":"event"},{"anonymous":false,"inputs":[{"indexed":false,"internalType":"uint24","name":"newRate","type":"uint24"}],"name":"RateChanged","type":"event"},{"anonymous":false,"inputs":[{"indexed":false,"internalType":"address","name":"who","type":"address"},{"indexed":false,"internalType":"uint24","name":"nextRate","type":"uint24"},{"indexed":false,"internalType":"uint40","name":"nextChange","type":"uint40"}],"name":"RateProposed","type":"event"},{"anonymous":false,"inputs":[{"indexed":true,"internalType":"address","name":"account","type":"address"},{"indexed":false,"internalType":"uint192","name":"amount","type":"uint192"}],"name":"Saved","type":"event"},{"anonymous":false,"inputs":[{"indexed":true,"internalType":"address","name":"account","type":"address"},{"indexed":false,"internalType":"uint192","name":"amount","type":"uint192"}],"name":"Withdrawn","type":"event"},{"inputs":[{"internalType":"address","name":"accountOwner","type":"address"}],"name":"accruedInterest","outputs":[{"internalType":"uint192","name":"","type":"uint192"}],"stateMutability":"view","type":"function"},{"inputs":[{"internalType":"address","name":"accountOwner","type":"address"},{"internalType":"uint256","name":"timestamp","type":"uint256"}],"name":"accruedInterest","outputs":[{"internalType":"uint192","name":"","type":"uint192"}],"stateMutability":"view","type":"function"},{"inputs":[{"internalType":"uint192","name":"targetAmount","type":"uint192"}],"name":"adjust","outputs":[],"stateMutability":"nonpayable","type":"function"},{"inputs":[],"name":"applyChange","outputs":[],"stateMutability":"nonpayable","type":"function"},{"inputs":[{"components":[{"internalType":"uint192","name":"saved","type":"uint192"},{"internalType":"uint64","name":"ticks","type":"uint64"}],"internalType":"struct Savings.Account","name":"account","type":"tuple"},{"internalType":"uint64","name":"ticks","type":"uint64"}],"name":"calculateInterest","outputs":[{"internalType":"uint192","name":"","type":"uint192"}],"stateMutability":"view","type":"function"},{"inputs":[{"internalType":"address","name":"target","type":"address"}],"name":"claimInterest","outputs":[{"internalType":"uint192","name":"","type":"uint192"}],"stateMutability":"nonpayable","type":"function"},{"inputs":[{"internalType":"address","name":"","type":"address"}],"name":"claimableInterest","outputs":[{"internalType":"uint192","name":"","type":"uint192"}],"stateMutability":"view","type":"function"},{"inputs":[],"name":"currentRatePPM","outputs":[{"internalType":"uint24","name":"","type":"uint24"}],"stateMutability":"view","type":"function"},{"inputs":[],"name":"currentTicks","outputs":[{"internalType":"uint64","name":"","type":"uint64"}],"stateMutability":"view","type":"function"},{"inputs":[],"name":"deuro","outputs":[{"internalType":"contract IERC20","name":"","type":"address"}],"stateMutability":"view","type":"function"},{"inputs":[],"name":"equity","outputs":[{"internalType":"contract IReserve","name":"","type":"address"}],"stateMutability":"view","type":"function"},{"inputs":[],"name":"nextChange","outputs":[{"internalType":"uint40","name":"","type":"uint40"}],"stateMutability":"view","type":"function"},{"inputs":[],"name":"nextRatePPM","outputs":[{"internalType":"uint24","name":"","type":"uint24"}],"stateMutability":"view","type":"function"},{"inputs":[{"internalType":"address","name":"","type":"address"}],"name":"nonCompounding","outputs":[{"internalType":"bool","name":"","type":"bool"}],"stateMutability":"view","type":"function"},{"inputs":[{"internalType":"uint24","name":"newRatePPM_","type":"uint24"},{"internalType":"address[]","name":"helpers","type":"address[]"}],"name":"proposeChange","outputs":[],"stateMutability":"nonpayable","type":"function"},{"inputs":[{"internalType":"address","name":"owner","type":"address"}],"name":"refreshBalance","outputs":[{"internalType":"uint192","name":"","type":"uint192"}],"stateMutability":"nonpayable","type":"function"},{"inputs":[],"name":"refreshMyBalance","outputs":[{"internalType":"uint192","name":"","type":"uint192"}],"stateMutability":"nonpayable","type":"function"},{"inputs":[{"internalType":"uint192","name":"amount","type":"uint192"},{"internalType":"bool","name":"compound","type":"bool"}],"name":"save","outputs":[],"stateMutability":"nonpayable","type":"function"},{"inputs":[{"internalType":"address","name":"owner","type":"address"},{"internalType":"uint192","name":"amount","type":"uint192"}],"name":"save","outputs":[],"stateMutability":"nonpayable","type":"function"},{"inputs":[{"internalType":"uint192","name":"amount","type":"uint192"}],"name":"save","outputs":[],"stateMutability":"nonpayable","type":"function"},{"inputs":[{"internalType":"address","name":"","type":"address"}],"name":"savings","outputs":[{"internalType":"uint192","name":"saved","type":"uint192"},{"internalType":"uint64","name":"ticks","type":"uint64"}],"stateMutability":"view","type":"function"},{"inputs":[{"internalType":"uint256","name":"timestamp","type":"uint256"}],"name":"ticks","outputs":[{"internalType":"uint64","name":"","type":"uint64"}],"stateMutability":"view","type":"function"},{"inputs":[{"internalType":"address","name":"target","type":"address"},{"internalType":"uint192","name":"amount","type":"uint192"}],"name":"withdraw","outputs":[{"internalType":"uint256","name":"","type":"uint256"}],"stateMutability":"nonpayable","type":"function"}]', + 'Savings', ); -class SavingsGateway extends _i1.GeneratedContract { - SavingsGateway({ +class Savings extends _i1.GeneratedContract { + Savings({ required _i1.EthereumAddress address, required _i1.Web3Client client, int? chainId, @@ -26,26 +30,9 @@ class SavingsGateway extends _i1.GeneratedContract { /// The optional [atBlock] parameter can be used to view historical data. When /// set, the function will be evaluated in the specified block. By default, the /// latest on-chain block will be used. - Future<_i1.EthereumAddress> GATEWAY({_i1.BlockNum? atBlock}) async { + Future accruedInterest( + {required _i1.EthereumAddress accountOwner, _i1.BlockNum? atBlock}) async { final function = self.abi.functions[1]; - assert(checkSignature(function, '338c5371')); - final params = []; - final response = await read( - function, - params, - atBlock, - ); - return (response[0] as _i1.EthereumAddress); - } - - /// The optional [atBlock] parameter can be used to view historical data. When - /// set, the function will be evaluated in the specified block. By default, the - /// latest on-chain block will be used. - Future accruedInterest({ - required _i1.EthereumAddress accountOwner, - _i1.BlockNum? atBlock, - }) async { - final function = self.abi.functions[2]; assert(checkSignature(function, '77267ec3')); final params = [accountOwner]; final response = await read( @@ -63,7 +50,7 @@ class SavingsGateway extends _i1.GeneratedContract { ({_i1.EthereumAddress accountOwner, BigInt timestamp}) args, { _i1.BlockNum? atBlock, }) async { - final function = self.abi.functions[3]; + final function = self.abi.functions[2]; assert(checkSignature(function, 'a696399d')); final params = [ args.accountOwner, @@ -81,16 +68,31 @@ class SavingsGateway extends _i1.GeneratedContract { /// like the gas price, nonce and max gas. The `data` and `to` fields will be /// set by the contract. Future adjust( - ({BigInt targetAmount, _i2.Uint8List frontendCode}) args, { + ({BigInt targetAmount}) args, { + required _i1.Credentials credentials, + _i1.Transaction? transaction, + }) async { + final function = self.abi.functions[3]; + assert(checkSignature(function, 'bdc65152')); + final params = [args.targetAmount]; + return write( + credentials, + transaction, + function, + params, + ); + } + + /// The optional [transaction] parameter can be used to override parameters + /// like the gas price, nonce and max gas. The `data` and `to` fields will be + /// set by the contract. + Future applyChange({ required _i1.Credentials credentials, _i1.Transaction? transaction, }) async { final function = self.abi.functions[4]; - assert(checkSignature(function, '753ef93c')); - final params = [ - args.targetAmount, - args.frontendCode, - ]; + assert(checkSignature(function, '4e65037e')); + final params = []; return write( credentials, transaction, @@ -106,7 +108,7 @@ class SavingsGateway extends _i1.GeneratedContract { ({dynamic account, BigInt ticks}) args, { _i1.BlockNum? atBlock, }) async { - final function = self.abi.functions[7]; + final function = self.abi.functions[5]; assert(checkSignature(function, '7915ce20')); final params = [ args.account, @@ -120,6 +122,43 @@ class SavingsGateway extends _i1.GeneratedContract { return (response[0] as BigInt); } + /// The optional [transaction] parameter can be used to override parameters + /// like the gas price, nonce and max gas. The `data` and `to` fields will be + /// set by the contract. + Future claimInterest( + ({_i1.EthereumAddress target}) args, { + required _i1.Credentials credentials, + _i1.Transaction? transaction, + }) async { + final function = self.abi.functions[6]; + assert(checkSignature(function, '88d742c2')); + final params = [args.target]; + return write( + credentials, + transaction, + function, + params, + ); + } + + /// The optional [atBlock] parameter can be used to view historical data. When + /// set, the function will be evaluated in the specified block. By default, the + /// latest on-chain block will be used. + Future claimableInterest( + ({_i1.EthereumAddress $param7}) args, { + _i1.BlockNum? atBlock, + }) async { + final function = self.abi.functions[7]; + assert(checkSignature(function, '4f24d20a')); + final params = [args.$param7]; + final response = await read( + function, + params, + atBlock, + ); + return (response[0] as BigInt); + } + /// The optional [atBlock] parameter can be used to view historical data. When /// set, the function will be evaluated in the specified block. By default, the /// latest on-chain block will be used. @@ -210,6 +249,46 @@ class SavingsGateway extends _i1.GeneratedContract { return (response[0] as BigInt); } + /// The optional [atBlock] parameter can be used to view historical data. When + /// set, the function will be evaluated in the specified block. By default, the + /// latest on-chain block will be used. + Future nonCompounding( + ({_i1.EthereumAddress $param8}) args, { + _i1.BlockNum? atBlock, + }) async { + final function = self.abi.functions[14]; + assert(checkSignature(function, 'cdb95c3b')); + final params = [args.$param8]; + final response = await read( + function, + params, + atBlock, + ); + return (response[0] as bool); + } + + /// The optional [transaction] parameter can be used to override parameters + /// like the gas price, nonce and max gas. The `data` and `to` fields will be + /// set by the contract. + Future proposeChange( + ({BigInt newRatePPM_, List<_i1.EthereumAddress> helpers}) args, { + required _i1.Credentials credentials, + _i1.Transaction? transaction, + }) async { + final function = self.abi.functions[15]; + assert(checkSignature(function, '1de252e4')); + final params = [ + args.newRatePPM_, + args.helpers, + ]; + return write( + credentials, + transaction, + function, + params, + ); + } + /// The optional [transaction] parameter can be used to override parameters /// like the gas price, nonce and max gas. The `data` and `to` fields will be /// set by the contract. @@ -218,7 +297,7 @@ class SavingsGateway extends _i1.GeneratedContract { required _i1.Credentials credentials, _i1.Transaction? transaction, }) async { - final function = self.abi.functions[15]; + final function = self.abi.functions[16]; assert(checkSignature(function, 'b77cd1c7')); final params = [args.owner]; return writeRaw( @@ -236,7 +315,7 @@ class SavingsGateway extends _i1.GeneratedContract { required _i1.Credentials credentials, _i1.Transaction? transaction, }) async { - final function = self.abi.functions[16]; + final function = self.abi.functions[17]; assert(checkSignature(function, '85bd12d1')); final params = []; return writeRaw( @@ -251,38 +330,15 @@ class SavingsGateway extends _i1.GeneratedContract { /// like the gas price, nonce and max gas. The `data` and `to` fields will be /// set by the contract. Future<_i2.Uint8List> save( - ({BigInt amount, _i2.Uint8List frontendCode}) args, { + ({BigInt amount, bool compound}) args, { required _i1.Credentials credentials, _i1.Transaction? transaction, }) async { - final function = self.abi.functions[17]; - assert(checkSignature(function, '9e2363dc')); + final function = self.abi.functions[18]; + assert(checkSignature(function, 'a776f78c')); final params = [ args.amount, - args.frontendCode, - ]; - return writeRaw( - credentials, - transaction, - function, - params, - ); - } - - /// The optional [transaction] parameter can be used to override parameters - /// like the gas price, nonce and max gas. The `data` and `to` fields will be - /// set by the contract. - Future<_i2.Uint8List> saveTo( - ({_i1.EthereumAddress owner, BigInt amount, _i2.Uint8List frontendCode}) args, { - required _i1.Credentials credentials, - _i1.Transaction? transaction, - }) async { - final function = self.abi.functions[19]; - assert(checkSignature(function, 'cbcf9676')); - final params = [ - args.owner, - args.amount, - args.frontendCode, + args.compound, ]; return writeRaw( credentials, @@ -295,7 +351,7 @@ class SavingsGateway extends _i1.GeneratedContract { /// The optional [atBlock] parameter can be used to view historical data. When /// set, the function will be evaluated in the specified block. By default, the /// latest on-chain block will be used. - Future savings({ + Future savings({ required _i1.EthereumAddress accountOwner, _i1.BlockNum? atBlock, }) async { @@ -307,23 +363,40 @@ class SavingsGateway extends _i1.GeneratedContract { params, atBlock, ); - return Savings(response); + return SavingsData(response); + } + + /// The optional [atBlock] parameter can be used to view historical data. When + /// set, the function will be evaluated in the specified block. By default, the + /// latest on-chain block will be used. + Future ticks( + ({BigInt timestamp}) args, { + _i1.BlockNum? atBlock, + }) async { + final function = self.abi.functions[22]; + assert(checkSignature(function, '534cb30d')); + final params = [args.timestamp]; + final response = await read( + function, + params, + atBlock, + ); + return (response[0] as BigInt); } /// The optional [transaction] parameter can be used to override parameters /// like the gas price, nonce and max gas. The `data` and `to` fields will be /// set by the contract. Future<_i2.Uint8List> withdraw( - ({_i1.EthereumAddress target, BigInt amount, _i2.Uint8List frontendCode}) args, { + ({_i1.EthereumAddress target, BigInt amount}) args, { required _i1.Credentials credentials, _i1.Transaction? transaction, }) async { - final function = self.abi.functions[24]; - assert(checkSignature(function, '829a0476')); + final function = self.abi.functions[23]; + assert(checkSignature(function, '1791dc5e')); final params = [ args.target, args.amount, - args.frontendCode, ]; return writeRaw( credentials, @@ -333,6 +406,30 @@ class SavingsGateway extends _i1.GeneratedContract { ); } + /// Returns a live stream of all InterestClaimed events emitted by this contract. + Stream interestClaimedEvents({ + _i1.BlockNum? fromBlock, + _i1.BlockNum? toBlock, + }) { + final event = self.event('InterestClaimed'); + final filter = _i1.FilterOptions.events( + contract: self, + event: event, + fromBlock: fromBlock, + toBlock: toBlock, + ); + return client.events(filter).map((_i1.FilterEvent result) { + final decoded = event.decodeResults( + result.topics!, + result.data!, + ); + return InterestClaimed( + decoded, + result, + ); + }); + } + /// Returns a live stream of all InterestCollected events emitted by this contract. Stream interestCollectedEvents({ _i1.BlockNum? fromBlock, @@ -454,8 +551,8 @@ class SavingsGateway extends _i1.GeneratedContract { } } -class Savings { - Savings(List response) +class SavingsData { + SavingsData(List response) : saved = (response[0] as BigInt), ticks = (response[1] as BigInt); @@ -464,17 +561,34 @@ class Savings { final BigInt ticks; } +class InterestClaimed { + InterestClaimed( + List response, + this.event, + ) : account = (response[0] as _i1.EthereumAddress), + amount = (response[1] as BigInt); + + final _i1.EthereumAddress account; + + final BigInt amount; + + final _i1.FilterEvent event; +} + class InterestCollected { InterestCollected( List response, this.event, ) : account = (response[0] as _i1.EthereumAddress), - interest = (response[1] as BigInt); + interest = (response[1] as BigInt), + compounded = (response[2] as bool); final _i1.EthereumAddress account; final BigInt interest; + final bool compounded; + final _i1.FilterEvent event; } diff --git a/cw_evm/lib/deuro/deuro_savings_gateway_contract.dart b/cw_evm/lib/deuro/deuro_savings_gateway_contract.dart new file mode 100644 index 000000000..bd8807385 --- /dev/null +++ b/cw_evm/lib/deuro/deuro_savings_gateway_contract.dart @@ -0,0 +1,535 @@ +// ignore_for_file: type=lint +// ignore_for_file: unused_local_variable, unused_element, deprecated_member_use, deprecated_member_use_from_same_package, use_function_type_syntax_for_parameters, unnecessary_const, avoid_init_to_null, invalid_override_different_default_values_named, prefer_expression_function_bodies, annotate_overrides, invalid_annotation_target, unnecessary_question_mark +// ignore_for_file: no_leading_underscores_for_library_prefixes +import 'package:web3dart/web3dart.dart' as _i1; +import 'dart:typed_data' as _i2; + +final _contractAbi = _i1.ContractAbi.fromJson( + '[{"inputs":[{"internalType":"contract IDecentralizedEURO","name":"deuro_","type":"address"},{"internalType":"uint24","name":"initialRatePPM","type":"uint24"},{"internalType":"address","name":"gateway_","type":"address"}],"stateMutability":"nonpayable","type":"constructor"},{"inputs":[],"name":"ChangeNotReady","type":"error"},{"inputs":[],"name":"ModuleDisabled","type":"error"},{"inputs":[],"name":"NoPendingChange","type":"error"},{"anonymous":false,"inputs":[{"indexed":true,"internalType":"address","name":"account","type":"address"},{"indexed":false,"internalType":"uint256","name":"interest","type":"uint256"}],"name":"InterestCollected","type":"event"},{"anonymous":false,"inputs":[{"indexed":false,"internalType":"uint24","name":"newRate","type":"uint24"}],"name":"RateChanged","type":"event"},{"anonymous":false,"inputs":[{"indexed":false,"internalType":"address","name":"who","type":"address"},{"indexed":false,"internalType":"uint24","name":"nextRate","type":"uint24"},{"indexed":false,"internalType":"uint40","name":"nextChange","type":"uint40"}],"name":"RateProposed","type":"event"},{"anonymous":false,"inputs":[{"indexed":true,"internalType":"address","name":"account","type":"address"},{"indexed":false,"internalType":"uint192","name":"amount","type":"uint192"}],"name":"Saved","type":"event"},{"anonymous":false,"inputs":[{"indexed":true,"internalType":"address","name":"account","type":"address"},{"indexed":false,"internalType":"uint192","name":"amount","type":"uint192"}],"name":"Withdrawn","type":"event"},{"inputs":[],"name":"GATEWAY","outputs":[{"internalType":"contract IFrontendGateway","name":"","type":"address"}],"stateMutability":"view","type":"function"},{"inputs":[{"internalType":"address","name":"accountOwner","type":"address"}],"name":"accruedInterest","outputs":[{"internalType":"uint192","name":"","type":"uint192"}],"stateMutability":"view","type":"function"},{"inputs":[{"internalType":"address","name":"accountOwner","type":"address"},{"internalType":"uint256","name":"timestamp","type":"uint256"}],"name":"accruedInterest","outputs":[{"internalType":"uint192","name":"","type":"uint192"}],"stateMutability":"view","type":"function"},{"inputs":[{"internalType":"uint192","name":"targetAmount","type":"uint192"},{"internalType":"bytes32","name":"frontendCode","type":"bytes32"}],"name":"adjust","outputs":[],"stateMutability":"nonpayable","type":"function"},{"inputs":[{"internalType":"uint192","name":"targetAmount","type":"uint192"}],"name":"adjust","outputs":[],"stateMutability":"nonpayable","type":"function"},{"inputs":[],"name":"applyChange","outputs":[],"stateMutability":"nonpayable","type":"function"},{"inputs":[{"components":[{"internalType":"uint192","name":"saved","type":"uint192"},{"internalType":"uint64","name":"ticks","type":"uint64"}],"internalType":"struct Savings.Account","name":"account","type":"tuple"},{"internalType":"uint64","name":"ticks","type":"uint64"}],"name":"calculateInterest","outputs":[{"internalType":"uint192","name":"","type":"uint192"}],"stateMutability":"view","type":"function"},{"inputs":[],"name":"currentRatePPM","outputs":[{"internalType":"uint24","name":"","type":"uint24"}],"stateMutability":"view","type":"function"},{"inputs":[],"name":"currentTicks","outputs":[{"internalType":"uint64","name":"","type":"uint64"}],"stateMutability":"view","type":"function"},{"inputs":[],"name":"deuro","outputs":[{"internalType":"contract IERC20","name":"","type":"address"}],"stateMutability":"view","type":"function"},{"inputs":[],"name":"equity","outputs":[{"internalType":"contract IReserve","name":"","type":"address"}],"stateMutability":"view","type":"function"},{"inputs":[],"name":"nextChange","outputs":[{"internalType":"uint40","name":"","type":"uint40"}],"stateMutability":"view","type":"function"},{"inputs":[],"name":"nextRatePPM","outputs":[{"internalType":"uint24","name":"","type":"uint24"}],"stateMutability":"view","type":"function"},{"inputs":[{"internalType":"uint24","name":"newRatePPM_","type":"uint24"},{"internalType":"address[]","name":"helpers","type":"address[]"}],"name":"proposeChange","outputs":[],"stateMutability":"nonpayable","type":"function"},{"inputs":[{"internalType":"address","name":"owner","type":"address"}],"name":"refreshBalance","outputs":[{"internalType":"uint192","name":"","type":"uint192"}],"stateMutability":"nonpayable","type":"function"},{"inputs":[],"name":"refreshMyBalance","outputs":[{"internalType":"uint192","name":"","type":"uint192"}],"stateMutability":"nonpayable","type":"function"},{"inputs":[{"internalType":"uint192","name":"amount","type":"uint192"},{"internalType":"bytes32","name":"frontendCode","type":"bytes32"}],"name":"save","outputs":[],"stateMutability":"nonpayable","type":"function"},{"inputs":[{"internalType":"address","name":"owner","type":"address"},{"internalType":"uint192","name":"amount","type":"uint192"}],"name":"save","outputs":[],"stateMutability":"nonpayable","type":"function"},{"inputs":[{"internalType":"address","name":"owner","type":"address"},{"internalType":"uint192","name":"amount","type":"uint192"},{"internalType":"bytes32","name":"frontendCode","type":"bytes32"}],"name":"save","outputs":[],"stateMutability":"nonpayable","type":"function"},{"inputs":[{"internalType":"uint192","name":"amount","type":"uint192"}],"name":"save","outputs":[],"stateMutability":"nonpayable","type":"function"},{"inputs":[{"internalType":"address","name":"","type":"address"}],"name":"savings","outputs":[{"internalType":"uint192","name":"saved","type":"uint192"},{"internalType":"uint64","name":"ticks","type":"uint64"}],"stateMutability":"view","type":"function"},{"inputs":[{"internalType":"uint256","name":"timestamp","type":"uint256"}],"name":"ticks","outputs":[{"internalType":"uint64","name":"","type":"uint64"}],"stateMutability":"view","type":"function"},{"inputs":[{"internalType":"address","name":"target","type":"address"},{"internalType":"uint192","name":"amount","type":"uint192"}],"name":"withdraw","outputs":[{"internalType":"uint256","name":"","type":"uint256"}],"stateMutability":"nonpayable","type":"function"},{"inputs":[{"internalType":"address","name":"target","type":"address"},{"internalType":"uint192","name":"amount","type":"uint192"},{"internalType":"bytes32","name":"frontendCode","type":"bytes32"}],"name":"withdraw","outputs":[{"internalType":"uint256","name":"","type":"uint256"}],"stateMutability":"nonpayable","type":"function"}]', + 'SavingsGateway', +); + +class SavingsGateway extends _i1.GeneratedContract { + SavingsGateway({ + required _i1.EthereumAddress address, + required _i1.Web3Client client, + int? chainId, + }) : super( + _i1.DeployedContract( + _contractAbi, + address, + ), + client, + chainId, + ); + + /// The optional [atBlock] parameter can be used to view historical data. When + /// set, the function will be evaluated in the specified block. By default, the + /// latest on-chain block will be used. + Future<_i1.EthereumAddress> GATEWAY({_i1.BlockNum? atBlock}) async { + final function = self.abi.functions[1]; + assert(checkSignature(function, '338c5371')); + final params = []; + final response = await read( + function, + params, + atBlock, + ); + return (response[0] as _i1.EthereumAddress); + } + + /// The optional [atBlock] parameter can be used to view historical data. When + /// set, the function will be evaluated in the specified block. By default, the + /// latest on-chain block will be used. + Future accruedInterest({ + required _i1.EthereumAddress accountOwner, + _i1.BlockNum? atBlock, + }) async { + final function = self.abi.functions[2]; + assert(checkSignature(function, '77267ec3')); + final params = [accountOwner]; + final response = await read( + function, + params, + atBlock, + ); + return (response[0] as BigInt); + } + + /// The optional [atBlock] parameter can be used to view historical data. When + /// set, the function will be evaluated in the specified block. By default, the + /// latest on-chain block will be used. + Future accruedInterest$2( + ({_i1.EthereumAddress accountOwner, BigInt timestamp}) args, { + _i1.BlockNum? atBlock, + }) async { + final function = self.abi.functions[3]; + assert(checkSignature(function, 'a696399d')); + final params = [ + args.accountOwner, + args.timestamp, + ]; + final response = await read( + function, + params, + atBlock, + ); + return (response[0] as BigInt); + } + + /// The optional [transaction] parameter can be used to override parameters + /// like the gas price, nonce and max gas. The `data` and `to` fields will be + /// set by the contract. + Future adjust( + ({BigInt targetAmount, _i2.Uint8List frontendCode}) args, { + required _i1.Credentials credentials, + _i1.Transaction? transaction, + }) async { + final function = self.abi.functions[4]; + assert(checkSignature(function, '753ef93c')); + final params = [ + args.targetAmount, + args.frontendCode, + ]; + return write( + credentials, + transaction, + function, + params, + ); + } + + /// The optional [atBlock] parameter can be used to view historical data. When + /// set, the function will be evaluated in the specified block. By default, the + /// latest on-chain block will be used. + Future calculateInterest( + ({dynamic account, BigInt ticks}) args, { + _i1.BlockNum? atBlock, + }) async { + final function = self.abi.functions[7]; + assert(checkSignature(function, '7915ce20')); + final params = [ + args.account, + args.ticks, + ]; + final response = await read( + function, + params, + atBlock, + ); + return (response[0] as BigInt); + } + + /// The optional [atBlock] parameter can be used to view historical data. When + /// set, the function will be evaluated in the specified block. By default, the + /// latest on-chain block will be used. + Future currentRatePPM({_i1.BlockNum? atBlock}) async { + final function = self.abi.functions[8]; + assert(checkSignature(function, '06a7b376')); + final params = []; + final response = await read( + function, + params, + atBlock, + ); + return (response[0] as BigInt); + } + + /// The optional [atBlock] parameter can be used to view historical data. When + /// set, the function will be evaluated in the specified block. By default, the + /// latest on-chain block will be used. + Future currentTicks({_i1.BlockNum? atBlock}) async { + final function = self.abi.functions[9]; + assert(checkSignature(function, 'b079f163')); + final params = []; + final response = await read( + function, + params, + atBlock, + ); + return (response[0] as BigInt); + } + + /// The optional [atBlock] parameter can be used to view historical data. When + /// set, the function will be evaluated in the specified block. By default, the + /// latest on-chain block will be used. + Future<_i1.EthereumAddress> deuro({_i1.BlockNum? atBlock}) async { + final function = self.abi.functions[10]; + assert(checkSignature(function, '82b8eaf5')); + final params = []; + final response = await read( + function, + params, + atBlock, + ); + return (response[0] as _i1.EthereumAddress); + } + + /// The optional [atBlock] parameter can be used to view historical data. When + /// set, the function will be evaluated in the specified block. By default, the + /// latest on-chain block will be used. + Future<_i1.EthereumAddress> equity({_i1.BlockNum? atBlock}) async { + final function = self.abi.functions[11]; + assert(checkSignature(function, '91a0ac6a')); + final params = []; + final response = await read( + function, + params, + atBlock, + ); + return (response[0] as _i1.EthereumAddress); + } + + /// The optional [atBlock] parameter can be used to view historical data. When + /// set, the function will be evaluated in the specified block. By default, the + /// latest on-chain block will be used. + Future nextChange({_i1.BlockNum? atBlock}) async { + final function = self.abi.functions[12]; + assert(checkSignature(function, 'b6f83c17')); + final params = []; + final response = await read( + function, + params, + atBlock, + ); + return (response[0] as BigInt); + } + + /// The optional [atBlock] parameter can be used to view historical data. When + /// set, the function will be evaluated in the specified block. By default, the + /// latest on-chain block will be used. + Future nextRatePPM({_i1.BlockNum? atBlock}) async { + final function = self.abi.functions[13]; + assert(checkSignature(function, '2e4b20ab')); + final params = []; + final response = await read( + function, + params, + atBlock, + ); + return (response[0] as BigInt); + } + + /// The optional [transaction] parameter can be used to override parameters + /// like the gas price, nonce and max gas. The `data` and `to` fields will be + /// set by the contract. + Future<_i2.Uint8List> refreshBalance( + ({_i1.EthereumAddress owner}) args, { + required _i1.Credentials credentials, + _i1.Transaction? transaction, + }) async { + final function = self.abi.functions[15]; + assert(checkSignature(function, 'b77cd1c7')); + final params = [args.owner]; + return writeRaw( + credentials, + transaction, + function, + params, + ); + } + + /// The optional [transaction] parameter can be used to override parameters + /// like the gas price, nonce and max gas. The `data` and `to` fields will be + /// set by the contract. + Future<_i2.Uint8List> refreshMyBalance({ + required _i1.Credentials credentials, + _i1.Transaction? transaction, + }) async { + final function = self.abi.functions[16]; + assert(checkSignature(function, '85bd12d1')); + final params = []; + return writeRaw( + credentials, + transaction, + function, + params, + ); + } + + /// The optional [transaction] parameter can be used to override parameters + /// like the gas price, nonce and max gas. The `data` and `to` fields will be + /// set by the contract. + Future<_i2.Uint8List> save( + ({BigInt amount, _i2.Uint8List frontendCode}) args, { + required _i1.Credentials credentials, + _i1.Transaction? transaction, + }) async { + final function = self.abi.functions[17]; + assert(checkSignature(function, '9e2363dc')); + final params = [ + args.amount, + args.frontendCode, + ]; + return writeRaw( + credentials, + transaction, + function, + params, + ); + } + + /// The optional [transaction] parameter can be used to override parameters + /// like the gas price, nonce and max gas. The `data` and `to` fields will be + /// set by the contract. + Future<_i2.Uint8List> saveTo( + ({_i1.EthereumAddress owner, BigInt amount, _i2.Uint8List frontendCode}) args, { + required _i1.Credentials credentials, + _i1.Transaction? transaction, + }) async { + final function = self.abi.functions[19]; + assert(checkSignature(function, 'cbcf9676')); + final params = [ + args.owner, + args.amount, + args.frontendCode, + ]; + return writeRaw( + credentials, + transaction, + function, + params, + ); + } + + /// The optional [atBlock] parameter can be used to view historical data. When + /// set, the function will be evaluated in the specified block. By default, the + /// latest on-chain block will be used. + Future savings({ + required _i1.EthereumAddress accountOwner, + _i1.BlockNum? atBlock, + }) async { + final function = self.abi.functions[21]; + assert(checkSignature(function, '1f7cdd5f')); + final params = [accountOwner]; + final response = await read( + function, + params, + atBlock, + ); + return Savings(response); + } + + /// The optional [transaction] parameter can be used to override parameters + /// like the gas price, nonce and max gas. The `data` and `to` fields will be + /// set by the contract. + Future<_i2.Uint8List> withdraw( + ({_i1.EthereumAddress target, BigInt amount, _i2.Uint8List frontendCode}) args, { + required _i1.Credentials credentials, + _i1.Transaction? transaction, + }) async { + final function = self.abi.functions[24]; + assert(checkSignature(function, '829a0476')); + final params = [ + args.target, + args.amount, + args.frontendCode, + ]; + return writeRaw( + credentials, + transaction, + function, + params, + ); + } + + /// Returns a live stream of all InterestCollected events emitted by this contract. + Stream interestCollectedEvents({ + _i1.BlockNum? fromBlock, + _i1.BlockNum? toBlock, + }) { + final event = self.event('InterestCollected'); + final filter = _i1.FilterOptions.events( + contract: self, + event: event, + fromBlock: fromBlock, + toBlock: toBlock, + ); + return client.events(filter).map((_i1.FilterEvent result) { + final decoded = event.decodeResults( + result.topics!, + result.data!, + ); + return InterestCollected( + decoded, + result, + ); + }); + } + + /// Returns a live stream of all RateChanged events emitted by this contract. + Stream rateChangedEvents({ + _i1.BlockNum? fromBlock, + _i1.BlockNum? toBlock, + }) { + final event = self.event('RateChanged'); + final filter = _i1.FilterOptions.events( + contract: self, + event: event, + fromBlock: fromBlock, + toBlock: toBlock, + ); + return client.events(filter).map((_i1.FilterEvent result) { + final decoded = event.decodeResults( + result.topics!, + result.data!, + ); + return RateChanged( + decoded, + result, + ); + }); + } + + /// Returns a live stream of all RateProposed events emitted by this contract. + Stream rateProposedEvents({ + _i1.BlockNum? fromBlock, + _i1.BlockNum? toBlock, + }) { + final event = self.event('RateProposed'); + final filter = _i1.FilterOptions.events( + contract: self, + event: event, + fromBlock: fromBlock, + toBlock: toBlock, + ); + return client.events(filter).map((_i1.FilterEvent result) { + final decoded = event.decodeResults( + result.topics!, + result.data!, + ); + return RateProposed( + decoded, + result, + ); + }); + } + + /// Returns a live stream of all Saved events emitted by this contract. + Stream savedEvents({ + _i1.BlockNum? fromBlock, + _i1.BlockNum? toBlock, + }) { + final event = self.event('Saved'); + final filter = _i1.FilterOptions.events( + contract: self, + event: event, + fromBlock: fromBlock, + toBlock: toBlock, + ); + return client.events(filter).map((_i1.FilterEvent result) { + final decoded = event.decodeResults( + result.topics!, + result.data!, + ); + return Saved( + decoded, + result, + ); + }); + } + + /// Returns a live stream of all Withdrawn events emitted by this contract. + Stream withdrawnEvents({ + _i1.BlockNum? fromBlock, + _i1.BlockNum? toBlock, + }) { + final event = self.event('Withdrawn'); + final filter = _i1.FilterOptions.events( + contract: self, + event: event, + fromBlock: fromBlock, + toBlock: toBlock, + ); + return client.events(filter).map((_i1.FilterEvent result) { + final decoded = event.decodeResults( + result.topics!, + result.data!, + ); + return Withdrawn( + decoded, + result, + ); + }); + } +} + +class Savings { + Savings(List response) + : saved = (response[0] as BigInt), + ticks = (response[1] as BigInt); + + final BigInt saved; + + final BigInt ticks; +} + +class InterestCollected { + InterestCollected( + List response, + this.event, + ) : account = (response[0] as _i1.EthereumAddress), + interest = (response[1] as BigInt); + + final _i1.EthereumAddress account; + + final BigInt interest; + + final _i1.FilterEvent event; +} + +class RateChanged { + RateChanged( + List response, + this.event, + ) : newRate = (response[0] as BigInt); + + final BigInt newRate; + + final _i1.FilterEvent event; +} + +class RateProposed { + RateProposed( + List response, + this.event, + ) : who = (response[0] as _i1.EthereumAddress), + nextRate = (response[1] as BigInt), + nextChange = (response[2] as BigInt); + + final _i1.EthereumAddress who; + + final BigInt nextRate; + + final BigInt nextChange; + + final _i1.FilterEvent event; +} + +class Saved { + Saved( + List response, + this.event, + ) : account = (response[0] as _i1.EthereumAddress), + amount = (response[1] as BigInt); + + final _i1.EthereumAddress account; + + final BigInt amount; + + final _i1.FilterEvent event; +} + +class Withdrawn { + Withdrawn( + List response, + this.event, + ) : account = (response[0] as _i1.EthereumAddress), + amount = (response[1] as BigInt); + + final _i1.EthereumAddress account; + + final BigInt amount; + + final _i1.FilterEvent event; +} diff --git a/cw_evm/lib/evm_chain_client_factory.dart b/cw_evm/lib/evm_chain_client_factory.dart index bd55af384..d20a84adc 100644 --- a/cw_evm/lib/evm_chain_client_factory.dart +++ b/cw_evm/lib/evm_chain_client_factory.dart @@ -1,6 +1,7 @@ import 'package:cw_evm/clients/arbitrum_client.dart'; import 'package:cw_evm/clients/base_client.dart'; import 'package:cw_evm/clients/bsc_client.dart'; +import "package:cw_evm/clients/robinhood_client.dart"; import 'package:cw_evm/clients/ethereum_client.dart'; import 'package:cw_evm/clients/polygon_client.dart'; import 'package:cw_evm/clients/evm_chain_client.dart'; @@ -32,6 +33,8 @@ class EVMChainClientFactory { return ArbitrumClient(); case 56: return BSCClient(); + case 4663: + return RobinhoodClient(); default: return EVMChainClient(chainId: chainId); } diff --git a/cw_evm/lib/evm_chain_default_tokens.dart b/cw_evm/lib/evm_chain_default_tokens.dart index ba5ecb353..abad65627 100644 --- a/cw_evm/lib/evm_chain_default_tokens.dart +++ b/cw_evm/lib/evm_chain_default_tokens.dart @@ -1,28 +1,56 @@ -import 'package:cw_core/erc20_token.dart'; -import 'package:cw_evm/tokens/arbitrum_tokens.dart'; -import 'package:cw_evm/tokens/base_tokens.dart'; -import 'package:cw_evm/tokens/bsc_tokens.dart'; -import 'package:cw_evm/tokens/ethereum_tokens.dart'; -import 'package:cw_evm/tokens/polygon_tokens.dart'; +import "package:cw_core/erc20_token.dart"; +import "package:cw_evm/tokens/arbitrum_tokens.dart"; +import "package:cw_evm/tokens/base_tokens.dart"; +import "package:cw_evm/tokens/bsc_tokens.dart"; +import "package:cw_evm/tokens/ethereum_tokens.dart"; +import "package:cw_evm/tokens/polygon_tokens.dart"; +import "package:cw_evm/tokens/robinhood_tokens.dart"; /// Default ERC20 tokens for each EVM chain and utility methods for interacting with them class EVMChainDefaultTokens { - static List getDefaultTokensByChainId(int chainId) { - return switch (chainId) { - 1 => EthereumTokens.tokens, - 137 => PolygonTokens.tokens, - 8453 => BaseTokens.tokens, - 42161 => ArbitrumTokens.tokens, - 56 => BSCTokens.tokens, - _ => [], - }; + static List getDefaultTokensByChainId(int chainId) => switch (chainId) { + 1 => EthereumTokens.tokens, + 137 => PolygonTokens.tokens, + 8453 => BaseTokens.tokens, + 42161 => ArbitrumTokens.tokens, + 56 => BSCTokens.tokens, + 4663 => RobinhoodTokens.tokens, + _ => [], + }; + + static List getDefaultTokenAddresses(int chainId) => + getDefaultTokensByChainId(chainId).map((token) => token.contractAddress).toList(); + + static List getDefaultTokenSymbols(int chainId) => + getDefaultTokensByChainId(chainId).map((token) => token.symbol.toUpperCase()).toList(); + + static final Map> _iconPathsByAddress = {}; + + static String? getDefaultIconPathByAddress(int chainId, String contractAddress) { + final iconPaths = _iconPathsByAddress.putIfAbsent(chainId, () { + final paths = {}; + + for (final token in getDefaultTokensByChainId(chainId)) { + final iconPath = token.iconPath; + + if (iconPath != null && iconPath.isNotEmpty) { + paths[token.contractAddress.toLowerCase()] = iconPath; + } + } + + return paths; + }); + + return iconPaths[contractAddress.toLowerCase()]; } - static List getDefaultTokenAddresses(int chainId) { - return getDefaultTokensByChainId(chainId).map((token) => token.contractAddress).toList(); - } + static Set getDefaultGroupsByAddress(int chainId, String contractAddress) { + for (final token in getDefaultTokensByChainId(chainId)) { + if (token.contractAddress.toLowerCase() == contractAddress.toLowerCase()) { + return token.groups; + } + } - static List getDefaultTokenSymbols(int chainId) { - return getDefaultTokensByChainId(chainId).map((token) => token.symbol.toUpperCase()).toList(); + return const {}; } } diff --git a/cw_evm/lib/evm_chain_exceptions.dart b/cw_evm/lib/evm_chain_exceptions.dart index 4e5f4df08..62dfb78b5 100644 --- a/cw_evm/lib/evm_chain_exceptions.dart +++ b/cw_evm/lib/evm_chain_exceptions.dart @@ -1,5 +1,11 @@ import 'package:cw_core/crypto_currency.dart'; +class EVMChainMnemonicIsIncorrectException implements Exception { + @override + String toString() => + 'EVM mnemonic has incorrect format. Mnemonic should contain 12 or 24 words separated by space.'; +} + class EVMChainTransactionCreationException implements Exception { final String exceptionMessage; diff --git a/cw_evm/lib/evm_chain_registry.dart b/cw_evm/lib/evm_chain_registry.dart index df2e5f322..8ff62bd1d 100644 --- a/cw_evm/lib/evm_chain_registry.dart +++ b/cw_evm/lib/evm_chain_registry.dart @@ -186,6 +186,7 @@ class EvmChainRegistry { WalletType.bsc, 'BSC', ); + } void _registerChain( diff --git a/cw_evm/lib/evm_chain_transaction_info.dart b/cw_evm/lib/evm_chain_transaction_info.dart index 128c00eab..6ce7a716c 100644 --- a/cw_evm/lib/evm_chain_transaction_info.dart +++ b/cw_evm/lib/evm_chain_transaction_info.dart @@ -1,18 +1,19 @@ // ignore_for_file: overridden_fields, annotate_overrides -import 'dart:math'; - +import 'package:cw_core/amount/money.dart'; +import 'package:cw_core/crypto_currency.dart'; +import 'package:cw_core/erc20_token.dart'; import 'package:cw_core/format_amount.dart'; -import 'package:cw_core/format_fixed.dart'; import 'package:cw_core/transaction_direction.dart'; import 'package:cw_core/transaction_info.dart'; +import 'package:cw_evm/evm_chain_registry.dart'; import 'package:cw_evm/utils/evm_chain_utils.dart'; class EVMChainTransactionInfo extends TransactionInfo { EVMChainTransactionInfo({ required this.id, required this.height, - required this.ethAmount, - required this.ethFee, + required this.amount, + required this.fee, required this.tokenSymbol, this.exponent = 18, required this.direction, @@ -24,19 +25,16 @@ class EVMChainTransactionInfo extends TransactionInfo { this.evmSignatureName, this.contractAddress, required this.chainId, - }) : amount = ethAmount.toInt(), - fee = ethFee.toInt(); + }); final String id; final int height; - final int amount; - final BigInt ethAmount; + final Money amount; final int exponent; final TransactionDirection direction; final DateTime date; final bool isPending; - final int fee; - final BigInt ethFee; + final Money fee; final int confirmations; final String tokenSymbol; String? _fiatAmount; @@ -49,39 +47,32 @@ class EVMChainTransactionInfo extends TransactionInfo { /// Get fee currency symbol based on wallet type String get feeCurrency => EVMChainUtils.getFeeCurrency(chainId); - String get _displayTokenSymbol { - if (chainId == 56 && tokenSymbol.toUpperCase() == 'BSC-USD') return 'USDT'; - - return tokenSymbol; - } - - @override - String amountFormatted() { - final amount = formatAmount((ethAmount / BigInt.from(10).pow(exponent)).toString()); - return '${amount.substring(0, min(10, amount.length))} $_displayTokenSymbol'; - } - @override String fiatAmount() => _fiatAmount ?? ''; @override void changeFiatAmount(String amount) => _fiatAmount = formatAmount(amount); - @override - String feeFormatted() => '${formatFixed(ethFee, 18)} $feeCurrency'; - factory EVMChainTransactionInfo.fromJson(Map data, int chainId) { + final decimals = data['exponent'] as int? ?? 18; + final tokenSymbol = data['tokenSymbol'] as String; + final currency = + Erc20Token(name: '', symbol: tokenSymbol, contractAddress: '', decimal: decimals); + + final feeCurrency = + EvmChainRegistry().getChainConfig(chainId)?.nativeCurrency ?? CryptoCurrency.eth; + return EVMChainTransactionInfo( id: data['id'] as String, height: data['height'] as int, - ethAmount: BigInt.parse(data['amount'] as String), - exponent: data['exponent'] as int? ?? 18, - ethFee: BigInt.parse(data['fee'] as String), + amount: Money(BigInt.parse(data['amount'] as String), currency), + exponent: decimals, + fee: Money(BigInt.parse(data['fee'] as String), feeCurrency), direction: TransactionDirection.values[data['direction'] as int], date: DateTime.fromMillisecondsSinceEpoch(data['date'] as int), isPending: data['isPending'] as bool? ?? false, confirmations: data['confirmations'] as int, - tokenSymbol: data['tokenSymbol'] as String, + tokenSymbol: tokenSymbol, to: data['to'] as String?, from: data['from'] as String?, evmSignatureName: data['evmSignatureName'] as String?, @@ -93,9 +84,9 @@ class EVMChainTransactionInfo extends TransactionInfo { Map toJson() => { 'id': id, 'height': height, - 'amount': ethAmount.toString(), + 'amount': amount.amount.toString(), 'exponent': exponent, - 'fee': ethFee.toString(), + 'fee': fee.amount.toString(), 'direction': direction.index, 'date': date.millisecondsSinceEpoch, 'isPending': isPending, diff --git a/cw_evm/lib/evm_chain_wallet.dart b/cw_evm/lib/evm_chain_wallet.dart index b5039d8a7..ae65a8878 100644 --- a/cw_evm/lib/evm_chain_wallet.dart +++ b/cw_evm/lib/evm_chain_wallet.dart @@ -1,11 +1,10 @@ import 'dart:async'; import 'dart:convert'; -import 'dart:io'; import 'dart:typed_data'; import 'package:bip32/bip32.dart' as bip32; import 'package:bip39/bip39.dart' as bip39; -import 'package:cw_core/cake_hive.dart'; +import 'package:cw_core/amount/money.dart'; import 'package:cw_core/crypto_currency.dart'; import 'package:cw_core/encryption_file_utils.dart'; import 'package:cw_core/erc20_token.dart'; @@ -28,7 +27,6 @@ import 'package:cw_evm/evm_chain_default_tokens.dart'; import 'package:cw_evm/evm_chain_exceptions.dart'; import 'package:cw_evm/evm_chain_registry.dart'; import 'package:cw_evm/evm_chain_transaction_credentials.dart'; -import 'package:cw_evm/utils/evm_chain_formatter.dart'; import 'package:cw_evm/evm_chain_transaction_history.dart'; import 'package:cw_evm/evm_chain_transaction_model.dart'; import 'package:cw_evm/evm_chain_transaction_priority.dart'; @@ -39,12 +37,10 @@ import 'package:cw_evm/hardware/evm_chain_bitbox_credentials.dart'; import 'package:cw_evm/hardware/evm_chain_ledger_credentials.dart'; import 'package:cw_evm/hardware/evm_chain_trezor_credentials.dart'; import 'package:hex/hex.dart'; -import 'package:hive/hive.dart'; import 'package:mobx/mobx.dart'; import 'package:shared_preferences/shared_preferences.dart'; import 'package:web3dart/crypto.dart'; import 'package:web3dart/web3dart.dart'; -import 'package:eth_sig_util/eth_sig_util.dart'; import 'contract/erc20.dart'; import 'evm_chain_transaction_info.dart'; @@ -103,17 +99,13 @@ abstract class EVMChainWalletBase walletInfo, initialChainId ?? _getInitialChainId(walletInfo.type)), balance = ObservableMap.of( { - nativeCurrency: initialBalance ?? EVMChainERC20Balance(BigInt.zero), + nativeCurrency: initialBalance ?? EVMChainERC20Balance(Money.zero(nativeCurrency)), }, ), super(walletInfo, derivationInfo) { this.walletInfo = walletInfo; transactionHistory = setUpTransactionHistory(walletInfo, password, encryptionFileUtils); - if (!CakeHive.isAdapterRegistered(Erc20Token.typeId)) { - CakeHive.registerAdapter(Erc20TokenAdapter()); - } - sharedPrefs.complete(SharedPreferences.getInstance()); } @@ -122,9 +114,7 @@ abstract class EVMChainWalletBase final String _password; final EncryptionFileUtils encryptionFileUtils; - late final Box erc20TokensBox; - - late Box evmChainErc20TokensBox; + List _erc20Tokens = []; late final Credentials _evmChainPrivateKey; @@ -218,8 +208,8 @@ abstract class EVMChainWalletBase // Automatically connect to node for the selected chain await connectToNode(node: node); - // Reload ERC20 tokens box for the new chain - await initErc20TokensBox(); + // Reload ERC20 tokens for the new chain + await initErc20Tokens(); // Reload transaction history from the new chain's file await transactionHistory.init(); @@ -229,202 +219,90 @@ abstract class EVMChainWalletBase await startSync(); } - void addInitialTokens() { + Future addInitialTokens() async { final initialErc20Tokens = EVMChainDefaultTokens.getDefaultTokensByChainId(selectedChainId); for (final token in initialErc20Tokens) { - if (!evmChainErc20TokensBox.containsKey(token.contractAddress)) { - evmChainErc20TokensBox.put(token.contractAddress, token); - } else { - // update existing token - final existingToken = evmChainErc20TokensBox.get(token.contractAddress); - evmChainErc20TokensBox.put( - token.contractAddress, - Erc20Token.copyWith(token, enabled: existingToken!.enabled), - ); - } + final existingToken = _findCachedToken(token.contractAddress); + + final newToken = Erc20Token.copyWith( + token, + enabled: existingToken?.enabled ?? token.enabled, + walletName: walletInfo.name, + chainId: selectedChainId, + ); + + await newToken.save(); + _upsertCachedToken(newToken); } } List get getDefaultTokenContractAddresses => EVMChainDefaultTokens.getDefaultTokenAddresses(selectedChainId); - Future initErc20TokensBox() async { - // Migration for old WalletType.ethereum wallets: - // Old wallets used a global erc20TokensBox (shared across all wallets). - // New system uses wallet-specific, chain-specific boxes. - // This checks if migration is needed and runs it once. - if (walletInfo.type == WalletType.ethereum) { - try { - // Try to access erc20TokensBox - if it exists, migration already ran - final _ = erc20TokensBox; - // Migration done, proceed with normal chain-specific logic below - } catch (_) { - // erc20TokensBox doesn't exist yet, run migration from global box - await _initEthereumErc20TokensBox(); - await _normalizeEvmChainErc20TokensBoxKeys(); - return; - } - } + Future initErc20Tokens() async { + _erc20Tokens = await Erc20Token.getAllForWallet(walletInfo.name, selectedChainId); - final chainId = selectedChainId; - - final boxName = EVMChainUtils.getErc20TokensBoxName(walletInfo.name, chainId); - - // Close existing box if it's already open (for chain switching) - try { - if (evmChainErc20TokensBox.isOpen) { - await evmChainErc20TokensBox.close(); - } - } catch (_) { - // Box might not be initialized yet, ignore - } - - // Check if box is already open, if so use it, otherwise open it - if (CakeHive.isBoxOpen(boxName)) { - evmChainErc20TokensBox = CakeHive.box(boxName); - } else { - evmChainErc20TokensBox = await CakeHive.openBox(boxName); - } - - await _normalizeEvmChainErc20TokensBoxKeys(); - - addInitialTokens(); + await addInitialTokens(); } - /// Ethereum-specific initialization with backward compatibility - Future _initEthereumErc20TokensBox() async { - // Opens a box specific to this wallet - evmChainErc20TokensBox = await CakeHive.openBox( - "${walletInfo.name.replaceAll(" ", "_")}_${Erc20Token.ethereumBoxName}", - ); + Erc20Token? _findCachedToken(String contractAddress) { + final lowerAddress = contractAddress.toLowerCase(); - erc20TokensBox = await CakeHive.openBox(Erc20Token.boxName); - - if (erc20TokensBox.isEmpty) { - if (evmChainErc20TokensBox.isEmpty) addInitialTokens(); - return; + for (final token in _erc20Tokens) { + if (token.contractAddress.toLowerCase() == lowerAddress) return token; } + return null; + } - final allValues = erc20TokensBox.values.toList(); + void _upsertCachedToken(Erc20Token token) { + final lowerAddress = token.contractAddress.toLowerCase(); - // Clear and delete the old token box - await erc20TokensBox.clear(); - await erc20TokensBox.deleteFromDisk(); - - // Add all the previous tokens with configs to the new box - await evmChainErc20TokensBox.addAll(allValues); + _erc20Tokens.removeWhere((t) => t.contractAddress.toLowerCase() == lowerAddress); + _erc20Tokens.add(token); } String getTransactionHistoryFileName() => EVMChainUtils.getTransactionHistoryFileName(selectedChainId); - /// Ensures all ERC20 token entries use lowercase contract addresses as - /// their Hive keys to avoid duplicates caused by case differences. - Future _normalizeEvmChainErc20TokensBoxKeys() async { - if (!evmChainErc20TokensBox.isOpen) return; - - final prefs = await sharedPrefs.future; - final migrationKey = 'erc20_box_normalized_${walletInfo.name}_$selectedChainId'; - - if (prefs.getBool(migrationKey) ?? false) return; - - final box = evmChainErc20TokensBox; - final keys = box.keys.toList(); - - if (keys.isEmpty) { - await prefs.setBool(migrationKey, true); - return; - } - - final Map normalizedTokens = {}; - var needsRewrite = false; - - for (final key in keys) { - final token = box.get(key); - - if (token == null) { - needsRewrite = true; - continue; - } - - final lowerKey = key is String ? key.toLowerCase() : token.contractAddress.toLowerCase(); - if (key is int) needsRewrite = true; - - final Erc20Token normalizedToken = - token.contractAddress == token.contractAddress.toLowerCase() - ? token - : Erc20Token( - name: token.name, - symbol: token.symbol, - contractAddress: token.contractAddress.toLowerCase(), - decimal: token.decimal, - enabled: token.enabled, - iconPath: token.iconPath, - tag: token.tag, - isPotentialScam: token.isPotentialScam, - ); - - if (!needsRewrite && (lowerKey != key || identical(normalizedToken, token) == false)) { - needsRewrite = true; - } - - final existing = normalizedTokens[lowerKey]; - - if (existing == null) { - normalizedTokens[lowerKey] = normalizedToken; - continue; - } - - final merged = Erc20Token( - name: normalizedToken.name, - symbol: normalizedToken.symbol, - contractAddress: lowerKey, - decimal: normalizedToken.decimal, - enabled: normalizedToken.enabled || existing.enabled, - iconPath: (normalizedToken.iconPath?.isNotEmpty ?? false) - ? normalizedToken.iconPath - : existing.iconPath, - tag: normalizedToken.tag ?? existing.tag, - isPotentialScam: normalizedToken.isPotentialScam || existing.isPotentialScam, - ); - - normalizedTokens[lowerKey] = merged; - } - - if (needsRewrite) { - await box.clear(); - for (final entry in normalizedTokens.entries) { - await box.put(entry.key, entry.value); - } - } - - await prefs.setBool(migrationKey, true); - } - Future checkIfScanProviderIsEnabled() async { final key = EVMChainUtils.getScanProviderPreferenceKey(selectedChainId); - return (await sharedPrefs.future).getBool(key) ?? true; + + try { + return (await sharedPrefs.future).getBool(key) ?? true; + } catch (e) { + printV("Could not read the $key preference: $e"); + return false; + } } EVMChainTransactionInfo getTransactionInfo( EVMChainTransactionModel transactionModel, String address, ) { + final decimals = transactionModel.tokenDecimal ?? 18; + final tokenSymbol = transactionModel.tokenSymbol ?? + EVMChainUtils.getDefaultTokenSymbol(transactionModel.chainId); + + final amountCurrency = Erc20Token( + name: '', + contractAddress: transactionModel.contractAddress, + decimal: decimals, + symbol: tokenSymbol, + ); return EVMChainTransactionInfo( id: transactionModel.hash, height: transactionModel.blockNumber, - ethAmount: transactionModel.amount, + amount: Money(transactionModel.amount, amountCurrency), direction: transactionModel.from == address ? TransactionDirection.outgoing : TransactionDirection.incoming, isPending: false, date: transactionModel.date, confirmations: transactionModel.confirmations, - ethFee: BigInt.from(transactionModel.gasUsed) * transactionModel.gasPrice, + fee: Money(BigInt.from(transactionModel.gasUsed) * transactionModel.gasPrice, currency), exponent: transactionModel.tokenDecimal ?? 18, - tokenSymbol: transactionModel.tokenSymbol ?? - EVMChainUtils.getDefaultTokenSymbol(transactionModel.chainId), + tokenSymbol: tokenSymbol, to: transactionModel.to, from: transactionModel.from, evmSignatureName: transactionModel.evmSignatureName, @@ -443,6 +321,12 @@ abstract class EVMChainWalletBase tag: token.tag ?? EVMChainUtils.getDefaultTokenTag(selectedChainId), iconPath: iconPath, isPotentialScam: token.isPotentialScam, + walletName: walletInfo.name, + chainId: selectedChainId, + groups: EVMChainDefaultTokens.getDefaultGroupsByAddress( + selectedChainId, + token.contractAddress, + ), ); } @@ -459,27 +343,31 @@ abstract class EVMChainWalletBase ); } - String _getUSDCContractAddress() { + String? _getUSDCContractAddress() { return switch (selectedChainId) { 1 => "0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48", 137 => "0x2791bca1f2de4661ed88a30c99a7a9449aa84174", 8453 => "0x833589fcd6edb6e08f4c7c32d4f71b54bda02913", 42161 => "0xaf88d065e77c8cC2239327C5EDb3A432268e5831", 56 => "0x8ac76a51cc950d9822d68b83fe1ad97b32cd580d", - _ => throw Exception("Unsupported chain ID: $selectedChainId"), + // Robinhood Chain does not have USDC so using USDG in its place + 4663 => "0x5fc5360d0400a0fd4f2af552add042d716f1d168", + _ => null, }; } @override Future checkNodeHealth() async { try { - // Check native balance await _client.getBalance(_evmChainPrivateKey.address); - // Check USDC token balance - String usdcContractAddress = _getUSDCContractAddress(); + final usdcAddress = _getUSDCContractAddress(); + if (usdcAddress != null) { + final usdcContractAddress = + Erc20Token(name: "USDC", symbol: "USDC", contractAddress: usdcAddress, decimal: 6); - await _client.fetchERC20Balances(_evmChainPrivateKey.address, usdcContractAddress); + await _client.fetchERC20Balances(_evmChainPrivateKey.address, usdcContractAddress); + } return true; } catch (e) { @@ -492,7 +380,7 @@ abstract class EVMChainWalletBase String idFor(String name, WalletType type) => '${walletTypeToString(type).toLowerCase()}_$name'; Future init() async { - await initErc20TokensBox(); + await initErc20Tokens(); await walletAddresses.init(); await transactionHistory.init(); @@ -531,73 +419,116 @@ abstract class EVMChainWalletBase // Ensure balance is initialized for current currency (in case currency changed) if (!balance.containsKey(currency)) { - balance[currency] = EVMChainERC20Balance(BigInt.zero); + balance[currency] = EVMChainERC20Balance(Money.zero(currency)); } await save(); } - bool isTokenPropertiesSuspicious(Erc20Token token) { - bool isTokenWhitelisted = getDefaultTokenContractAddresses - .any((element) => element.toLowerCase() == token.contractAddress.toLowerCase()); + static const _urlLikeSuspiciousMarkers = [ + 't.me', + '.me', + 'telegram', + 'http', + 'https', + '.com', + '.org', + '.top', + '.live', + '.xyz', + 'www', + '🎁', + 'airdrop', + 'distribution', + ]; - final defaultTokenSymbols = EVMChainDefaultTokens.getDefaultTokenSymbols(selectedChainId); + static final _suspiciousWordPattern = RegExp(r'\b(bot|claim|reward)\b', caseSensitive: false); + + static const _knownNonEvmNativeSymbols = { + 'ICP', + 'SOL', + 'TRX', + 'ATOM', + 'DOT', + 'ADA', + 'XRP', + 'XLM', + 'XMR', + 'ALGO', + 'NEAR', + 'TON', + 'HBAR', + 'APT', + 'SUI', + 'KAS', + }; + + static bool _hasSuspiciousData(String normalized) { + final lower = normalized.toLowerCase(); + if (_urlLikeSuspiciousMarkers.any(lower.contains)) return true; + return _suspiciousWordPattern.hasMatch(lower); + } + + bool isTokenPropertiesSuspicious( + Erc20Token token, { + Set? cachedWhitelistLower, + Set? cachedDefaultSymbolsUpper, + }) { + final whitelistLower = cachedWhitelistLower ?? + getDefaultTokenContractAddresses.map((a) => a.toLowerCase()).toSet(); + final defaultSymbolsUpper = cachedDefaultSymbolsUpper ?? + EVMChainDefaultTokens.getDefaultTokenSymbols(selectedChainId).toSet(); + + final isTokenWhitelisted = whitelistLower.contains(token.contractAddress.toLowerCase()); - // Normalize the token data to check for homoglyph spoofing attack, characters that look like ASCII (Cyrillic, Greek, etc.) final normalizedName = normalizeHomoglyphs(token.name.trim().toUpperCase()); final normalizedSymbol = normalizeHomoglyphs(token.symbol.trim().toUpperCase()); final normalizedTitle = normalizeHomoglyphs(token.title.trim().toUpperCase()); - final suspiciousStrings = [ - 't.me', - '.me', - 'telegram', - 'http', - 'https', - '.com', - '.org', - '.top', - '.live', - 'airdrop', - 'reward', - 'distribution', - 'www', - '.xyz', - '🎁', - 'bot', - 'claim', - 'reward', - ]; - - final hasSuspiciousData = suspiciousStrings.any( - (element) => - normalizedName.toLowerCase().contains(element) || - normalizedSymbol.toLowerCase().contains(element) || - normalizedTitle.toLowerCase().contains(element), - ); + final hasSuspiciousData = _hasSuspiciousData(normalizedName) || + _hasSuspiciousData(normalizedSymbol) || + _hasSuspiciousData(normalizedTitle); final nativeSymbol = currency.title.toUpperCase(); final hasSuspiciousNativeSymbol = normalizedSymbol == nativeSymbol && !isTokenWhitelisted; final hasSuspiciousDefaultTokenSymbol = - defaultTokenSymbols.contains(normalizedSymbol) && !isTokenWhitelisted; + defaultSymbolsUpper.contains(normalizedSymbol) && !isTokenWhitelisted; - return hasSuspiciousData || hasSuspiciousNativeSymbol || hasSuspiciousDefaultTokenSymbol; + final hasSuspiciousNonEvmNativeSymbol = + _knownNonEvmNativeSymbols.contains(normalizedSymbol) && !isTokenWhitelisted; + + return hasSuspiciousData || + hasSuspiciousNativeSymbol || + hasSuspiciousDefaultTokenSymbol || + hasSuspiciousNonEvmNativeSymbol; } - Future _checkForExistingScamTokens() async { - for (var token in erc20Currencies) { - bool isPotentialScam = false; + String get _scamCheckDoneKey => 'evm_scam_check_v2_done_${walletInfo.name}'; - if (isTokenPropertiesSuspicious(token)) { - isPotentialScam = true; + Future _checkForExistingScamTokens() async { + final prefs = await sharedPrefs.future; + if (prefs.getBool(_scamCheckDoneKey) == true) return; + + final whitelistLower = getDefaultTokenContractAddresses.map((a) => a.toLowerCase()).toSet(); + final defaultSymbolsUpper = + EVMChainDefaultTokens.getDefaultTokenSymbols(selectedChainId).toSet(); + + for (var token in erc20Currencies) { + final suspicious = isTokenPropertiesSuspicious( + token, + cachedWhitelistLower: whitelistLower, + cachedDefaultSymbolsUpper: defaultSymbolsUpper, + ); + + if (suspicious && !token.isPotentialScam) { token.isPotentialScam = true; token.iconPath = null; await token.save(); + continue; } - // For fixing wrongly classified tokens - if (!isPotentialScam && token.isPotentialScam) { + if (!suspicious && token.isPotentialScam) { token.isPotentialScam = false; if (token.iconPath == null || token.iconPath!.isEmpty) { @@ -613,23 +544,25 @@ abstract class EVMChainWalletBase await token.save(); } } + + await prefs.setBool(_scamCheckDoneKey, true); } Future discoverTokensFromMoralis() async { try { - if (!evmChainErc20TokensBox.isOpen) return MoralisDiscoveryResult.empty; - final address = walletAddresses.address; if (address.isEmpty) return MoralisDiscoveryResult.empty; - final chainName = EVMChainUtils.getDefaultTokenSymbol(selectedChainId).toLowerCase(); + final chainName = EVMChainUtils.getMoralisChainName(selectedChainId); + if (chainName == null) { + return MoralisDiscoveryResult.empty; + } final walletTokens = await _client.fetchWalletTokensFromMoralis(address, chainName); if (walletTokens.isEmpty) return MoralisDiscoveryResult.empty; final existingTokenAddresses = { - for (final token in evmChainErc20TokensBox.values) - token.contractAddress.toLowerCase(): token, + for (final token in _erc20Tokens) token.contractAddress.toLowerCase(): token, }; final whitelistedTokenAddresses = @@ -664,6 +597,9 @@ abstract class EVMChainWalletBase DiscoveredToken( token: newToken, balanceWei: token.balanceWei, + verifiedContract: token.verifiedContract, + moralisUsdPrice: token.usdPrice, + moralisUsdValue: token.usdValue, ), ); } @@ -757,7 +693,7 @@ abstract class EVMChainWalletBase /// - The addressHex for the receiving wallet, /// - A contract address which would be essential in determining if to calculate the estimate for ERC20 or native ETH Future calculateActualEstimatedFeeForCreateTransaction({ - required amount, + required Money amount, required String? contractAddress, required String receivingAddressHex, required TransactionPriority? priority, @@ -774,7 +710,6 @@ abstract class EVMChainWalletBase final gasBaseFee = await _client.getGasBaseFee(); final gasPrice = await _client.getGasUnitPrice(); - // Validate that we got valid gas price if (gasPrice <= 0) { printV('Invalid gas price received: $gasPrice'); throw EVMChainTransactionFeesException('Failed to retrieve gas price from node'); @@ -791,7 +726,7 @@ abstract class EVMChainWalletBase final estimatedGas = await _client.getEstimatedGasUnitsForTransaction( contractAddress: contractAddress, senderAddress: _evmChainPrivateKey.address, - value: EtherAmount.fromBigInt(EtherUnit.wei, amount!), + value: EtherAmount.fromBigInt(EtherUnit.wei, amount.amount), gasPrice: EtherAmount.fromInt(EtherUnit.wei, adjustedGasPrice), toAddress: EthereumAddress.fromHex(receivingAddressHex), maxFeePerGas: EtherAmount.fromInt(EtherUnit.wei, maxFeePerGas), @@ -932,15 +867,15 @@ abstract class EVMChainWalletBase 'Currency ${_credentials.currency.title} ${_credentials.currency.tag} is not accessible in the wallet, try to enable it first.')); final currencyBalance = balance[transactionCurrency]!; - BigInt totalAmount = BigInt.zero; - BigInt estimatedFeesForTransaction = BigInt.zero; - int exponent = transactionCurrency is Erc20Token ? transactionCurrency.decimal : 18; - String? contractAddress; - int estimatedGasUnitsForTransaction = 0; - int maxFeePerGasForTransaction = 0; - String toAddress = _credentials.outputs.first.isParsedAddress + final toAddress = _credentials.outputs.first.isParsedAddress ? _credentials.outputs.first.extractedAddress! : _credentials.outputs.first.address; + var totalAmount = Money.zero(transactionCurrency); + var estimatedFeesForTransaction = Money.zero(currency); + var estimatedGasUnitsForTransaction = 0; + var maxFeePerGasForTransaction = 0; + + String? contractAddress; if (transactionCurrency is Erc20Token) { contractAddress = transactionCurrency.contractAddress; @@ -948,23 +883,12 @@ abstract class EVMChainWalletBase // so far this can not be made with Ethereum as Ethereum does not support multiple recipients if (hasMultiDestination) { - if (outputs.any((item) => item.sendAll || (item.formattedCryptoAmount ?? 0) <= 0)) { + if (outputs.any((item) => item.sendAll || item.cryptoAmount.amount <= BigInt.zero)) { throw EVMChainTransactionCreationException(transactionCurrency); } - totalAmount = outputs.fold( - BigInt.zero, - (acc, output) { - if (output.cryptoAmount != null && output.cryptoAmount!.isNotEmpty) { - return acc + - EVMChainFormatter.parseEVMChainAmountToBigInt( - output.cryptoAmount!, - decimals: exponent, - ); - } - return acc; - }, - ); + totalAmount = outputs.fold(Money.zero(transactionCurrency), + (acc, output) => acc + output.cryptoAmount.copyWith(currency: transactionCurrency)); final gasFeesModel = await calculateActualEstimatedFeeForCreateTransaction( amount: totalAmount, @@ -973,28 +897,22 @@ abstract class EVMChainWalletBase contractAddress: contractAddress, ); - estimatedFeesForTransaction = BigInt.from(gasFeesModel.estimatedGasFee); + estimatedFeesForTransaction = + estimatedFeesForTransaction.copyWith(amount: BigInt.from(gasFeesModel.estimatedGasFee)); estimatedGasUnitsForTransaction = gasFeesModel.estimatedGasUnits; maxFeePerGasForTransaction = gasFeesModel.maxFeePerGas; - if (currencyBalance.balance < totalAmount) { + if (currencyBalance.available < totalAmount) { throw EVMChainTransactionCreationException(transactionCurrency); } } else { final output = outputs.first; if (!output.sendAll) { - if (output.cryptoAmount != null && output.cryptoAmount!.isNotEmpty) { - totalAmount = EVMChainFormatter.parseEVMChainAmountToBigInt( - output.cryptoAmount!, - decimals: exponent, - ); - } else { - totalAmount = BigInt.zero; - } + totalAmount = output.cryptoAmount.copyWith(currency: transactionCurrency); } if (output.sendAll && transactionCurrency is Erc20Token) { - totalAmount = currencyBalance.balance; + totalAmount = currencyBalance.available; } final gasFeesModel = await calculateActualEstimatedFeeForCreateTransaction( @@ -1004,7 +922,8 @@ abstract class EVMChainWalletBase contractAddress: contractAddress, ); - estimatedFeesForTransaction = BigInt.from(gasFeesModel.estimatedGasFee); + estimatedFeesForTransaction = + estimatedFeesForTransaction.copyWith(amount: BigInt.from(gasFeesModel.estimatedGasFee)); estimatedGasUnitsForTransaction = gasFeesModel.estimatedGasUnits; maxFeePerGasForTransaction = gasFeesModel.maxFeePerGas; @@ -1012,30 +931,30 @@ abstract class EVMChainWalletBase if (selectedChainId == 8453) { // Applying a small buffer to account for gas price fluctuations // 10% or minimum 10,000 wei, whichever is higher - final refinedGasFee = estimatedFeesForTransaction; + final refinedGasFee = estimatedFeesForTransaction.amount; final gasBufferPercent = refinedGasFee * BigInt.from(110) ~/ BigInt.from(100); final gasBufferMin = refinedGasFee + BigInt.from(10000); final gasBuffer = gasBufferPercent > gasBufferMin ? gasBufferPercent : gasBufferMin; // Using the buffered fee for the final amount - totalAmount = (currencyBalance.balance - gasBuffer); - estimatedFeesForTransaction = gasBuffer; + totalAmount = totalAmount.copyWith(amount: currencyBalance.available.amount - gasBuffer); + estimatedFeesForTransaction = estimatedFeesForTransaction.copyWith(amount: gasBuffer); } else { // Calculating the final amount with the estimated gas fee - totalAmount = (currencyBalance.balance - estimatedFeesForTransaction); + totalAmount = currencyBalance.available - estimatedFeesForTransaction; } } // check the fees on the base currency - if (estimatedFeesForTransaction > balance[currency]!.balance) { + if (estimatedFeesForTransaction > balance[currency]!.available) { throw EVMChainTransactionFeesException.fromCurrency(currency.title); } - if (currencyBalance.balance < totalAmount) { + if (currencyBalance.available < totalAmount) { throw EVMChainTransactionCreationException(transactionCurrency); } if (transactionCurrency is! Erc20Token && - totalAmount + estimatedFeesForTransaction > currencyBalance.balance) { + totalAmount + estimatedFeesForTransaction > currencyBalance.available) { throw EVMChainTransactionFeesException.fromCurrency(currency.title); } } @@ -1056,7 +975,6 @@ abstract class EVMChainWalletBase currency: transactionCurrency, feeCurrency: EVMChainUtils.getFeeCurrency(selectedChainId), maxFeePerGas: maxFeePerGasForTransaction, - exponent: exponent, contractAddress: transactionCurrency is Erc20Token ? transactionCurrency.contractAddress : null, data: hexOpReturnMemo, @@ -1070,7 +988,7 @@ abstract class EVMChainWalletBase Future createCallDataTransaction( String to, String dataHex, - BigInt valueWei, + Money valueWei, EVMChainTransactionPriority? priority, String? sourceTokenAddress, BigInt? sourceTokenAmount, { @@ -1082,6 +1000,7 @@ abstract class EVMChainWalletBase 56 => CryptoCurrency.bnb, 8453 => CryptoCurrency.baseEth, 42161 => CryptoCurrency.arbEth, + 4663 => CryptoCurrency.robEth, _ => CryptoCurrency.eth, }; @@ -1101,37 +1020,36 @@ abstract class EVMChainWalletBase gas = GasParamsHandler.zero(); } - // Validate NATIVE Balance (for Gas + Value) - final nativeBal = balance[nativeCurrency]?.balance ?? BigInt.zero; - final requiredNative = valueWei + BigInt.from(gas.estimatedGasFee); + final nativeBal = balance[nativeCurrency]?.available ?? Money.zero(nativeCurrency); + var requiredNative = Money.fromInt(gas.estimatedGasFee, nativeCurrency); + + if (valueWei.currency == nativeCurrency) { + requiredNative += valueWei; + } if (requiredNative > nativeBal) { throw Exception('Not enough ${nativeCurrency.title} to cover value and fees.'); } - // Validate ERC20 Balance (Only if source is NOT native) final cleanAddress = sourceTokenAddress?.toLowerCase() ?? ''; - // Check for both 0xeeee... AND 0x0000... (Zero Address) - bool isNativeSource = sourceTokenAddress == null || + final isNativeSource = sourceTokenAddress == null || cleanAddress == '0xeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee' || cleanAddress == '0x0000000000000000000000000000000000000000'; if (!isNativeSource && sourceTokenAmount != null && sourceTokenAmount > BigInt.zero) { - // Filter list to find match. final matchingTokens = balance.keys .where((k) => k is Erc20Token && k.contractAddress.toLowerCase() == cleanAddress); if (matchingTokens.isEmpty) { - // Token is not in the wallet balance map -> Balance is 0 throw Exception('Insufficient token balance (Token not found in wallet).'); } final tokenKey = matchingTokens.first; - final tokenBalance = balance[tokenKey]?.balance ?? BigInt.zero; + final tokenBalance = balance[tokenKey]?.available ?? Money.zero(tokenKey); - if (tokenBalance < sourceTokenAmount) { - throw Exception('Insufficient ${tokenKey.title} balance to cover the transaction amount.'); + if (tokenBalance < Money(sourceTokenAmount, tokenKey)) { + throw Exception('Insufficient ${tokenKey.symbol} balance to cover the transaction amount.'); } } @@ -1144,13 +1062,12 @@ abstract class EVMChainWalletBase privateKey: _evmChainPrivateKey, toAddress: to, amount: valueWei, - gasFee: BigInt.from(gas.estimatedGasFee), + gasFee: Money.fromInt(gas.estimatedGasFee, currency), estimatedGasUnits: gasUnits, maxFeePerGas: gas.maxFeePerGas, priority: priority, currency: nativeCurrency, feeCurrency: nativeCurrency.title, - exponent: 18, contractAddress: null, data: dataHex, gasPrice: gas.gasPrice, @@ -1161,23 +1078,23 @@ abstract class EVMChainWalletBase } } - Future createApprovalTransaction(BigInt amount, String spender, - CryptoCurrency token, EVMChainTransactionPriority? priority, String feeCurrency, + Future createApprovalTransaction( + Money amount, String spender, EVMChainTransactionPriority? priority, {bool useBlinkProtection = true}) async { - final CryptoCurrency transactionCurrency = - balance.keys.firstWhere((element) => element.title == token.title); + final transactionCurrency = + balance.keys.firstWhere((element) => element.symbol == amount.currency.symbol); assert(transactionCurrency is Erc20Token); final data = _client.getEncodedDataForApprovalTransaction( contractAddress: EthereumAddress.fromHex((transactionCurrency as Erc20Token).contractAddress), - value: EtherAmount.fromBigInt(EtherUnit.wei, amount), + value: EtherAmount.fromBigInt(EtherUnit.wei, amount.amount), toAddress: EthereumAddress.fromHex(spender), ); - final tokenContract = (transactionCurrency as Erc20Token).contractAddress; + final tokenContract = transactionCurrency.contractAddress; final gasFeesModel = await calculateActualEstimatedFeeForCreateTransaction( - amount: BigInt.zero, + amount: Money.zero(currency), receivingAddressHex: tokenContract, priority: priority, contractAddress: tokenContract, @@ -1193,11 +1110,9 @@ abstract class EVMChainWalletBase spender: spender, amount: amount, priority: priority, - gasFee: BigInt.from(gasFeesModel.estimatedGasFee), + gasFee: Money.fromInt(gasFeesModel.estimatedGasFee, currency), maxFeePerGas: gasFeesModel.maxFeePerGas, - feeCurrency: feeCurrency, estimatedGasUnits: safeGasUnits, - exponent: transactionCurrency.decimal, contractAddress: tokenContract, gasPrice: gasFeesModel.gasPrice, useBlinkProtection: useBlinkProtection, @@ -1228,6 +1143,10 @@ abstract class EVMChainWalletBase @override Future> fetchTransactions() async { + if (!await checkIfScanProviderIsEnabled()) { + return {}; + } + final List transactions = []; final List>> erc20TokensTransactions = []; @@ -1286,6 +1205,8 @@ abstract class EVMChainWalletBase _hasEvmTokenContractAddress(newTxInfo) && !_hasEvmTokenContractAddress(existingTxInfo)) { result[transactionModel.hash] = newTxInfo; + } else if (existingTxInfo.isPending) { + result[transactionModel.hash] = newTxInfo; } } @@ -1347,7 +1268,8 @@ abstract class EVMChainWalletBase String toJSON() => json.encode({ 'mnemonic': _mnemonic, 'private_key': privateKey, - 'balance': balance[currency]?.toJSON() ?? EVMChainERC20Balance(BigInt.zero).toJSON(), + 'balance': + balance[currency]?.toJSON() ?? EVMChainERC20Balance(Money.zero(currency)).toJSON(), 'passphrase': passphrase, 'selected_chain_id': selectedChainId, }); @@ -1363,9 +1285,9 @@ abstract class EVMChainWalletBase try { final balance = await _client.getBalance(_evmChainPrivateKey.address); - return EVMChainERC20Balance(balance.getInWei); + return EVMChainERC20Balance(Money(balance.getInWei, currency)); } catch (_) { - return balance[currency] ?? EVMChainERC20Balance(BigInt.zero); + return balance[currency] ?? EVMChainERC20Balance(Money.zero(currency)); } } @@ -1383,32 +1305,21 @@ abstract class EVMChainWalletBase } Future _fetchErc20Balances() async { - // Check if box is open before accessing it - if (!evmChainErc20TokensBox.isOpen) { - return; - } - // First, clean up any tokens in balance map that don't belong to current chain // This handles tokens from previous chains that might still be in the balance map final tokensInBalance = balance.keys.whereType().toList(); - final tokensInBox = evmChainErc20TokensBox.values.toList(); - final boxTokenAddresses = tokensInBox.map((t) => t.contractAddress.toLowerCase()).toSet(); + final tokens = _erc20Tokens.toList(); + final cachedTokenAddresses = tokens.map((t) => t.contractAddress.toLowerCase()).toSet(); for (var token in tokensInBalance) { - // Remove token if it's not in the current box or doesn't match current chain - if (!boxTokenAddresses.contains(token.contractAddress.toLowerCase()) || + // Remove token if it's not in the current token list or doesn't match current chain + if (!cachedTokenAddresses.contains(token.contractAddress.toLowerCase()) || !_isTokenMatchingChain(token)) { balance.remove(token); } } - // Get a snapshot of tokens from current box to avoid issues if box is closed during iteration - final tokens = tokensInBox; - for (var token in tokens) { - // Check if box is still open before operating on tokens - if (!evmChainErc20TokensBox.isOpen) break; - if (!_isTokenMatchingChain(token)) { printV('NOTEE!!!: Token ${token.title} is not matching the currency ${currency.title}'); try { @@ -1422,10 +1333,7 @@ abstract class EVMChainWalletBase try { if (token.enabled) { - balance[token] = await _client.fetchERC20Balances( - _evmChainPrivateKey.address, - token.contractAddress, - ); + balance[token] = await _client.fetchERC20Balances(_evmChainPrivateKey.address, token); } else { balance.remove(token); } @@ -1499,15 +1407,7 @@ abstract class EVMChainWalletBase @override Future updateTransactionsHistory() async => await _updateTransactions(); - List get erc20Currencies { - try { - if (!evmChainErc20TokensBox.isOpen) return []; - - return evmChainErc20TokensBox.values.toList(); - } catch (_) { - return []; - } - } + List get erc20Currencies => _erc20Tokens.toList(); Future addErc20Token(Erc20Token token) async { final isSuspicious = isTokenPropertiesSuspicious(token); @@ -1516,11 +1416,18 @@ abstract class EVMChainWalletBase String? iconPath; if ((token.iconPath == null || token.iconPath!.isEmpty) && !token.isPotentialScam) { - try { - iconPath = CryptoCurrency.all - .firstWhere((element) => element.title.toUpperCase() == token.symbol.toUpperCase()) - .iconPath; - } catch (_) {} + iconPath = EVMChainDefaultTokens.getDefaultIconPathByAddress( + selectedChainId, + token.contractAddress, + ); + + if (iconPath == null || iconPath.isEmpty) { + try { + iconPath = CryptoCurrency.all + .firstWhere((element) => element.title.toUpperCase() == token.symbol.toUpperCase()) + .iconPath; + } catch (_) {} + } } else if (!token.isPotentialScam) { iconPath = token.iconPath; } @@ -1528,37 +1435,26 @@ abstract class EVMChainWalletBase final newToken = createNewErc20TokenObject(token, iconPath); if (newToken.enabled) { - try { - final erc20Balance = await _client.fetchERC20Balances( - _evmChainPrivateKey.address, - newToken.contractAddress, - ); + balance[newToken] = await _client.fetchERC20Balances(_evmChainPrivateKey.address, newToken); - balance[newToken] = erc20Balance; - - await evmChainErc20TokensBox.put(newToken.contractAddress, newToken); - } on Exception catch (_) { - rethrow; - } + await newToken.save(); } else { - await evmChainErc20TokensBox.put(newToken.contractAddress, newToken); + await newToken.save(); balance.remove(newToken); } + + _upsertCachedToken(newToken); } Future deleteErc20Token(Erc20Token token, {bool shouldUpdateBalance = true}) async { - // Check if box is open before trying to delete - if (!evmChainErc20TokensBox.isOpen) { - balance.remove(token); - return; + try { + await Erc20Token.deleteForWallet(walletInfo.name, selectedChainId, token.contractAddress); + } catch (e) { + printV('Error deleting token: $e'); } - try { - await token.delete(); - } catch (e) { - // Token might be from a closed box, just remove from balance - printV('Error deleting token from box: $e'); - } + _erc20Tokens + .removeWhere((t) => t.contractAddress.toLowerCase() == token.contractAddress.toLowerCase()); balance.remove(token); await removeTokenTransactionsInHistory(token); @@ -1572,7 +1468,7 @@ abstract class EVMChainWalletBase await transactionHistory.save(); } - Future getErc20Token(String contractAddress, String chainName) async { + Future getErc20Token(String contractAddress, String? chainName) async { try { return await _client.getErc20Token(contractAddress, chainName); } catch (e) { @@ -1604,9 +1500,6 @@ abstract class EVMChainWalletBase if (!hasKeysFile) rethrow; } - final balance = EVMChainERC20Balance.fromJSON(data?['balance'] as String?) ?? - EVMChainERC20Balance(BigInt.zero); - final WalletKeysData keysData; // Migrate wallet from the old scheme to the new .keys file scheme if (!hasKeysFile) { @@ -1651,6 +1544,10 @@ abstract class EVMChainWalletBase // Use saved chainId if available, otherwise use the computed chainId final initialChainIdForWallet = savedChainId ?? chainId; + final balance = + EVMChainERC20Balance.fromJSON(data?['balance'] as String?, chainConfig.nativeCurrency) ?? + EVMChainERC20Balance(Money.zero(chainConfig.nativeCurrency)); + return EVMChainWallet( walletInfo: walletInfo, derivationInfo: await walletInfo.getDerivationInfo(), @@ -1666,30 +1563,6 @@ abstract class EVMChainWalletBase ); } - @override - Future renameWalletFiles(String newWalletName) async { - final transactionHistoryFileNameForWallet = getTransactionHistoryFileName(); - - final currentWalletPath = await pathForWallet(name: walletInfo.name, type: type); - final currentWalletFile = File(currentWalletPath); - - final currentDirPath = await pathForWalletDir(name: walletInfo.name, type: type); - final currentTransactionsFile = File('$currentDirPath/$transactionHistoryFileNameForWallet'); - - // Copies current wallet files into new wallet name's dir and files - if (currentWalletFile.existsSync()) { - final newWalletPath = await pathForWallet(name: newWalletName, type: type); - await currentWalletFile.copy(newWalletPath); - } - if (currentTransactionsFile.existsSync()) { - final newDirPath = await pathForWalletDir(name: newWalletName, type: type); - await currentTransactionsFile.copy('$newDirPath/$transactionHistoryFileNameForWallet'); - } - - // Delete old name's dir and files - await Directory(currentDirPath).delete(recursive: true); - } - void _setTransactionUpdateTimer() { if (_transactionsUpdateTimer?.isActive ?? false) { _transactionsUpdateTimer!.cancel(); @@ -1714,26 +1587,46 @@ abstract class EVMChainWalletBase if (isEnabled) { _updateTransactions(); _setTransactionUpdateTimer(); - } else { - _transactionsUpdateTimer?.cancel(); } } @override - Future signMessage(String message, {String? address}) async { - return bytesToHex(await _evmChainPrivateKey.signPersonalMessage(ascii.encode(message))); - } + Future signMessage(String message, {String? address}) async => + bytesToHex(await _evmChainPrivateKey.signPersonalMessage(utf8.encode(message))); @override Future verifyMessage(String message, String signature, {String? address}) async { - if (address == null) { + if (address == null || address.isEmpty) { + return false; + } + + try { + final signatureBytes = hexToBytes(signature.trim().toLowerCase()); + if (signatureBytes.length != 65) { + return false; + } + + final messageBytes = utf8.encode(message); + final prefixedMessage = Uint8List.fromList( + ascii.encode("\x19Ethereum Signed Message:\n${messageBytes.length}") + messageBytes, + ); + final v = signatureBytes[64] < 27 ? signatureBytes[64] + 27 : signatureBytes[64]; + final publicKey = ecRecover( + keccak256(prefixedMessage), + MsgSignature( + bytesToUnsignedInt(signatureBytes.sublist(0, 32)), + bytesToUnsignedInt(signatureBytes.sublist(32, 64)), + v, + ), + ); + + final paddedPublicKey = Uint8List(64)..setRange(64 - publicKey.length, 64, publicKey); + final recoveredAddress = EthereumAddress.fromPublicKey(paddedPublicKey); + return recoveredAddress.hexNo0x == strip0x(address.trim().toLowerCase()); + } catch (e) { + printV("Failed to verify EVM message signature: $e"); return false; } - final recoveredAddress = EthSigUtil.recoverPersonalSignature( - message: ascii.encode(message), - signature: signature, - ); - return recoveredAddress.toUpperCase() == address.toUpperCase(); } Web3Client? getWeb3Client() => _client.getWeb3Client(); @@ -1770,10 +1663,16 @@ class GasParamsHandler { class DiscoveredToken { final Erc20Token token; final BigInt balanceWei; + final bool verifiedContract; + final double? moralisUsdPrice; + final double? moralisUsdValue; const DiscoveredToken({ required this.token, required this.balanceWei, + required this.verifiedContract, + this.moralisUsdPrice, + this.moralisUsdValue, }); } diff --git a/cw_evm/lib/evm_chain_wallet_addresses.dart b/cw_evm/lib/evm_chain_wallet_addresses.dart index f7cef7513..fad6eb688 100644 --- a/cw_evm/lib/evm_chain_wallet_addresses.dart +++ b/cw_evm/lib/evm_chain_wallet_addresses.dart @@ -1,18 +1,15 @@ -import 'dart:developer'; +import "dart:developer"; -import 'package:cw_core/payment_uris.dart'; -import 'package:cw_core/wallet_addresses.dart'; -import 'package:cw_core/wallet_info.dart'; -import 'package:mobx/mobx.dart'; +import "package:cw_core/payment_uris.dart"; +import "package:cw_core/wallet_addresses.dart"; +import "package:mobx/mobx.dart"; -part 'evm_chain_wallet_addresses.g.dart'; +part "evm_chain_wallet_addresses.g.dart"; class EVMChainWalletAddresses = EVMChainWalletAddressesBase with _$EVMChainWalletAddresses; abstract class EVMChainWalletAddressesBase extends WalletAddresses with Store { - EVMChainWalletAddressesBase(WalletInfo walletInfo, this._selectedChainId) - : address = '', - super(walletInfo); + EVMChainWalletAddressesBase(super.walletInfo, this._selectedChainId) : address = ""; @override @observable @@ -33,7 +30,7 @@ abstract class EVMChainWalletAddressesBase extends WalletAddresses with Store { Future updateAddressesInBox() async { try { addressesMap.clear(); - addressesMap[address] = ''; + addressesMap[address] = ""; await saveAddressesInBox(); } catch (e) { log(e.toString()); @@ -41,20 +38,10 @@ abstract class EVMChainWalletAddressesBase extends WalletAddresses with Store { } @override - PaymentURI getPaymentUri(String amount) { - switch (_selectedChainId) { - case 1: - return EthereumURI(amount: amount, address: address); - case 137: - return PolygonURI(amount: amount, address: address); - case 8453: - return BaseURI(amount: amount, address: address); - case 42161: - return ArbitrumURI(amount: amount, address: address); - case 56: - return BSCURI(amount: amount, address: address); - default: - return EthereumURI(amount: amount, address: address); - } - } + PaymentURI getPaymentUri(String amount) => ERC681URI( + address: address, + amount: amount, + chainId: _selectedChainId, + contractAddress: null, + ); } diff --git a/cw_evm/lib/evm_chain_wallet_service.dart b/cw_evm/lib/evm_chain_wallet_service.dart index d63686225..a1217a36d 100644 --- a/cw_evm/lib/evm_chain_wallet_service.dart +++ b/cw_evm/lib/evm_chain_wallet_service.dart @@ -1,14 +1,19 @@ -import 'dart:io'; +import "dart:io"; import 'package:bip39/bip39.dart' as bip39; import 'package:cw_core/encryption_file_utils.dart'; +import "package:cw_core/erc20_token.dart"; +import "package:cw_core/imported_nft.dart"; import 'package:cw_core/pathForWallet.dart'; +import 'package:cw_core/utils/print_verbose.dart'; import 'package:cw_core/wallet_base.dart'; import 'package:cw_core/wallet_info.dart'; import 'package:cw_core/wallet_service.dart'; import 'package:cw_core/wallet_type.dart'; +import 'package:path/path.dart' as p; import 'package:cw_evm/clients/evm_chain_client.dart'; import 'package:cw_evm/evm_chain_client_factory.dart'; +import 'package:cw_evm/evm_chain_exceptions.dart'; import 'package:cw_evm/evm_chain_registry.dart'; import 'package:cw_evm/evm_chain_wallet.dart'; import 'package:cw_evm/evm_chain_wallet_creation_credentials.dart'; @@ -32,6 +37,9 @@ class EVMChainWalletService extends WalletService< return _registry.getRegisteredWalletTypes(); } + List get _importedNFTChains => + _registry.getAllChains().map((chain) => chain.shortCode).toList(); + Future _findWalletByName(String name) async { for (final type in _evmWalletTypes) { final walletInfo = await WalletInfo.get(name, type); @@ -46,8 +54,8 @@ class EVMChainWalletService extends WalletService< @override WalletType getType() { throw UnsupportedError( - 'EVMChainWalletService is unified and does not have a single type. ' - 'Use walletInfo.type instead.', + "EVMChainWalletService is unified and does not have a single type. " + "Use walletInfo.type instead.", ); } @@ -57,7 +65,7 @@ class EVMChainWalletService extends WalletService< Future saveBackup(String name, {WalletInfo? walletInfo}) async { final info = walletInfo ?? await _findWalletByName(name); if (info == null) { - throw Exception('Wallet not found: $name'); + throw Exception("Wallet not found: $name"); } final backupWalletDirPath = await pathForWalletDir(name: "$name.backup", type: info.type); @@ -73,7 +81,7 @@ class EVMChainWalletService extends WalletService< Future restoreWalletFilesFromBackup(String name) async { final walletInfo = await _findWalletByName(name); if (walletInfo == null) { - throw Exception('Wallet not found: $name'); + throw Exception("Wallet not found: $name"); } final backupWalletDirPath = await pathForWalletDir(name: "$name.backup", type: walletInfo.type); @@ -94,7 +102,7 @@ class EVMChainWalletService extends WalletService< // Get chainId from wallet type final chainConfig = _registry.getChainConfigByWalletType(walletInfo.type); if (chainConfig == null) { - throw Exception('Chain config not found for wallet type: ${walletInfo.type}'); + throw Exception("Chain config not found for wallet type: ${walletInfo.type}"); } final initialChainId = chainConfig.chainId; @@ -122,7 +130,7 @@ class EVMChainWalletService extends WalletService< ); await wallet.init(); - wallet.addInitialTokens(); + await wallet.addInitialTokens(); await wallet.save(); return wallet; } @@ -131,7 +139,7 @@ class EVMChainWalletService extends WalletService< Future openWallet(String name, String password) async { final walletInfo = await _findWalletByName(name); if (walletInfo == null) { - throw Exception('Wallet not found'); + throw Exception("Wallet not found"); } try { @@ -143,7 +151,7 @@ class EVMChainWalletService extends WalletService< ); await wallet.init(); - wallet.addInitialTokens(); + await wallet.addInitialTokens(); await wallet.save(); await saveBackup(name); return wallet; @@ -158,7 +166,7 @@ class EVMChainWalletService extends WalletService< ); await wallet.init(); - wallet.addInitialTokens(); + await wallet.addInitialTokens(); await wallet.save(); return wallet; } @@ -166,29 +174,47 @@ class EVMChainWalletService extends WalletService< @override Future rename(String currentName, String password, String newName) async { + if (currentName == newName) return; + final currentWalletInfo = await _findWalletByName(currentName); if (currentWalletInfo == null) { - throw Exception('Wallet not found'); + throw Exception("Wallet not found"); } - final currentWallet = await _openWalletInstance( - password: password, - name: currentName, - walletInfo: currentWalletInfo, - encryptionFileUtils: encryptionFileUtilsFor(isDirect), - ); + final type = currentWalletInfo.type; - await currentWallet.renameWalletFiles(newName); + await copyWalletFilesTo(fromName: currentName, toName: newName, type: type); + await saveBackup(newName, walletInfo: currentWalletInfo); - // Update walletInfo with new name before saving backup - final newWalletInfo = currentWalletInfo; - newWalletInfo.id = WalletBase.idFor(newName, currentWalletInfo.type); - newWalletInfo.name = newName; + currentWalletInfo.id = WalletBase.idFor(newName, type); + currentWalletInfo.name = newName; + await currentWalletInfo.save(); - // Pass walletInfo to saveBackup to avoid lookup (since WalletInfo not saved yet) - await saveBackup(newName, walletInfo: newWalletInfo); + final oldNameStillUsed = (await _findWalletByName(currentName)) != null; + if (oldNameStillUsed) { + for (final token in await Erc20Token.selectList("walletName = ?", [currentName])) { + final copiedToken = Erc20Token.copyWith(token, walletName: newName); + await copiedToken.save(); + } - await newWalletInfo.save(); + final ownNFTs = (await ImportedNFT.getAllForWallet(currentName)) + .where((nft) => _importedNFTChains.contains(nft.chain)); + for (final nft in ownNFTs) { + await ImportedNFT.copyWith(nft, walletName: newName).save(); + } + } else { + await Erc20Token.renameWallet(currentName, newName); + await ImportedNFT.renameWallet(currentName, newName, chains: _importedNFTChains); + } + + final oldDir = Directory(p.join(await pathForWalletTypeDir(type: type), currentName)); + if (oldDir.existsSync()) { + try { + await oldDir.delete(recursive: true); + } catch (e) { + printV('rename: failed to delete old wallet dir "$currentName": $e'); + } + } } @override @@ -196,12 +222,16 @@ class EVMChainWalletService extends WalletService< EVMChainRestoreWalletFromSeedCredentials credentials, { bool? isTestnet, }) async { + if (!bip39.validateMnemonic(credentials.mnemonic)) { + throw EVMChainMnemonicIsIncorrectException(); + } + final walletInfo = credentials.walletInfo!; // Get chainId from wallet type final chainConfig = _registry.getChainConfigByWalletType(walletInfo.type); if (chainConfig == null) { - throw Exception('Chain config not found for wallet type: ${walletInfo.type}'); + throw Exception("Chain config not found for wallet type: ${walletInfo.type}"); } final initialChainId = chainConfig.chainId; @@ -227,7 +257,7 @@ class EVMChainWalletService extends WalletService< ); await wallet.init(); - wallet.addInitialTokens(); + await wallet.addInitialTokens(); await wallet.save(); return wallet; } @@ -242,7 +272,7 @@ class EVMChainWalletService extends WalletService< // Get chainId from wallet type final chainConfig = _registry.getChainConfigByWalletType(walletInfo.type); if (chainConfig == null) { - throw Exception('Chain config not found for wallet type: ${walletInfo.type}'); + throw Exception("Chain config not found for wallet type: ${walletInfo.type}"); } final initialChainId = chainConfig.chainId; @@ -267,7 +297,7 @@ class EVMChainWalletService extends WalletService< ); await wallet.init(); - wallet.addInitialTokens(); + await wallet.addInitialTokens(); await wallet.save(); return wallet; } @@ -281,7 +311,7 @@ class EVMChainWalletService extends WalletService< // Get chainId from wallet type final chainConfig = _registry.getChainConfigByWalletType(walletInfo.type); if (chainConfig == null) { - throw Exception('Chain config not found for wallet type: ${walletInfo.type}'); + throw Exception("Chain config not found for wallet type: ${walletInfo.type}"); } final initialChainId = chainConfig.chainId; @@ -305,7 +335,7 @@ class EVMChainWalletService extends WalletService< ); await wallet.init(); - wallet.addInitialTokens(); + await wallet.addInitialTokens(); await wallet.save(); return wallet; } @@ -324,11 +354,16 @@ class EVMChainWalletService extends WalletService< Future remove(String wallet) async { final walletInfo = await _findWalletByName(wallet); if (walletInfo == null) { - throw Exception('Wallet not found'); + throw Exception("Wallet not found"); } File(await pathForWalletDir(name: wallet, type: walletInfo.type)).delete(recursive: true); await WalletInfo.delete(walletInfo); + final nameStillUsed = (await _findWalletByName(wallet)) != null; + if (!nameStillUsed) { + await Erc20Token.deleteAllForWallet(wallet); + await ImportedNFT.deleteAllForWallet(wallet, chains: _importedNFTChains); + } } EVMChainWallet _createWalletInstance({ @@ -346,7 +381,7 @@ class EVMChainWalletService extends WalletService< final chainConfig = _registry.getChainConfigByWalletType(walletType); if (chainConfig == null) { - throw Exception('Chain config not found for wallet type: $walletType'); + throw Exception("Chain config not found for wallet type: $walletType"); } return EVMChainWallet( diff --git a/cw_evm/lib/evm_erc20_balance.dart b/cw_evm/lib/evm_erc20_balance.dart index 54dec1e8c..3998728da 100644 --- a/cw_evm/lib/evm_erc20_balance.dart +++ b/cw_evm/lib/evm_erc20_balance.dart @@ -1,32 +1,23 @@ -import 'dart:convert'; +import "dart:convert"; -import 'package:cw_core/balance.dart'; +import "package:cw_core/amount/money.dart"; +import "package:cw_core/balance.dart"; +import "package:cw_core/currency.dart"; class EVMChainERC20Balance extends Balance { - EVMChainERC20Balance(this.balance, {this.exponent = 18}) : super(balance, balance); + EVMChainERC20Balance(Money balance) : super(balance, Money.zero(balance.currency)); - final BigInt balance; - final int exponent; + String toJSON() => json.encode({"balanceInWei": available.amount.toString()}); - String toJSON() => json.encode({ - 'balanceInWei': balance.toString(), - 'exponent': exponent, - }); - - static EVMChainERC20Balance? fromJSON(String? jsonSource) { - if (jsonSource == null) { - return null; - } + static EVMChainERC20Balance? fromJSON(String? jsonSource, Currency currency) { + if (jsonSource == null) return null; final decoded = json.decode(jsonSource) as Map; try { - return EVMChainERC20Balance( - BigInt.parse(decoded['balanceInWei']), - exponent: decoded['exponent'], - ); + return EVMChainERC20Balance(Money(BigInt.parse(decoded["balanceInWei"]), currency)); } catch (e) { - return EVMChainERC20Balance(BigInt.zero); + return EVMChainERC20Balance(Money.zero(currency)); } } } diff --git a/cw_evm/lib/pending_evm_chain_transaction.dart b/cw_evm/lib/pending_evm_chain_transaction.dart index 3e9ad39f2..15ad59752 100644 --- a/cw_evm/lib/pending_evm_chain_transaction.dart +++ b/cw_evm/lib/pending_evm_chain_transaction.dart @@ -1,6 +1,6 @@ import 'dart:typed_data'; -import 'package:cw_core/format_fixed.dart'; +import 'package:cw_core/amount/money.dart'; import 'package:cw_core/pending_transaction.dart'; import 'package:web3dart/crypto.dart'; import 'package:hex/hex.dart' as Hex; @@ -8,36 +8,36 @@ import 'package:hex/hex.dart' as Hex; class PendingEVMChainTransaction with PendingTransaction { final Function sendTransaction; final Uint8List signedTransaction; - final BigInt fee; - final String feeCurrency; - final String amount; - final int exponent; final bool isInfiniteApproval; PendingEVMChainTransaction({ required this.sendTransaction, required this.signedTransaction, required this.fee, - required this.feeCurrency, required this.amount, - required this.exponent, this.isInfiniteApproval = false, }); @override String get amountFormatted { if (isInfiniteApproval) return "∞"; - return formatFixed(BigInt.parse(amount), exponent); + return amount.toString(); } + @override + final Money amount; + + @override + final Money fee; + @override Future commit() async => await sendTransaction(); @override - String get feeFormatted => "$feeFormattedValue $feeCurrency"; + String get feeFormatted => fee.toStringWithSymbol(fractionalDigits: 10); @override - String get feeFormattedValue => formatFixed(fee, 18, fractionalDigits: 10); + String get feeFormattedValue => fee.toStringWithPrecision(fractionalDigits: 10); @override String get hex => bytesToHex(signedTransaction, include0x: true); @@ -61,7 +61,5 @@ class PendingEVMChainTransaction with PendingTransaction { } @override - Future> commitUR() { - throw UnimplementedError(); - } + Future> commitUR() => throw UnimplementedError(); } diff --git a/cw_evm/lib/tokens/robinhood_tokens.dart b/cw_evm/lib/tokens/robinhood_tokens.dart new file mode 100644 index 000000000..eba60a774 --- /dev/null +++ b/cw_evm/lib/tokens/robinhood_tokens.dart @@ -0,0 +1,409 @@ +import "package:cw_core/crypto_currency.dart"; +import "package:cw_core/currency_groups.dart"; +import "package:cw_core/erc20_token.dart"; + +class RobinhoodTokens { + static List get tokens { + final tokens = [ + Erc20Token( + name: "Global Dollar", + symbol: "USDG", + contractAddress: "0x5fc5360d0400a0fd4f2af552add042d716f1d168", + decimal: 6, + enabled: true, + iconPath: "assets/images/usdg_icon.png", + groups: const {CurrencyGroups.stablecoin}, + ), + Erc20Token( + name: "Ethena USDe", + symbol: "USDe", + contractAddress: "0x5d3a1ff2b6bab83b63cd9ad0787074081a52ef34", + decimal: 18, + enabled: true, + groups: const {CurrencyGroups.stablecoin}, + ), + Erc20Token( + name: "WETH", + symbol: "WETH", + contractAddress: "0x0bd7d308f8e1639fab988df18a8011f41eacad73", + decimal: 18, + enabled: false, + ), + Erc20Token( + name: "Apple", + symbol: "AAPL", + contractAddress: "0xaf3d76f1834a1d425780943c99ea8a608f8a93f9", + decimal: 18, + enabled: false, + iconPath: "assets/images/stocks/apple.webp", + groups: const {CurrencyGroups.tokenizedStock}, + ), + Erc20Token( + name: "AMD", + symbol: "AMD", + contractAddress: "0x86923f96303d656e4aa86d9d42d1e57ad2023fdc", + decimal: 18, + enabled: false, + iconPath: "assets/images/stocks/amd.png", + groups: const {CurrencyGroups.tokenizedStock}, + ), + Erc20Token( + name: "Amazon", + symbol: "AMZN", + contractAddress: "0x12f190a9f9d7d37a250758b26824b97ce941bf54", + decimal: 18, + enabled: false, + iconPath: "assets/images/stocks/amznx.webp", + groups: const {CurrencyGroups.tokenizedStock}, + ), + Erc20Token( + name: "ASML Holding NV", + symbol: "ASML", + contractAddress: "0x47f93d52cbec7c6d2cfc080e154002370a60daea", + decimal: 18, + enabled: false, + iconPath: "assets/images/stocks/asml.png", + groups: const {CurrencyGroups.tokenizedStock}, + ), + Erc20Token( + name: "Broadcom", + symbol: "AVGO", + contractAddress: "0x156e175dd063a8ce274c50654ef40e0032b3fbcf", + decimal: 18, + enabled: false, + iconPath: "assets/images/stocks/avgox.webp", + groups: const {CurrencyGroups.tokenizedStock}, + ), + Erc20Token( + name: "Alibaba", + symbol: "BABA", + contractAddress: "0xad25ac6c84d497db898fa1e8387bf6af3532a1c4", + decimal: 18, + enabled: false, + iconPath: "assets/images/stocks/baba.png", + groups: const {CurrencyGroups.tokenizedStock}, + ), + Erc20Token( + name: "Coinbase", + symbol: "COIN", + contractAddress: "0x6330d8c3178a418788df01a47479c0ce7ccf450b", + decimal: 18, + enabled: false, + iconPath: "assets/images/stocks/coinx.webp", + groups: const {CurrencyGroups.tokenizedStock}, + ), + Erc20Token( + name: "Costco", + symbol: "COST", + contractAddress: "0x4ea005168d7f09a7a0ba9d1def21a479950e44c2", + decimal: 18, + enabled: false, + iconPath: "assets/images/stocks/cost.png", + groups: const {CurrencyGroups.tokenizedStock}, + ), + Erc20Token( + name: "Circle Internet Group", + symbol: "CRCL", + contractAddress: "0xdf0992e440dd0be65bd8439b609d6d4366bf1cb5", + decimal: 18, + enabled: false, + iconPath: "assets/images/stocks/crclx.webp", + groups: const {CurrencyGroups.tokenizedStock}, + ), + Erc20Token( + name: "Datadog", + symbol: "DDOG", + contractAddress: "0x27c99fbde9d0d2aa4f4bfb4943f237843ddf6958", + decimal: 18, + enabled: false, + iconPath: "assets/images/stocks/ddog.png", + groups: const {CurrencyGroups.tokenizedStock}, + ), + Erc20Token( + name: "Dell", + symbol: "DELL", + contractAddress: "0x941ae714ec6d8130c7b75d67160ca08f1e7d11dd", + decimal: 18, + enabled: false, + iconPath: "assets/images/stocks/dell.png", + groups: const {CurrencyGroups.tokenizedStock}, + ), + Erc20Token( + name: "Ford Motor", + symbol: "F", + contractAddress: "0x25c288e6d899b9bc30160965ad9644c67e73be0c", + decimal: 18, + enabled: false, + iconPath: "assets/images/stocks/f.png", + groups: const {CurrencyGroups.tokenizedStock}, + ), + Erc20Token( + name: "GameStop", + symbol: "GME", + contractAddress: "0x1b0e319c6a659f002271b69db8a7df2f911c153e", + decimal: 18, + enabled: false, + iconPath: "assets/images/stocks/gmex.webp", + groups: const {CurrencyGroups.tokenizedStock}, + ), + Erc20Token( + name: "Alphabet Class A", + symbol: "GOOGL", + contractAddress: "0x2e0847e8910a9732eb3fb1bb4b70a580adad4fe3", + decimal: 18, + enabled: false, + iconPath: "assets/images/stocks/googlx.webp", + groups: const {CurrencyGroups.tokenizedStock}, + ), + Erc20Token( + name: "Intel", + symbol: "INTC", + contractAddress: "0xc72b96e0e48ecd4dc75e1e45396e26300bc39681", + decimal: 18, + enabled: false, + iconPath: "assets/images/stocks/intcx.webp", + groups: const {CurrencyGroups.tokenizedStock}, + ), + Erc20Token( + name: "Intuit", + symbol: "INTU", + contractAddress: "0x56d23bee5f41a7120170b0c603dae30128e460e9", + decimal: 18, + enabled: false, + iconPath: "assets/images/stocks/intu.png", + groups: const {CurrencyGroups.tokenizedStock}, + ), + Erc20Token( + name: "Eli Lilly", + symbol: "LLY", + contractAddress: "0x8005d266423c7ea827372c9c864491e5786600ea", + decimal: 18, + enabled: false, + iconPath: "assets/images/stocks/llyx.webp", + groups: const {CurrencyGroups.tokenizedStock}, + ), + Erc20Token( + name: "Lululemon", + symbol: "LULU", + contractAddress: "0x4e62068525ab11fe768e29dfd00ef909b9803016", + decimal: 18, + enabled: false, + iconPath: "assets/images/stocks/lulu.png", + groups: const {CurrencyGroups.tokenizedStock}, + ), + Erc20Token( + name: "Meta Platforms", + symbol: "META", + contractAddress: "0xc0d6457c16cc70d6790dd43521c899c87ce02f35", + decimal: 18, + enabled: false, + iconPath: "assets/images/stocks/metax.webp", + groups: const {CurrencyGroups.tokenizedStock}, + ), + Erc20Token( + name: "Marvell Technology", + symbol: "MRVL", + contractAddress: "0x62fd0668e10d8b72339be2dcf7643001688ff13b", + decimal: 18, + enabled: false, + iconPath: "assets/images/stocks/mrvlx.webp", + groups: const {CurrencyGroups.tokenizedStock}, + ), + Erc20Token( + name: "Microsoft", + symbol: "MSFT", + contractAddress: "0xe93237c50d904957cf27e7b1133b510c669c2e74", + decimal: 18, + enabled: false, + iconPath: "assets/images/stocks/msftx.webp", + groups: const {CurrencyGroups.tokenizedStock}, + ), + Erc20Token( + name: "Strategy Inc.", + symbol: "MSTR", + contractAddress: "0xec262a75e413fafd0df80480274532c79d42da09", + decimal: 18, + enabled: false, + iconPath: "assets/images/stocks/mstrx.webp", + groups: const {CurrencyGroups.tokenizedStock}, + ), + Erc20Token( + name: "Micron Technology", + symbol: "MU", + contractAddress: "0xff080c8ce2e5feadaca0da81314ae59d232d4afd", + decimal: 18, + enabled: false, + iconPath: "assets/images/stocks/mu.png", + groups: const {CurrencyGroups.tokenizedStock}, + ), + Erc20Token( + name: "Netflix", + symbol: "NFLX", + contractAddress: "0xe0444ef8bf4ed74f74fd73686e2ddf4c1c5591e8", + decimal: 18, + enabled: false, + iconPath: "assets/images/stocks/nflxx.webp", + groups: const {CurrencyGroups.tokenizedStock}, + ), + Erc20Token( + name: "Nu", + symbol: "NU", + contractAddress: "0x408c14038a04f7bd235329e26d2bf569ee20e250", + decimal: 18, + enabled: false, + iconPath: "assets/images/stocks/nu.png", + groups: const {CurrencyGroups.tokenizedStock}, + ), + Erc20Token( + name: "NVIDIA", + symbol: "NVDA", + contractAddress: "0xd0601ce157db5bdc3162bbac2a2c8af5320d9eec", + decimal: 18, + enabled: false, + iconPath: "assets/images/stocks/nvdax.webp", + groups: const {CurrencyGroups.tokenizedStock}, + ), + Erc20Token( + name: "Oracle", + symbol: "ORCL", + contractAddress: "0xb0992820e760d836549ba69bc7598b4af75dee03", + decimal: 18, + enabled: false, + iconPath: "assets/images/stocks/orclx.webp", + groups: const {CurrencyGroups.tokenizedStock}, + ), + Erc20Token( + name: "Palantir Technologies", + symbol: "PLTR", + contractAddress: "0x894e1ec2d74ffe5aef8dc8a9e84686accb964f2a", + decimal: 18, + enabled: false, + iconPath: "assets/images/stocks/pltrx.webp", + groups: const {CurrencyGroups.tokenizedStock}, + ), + Erc20Token( + name: "Qualcomm", + symbol: "QCOM", + contractAddress: "0x0f17206447090e464c277571124dd2688e48aea9", + decimal: 18, + enabled: false, + iconPath: "assets/images/stocks/qcom.png", + groups: const {CurrencyGroups.tokenizedStock}, + ), + Erc20Token( + name: "Invesco QQQ", + symbol: "QQQ", + contractAddress: "0xd5f3879160bc7c32ebb4dc785f8a4f505888de68", + decimal: 18, + enabled: false, + iconPath: "assets/images/stocks/qqqx.webp", + groups: const {CurrencyGroups.tokenizedStock}, + ), + Erc20Token( + name: "Rivian Automotive", + symbol: "RIVN", + contractAddress: "0xb1bf26c1d20ff267a4f93550d1e0d06ac40a114b", + decimal: 18, + enabled: false, + iconPath: "assets/images/stocks/rivn.png", + groups: const {CurrencyGroups.tokenizedStock}, + ), + Erc20Token( + name: "Shopify", + symbol: "SHOP", + contractAddress: "0xf53f66751b1eff985311b693531e3290f600c410", + decimal: 18, + enabled: false, + iconPath: "assets/images/stocks/shop.png", + groups: const {CurrencyGroups.tokenizedStock}, + ), + Erc20Token( + name: "SoFi Technologies", + symbol: "SOFI", + contractAddress: "0x98e75885157c80992a8d41b696d8c9c6fb30a926", + decimal: 18, + enabled: false, + iconPath: "assets/images/stocks/sofi.png", + groups: const {CurrencyGroups.tokenizedStock}, + ), + Erc20Token( + name: "SPDR S&P 500 ETF Trust", + symbol: "SPY", + contractAddress: "0x117cc2133c37b721f49de2a7a74833232b3b4c0c", + decimal: 18, + enabled: false, + iconPath: "assets/images/stocks/spyx.webp", + groups: const {CurrencyGroups.tokenizedStock}, + ), + Erc20Token( + name: "Tesla", + symbol: "TSLA", + contractAddress: "0x322f0929c4625ed5bad873c95208d54e1c003b2d", + decimal: 18, + enabled: false, + iconPath: "assets/images/stocks/tslax.webp", + groups: const {CurrencyGroups.tokenizedStock}, + ), + Erc20Token( + name: "Taiwan Semiconductor Manufacturing", + symbol: "TSM", + contractAddress: "0x58ffe4a942d3885baa22d7520691f611ef09e7aa", + decimal: 18, + enabled: false, + iconPath: "assets/images/stocks/tsm.png", + groups: const {CurrencyGroups.tokenizedStock}, + ), + Erc20Token( + name: "Take-Two Interactive Software", + symbol: "TTWO", + contractAddress: "0x5e81213613b6b86eab4c6c50d718d34359459786", + decimal: 18, + enabled: false, + iconPath: "assets/images/stocks/ttwo.png", + groups: const {CurrencyGroups.tokenizedStock}, + ), + Erc20Token( + name: "UPS", + symbol: "UPS", + contractAddress: "0xf23250dac154d05bb671cb0d0ebef3c635c79ce2", + decimal: 18, + enabled: false, + iconPath: "assets/images/stocks/ups.png", + groups: const {CurrencyGroups.tokenizedStock}, + ), + Erc20Token( + name: "Workday", + symbol: "WDAY", + contractAddress: "0x82da4646242e1d962e96e932269dc644c94a9caa", + decimal: 18, + enabled: false, + iconPath: "assets/images/stocks/wday.png", + groups: const {CurrencyGroups.tokenizedStock}, + ), + Erc20Token( + name: "ExxonMobil Holdings Corporation", + symbol: "XOM", + contractAddress: "0xf9b46d3d1b22199d4d1025a9cedb540a33f1a2d5", + decimal: 18, + enabled: false, + iconPath: "assets/images/stocks/xomx.webp", + groups: const {CurrencyGroups.tokenizedStock}, + ), + ]; + + return tokens.map((token) { + String? iconPath; + if (token.iconPath?.isEmpty ?? true) { + try { + iconPath = CryptoCurrency.all + .firstWhere((element) => element.title.toUpperCase() == token.symbol.toUpperCase()) + .iconPath; + } catch (_) {} + } else { + iconPath = token.iconPath; + } + + return Erc20Token.copyWith(token, icon: iconPath, tag: "ROB"); + }).toList(); + } +} diff --git a/cw_evm/lib/usdt0/usdt0_service.dart b/cw_evm/lib/usdt0/usdt0_service.dart index 963994b0c..7aa79c95e 100644 --- a/cw_evm/lib/usdt0/usdt0_service.dart +++ b/cw_evm/lib/usdt0/usdt0_service.dart @@ -1,3 +1,4 @@ +import 'package:cw_core/amount/money.dart'; import 'package:cw_core/erc20_token.dart'; import 'package:cw_core/pending_transaction.dart'; import 'package:cw_evm/contract/erc20.dart'; @@ -80,11 +81,9 @@ class USDT0Service { ); final pendingApproval = await wallet.createApprovalTransaction( - maxUint, + Money(maxUint, token), adapter, - token, priority, - EVMChainUtils.getFeeCurrency(wallet.selectedChainId), useBlinkProtection: useBlinkProtection, ); await pendingApproval.commit(); @@ -116,7 +115,7 @@ class USDT0Service { return wallet.createCallDataTransaction( oftAddress, encoded.dataHex, - encoded.valueWei, + Money(encoded.valueWei, token), priority, token.contractAddress, amount, diff --git a/cw_evm/lib/utils/evm_chain_formatter.dart b/cw_evm/lib/utils/evm_chain_formatter.dart index f9f3a73bd..5a9c321c8 100644 --- a/cw_evm/lib/utils/evm_chain_formatter.dart +++ b/cw_evm/lib/utils/evm_chain_formatter.dart @@ -11,40 +11,6 @@ class EVMChainFormatter { } } - /// Parse EVM chain amount to BigInt to avoid integer overflow for large amounts - /// [decimals] defaults to 18 for native ETH/POL, but should be set to token decimals for ERC20 tokens - static BigInt parseEVMChainAmountToBigInt(String amount, {int decimals = evmDecimals}) { - try { - String cleanAmount = amount.replaceAll(',', '.'); - - bool isNegative = cleanAmount.startsWith('-'); - if (isNegative) { - cleanAmount = cleanAmount.substring(1); - } - - final parts = cleanAmount.split('.'); - String whole = parts[0].isEmpty ? '0' : parts[0]; - String fraction = parts.length > 1 ? parts[1] : ''; - - // 3. Strict Truncation Logic - // If fraction is longer than decimals, strictly cut it off. - // If shorter, pad it with zeros. - if (fraction.length > decimals) { - fraction = fraction.substring(0, decimals); - } else { - fraction = fraction.padRight(decimals, '0'); - } - - final wholeBigInt = BigInt.parse(whole); - final fractionBigInt = BigInt.parse(fraction); - final multiplier = BigInt.from(10).pow(decimals); - - return (wholeBigInt * multiplier) + fractionBigInt; - } catch (_) { - return BigInt.zero; - } - } - static String truncateDecimals(String amount, int decimals) { final parts = amount.split("."); diff --git a/cw_evm/lib/utils/evm_chain_utils.dart b/cw_evm/lib/utils/evm_chain_utils.dart index 60355cabf..5a7611a31 100644 --- a/cw_evm/lib/utils/evm_chain_utils.dart +++ b/cw_evm/lib/utils/evm_chain_utils.dart @@ -1,26 +1,24 @@ -import 'package:cw_core/erc20_token.dart'; -import 'package:cw_evm/evm_chain_transaction_priority.dart'; -import 'package:web3dart/web3dart.dart' show EtherAmount, EtherUnit; +import "package:cw_evm/evm_chain_transaction_priority.dart"; +import "package:web3dart/web3dart.dart" show EtherAmount, EtherUnit; /// Utility class for chain-specific EVM chain operations class EVMChainUtils { - static int getTotalPriorityFee(EVMChainTransactionPriority priority, int chainId) { - return switch (chainId) { - 1 => _ethereumPriorityFee(priority), - 137 => _polygonPriorityFee(priority), - 8453 => _basePriorityFee(priority), - 56 => _ethereumPriorityFee(priority), - 42161 => 0, // Arbitrum doesn't use priority fees - _ => _ethereumPriorityFee(priority), - }; - } + static int getTotalPriorityFee(EVMChainTransactionPriority priority, int chainId) => + switch (chainId) { + 1 => _ethereumPriorityFee(priority), + 137 => _polygonPriorityFee(priority), + 8453 => _basePriorityFee(priority), + 56 => _ethereumPriorityFee(priority), + 42161 => 0, // Arbitrum doesn't use priority fees + 4663 => 0, // Robinhood Chain (Arbitrum Orbit) doesn't use priority fees + _ => _ethereumPriorityFee(priority), + }; - static bool hasPriorityFee(int chainId) { - return switch (chainId) { - 42161 => false, // Arbitrum doesn't use priority fees - _ => true, - }; - } + static bool hasPriorityFee(int chainId) => switch (chainId) { + 42161 => false, // Arbitrum doesn't use priority fees + 4663 => false, // Robinhood Chain (Arbitrum Orbit) doesn't use priority fees + _ => true, + }; static int computeBufferedMaxFeePerGasWei({ required int? gasBaseFee, @@ -38,78 +36,68 @@ class EVMChainUtils { return gasPrice + priorityFeeWei; } - static String getErc20TokensBoxName(String walletName, int chainId) { - final sanitizedName = walletName.replaceAll(" ", "_"); - - return switch (chainId) { - 1 => "${sanitizedName}_${Erc20Token.ethereumBoxName}", - 137 => "${sanitizedName}_${Erc20Token.polygonBoxName}", - 8453 => "${sanitizedName}_${Erc20Token.baseBoxName}", - 42161 => "${sanitizedName}_${Erc20Token.arbitrumBoxName}", - 56 => "${sanitizedName}_${Erc20Token.bscBoxName}", - _ => "${sanitizedName}_${Erc20Token.ethereumBoxName}", - }; - } - - static String getTransactionHistoryFileName(int chainId) { - return switch (chainId) { - 1 => 'transactions.json', // Ethereum - 137 => 'polygon_transactions.json', - 8453 => 'base_transactions.json', - 42161 => 'arbitrum_transactions.json', - 56 => 'bsc_transactions.json', - _ => 'transactions_$chainId.json', // Generic format for other chains - }; - } + static String getTransactionHistoryFileName(int chainId) => switch (chainId) { + 1 => "transactions.json", // Ethereum + 137 => "polygon_transactions.json", + 8453 => "base_transactions.json", + 42161 => "arbitrum_transactions.json", + 56 => "bsc_transactions.json", + 4663 => "robinhood_transactions.json", + _ => "transactions_$chainId.json", // Generic format for other chains + }; /// Get scan provider preference key for a wallet type - static String getScanProviderPreferenceKey(int chainId) { - return switch (chainId) { - 1 => 'use_etherscan', - 137 => 'use_polygonscan', - 8453 => 'use_basescan', - 42161 => 'use_arbiscan', - 56 => 'use_bscscan', - _ => 'use_etherscan', - }; - } + static String getScanProviderPreferenceKey(int chainId) => switch (chainId) { + 1 => "use_etherscan", + 137 => "use_polygonscan", + 8453 => "use_base_scan", + 42161 => "use_arbitrum_scan", + 56 => "use_bscscan", + 4663 => "use_robinhood_scan", + _ => "use_etherscan", + }; - static String getDefaultTokenTag(int chainId) { - return switch (chainId) { - 1 => 'ETH', - 137 => 'POL', - 8453 => 'BASE', - 42161 => 'ARB', - 56 => 'BSC', - _ => 'ETH', - }; - } + static String getDefaultTokenTag(int chainId) => switch (chainId) { + 1 => "ETH", + 137 => "POL", + 8453 => "BASE", + 42161 => "ARB", + 56 => "BSC", + 4663 => "ROB", + _ => "ETH", + }; - static String getFeeCurrency(int chainId) { - return switch (chainId) { - 1 => 'ETH', - 137 => 'POL', - 8453 => 'ETH', - 42161 => 'ETH', - 56 => 'BNB', - _ => 'ETH', - }; - } + static String getFeeCurrency(int chainId) => switch (chainId) { + 1 => "ETH", + 137 => "POL", + 8453 => "ETH", + 42161 => "ETH", + 56 => "BNB", + 4663 => "ETH", + _ => "ETH", + }; - static String getDefaultTokenSymbol(int chainId) { - return switch (chainId) { - 1 => 'ETH', - 137 => 'POL', - 8453 => 'BASE', - 42161 => 'ARBITRUM', - 56 => 'BSC', - _ => 'ETH', - }; - } + static String getDefaultTokenSymbol(int chainId) => switch (chainId) { + 1 => "ETH", + 137 => "POL", + 8453 => "BASE", + 42161 => "ARBITRUM", + 56 => "BSC", + 4663 => "ETH", + _ => "ETH", + }; - static int _ethereumPriorityFee(EVMChainTransactionPriority priority) { - return EtherAmount.fromInt(EtherUnit.gwei, priority.tip).getInWei.toInt(); - } + static String? getMoralisChainName(int chainId) => switch (chainId) { + 1 => "eth", + 137 => "polygon", + 8453 => "base", + 42161 => "arbitrum", + 56 => "bsc", + _ => null, + }; + + static int _ethereumPriorityFee(EVMChainTransactionPriority priority) => + EtherAmount.fromInt(EtherUnit.gwei, priority.tip).getInWei.toInt(); // Polygon priority fee calculation (minimum 25 gwei + additional based on priority) static int _polygonPriorityFee(EVMChainTransactionPriority priority) { @@ -127,12 +115,11 @@ class EVMChainUtils { return minPriorityFeeWei + additionalPriorityFee; } - static int _basePriorityFee(EVMChainTransactionPriority priority) { - return switch (priority) { - EVMChainTransactionPriority.fast => EtherAmount.fromInt(EtherUnit.mwei, 5).getInWei.toInt(), - EVMChainTransactionPriority.medium => EtherAmount.fromInt(EtherUnit.mwei, 3).getInWei.toInt(), - EVMChainTransactionPriority.slow => EtherAmount.fromInt(EtherUnit.mwei, 1).getInWei.toInt(), - _ => EtherAmount.fromInt(EtherUnit.mwei, 1).getInWei.toInt(), - }; - } + static int _basePriorityFee(EVMChainTransactionPriority priority) => switch (priority) { + EVMChainTransactionPriority.fast => EtherAmount.fromInt(EtherUnit.mwei, 5).getInWei.toInt(), + EVMChainTransactionPriority.medium => + EtherAmount.fromInt(EtherUnit.mwei, 3).getInWei.toInt(), + EVMChainTransactionPriority.slow => EtherAmount.fromInt(EtherUnit.mwei, 1).getInWei.toInt(), + _ => EtherAmount.fromInt(EtherUnit.mwei, 1).getInWei.toInt(), + }; } diff --git a/cw_evm/pubspec.yaml b/cw_evm/pubspec.yaml index 08af6583b..274aeb7b0 100644 --- a/cw_evm/pubspec.yaml +++ b/cw_evm/pubspec.yaml @@ -21,6 +21,7 @@ dependencies: http: ^1.1.0 hive: ^2.2.3 collection: ^1.17.1 + path: ^1.8.0 shared_preferences: ^2.0.15 mobx: ^2.0.7+4 cw_core: @@ -50,7 +51,7 @@ dev_dependencies: sdk: flutter build_runner: ^2.4.15 mobx_codegen: ^2.0.7 - hive_generator: ^2.0.1 +# hive_generator: ^2.0.1 flutter_lints: ^2.0.0 flutter: diff --git a/cw_evm/pubspec_overrides.yaml b/cw_evm/pubspec_overrides.yaml new file mode 120000 index 000000000..d957432d3 --- /dev/null +++ b/cw_evm/pubspec_overrides.yaml @@ -0,0 +1 @@ +../pubspec_overrides.yaml \ No newline at end of file diff --git a/cw_evm/test/robinhood_tokens_test.dart b/cw_evm/test/robinhood_tokens_test.dart new file mode 100644 index 000000000..d4a39ed2f --- /dev/null +++ b/cw_evm/test/robinhood_tokens_test.dart @@ -0,0 +1,91 @@ +import "package:cw_core/currency_groups.dart"; +import "package:cw_evm/tokens/robinhood_tokens.dart"; +import "package:cw_evm/utils/evm_chain_utils.dart"; +import "package:flutter_test/flutter_test.dart"; + +void main() { + group("RobinhoodTokens integrity", () { + final tokens = RobinhoodTokens.tokens; + final addressPattern = RegExp(r"^0x[0-9a-f]{40}$"); + + test("token list is non-empty", () => expect(tokens.isNotEmpty, isTrue)); + + test("every contract address is a lowercased 0x-prefixed 40-hex-char address", () { + for (final token in tokens) { + expect( + addressPattern.hasMatch(token.contractAddress), + isTrue, + reason: "bad address for ${token.symbol}: ${token.contractAddress}", + ); + } + }); + + test("every token has a non-empty name and symbol", () { + for (final token in tokens) { + expect(token.name.trim(), isNotEmpty, reason: "empty name for ${token.contractAddress}"); + expect( + token.symbol.trim(), + isNotEmpty, + reason: "empty symbol for ${token.contractAddress}", + ); + } + }); + + test("every decimal is within the 0..18 range", () { + for (final token in tokens) { + expect( + token.decimal, + inInclusiveRange(0, 18), + reason: "bad decimal for ${token.symbol}: ${token.decimal}", + ); + } + }); + + test("no duplicate contract addresses", () { + final seen = {}; + for (final token in tokens) { + final addr = token.contractAddress.toLowerCase(); + expect(seen.add(addr), isTrue, reason: "duplicate address $addr (${token.symbol})"); + } + }); + + test("USDG is the 6-decimal stablecoin and is enabled by default", () { + final usdg = tokens.firstWhere((t) => t.symbol == "USDG"); + expect(usdg.decimal, 6); + expect(usdg.enabled, isTrue); + }); + + test("tokenized stock tokens exist and ship disabled", () { + final stocks = tokens.where((t) => t.groups.contains(CurrencyGroups.tokenizedStock)).toList(); + expect(stocks, isNotEmpty); + expect(stocks.every((t) => !t.enabled), isTrue); + }); + }); + + group("EVMChainUtils Robinhood chain params", () { + test("getMoralisChainName is null for Robinhood (Moralis does not index 4663)", () { + expect(EVMChainUtils.getMoralisChainName(4663), isNull); + }); + + test("getMoralisChainName maps supported chains to Moralis slugs", () { + expect(EVMChainUtils.getMoralisChainName(1), "eth"); + expect(EVMChainUtils.getMoralisChainName(137), "polygon"); + expect(EVMChainUtils.getMoralisChainName(8453), "base"); + expect(EVMChainUtils.getMoralisChainName(42161), "arbitrum"); + expect(EVMChainUtils.getMoralisChainName(56), "bsc"); + }); + + test("getMoralisChainName is null for unknown chains", () { + expect(EVMChainUtils.getMoralisChainName(999999), isNull); + }); + + test("Robinhood is an Orbit chain with no priority fee", () { + expect(EVMChainUtils.hasPriorityFee(4663), isFalse); + }); + + test("Robinhood default token tag is ROB and fee currency is ETH", () { + expect(EVMChainUtils.getDefaultTokenTag(4663), "ROB"); + expect(EVMChainUtils.getFeeCurrency(4663), "ETH"); + }); + }); +} diff --git a/cw_monero/analysis_options.yaml b/cw_monero/analysis_options.yaml deleted file mode 100644 index a5744c1cf..000000000 --- a/cw_monero/analysis_options.yaml +++ /dev/null @@ -1,4 +0,0 @@ -include: package:flutter_lints/flutter.yaml - -# Additional information about this file can be found at -# https://dart.dev/guides/language/analysis-options diff --git a/cw_monero/lib/api/structs/pending_transaction.dart b/cw_monero/lib/api/structs/pending_transaction.dart index db11c4f78..0934d0d19 100644 --- a/cw_monero/lib/api/structs/pending_transaction.dart +++ b/cw_monero/lib/api/structs/pending_transaction.dart @@ -4,11 +4,13 @@ class PendingTransactionDescription { required this.fee, required this.hash, required this.hex, - required this.pointerAddress}); + required this.pointerAddress, + required this.txCount}); final int amount; final int fee; final String hash; final String hex; final int pointerAddress; + final int txCount; } diff --git a/cw_monero/lib/api/transaction_history.dart b/cw_monero/lib/api/transaction_history.dart index 9248e8c26..dc8bc5273 100644 --- a/cw_monero/lib/api/transaction_history.dart +++ b/cw_monero/lib/api/transaction_history.dart @@ -16,6 +16,19 @@ import 'package:monero/src/wallet2.dart'; import 'package:monero/src/generated_bindings_monero.g.dart' as monero_gen; import 'package:mutex/mutex.dart'; +String _formatTransactionError(String error) { + final message = error.replaceAll( + RegExp( + r'(?:[A-Za-z]:)?[\\/][^\s:]*\.(?:c|cc|cpp|cxx|h|hpp|hxx):\d+:(?:[A-Za-z0-9_]+:)?\s*', + ), + '', + ); + if (message.contains("RPC error")) { + return "Invalid node response, please try again or switch node\n\ntrace: $message"; + } + return message; +} + Map> txKeys = {}; String getTxKey(String txId) { txKeys[currentWallet!.ffiAddress()] ??= {}; @@ -119,12 +132,15 @@ Future getTransaction(String txId) async { return txCache[currentWallet!.ffiAddress()]![txId]!; } await txHistoryMutex.acquire(); - final tx = txhistory!.transactionById(txId); - final txDart = Transaction(txInfo: tx); - txCache[currentWallet!.ffiAddress()] ??= {}; - txCache[currentWallet!.ffiAddress()]![txId] = txDart; - txHistoryMutex.release(); - return txDart; + try { + final tx = txhistory!.transactionById(txId); + final txDart = Transaction(txInfo: tx); + txCache[currentWallet!.ffiAddress()] ??= {}; + txCache[currentWallet!.ffiAddress()]![txId] = txDart; + return txDart; + } finally { + txHistoryMutex.release(); + } } Future createTransactionSync( @@ -136,6 +152,11 @@ Future createTransactionSync( List preferredInputs = const []}) async { final amt = amount == null ? 0 : currentWallet!.amountFromString(amount); + if (amount != null && amt == 0) { + throw MoneroTransactionCreationException( + 'Refusing to create a transaction with a zero amount: 0 is the sweep-all '); + } + final waddr = currentWallet!.ffiAddress(); // force reconnection in case the os killed the connection? @@ -185,11 +206,7 @@ Future createTransactionSync( })(); if (error != null) { - String message = error; - if (message.contains("RPC error")) { - message = "Invalid node response, please try again or switch node\n\ntrace: $message"; - } - throw CreationTransactionException(message: message); + throw CreationTransactionException(message: _formatTransactionError(error)); } final rAmt = pendingTx.amount(); @@ -202,6 +219,7 @@ Future createTransactionSync( fee: rFee, hash: rHash, hex: rHex, + txCount: pendingTx.txCount(), pointerAddress: pendingTx.ffiAddress(), ); } @@ -237,7 +255,7 @@ Future createTransactionMultDest( final Wallet2PendingTransaction tx = MoneroPendingTransaction(txptr); if (tx.status() != 0) { - throw CreationTransactionException(message: tx.errorString()); + throw CreationTransactionException(message: _formatTransactionError(tx.errorString())); } return PendingTransactionDescription( @@ -246,6 +264,7 @@ Future createTransactionMultDest( hash: tx.txid(''), hex: tx.hex(''), pointerAddress: tx.ffiAddress(), + txCount: tx.txCount(), ); } @@ -282,7 +301,7 @@ Future commitTransaction( })(); } if (error != null && error != "no tx keys found for this txid") { - throw CreationTransactionException(message: error); + throw CreationTransactionException(message: _formatTransactionError(error)); } unawaited(() async { storeSync(force: true); diff --git a/cw_monero/lib/api/wallet.dart b/cw_monero/lib/api/wallet.dart index 4b3132575..7e094235a 100644 --- a/cw_monero/lib/api/wallet.dart +++ b/cw_monero/lib/api/wallet.dart @@ -4,6 +4,8 @@ import 'dart:ffi'; import 'dart:isolate'; import 'dart:math'; +import 'package:cw_core/amount/money.dart'; +import 'package:cw_core/crypto_currency.dart'; import 'package:cw_core/utils/print_verbose.dart'; import 'package:cw_monero/api/account_list.dart'; import 'package:cw_monero/api/exceptions/setup_wallet_exception.dart'; @@ -127,9 +129,7 @@ String getSeedLegacy(String? language) { return legacy; } -String getPassphrase() { - return currentWallet?.getCacheAttribute(key: "cakewallet.passphrase") ?? ""; -} +String getPassphrase() => currentWallet?.getCacheAttribute(key: "cakewallet.passphrase") ?? ""; Map>> addressCache = {}; @@ -155,34 +155,54 @@ String getAddress({int accountIndex = 0, int addressIndex = 0}) { return addressCache[currentWallet!.ffiAddress()]![accountIndex]![addressIndex]!; } -int getFullBalance({int accountIndex = 0}) { - return currentWallet?.balance(accountIndex: accountIndex) ?? 0; -} +Money getFullBalance({int accountIndex = 0}) => + Money.fromInt(currentWallet?.balance(accountIndex: accountIndex) ?? 0, CryptoCurrency.xmr); -int getUnlockedBalance({int accountIndex = 0}) => - currentWallet?.unlockedBalance(accountIndex: accountIndex) ?? 0; +Money getUnlockedBalance({int accountIndex = 0}) => Money.fromInt( + currentWallet?.unlockedBalance(accountIndex: accountIndex) ?? 0, CryptoCurrency.xmr); int getCurrentHeight() => currentWallet?.blockChainHeight() ?? 0; int cachedNodeHeight = 0; bool isHeightRefreshing = false; + +Future? _nodeHeightInFlight; + +Future _refreshNodeHeight() { + final inFlight = _nodeHeightInFlight; + if (inFlight != null) return inFlight; + + try { + final wallet = currentWallet; + if (wallet == null) return Future.value(cachedNodeHeight); + + final wptrAddress = wallet.ffiAddress(); + isHeightRefreshing = true; + final f = Isolate.run(() async { + return monero.Wallet_daemonBlockChainHeight(Pointer.fromAddress(wptrAddress)); + }).then((h) { + cachedNodeHeight = h; + return h; + }).catchError((_) => cachedNodeHeight).whenComplete(() { + isHeightRefreshing = false; + _nodeHeightInFlight = null; + }); + _nodeHeightInFlight = f; + return f; + } catch (e) { + printV("error refreshing node height: $e"); + isHeightRefreshing = false; + _nodeHeightInFlight = null; + return Future.value(cachedNodeHeight); + } +} + int getNodeHeightSync() { if (isHeightRefreshing == false) { if (cachedNodeHeight != 0 && getWlptr()?.height() == 1) { return cachedNodeHeight; } - (() async { - try { - isHeightRefreshing = true; - final wptrAddress = currentWallet!.ffiAddress(); - cachedNodeHeight = await Isolate.run(() async { - return monero.Wallet_daemonBlockChainHeight(Pointer.fromAddress(wptrAddress)); - }); - } catch (_) { - } finally { - isHeightRefreshing = false; - } - })(); + unawaited(_refreshNodeHeight()); } return cachedNodeHeight; } @@ -258,6 +278,10 @@ void setRecoveringFromSeed({required bool isRecovery}) { final storeMutex = Mutex(); +void refresh() { + currentWallet!.refresh(); +} + int lastStorePointer = 0; int lastStoreHeight = 0; void storeSync({bool force = false}) async { @@ -316,6 +340,9 @@ class SyncListener { int _cachedBlockchainHeight; int _lastKnownBlockHeight; int _initialSyncHeight; + int _lastKnownNodeHeight = 0; + int _ticksSinceEmit = 0; + static const int _heartbeatTicks = 5; Future getNodeHeightOrUpdate(int baseHeight) async { if (_cachedBlockchainHeight < baseHeight || _cachedBlockchainHeight == 0) { @@ -328,6 +355,8 @@ class SyncListener { void _start() { _cachedBlockchainHeight = 0; _lastKnownBlockHeight = 0; + _lastKnownNodeHeight = 0; + _ticksSinceEmit = 0; _initialSyncHeight = 0; _updateSyncInfoTimer ??= Timer.periodic(Duration(milliseconds: 1200), (_) async { if (isNewTransactionExist()) { @@ -352,11 +381,17 @@ class SyncListener { } final bchHeight = max(nodeHeight, syncHeight); // printV("syncHeight: $syncHeight, _lastKnownBlockHeight: $_lastKnownBlockHeight, bchHeight: $bchHeight"); - if (_lastKnownBlockHeight == syncHeight) { + + _ticksSinceEmit++; + final nothingChanged = _lastKnownBlockHeight == syncHeight && + _lastKnownNodeHeight == nodeHeight; + if (nothingChanged && _ticksSinceEmit < _heartbeatTicks) { return; } + _ticksSinceEmit = 0; _lastKnownBlockHeight = syncHeight; + _lastKnownNodeHeight = nodeHeight; final track = bchHeight - _initialSyncHeight; final diff = track - (bchHeight - syncHeight); final ptc = diff <= 0 ? 0.0 : diff / track; @@ -407,7 +442,11 @@ void startRefresh() => startRefreshSync(); Future store() async => _storeSync(0); -Future getNodeHeight() async => getNodeHeightSync(); +Future getNodeHeight() async { + final cached = getNodeHeightSync(); + if (cached != 0) return cached; + return _refreshNodeHeight(); +} void rescanBlockchainAsync() => currentWallet!.rescanBlockchainAsync(); @@ -419,14 +458,11 @@ Future setTrustedDaemon(bool trusted) async => currentWallet!.setTrustedDaemon(a Future trustedDaemon() async => currentWallet!.trustedDaemon(); -String signMessage(String message, {String address = ""}) { - return currentWallet!.signMessage(message: message, address: address); -} +String signMessage(String message, {String address = ""}) => + currentWallet!.signMessage(message: message, address: address); -bool verifyMessage(String message, String address, String signature) { - return currentWallet! - .verifySignedMessage(message: message, address: address, signature: signature); -} +bool verifyMessage(String message, String address, String signature) => + currentWallet!.verifySignedMessage(message: message, address: address, signature: signature); Map> debugCallLength() => monero.debugCallLength; diff --git a/cw_monero/lib/api/wallet_manager.dart b/cw_monero/lib/api/wallet_manager.dart index 2bdcf0bac..38483a6eb 100644 --- a/cw_monero/lib/api/wallet_manager.dart +++ b/cw_monero/lib/api/wallet_manager.dart @@ -101,7 +101,6 @@ void createWallet( currentWallet!.setCacheAttribute(key: "cakewallet.passphrase", value: passphrase); currentWallet!.store(path: path); openedWalletsByPath[path] = currentWallet!; - _lastOpenedWallet = path; } bool isWalletExist({required String path}) { @@ -147,7 +146,11 @@ void restoreWalletFromSeedSync( openedWalletsByPath[path] = currentWallet!; currentWallet!.store(path: path); - _lastOpenedWallet = path; +} + +void storePassphrase({required String path, required String passphrase}) { + currentWallet!.setCacheAttribute(key: "cakewallet.passphrase", value: passphrase); + currentWallet!.store(path: path); } void restoreWalletFromKeys( @@ -214,8 +217,12 @@ void restoreWalletFromKeys( currentWallet = newW; + if (restoreHeight > 0) { + setRefreshFromBlockHeight(height: restoreHeight); + currentWallet!.store(path: path); + } + openedWalletsByPath[path] = currentWallet!; - _lastOpenedWallet = path; } // English only, because normalization. @@ -298,6 +305,10 @@ void restoreWalletFromSpendKeySync( currentWallet = newW; + if (restoreHeight > 0) { + setRefreshFromBlockHeight(height: restoreHeight); + currentWallet!.store(path: path); + } currentWallet!.setCacheAttribute(key: "cakewallet.seed", value: seed); storeSync(); @@ -305,11 +316,8 @@ void restoreWalletFromSpendKeySync( setupBackgroundSync(password, currentWallet!); openedWalletsByPath[path] = currentWallet!; - _lastOpenedWallet = path; } -String _lastOpenedWallet = ""; - Future restoreWalletFromHardwareWallet( {required String path, required String password, @@ -333,8 +341,10 @@ Future restoreWalletFromHardwareWallet( } currentWallet = newW; + + setRefreshFromBlockHeight(height: restoreHeight); + currentWallet!.store(path: path); - _lastOpenedWallet = path; openedWalletsByPath[path] = currentWallet!; } @@ -346,72 +356,70 @@ Future loadWallet({required String path, required String password, int net currentWallet = openedWalletsByPath[path]!; return; } - if (currentWallet == null || path != _lastOpenedWallet) { - if (currentWallet != null) { - final addr = currentWallet!.ffiAddress(); - Isolate.run(() { - monero.Wallet_store(Pointer.fromAddress(addr)); - }); - } - txhistory = null; - /// Get the device type - /// 0: Software Wallet - /// 1: Ledger - /// 2: Trezor - var deviceType = 0; - - if (Platform.isAndroid || Platform.isIOS) { - deviceType = wmPtr.queryWalletDevice( - keysFileName: "$path.keys", - password: password, - kdfRounds: 1, - ); - final status = wmPtr.errorString(); - if (status != "") { - printV("loadWallet:" + status); - // This is most likely closeWallet call leaking error. This is fine. - if (status.contains("failed to save file")) { - printV("loadWallet: error leaked: $status"); - deviceType = 0; - } else { - throw WalletOpeningException(message: status); - } - } - } else { - deviceType = 0; - } - - if (deviceType == 1) { - if (gLedger == null) { - throw Exception("Tried to open a ledger wallet with no ledger connected"); - } - enableLedgerExchange(gLedger!); - } - - final addr = wmPtr.ffiAddress(); - final newWptrAddr = await Isolate.run(() { - return monero.WalletManager_openWallet(Pointer.fromAddress(addr), - path: path, password: password) - .address; + if (currentWallet != null) { + final addr = currentWallet!.ffiAddress(); + Isolate.run(() { + monero.Wallet_store(Pointer.fromAddress(addr)); }); - - final newW = MoneroWallet(Pointer.fromAddress(newWptrAddr)); - - int status = newW.status(); - if (status != 0) { - final err = newW.errorString(); - printV("loadWallet:" + err); - throw WalletOpeningException(message: err); - } - if (deviceType == 0) { - setupBackgroundSync(password, newW); - } - - currentWallet = newW; - _lastOpenedWallet = path; - openedWalletsByPath[path] = currentWallet!; } + txhistory = null; + + /// Get the device type + /// 0: Software Wallet + /// 1: Ledger + /// 2: Trezor + var deviceType = 0; + + if (Platform.isAndroid || Platform.isIOS) { + deviceType = wmPtr.queryWalletDevice( + keysFileName: "$path.keys", + password: password, + kdfRounds: 1, + ); + final status = wmPtr.errorString(); + if (status != "") { + printV("loadWallet:" + status); + // This is most likely closeWallet call leaking error. This is fine. + if (status.contains("failed to save file")) { + printV("loadWallet: error leaked: $status"); + deviceType = 0; + } else { + throw WalletOpeningException(message: status); + } + } + } else { + deviceType = 0; + } + + if (deviceType == 1) { + if (gLedger == null) { + throw Exception("Tried to open a ledger wallet with no ledger connected"); + } + enableLedgerExchange(gLedger!); + } + + final addr = wmPtr.ffiAddress(); + final newWptrAddr = await Isolate.run(() { + return monero.WalletManager_openWallet(Pointer.fromAddress(addr), + path: path, password: password) + .address; + }); + + final newW = MoneroWallet(Pointer.fromAddress(newWptrAddr)); + + int status = newW.status(); + if (status != 0) { + final err = newW.errorString(); + printV("loadWallet:" + err); + throw WalletOpeningException(message: err); + } + if (deviceType == 0) { + setupBackgroundSync(password, newW); + } + + currentWallet = newW; + openedWalletsByPath[path] = currentWallet!; } void setupBackgroundSync(String password, Wallet2Wallet wallet) { diff --git a/cw_monero/lib/ledger.dart b/cw_monero/lib/ledger.dart index 06f31bcb8..0ff8e41fe 100644 --- a/cw_monero/lib/ledger.dart +++ b/cw_monero/lib/ledger.dart @@ -2,6 +2,7 @@ import 'dart:async'; import 'dart:ffi'; import 'dart:typed_data'; +import 'package:cw_core/hardware/hardware_wallet_service.dart'; import 'package:cw_core/utils/print_verbose.dart'; import 'package:ffi/ffi.dart'; import 'package:flutter/foundation.dart'; @@ -114,3 +115,9 @@ void _logLedgerCommand(Uint8List command, [bool isResponse = true]) { printV("> ${_ledgerMoneroCommands[command[1]]} ${toHexString(command.sublist(2))}"); } } + +class MoneroLedgerService extends HardwareWalletService { + final LedgerConnection connection; + + MoneroLedgerService(this.connection); +} diff --git a/cw_monero/lib/monero_balance.dart b/cw_monero/lib/monero_balance.dart new file mode 100644 index 000000000..eec2ec769 --- /dev/null +++ b/cw_monero/lib/monero_balance.dart @@ -0,0 +1,17 @@ +import 'package:cw_core/amount/money.dart'; +import 'package:cw_core/balance.dart'; +import 'package:cw_core/crypto_currency.dart'; + +class MoneroBalance extends Balance { + MoneroBalance({ + required this.fullBalance, + required Money unlockedBalance, + Money? frozen, + }) : super( + unlockedBalance, + fullBalance - unlockedBalance, + frozen: frozen ?? Money.zero(CryptoCurrency.xmr), + ); + + final Money fullBalance; +} diff --git a/cw_monero/lib/monero_subaddress_list.dart b/cw_monero/lib/monero_subaddress_list.dart index 26320b95f..7f1a64f1e 100644 --- a/cw_monero/lib/monero_subaddress_list.dart +++ b/cw_monero/lib/monero_subaddress_list.dart @@ -34,8 +34,9 @@ abstract class MoneroSubaddressListBase with Store { try { _isUpdating = true; refresh(accountIndex: accountIndex); + final newAddrs = await getAll(); subaddresses.clear(); - subaddresses.addAll(await getAll()); + subaddresses.addAll(newAddrs); _isUpdating = false; } catch (e) { _isUpdating = false; diff --git a/cw_monero/lib/monero_transaction_info.dart b/cw_monero/lib/monero_transaction_info.dart index b7ddd0ca6..3f1567122 100644 --- a/cw_monero/lib/monero_transaction_info.dart +++ b/cw_monero/lib/monero_transaction_info.dart @@ -1,5 +1,4 @@ -import 'package:cw_core/crypto_currency.dart'; -import 'package:cw_core/format_amount.dart'; +import 'package:cw_core/amount/money.dart'; import 'package:cw_core/transaction_direction.dart'; import 'package:cw_core/transaction_info.dart'; @@ -15,23 +14,10 @@ class MoneroTransactionInfo extends TransactionInfo { final DateTime date; final int accountIndex; final bool isPending; - final int amount; - final int fee; + final Money amount; + final Money fee; final int addressIndex; final int confirmations; String? recipientAddress; String? key; - String? _fiatAmount; - - @override - String amountFormatted() => '${CryptoCurrency.xmr.formatAmount(BigInt.from(amount))} XMR'; - - @override - String fiatAmount() => _fiatAmount ?? ''; - - @override - void changeFiatAmount(String amount) => _fiatAmount = formatAmount(amount); - - @override - String feeFormatted() => '${CryptoCurrency.xmr.formatAmount(BigInt.from(fee))} XMR'; } diff --git a/cw_monero/lib/monero_wallet.dart b/cw_monero/lib/monero_wallet.dart index d72547fa2..a76e7d628 100644 --- a/cw_monero/lib/monero_wallet.dart +++ b/cw_monero/lib/monero_wallet.dart @@ -3,12 +3,11 @@ import 'dart:ffi'; import 'dart:io'; import 'dart:isolate'; -import 'package:cw_core/monero_amount_format.dart'; +import 'package:cw_core/amount/money.dart'; import 'package:cw_core/pathForWallet.dart'; import 'package:cw_core/transaction_priority.dart'; import 'package:cw_core/account.dart'; import 'package:cw_core/crypto_currency.dart'; -import 'package:cw_core/monero_balance.dart'; import 'package:cw_core/monero_transaction_priority.dart'; import 'package:cw_core/monero_wallet_keys.dart'; import 'package:cw_core/monero_wallet_utils.dart'; @@ -30,6 +29,7 @@ import 'package:cw_monero/api/wallet.dart' as monero_wallet; import 'package:cw_monero/api/wallet_manager.dart'; import 'package:cw_monero/exceptions/monero_transaction_creation_exception.dart'; import 'package:cw_monero/ledger.dart'; +import 'package:cw_monero/monero_balance.dart'; import 'package:cw_monero/monero_transaction_creation_credentials.dart'; import 'package:cw_monero/monero_transaction_history.dart'; import 'package:cw_monero/monero_transaction_info.dart'; @@ -37,6 +37,7 @@ import 'package:cw_monero/monero_unspent.dart'; import 'package:cw_monero/monero_wallet_addresses.dart'; import 'package:cw_monero/monero_wallet_service.dart'; import 'package:cw_monero/pending_monero_transaction.dart'; +import 'package:cw_monero/trezor.dart'; import 'package:flutter/foundation.dart'; import 'package:hive/hive.dart'; import 'package:ledger_flutter_plus/ledger_flutter_plus.dart'; @@ -119,6 +120,15 @@ abstract class MoneroWalletBase @override bool get hasRescan => true; + @override + bool get hasCoinControl => true; + + @override + bool get hasAccountsSupport => true; + + @override + bool get hasNativeAccounts => true; + @override String get seed => monero_wallet.getSeed(); String seedLegacy(String? language) => monero_wallet.getSeedLegacy(language); @@ -369,18 +379,53 @@ abstract class MoneroWalletBase }; } - bool needExportOutputs(int amount) { + bool hasUnknownKeyImages() => currentWallet!.hasUnknownKeyImages(); + + bool needExportOutputs(Money amount) { if (int.tryParse(currentWallet!.secretSpendKey()) != 0) { return false; } // viewOnlyBalance - balance that we can spend // TODO(mrcyjanek): remove hasUnknownKeyImages when we cleanup coin control - return (currentWallet!.viewOnlyBalance(accountIndex: walletAddresses.account!.id) < amount) || + return (currentWallet!.viewOnlyBalance(accountIndex: walletAddresses.account!.id) < + amount.amount.toInt()) || currentWallet!.hasUnknownKeyImages(); } + MoneroTrezorService? trezorService; + + Trezor _getTrezor() => Trezor(trezorService ?? (throw Exception("Trezor not connected"))); + + Future syncTrezor() async { + if (trezorService == null) throw Exception("Trezor not connected"); + + final ptr = Pointer.fromAddress(currentWallet!.ffiAddress()); + final tdis = monero.Wallet_exportTrezorTdis(ptr); + final response = await _getTrezor().keyImageSync(tdis); + final success = monero.Wallet_importTrezorEncryptedKeyImagesJson(ptr, response); + + if (!success) throw Exception(monero.Wallet_errorString(ptr)); + } + + Future signTrezorTransaction(String json) => _getTrezor().signTransaction(json); + @override Future createTransaction(Object credentials) async { + if (hardwareWalletType == HardwareWalletType.trezor) { + for (int i = 0; i < 2; i++) { + try { + return await _createTransaction(credentials); + } catch (e) { + printV(e); + } + await save(); + await Future.delayed(Duration(seconds: i)); + } + } + return await _createTransaction(credentials); + } + + Future _createTransaction(Object credentials) async { final _credentials = credentials as MoneroTransactionCreationCredentials; final inputs = []; final outputs = _credentials.outputs; @@ -394,9 +439,7 @@ abstract class MoneroWalletBase throw MoneroTransactionCreationException('The wallet is not synced.'); } - if (unspentCoins.isEmpty) { - await updateUnspent(); - } + await updateUnspent(); for (final utx in unspentCoins) { if (utx.isSending) { @@ -405,14 +448,13 @@ abstract class MoneroWalletBase } if (hasMultiDestination) { - if (outputs.any((item) => item.sendAll || (item.formattedCryptoAmount ?? 0) <= 0)) { + if (outputs.any((item) => item.sendAll || item.cryptoAmount.amount <= BigInt.zero)) { throw MoneroTransactionCreationException('You do not have enough XMR to send this amount.'); } - final int totalAmount = - outputs.fold(0, (acc, value) => acc + (value.formattedCryptoAmount ?? 0)); + final totalAmount = outputs.fold(0, (acc, value) => acc + value.cryptoAmount.amount.toInt()); - if (unlockedBalance < totalAmount) { + if (unlockedBalance < Money.fromInt(totalAmount, CryptoCurrency.xmr)) { throw MoneroTransactionCreationException('You do not have enough XMR to send this amount.'); } @@ -421,8 +463,7 @@ abstract class MoneroWalletBase final moneroOutputs = outputs.map((output) { final outputAddress = output.isParsedAddress ? output.extractedAddress : output.address; - return MoneroOutput( - address: outputAddress!, amount: output.cryptoAmount!.replaceAll(',', '.')); + return MoneroOutput(address: outputAddress!, amount: output.cryptoAmount.toString()); }).toList(); pendingTransactionDescription = await transaction_history.createTransactionMultDest( @@ -434,7 +475,12 @@ abstract class MoneroWalletBase } else { final output = outputs.first; final address = output.isParsedAddress ? output.extractedAddress : output.address; - final amount = output.sendAll ? null : output.cryptoAmount!.replaceAll(',', '.'); + + if (!output.sendAll && output.cryptoAmount.amount <= BigInt.zero) { + throw MoneroTransactionCreationException('Amount must be greater than 0.'); + } + + final amount = output.sendAll ? null : output.cryptoAmount.toString(); // if ((formattedAmount != null && unlockedBalance < formattedAmount) || // (formattedAmount == null && unlockedBalance <= 0)) { @@ -454,7 +500,10 @@ abstract class MoneroWalletBase paymentId: ''); } - // final status = monero.PendingTransaction_status(pendingTransactionDescription); + if (pendingTransactionDescription.txCount != 1) { + throw MoneroTransactionCreationException( + "This payment would be split into ${pendingTransactionDescription.txCount} transactions. Send smaller transaction to yourself first.",); + } return PendingMoneroTransaction(pendingTransactionDescription, this); } @@ -611,11 +660,19 @@ abstract class MoneroWalletBase await walletInfo.save(); } - Future updateUnspent() async { + Future? _updateUnspentInFlight; + + /// Concurrent callers share one run: two runs at once work on the same [unspentCoins] list, and + /// the clean up of the one then deletes the coin infos of the other. + Future updateUnspent() => _updateUnspentInFlight ??= + _updateUnspent().whenComplete(() => _updateUnspentInFlight = null); + + Future _updateUnspent() async { try { refreshCoins(walletAddresses.account!.id); - unspentCoins.clear(); + // Collected separately, so nobody ever sees a half filled list + final unspentCoins = []; final coinCount = await countOfCoins(); for (var i = 0; i < coinCount; i++) { @@ -633,12 +690,21 @@ abstract class MoneroWalletBase ); // TODO: double-check the logic here if (unspent.hash.isNotEmpty) { - final tx = await transaction_history.getTransaction(unspent.hash); - unspent.isChange = tx.isSpend == true; + try { + final tx = await transaction_history.getTransaction(unspent.hash); + unspent.isChange = tx.isSpend == true; + } catch (e) { + // The transaction may not be in the (not yet refreshed) history, that must not + // keep the output, and all the ones after it, out of coin control. + printV("isChange lookup failed for ${unspent.hash}: $e"); + } } unspentCoins.add(unspent); } } + this.unspentCoins + ..clear() + ..addAll(unspentCoins); if (unspentCoinsInfo.isEmpty) { unspentCoins.forEach((coin) => _addCoinInfo(coin)); @@ -646,22 +712,38 @@ abstract class MoneroWalletBase } if (unspentCoins.isNotEmpty) { - unspentCoins.forEach((coin) { - final coinInfoList = unspentCoinsInfo.values.where((element) => - element.walletId.contains(id) && - element.accountIndex == walletAddresses.account!.id && - element.keyImage!.contains(coin.keyImage!)); + final usedInfoKeys = {}; + for (final coin in unspentCoins) { + final coinInfoList = unspentCoinsInfo.values + .where((element) => + element.walletId.contains(id) && + element.accountIndex == walletAddresses.account!.id && + !usedInfoKeys.contains(element.key) && + _isSameCoin(element, coin)) + .toList(); if (coinInfoList.isNotEmpty) { - final coinInfo = coinInfoList.first; + // The key image only breaks a tie between identical outputs of one transaction + final coinInfo = coinInfoList.firstWhere( + (element) => element.keyImage == coin.keyImage, + orElse: () => coinInfoList.first, + ); + usedInfoKeys.add(coinInfo.key); coin.isFrozen = coinInfo.isFrozen; coin.isSending = coinInfo.isSending; coin.note = coinInfo.note; + + // The key image of a hardware wallet's output is only known after syncing with the + // device; keep the existing info (frozen state, note) instead of orphaning it. + if (coinInfo.keyImage != coin.keyImage) { + coinInfo.keyImage = coin.keyImage; + await coinInfo.save(); + } } else { - _addCoinInfo(coin); + await _addCoinInfo(coin); } - }); + } } await _refreshUnspentCoinsInfo(); @@ -676,6 +758,12 @@ abstract class MoneroWalletBase } } + /// The key image can't identify an output: a hardware wallet doesn't know the key images of its + /// outputs until they are synced with the device, until then they all carry the same + /// placeholder, which made every output match the info of the first one. + bool _isSameCoin(UnspentCoinsInfo info, MoneroUnspent coin) => + info.hash == coin.hash && info.address == coin.address && info.value == coin.value; + Future _addCoinInfo(MoneroUnspent coin) async { final newInfo = UnspentCoinsInfo( walletId: id, @@ -701,8 +789,7 @@ abstract class MoneroWalletBase if (currentWalletUnspentCoins.isNotEmpty) { currentWalletUnspentCoins.forEach((element) { - final existUnspentCoins = - unspentCoins.where((coin) => element.keyImage!.contains(coin.keyImage!)); + final existUnspentCoins = unspentCoins.where((coin) => _isSameCoin(element, coin)); if (existUnspentCoins.isEmpty) { keys.add(element.key); @@ -772,10 +859,10 @@ abstract class MoneroWalletBase row.isSpend ? TransactionDirection.outgoing : TransactionDirection.incoming, row.timeStamp, row.isPending, - row.amount, + Money.fromInt(row.amount, currency), row.accountIndex, 0, - row.fee, + Money.fromInt(row.fee, currency), row.confirmations, )..additionalInfo = { 'key': row.key, @@ -854,25 +941,25 @@ abstract class MoneroWalletBase final fullBalance = monero_wallet.getFullBalance(accountIndex: walletAddresses.account!.id); final frozenBalance = _getFrozenBalance(); if (balance[currency]!.fullBalance != fullBalance || - balance[currency]!.unlockedBalance != unlockedBalance || - balance[currency]!.frozenBalance != frozenBalance) { + balance[currency]!.available != unlockedBalance || + balance[currency]!.frozen != frozenBalance) { balance[currency] = MoneroBalance( - fullBalance: fullBalance, unlockedBalance: unlockedBalance, frozenBalance: frozenBalance); + fullBalance: fullBalance, unlockedBalance: unlockedBalance, frozen: frozenBalance); } } - int _getUnlockedBalance() => + Money _getUnlockedBalance() => monero_wallet.getUnlockedBalance(accountIndex: walletAddresses.account!.id); - int _getFrozenBalance() { + Money _getFrozenBalance() { var frozenBalance = 0; - for (var coin in unspentCoinsInfo.values.where((element) => + for (final coin in unspentCoinsInfo.values.where((element) => element.walletId == id && element.accountIndex == walletAddresses.account!.id)) { if (coin.isFrozen && !coin.isSending) frozenBalance += coin.value; } - return frozenBalance; + return Money.fromInt(frozenBalance, CryptoCurrency.xmr); } void _onNewBlock(int height, int blocksLeft, double ptc) async { @@ -946,9 +1033,4 @@ abstract class MoneroWalletBase Future setLedgerConnection(LedgerConnection connection) async { await enableLedgerExchange(connection); } - - @override - String formatCryptoAmount(String amount) { - return moneroAmountToString(amount: int.parse(amount)); - } } diff --git a/cw_monero/lib/monero_wallet_addresses.dart b/cw_monero/lib/monero_wallet_addresses.dart index ee211ef90..01f711bed 100644 --- a/cw_monero/lib/monero_wallet_addresses.dart +++ b/cw_monero/lib/monero_wallet_addresses.dart @@ -90,7 +90,7 @@ abstract class MoneroWalletAddressesBase extends WalletAddresses with Store { addressesMap.clear(); addressInfos.clear(); - accountList.accounts.forEach((account) async { + for (final account in accountList.accounts) { await _subaddressList.update(accountIndex: account.id); _subaddressList.subaddresses.forEach((subaddress) { addressesMap[subaddress.address] = subaddress.label; @@ -102,7 +102,7 @@ abstract class MoneroWalletAddressesBase extends WalletAddresses with Store { address: subaddress.address, label: subaddress.label)); }); - }); + } await saveAddressesInBox(); } catch (e) { diff --git a/cw_monero/lib/monero_wallet_service.dart b/cw_monero/lib/monero_wallet_service.dart index 16c83074e..8066a1a53 100644 --- a/cw_monero/lib/monero_wallet_service.dart +++ b/cw_monero/lib/monero_wallet_service.dart @@ -3,8 +3,8 @@ import 'dart:ffi'; import 'dart:io'; import 'dart:isolate'; -import 'package:collection/collection.dart'; import 'package:cw_core/get_height_by_date.dart'; +import 'package:cw_core/hardware/hardware_wallet_service.dart'; import 'package:cw_core/monero_wallet_utils.dart'; import 'package:cw_core/pathForWallet.dart'; import 'package:cw_core/unspent_coins_info.dart'; @@ -20,8 +20,8 @@ import 'package:cw_monero/api/wallet_manager.dart'; import 'package:cw_monero/bip39_seed.dart'; import 'package:cw_monero/ledger.dart'; import 'package:cw_monero/monero_wallet.dart'; +import 'package:cw_monero/trezor.dart'; import 'package:hive/hive.dart'; -import 'package:ledger_flutter_plus/ledger_flutter_plus.dart'; import 'package:monero/monero.dart' as monero; import 'package:polyseed/polyseed.dart'; @@ -44,10 +44,15 @@ class MoneroNewWalletCredentials extends WalletCredentials { } class MoneroRestoreWalletFromHardwareCredentials extends WalletCredentials { - MoneroRestoreWalletFromHardwareCredentials( - {required String name, required this.ledgerConnection, int height = 0, String? password}) - : super(name: name, password: password, height: height); - LedgerConnection ledgerConnection; + MoneroRestoreWalletFromHardwareCredentials({ + required String name, + required this.hardwareWalletService, + int height = 0, + String? password, + String? passphrase, + }) : super(name: name, password: password, height: height, passphrase: passphrase); + + final HardwareWalletService hardwareWalletService; } class MoneroRestoreWalletFromSeedCredentials extends WalletCredentials { @@ -187,7 +192,7 @@ class MoneroWalletService extends WalletService< unspentCoinsInfo: unspentCoinsInfoSource, password: password); - if (wallet.isHardwareWallet) { + if (wallet.hardwareWalletType == HardwareWalletType.ledger) { wallet.setLedgerConnection(gLedger!); gLedger = null; } @@ -295,12 +300,35 @@ class MoneroWalletService extends WalletService< try { final path = await pathForWallet(name: credentials.name, type: getType()); final password = credentials.password; - final height = credentials.height; - enableLedgerExchange(credentials.ledgerConnection); + if (credentials.hardwareWalletService case MoneroLedgerService service) { + enableLedgerExchange(service.connection); - await monero_wallet_manager.restoreWalletFromHardwareWallet( - path: path, password: password!, restoreHeight: height!, deviceName: 'Ledger'); + await monero_wallet_manager.restoreWalletFromHardwareWallet( + path: path, + password: password!, + restoreHeight: credentials.height!, + deviceName: 'Ledger', + ); + } else if (credentials.hardwareWalletService case final MoneroTrezorService service) { + final trezor = Trezor(service); + + final watchCredentials = await trezor.getWatchCredentials(); + + monero_wallet_manager.restoreWalletFromKeys( + path: path, + password: credentials.password!, + language: "English", + restoreHeight: credentials.height!, + address: watchCredentials.address, + viewKey: watchCredentials.watchKey, + spendKey: "", + ); + + if (credentials.passphrase != null) { + monero_wallet_manager.storePassphrase(path: path, passphrase: credentials.passphrase!); + } + } final wallet = MoneroWallet( walletInfo: credentials.walletInfo!, @@ -549,7 +577,7 @@ class MoneroWalletService extends WalletService< if (walletInfo == null) { return false; } - return walletInfo.isHardwareWallet; + return walletInfo.hardwareWalletType == HardwareWalletType.ledger; } } diff --git a/cw_monero/lib/pending_monero_transaction.dart b/cw_monero/lib/pending_monero_transaction.dart index 9dec47725..5fbac42c6 100644 --- a/cw_monero/lib/pending_monero_transaction.dart +++ b/cw_monero/lib/pending_monero_transaction.dart @@ -1,14 +1,17 @@ import 'dart:async'; +import 'dart:ffi'; +import 'package:cw_core/amount/money.dart'; +import 'package:cw_core/utils/print_verbose.dart'; +import 'package:cw_core/wallet_info.dart'; +import 'package:cw_core/crypto_currency.dart'; +import 'package:cw_core/pending_transaction.dart'; import 'package:cw_monero/api/account_list.dart'; import 'package:cw_monero/api/structs/pending_transaction.dart'; import 'package:cw_monero/api/transaction_history.dart' as monero_transaction_history; -import 'package:cw_core/crypto_currency.dart'; -import 'package:cw_core/amount_converter.dart'; - -import 'package:cw_core/pending_transaction.dart'; import 'package:cw_monero/api/wallet.dart'; import 'package:cw_monero/monero_wallet.dart'; +import 'package:monero/monero.dart' as monero; class DoubleSpendException implements Exception { DoubleSpendException(); @@ -24,6 +27,10 @@ class PendingMoneroTransaction with PendingTransaction { final PendingTransactionDescription pendingTransactionDescription; final MoneroWalletBase wallet; + Money get amount => Money.fromInt(pendingTransactionDescription.amount, CryptoCurrency.xmr); + + Money get fee => Money.fromInt(pendingTransactionDescription.fee, CryptoCurrency.xmr); + @override String get id => pendingTransactionDescription.hash; @@ -31,32 +38,41 @@ class PendingMoneroTransaction with PendingTransaction { String get hex => pendingTransactionDescription.hex; @override - String get amountFormatted => - AmountConverter.amountIntToString(CryptoCurrency.xmr, pendingTransactionDescription.amount); + String get amountFormatted => amount.toString(); @override - String get feeFormatted => "$feeFormattedValue XMR"; - - @override - String get feeFormattedValue => - AmountConverter.amountIntToString(CryptoCurrency.xmr, pendingTransactionDescription.fee); - - @override - bool shouldCommitUR() => isViewOnly; + bool shouldCommitUR() => isViewOnly && wallet.hardwareWalletType != HardwareWalletType.trezor; @override Future commit() async { - try { - await monero_transaction_history.commitTransactionFromPointerAddress( - address: pendingTransactionDescription.pointerAddress, useUR: false); - } catch (e) { - final message = e.toString(); + if (wallet.hardwareWalletType == HardwareWalletType.trezor) { + final ptr = Pointer.fromAddress(pendingTransactionDescription.pointerAddress); + final ret = await monero.PendingTransaction_commitTrezor(ptr, 0); - if (message.contains('Reason: double spend')) { - throw DoubleSpendException(); + final json = await wallet.signTrezorTransaction(ret); + + final wptr = Pointer.fromAddress(currentWallet!.ffiAddress()); + + final suc = await monero.Wallet_submitTransactionHex(wptr, json); + + if (!suc) { + final err = monero.UnsignedTransaction_errorString(ptr); + printV(err); + throw err; } + } else { + try { + await monero_transaction_history.commitTransactionFromPointerAddress( + address: pendingTransactionDescription.pointerAddress, useUR: false); + } catch (e) { + final message = e.toString(); - rethrow; + if (message.contains('Reason: double spend')) { + throw DoubleSpendException(); + } + + rethrow; + } } storeSync(force: true); unawaited(() async { diff --git a/cw_monero/lib/trezor.dart b/cw_monero/lib/trezor.dart new file mode 100644 index 000000000..bc8cc1168 --- /dev/null +++ b/cw_monero/lib/trezor.dart @@ -0,0 +1,64 @@ +import "dart:convert"; + +import "package:cw_core/hardware/hardware_wallet_service.dart"; +import "package:mutex/mutex.dart"; +import "package:trezor_flutter/trezor_flutter.dart"; + +class MoneroTrezorService extends HardwareWalletService { + MoneroTrezorService(this.client); + + final TrezorClient client; + + static final Mutex _mutex = Mutex(); + + static Future runBlocking(Future Function() operation) => _mutex.protect(operation); +} + +class MoneroTrezorWatchCredentials { + const MoneroTrezorWatchCredentials(this.watchKey, this.address); + + final String watchKey; + final String address; +} + +class Trezor { + Trezor(this.service); + + final MoneroTrezorService service; + + Future getWatchCredentials() async { + final credentials = await MoneroTrezorService.runBlocking(TrezorMonero(service.client).getWatchCredentials); + + return MoneroTrezorWatchCredentials(credentials.$1, credentials.$2); + } + + Future keyImageSync(String tdis) async { + final tdisMap = jsonDecode(tdis) as Map; + final tdisList = tdisMap["tdis"] as List; + + final txIds = []; + for (final tdi in tdisList) { + txIds.add( + MoneroKeyImageTxData( + outKey: tdi["out_key"] as String, + txPubKey: tdi["tx_pub_key"] as String, + internalOutputIndex: tdi["internal_output_index"] as int, + subAddrMajor: tdi["sub_addr_major"] as int, + subAddrMinor: tdi["sub_addr_minor"] as int, + additionalTxPubKeys: + (tdi["additional_tx_pub_keys"] as List?)?.map((e) => e as String).toList() ?? [], + ), + ); + } + final keyImages = await MoneroTrezorService.runBlocking( + () => TrezorMonero(service.client).syncKeyImages(txIds), + ); + + return jsonEncode(keyImages.toMap()); + } + + Future signTransaction(String json) => MoneroTrezorService.runBlocking( + () => + TrezorMonero(service.client).signTransaction(jsonDecode(json) as Map), + ); +} diff --git a/cw_monero/pubspec.lock b/cw_monero/pubspec.lock deleted file mode 100644 index 79bff7991..000000000 --- a/cw_monero/pubspec.lock +++ /dev/null @@ -1,1079 +0,0 @@ -# Generated by pub -# See https://dart.dev/tools/pub/glossary#lockfile -packages: - _fe_analyzer_shared: - dependency: transitive - description: - name: _fe_analyzer_shared - sha256: "16e298750b6d0af7ce8a3ba7c18c69c3785d11b15ec83f6dcd0ad2a0009b3cab" - url: "https://pub.dev" - source: hosted - version: "76.0.0" - _macros: - dependency: transitive - description: dart - source: sdk - version: "0.3.3" - analyzer: - dependency: transitive - description: - name: analyzer - sha256: "1f14db053a8c23e260789e9b0980fa27f2680dd640932cae5e1137cce0e46e1e" - url: "https://pub.dev" - source: hosted - version: "6.11.0" - args: - dependency: transitive - description: - name: args - sha256: bf9f5caeea8d8fe6721a9c358dd8a5c1947b27f1cfaa18b39c301273594919e6 - url: "https://pub.dev" - source: hosted - version: "2.6.0" - asn1lib: - dependency: transitive - description: - name: asn1lib - sha256: "4bae5ae63e6d6dd17c4aac8086f3dec26c0236f6a0f03416c6c19d830c367cf5" - url: "https://pub.dev" - source: hosted - version: "1.5.8" - async: - dependency: transitive - description: - name: async - sha256: "758e6d74e971c3e5aceb4110bfd6698efc7f501675bcfe0c775459a8140750eb" - url: "https://pub.dev" - source: hosted - version: "2.13.0" - bech32: - dependency: transitive - description: - path: "." - ref: "05755063b593aa6cca0a4820a318e0ce17de6192" - resolved-ref: "05755063b593aa6cca0a4820a318e0ce17de6192" - url: "https://github.com/cake-tech/bech32.git" - source: git - version: "0.2.2" - bip32: - dependency: "direct main" - description: - name: bip32 - sha256: "54787cd7a111e9d37394aabbf53d1fc5e2e0e0af2cd01c459147a97c0e3f8a97" - url: "https://pub.dev" - source: hosted - version: "2.0.0" - bip39: - dependency: "direct main" - description: - name: bip39 - sha256: de1ee27ebe7d96b84bb3a04a4132a0a3007dcdd5ad27dd14aa87a29d97c45edc - url: "https://pub.dev" - source: hosted - version: "1.0.6" - blockchain_utils: - dependency: transitive - description: - path: "." - ref: cake-update-v2 - resolved-ref: "59fdf29d72068e0522a96a8953ed7272833a9f57" - url: "https://github.com/cake-tech/blockchain_utils" - source: git - version: "3.3.0" - bluez: - dependency: transitive - description: - name: bluez - sha256: "61a7204381925896a374301498f2f5399e59827c6498ae1e924aaa598751b545" - url: "https://pub.dev" - source: hosted - version: "0.8.3" - boolean_selector: - dependency: transitive - description: - name: boolean_selector - sha256: "8aab1771e1243a5063b8b0ff68042d67334e3feab9e95b9490f9a6ebf73b42ea" - url: "https://pub.dev" - source: hosted - version: "2.1.2" - bs58check: - dependency: transitive - description: - name: bs58check - sha256: c4a164d42b25c2f6bc88a8beccb9fc7d01440f3c60ba23663a20a70faf484ea9 - url: "https://pub.dev" - source: hosted - version: "1.0.2" - build: - dependency: transitive - description: - name: build - sha256: "80184af8b6cb3e5c1c4ec6d8544d27711700bc3e6d2efad04238c7b5290889f0" - url: "https://pub.dev" - source: hosted - version: "2.4.1" - build_config: - dependency: transitive - description: - name: build_config - sha256: bf80fcfb46a29945b423bd9aad884590fb1dc69b330a4d4700cac476af1708d1 - url: "https://pub.dev" - source: hosted - version: "1.1.1" - build_daemon: - dependency: transitive - description: - name: build_daemon - sha256: "79b2aef6ac2ed00046867ed354c88778c9c0f029df8a20fe10b5436826721ef9" - url: "https://pub.dev" - source: hosted - version: "4.0.2" - build_resolvers: - dependency: "direct dev" - description: - name: build_resolvers - sha256: b9e4fda21d846e192628e7a4f6deda6888c36b5b69ba02ff291a01fd529140f0 - url: "https://pub.dev" - source: hosted - version: "2.4.4" - build_runner: - dependency: "direct dev" - description: - name: build_runner - sha256: "058fe9dce1de7d69c4b84fada934df3e0153dd000758c4d65964d0166779aa99" - url: "https://pub.dev" - source: hosted - version: "2.4.15" - build_runner_core: - dependency: transitive - description: - name: build_runner_core - sha256: "22e3aa1c80e0ada3722fe5b63fd43d9c8990759d0a2cf489c8c5d7b2bdebc021" - url: "https://pub.dev" - source: hosted - version: "8.0.0" - built_collection: - dependency: transitive - description: - name: built_collection - sha256: "376e3dd27b51ea877c28d525560790aee2e6fbb5f20e2f85d5081027d94e2100" - url: "https://pub.dev" - source: hosted - version: "5.1.1" - built_value: - dependency: transitive - description: - name: built_value - sha256: "8b158ab94ec6913e480dc3f752418348b5ae099eb75868b5f4775f0572999c61" - url: "https://pub.dev" - source: hosted - version: "8.9.4" - cake_backup: - dependency: transitive - description: - path: "." - ref: "3aba867dcab6737f6707782f5db15d71f303db38" - resolved-ref: "3aba867dcab6737f6707782f5db15d71f303db38" - url: "https://github.com/cake-tech/cake_backup.git" - source: git - version: "1.0.0+1" - characters: - dependency: transitive - description: - name: characters - sha256: f71061c654a3380576a52b451dd5532377954cf9dbd272a78fc8479606670803 - url: "https://pub.dev" - source: hosted - version: "1.4.0" - checked_yaml: - dependency: transitive - description: - name: checked_yaml - sha256: feb6bed21949061731a7a75fc5d2aa727cf160b91af9a3e464c5e3a32e28b5ff - url: "https://pub.dev" - source: hosted - version: "2.0.3" - clock: - dependency: transitive - description: - name: clock - sha256: fddb70d9b5277016c77a80201021d40a2247104d9f4aa7bab7157b7e3f05b84b - url: "https://pub.dev" - source: hosted - version: "1.1.2" - code_builder: - dependency: transitive - description: - name: code_builder - sha256: "0ec10bf4a89e4c613960bf1e8b42c64127021740fb21640c29c909826a5eea3e" - url: "https://pub.dev" - source: hosted - version: "4.10.1" - collection: - dependency: transitive - description: - name: collection - sha256: "2f5709ae4d3d59dd8f7cd309b4e023046b57d8a6c82130785d2b0e5868084e76" - url: "https://pub.dev" - source: hosted - version: "1.19.1" - convert: - dependency: transitive - description: - name: convert - sha256: b30acd5944035672bc15c6b7a8b47d773e41e2f17de064350988c5d02adb1c68 - url: "https://pub.dev" - source: hosted - version: "3.1.2" - crypto: - dependency: transitive - description: - name: crypto - sha256: "1e445881f28f22d6140f181e07737b22f1e099a5e1ff94b0af2f9e4a463f4855" - url: "https://pub.dev" - source: hosted - version: "3.0.6" - cryptography: - dependency: transitive - description: - name: cryptography - sha256: d146b76d33d94548cf035233fbc2f4338c1242fa119013bead807d033fc4ae05 - url: "https://pub.dev" - source: hosted - version: "2.7.0" - cupertino_icons: - dependency: transitive - description: - name: cupertino_icons - sha256: ba631d1c7f7bef6b729a622b7b752645a2d076dba9976925b8f25725a30e1ee6 - url: "https://pub.dev" - source: hosted - version: "1.0.8" - cw_core: - dependency: "direct main" - description: - path: "../cw_core" - relative: true - source: path - version: "0.0.1" - dart_style: - dependency: transitive - description: - name: dart_style - sha256: "7306ab8a2359a48d22310ad823521d723acfed60ee1f7e37388e8986853b6820" - url: "https://pub.dev" - source: hosted - version: "2.3.8" - dbus: - dependency: transitive - description: - name: dbus - sha256: "79e0c23480ff85dc68de79e2cd6334add97e48f7f4865d17686dd6ea81a47e8c" - url: "https://pub.dev" - source: hosted - version: "0.7.11" - decimal: - dependency: transitive - description: - name: decimal - sha256: "24a261d5d5c87e86c7651c417a5dbdf8bcd7080dd592533910e8d0505a279f21" - url: "https://pub.dev" - source: hosted - version: "2.3.3" - encrypt: - dependency: "direct main" - description: - name: encrypt - sha256: "62d9aa4670cc2a8798bab89b39fc71b6dfbacf615de6cf5001fb39f7e4a996a2" - url: "https://pub.dev" - source: hosted - version: "5.0.3" - fake_async: - dependency: transitive - description: - name: fake_async - sha256: "5368f224a74523e8d2e7399ea1638b37aecfca824a3cc4dfdf77bf1fa905ac44" - url: "https://pub.dev" - source: hosted - version: "1.3.3" - ffi: - dependency: "direct main" - description: - name: ffi - sha256: "16ed7b077ef01ad6170a3d0c57caa4a112a38d7a2ed5602e0aca9ca6f3d98da6" - url: "https://pub.dev" - source: hosted - version: "2.1.3" - file: - dependency: transitive - description: - name: file - sha256: a3b4f84adafef897088c160faf7dfffb7696046cb13ae90b508c2cbc95d3b8d4 - url: "https://pub.dev" - source: hosted - version: "7.0.1" - fixnum: - dependency: transitive - description: - name: fixnum - sha256: b6dc7065e46c974bc7c5f143080a6764ec7a4be6da1285ececdc37be96de53be - url: "https://pub.dev" - source: hosted - version: "1.1.1" - flutter: - dependency: "direct main" - description: flutter - source: sdk - version: "0.0.0" - flutter_mobx: - dependency: "direct main" - description: - name: flutter_mobx - sha256: ba5e93467866a2991259dc51cffd41ef45f695c667c2b8e7b087bf24118b50fe - url: "https://pub.dev" - source: hosted - version: "2.3.0" - flutter_test: - dependency: "direct dev" - description: flutter - source: sdk - version: "0.0.0" - flutter_web_bluetooth: - dependency: transitive - description: - name: flutter_web_bluetooth - sha256: "1363831def5eed1e1064d1eca04e8ccb35446e8f758579c3c519e156b77926da" - url: "https://pub.dev" - source: hosted - version: "1.0.0" - frontend_server_client: - dependency: transitive - description: - name: frontend_server_client - sha256: f64a0333a82f30b0cca061bc3d143813a486dc086b574bfb233b7c1372427694 - url: "https://pub.dev" - source: hosted - version: "4.0.0" - glob: - dependency: transitive - description: - name: glob - sha256: c3f1ee72c96f8f78935e18aa8cecced9ab132419e8625dc187e1c2408efc20de - url: "https://pub.dev" - source: hosted - version: "2.1.3" - graphs: - dependency: transitive - description: - name: graphs - sha256: "741bbf84165310a68ff28fe9e727332eef1407342fca52759cb21ad8177bb8d0" - url: "https://pub.dev" - source: hosted - version: "2.3.2" - hashlib: - dependency: transitive - description: - name: hashlib - sha256: e13e8237d93fb275cd1c55fc339bb90638994d1a4f140c7ee270173b51f3d169 - url: "https://pub.dev" - source: hosted - version: "1.21.1" - hashlib_codecs: - dependency: transitive - description: - name: hashlib_codecs - sha256: "8cea9ccafcfeaa7324d2ae52c61c69f7ff71f4237507a018caab31b9e416e3b1" - url: "https://pub.dev" - source: hosted - version: "2.6.0" - hex: - dependency: transitive - description: - name: hex - sha256: "4e7cd54e4b59ba026432a6be2dd9d96e4c5205725194997193bf871703b82c4a" - url: "https://pub.dev" - source: hosted - version: "0.2.0" - hive: - dependency: transitive - description: - name: hive - sha256: "8dcf6db979d7933da8217edcec84e9df1bdb4e4edc7fc77dbd5aa74356d6d941" - url: "https://pub.dev" - source: hosted - version: "2.2.3" - hive_generator: - dependency: "direct dev" - description: - name: hive_generator - sha256: "06cb8f58ace74de61f63500564931f9505368f45f98958bd7a6c35ba24159db4" - url: "https://pub.dev" - source: hosted - version: "2.0.1" - http: - dependency: "direct main" - description: - name: http - sha256: fe7ab022b76f3034adc518fb6ea04a82387620e19977665ea18d30a1cf43442f - url: "https://pub.dev" - source: hosted - version: "1.3.0" - http_multi_server: - dependency: transitive - description: - name: http_multi_server - sha256: aa6199f908078bb1c5efb8d8638d4ae191aac11b311132c3ef48ce352fb52ef8 - url: "https://pub.dev" - source: hosted - version: "3.2.2" - http_parser: - dependency: transitive - description: - name: http_parser - sha256: "2aa08ce0341cc9b354a498388e30986515406668dbcc4f7c950c3e715496693b" - url: "https://pub.dev" - source: hosted - version: "4.0.2" - intl: - dependency: "direct main" - description: - name: intl - sha256: d6f56758b7d3014a48af9701c085700aac781a92a87a62b1333b46d8879661cf - url: "https://pub.dev" - source: hosted - version: "0.19.0" - io: - dependency: transitive - description: - name: io - sha256: dfd5a80599cf0165756e3181807ed3e77daf6dd4137caaad72d0b7931597650b - url: "https://pub.dev" - source: hosted - version: "1.0.5" - js: - dependency: transitive - description: - name: js - sha256: f2c445dce49627136094980615a031419f7f3eb393237e4ecd97ac15dea343f3 - url: "https://pub.dev" - source: hosted - version: "0.6.7" - json_annotation: - dependency: transitive - description: - name: json_annotation - sha256: "1ce844379ca14835a50d2f019a3099f419082cfdd231cd86a142af94dd5c6bb1" - url: "https://pub.dev" - source: hosted - version: "4.9.0" - leak_tracker: - dependency: transitive - description: - name: leak_tracker - sha256: "6bb818ecbdffe216e81182c2f0714a2e62b593f4a4f13098713ff1685dfb6ab0" - url: "https://pub.dev" - source: hosted - version: "10.0.9" - leak_tracker_flutter_testing: - dependency: transitive - description: - name: leak_tracker_flutter_testing - sha256: f8b613e7e6a13ec79cfdc0e97638fddb3ab848452eff057653abd3edba760573 - url: "https://pub.dev" - source: hosted - version: "3.0.9" - leak_tracker_testing: - dependency: transitive - description: - name: leak_tracker_testing - sha256: "6ba465d5d76e67ddf503e1161d1f4a6bc42306f9d66ca1e8f079a47290fb06d3" - url: "https://pub.dev" - source: hosted - version: "3.0.1" - ledger_flutter_plus: - dependency: "direct main" - description: - name: ledger_flutter_plus - sha256: "1c03f3c4a9754b5f0170a9eb9552ec54fa86e985f8ee71a255ee2c5629b53d31" - url: "https://pub.dev" - source: hosted - version: "1.5.1" - ledger_usb_plus: - dependency: transitive - description: - name: ledger_usb_plus - sha256: "21cc5d976cf7edb3518bd2a0c4164139cbb0817d2e4f2054707fc4edfdf9ce87" - url: "https://pub.dev" - source: hosted - version: "1.0.4" - logging: - dependency: transitive - description: - name: logging - sha256: c8245ada5f1717ed44271ed1c26b8ce85ca3228fd2ffdb75468ab01979309d61 - url: "https://pub.dev" - source: hosted - version: "1.3.0" - macros: - dependency: transitive - description: - name: macros - sha256: "1d9e801cd66f7ea3663c45fc708450db1fa57f988142c64289142c9b7ee80656" - url: "https://pub.dev" - source: hosted - version: "0.1.3-main.0" - matcher: - dependency: transitive - description: - name: matcher - sha256: dc58c723c3c24bf8d3e2d3ad3f2f9d7bd9cf43ec6feaa64181775e60190153f2 - url: "https://pub.dev" - source: hosted - version: "0.12.17" - material_color_utilities: - dependency: transitive - description: - name: material_color_utilities - sha256: f7142bb1154231d7ea5f96bc7bde4bda2a0945d2806bb11670e30b850d56bdec - url: "https://pub.dev" - source: hosted - version: "0.11.1" - meta: - dependency: transitive - description: - name: meta - sha256: e3641ec5d63ebf0d9b41bd43201a66e3fc79a65db5f61fc181f04cd27aab950c - url: "https://pub.dev" - source: hosted - version: "1.16.0" - mime: - dependency: transitive - description: - name: mime - sha256: "41a20518f0cb1256669420fdba0cd90d21561e560ac240f26ef8322e45bb7ed6" - url: "https://pub.dev" - source: hosted - version: "2.0.0" - mobx: - dependency: "direct main" - description: - name: mobx - sha256: bf1a90e5bcfd2851fc6984e20eef69557c65d9e4d0a88f5be4cf72c9819ce6b0 - url: "https://pub.dev" - source: hosted - version: "2.5.0" - mobx_codegen: - dependency: "direct dev" - description: - name: mobx_codegen - sha256: "990da80722f7d7c0017dec92040b31545d625b15d40204c36a1e63d167c73cdc" - url: "https://pub.dev" - source: hosted - version: "2.7.0" - mockito: - dependency: "direct dev" - description: - name: mockito - sha256: f99d8d072e249f719a5531735d146d8cf04c580d93920b04de75bef6dfb2daf6 - url: "https://pub.dev" - source: hosted - version: "5.4.5" - monero: - dependency: "direct main" - description: - path: "impls/monero.dart" - ref: bc8d1a0b75b97156d71579581b4cdfe58c777ed2 - resolved-ref: bc8d1a0b75b97156d71579581b4cdfe58c777ed2 - url: "https://github.com/mrcyjanek/monero_c" - source: git - version: "0.0.0" - mutex: - dependency: "direct main" - description: - name: mutex - sha256: "8827da25de792088eb33e572115a5eb0d61d61a3c01acbc8bcbe76ed78f1a1f2" - url: "https://pub.dev" - source: hosted - version: "3.1.0" - nested: - dependency: transitive - description: - name: nested - sha256: "03bac4c528c64c95c722ec99280375a6f2fc708eec17c7b3f07253b626cd2a20" - url: "https://pub.dev" - source: hosted - version: "1.0.0" - on_chain: - dependency: transitive - description: - path: "." - ref: "096865a8c6b89c260beadfec04f7e184c40a3273" - resolved-ref: "096865a8c6b89c260beadfec04f7e184c40a3273" - url: "https://github.com/cake-tech/on_chain.git" - source: git - version: "3.7.0" - package_config: - dependency: transitive - description: - name: package_config - sha256: "92d4488434b520a62570293fbd33bb556c7d49230791c1b4bbd973baf6d2dc67" - url: "https://pub.dev" - source: hosted - version: "2.1.1" - path: - dependency: transitive - description: - name: path - sha256: "75cca69d1490965be98c73ceaea117e8a04dd21217b37b292c9ddbec0d955bc5" - url: "https://pub.dev" - source: hosted - version: "1.9.1" - path_provider: - dependency: "direct main" - description: - name: path_provider - sha256: "50c5dd5b6e1aaf6fb3a78b33f6aa3afca52bf903a8a5298f53101fdaee55bbcd" - url: "https://pub.dev" - source: hosted - version: "2.1.5" - path_provider_android: - dependency: transitive - description: - name: path_provider_android - sha256: "4adf4fd5423ec60a29506c76581bc05854c55e3a0b72d35bb28d661c9686edf2" - url: "https://pub.dev" - source: hosted - version: "2.2.15" - path_provider_foundation: - dependency: transitive - description: - name: path_provider_foundation - sha256: "4843174df4d288f5e29185bd6e72a6fbdf5a4a4602717eed565497429f179942" - url: "https://pub.dev" - source: hosted - version: "2.4.1" - path_provider_linux: - dependency: transitive - description: - name: path_provider_linux - sha256: f7a1fe3a634fe7734c8d3f2766ad746ae2a2884abe22e241a8b301bf5cac3279 - url: "https://pub.dev" - source: hosted - version: "2.2.1" - path_provider_platform_interface: - dependency: transitive - description: - name: path_provider_platform_interface - sha256: "88f5779f72ba699763fa3a3b06aa4bf6de76c8e5de842cf6f29e2e06476c2334" - url: "https://pub.dev" - source: hosted - version: "2.1.2" - path_provider_windows: - dependency: transitive - description: - name: path_provider_windows - sha256: bd6f00dbd873bfb70d0761682da2b3a2c2fccc2b9e84c495821639601d81afe7 - url: "https://pub.dev" - source: hosted - version: "2.3.0" - petitparser: - dependency: transitive - description: - name: petitparser - sha256: c15605cd28af66339f8eb6fbe0e541bfe2d1b72d5825efc6598f3e0a31b9ad27 - url: "https://pub.dev" - source: hosted - version: "6.0.2" - platform: - dependency: transitive - description: - name: platform - sha256: "5d6b1b0036a5f331ebc77c850ebc8506cbc1e9416c27e59b439f917a902a4984" - url: "https://pub.dev" - source: hosted - version: "3.1.6" - plugin_platform_interface: - dependency: transitive - description: - name: plugin_platform_interface - sha256: "4820fbfdb9478b1ebae27888254d445073732dae3d6ea81f0b7e06d5dedc3f02" - url: "https://pub.dev" - source: hosted - version: "2.1.8" - pointycastle: - dependency: transitive - description: - name: pointycastle - sha256: "4be0097fcf3fd3e8449e53730c631200ebc7b88016acecab2b0da2f0149222fe" - url: "https://pub.dev" - source: hosted - version: "3.9.1" - polyseed: - dependency: "direct main" - description: - name: polyseed - sha256: ccc6ba0ed3976837e770a324a6295fbf3d7c0d05fc0fa1906e36479b07c2675b - url: "https://pub.dev" - source: hosted - version: "0.0.7" - pool: - dependency: transitive - description: - name: pool - sha256: "20fe868b6314b322ea036ba325e6fc0711a22948856475e2c2b6306e8ab39c2a" - url: "https://pub.dev" - source: hosted - version: "1.5.1" - provider: - dependency: transitive - description: - name: provider - sha256: c8a055ee5ce3fd98d6fc872478b03823ffdb448699c6ebdbbc71d59b596fd48c - url: "https://pub.dev" - source: hosted - version: "6.1.2" - pub_semver: - dependency: transitive - description: - name: pub_semver - sha256: "7b3cfbf654f3edd0c6298ecd5be782ce997ddf0e00531b9464b55245185bbbbd" - url: "https://pub.dev" - source: hosted - version: "2.1.5" - pubspec_parse: - dependency: transitive - description: - name: pubspec_parse - sha256: "81876843eb50dc2e1e5b151792c9a985c5ed2536914115ed04e9c8528f6647b0" - url: "https://pub.dev" - source: hosted - version: "1.4.0" - rational: - dependency: transitive - description: - name: rational - sha256: cb808fb6f1a839e6fc5f7d8cb3b0a10e1db48b3be102de73938c627f0b636336 - url: "https://pub.dev" - source: hosted - version: "2.2.3" - rxdart: - dependency: transitive - description: - name: rxdart - sha256: "5c3004a4a8dbb94bd4bf5412a4def4acdaa12e12f269737a5751369e12d1a962" - url: "https://pub.dev" - source: hosted - version: "0.28.0" - shelf: - dependency: transitive - description: - name: shelf - sha256: ad29c505aee705f41a4d8963641f91ac4cee3c8fad5947e033390a7bd8180fa4 - url: "https://pub.dev" - source: hosted - version: "1.4.1" - shelf_web_socket: - dependency: transitive - description: - name: shelf_web_socket - sha256: cc36c297b52866d203dbf9332263c94becc2fe0ceaa9681d07b6ef9807023b67 - url: "https://pub.dev" - source: hosted - version: "2.0.1" - sky_engine: - dependency: transitive - description: flutter - source: sdk - version: "0.0.0" - socks5_proxy: - dependency: transitive - description: - path: "." - ref: "27ad7c2efae8d7460325c74b90f660085cbd0685" - resolved-ref: "27ad7c2efae8d7460325c74b90f660085cbd0685" - url: "https://github.com/LacticWhale/socks_dart" - source: git - version: "2.1.0" - socks_socket: - dependency: transitive - description: - path: "." - ref: e6232c53c1595469931ababa878759a067c02e94 - resolved-ref: e6232c53c1595469931ababa878759a067c02e94 - url: "https://github.com/sneurlax/socks_socket" - source: git - version: "1.1.1" - source_gen: - dependency: transitive - description: - name: source_gen - sha256: "14658ba5f669685cd3d63701d01b31ea748310f7ab854e471962670abcf57832" - url: "https://pub.dev" - source: hosted - version: "1.5.0" - source_helper: - dependency: transitive - description: - name: source_helper - sha256: "86d247119aedce8e63f4751bd9626fc9613255935558447569ad42f9f5b48b3c" - url: "https://pub.dev" - source: hosted - version: "1.3.5" - source_span: - dependency: transitive - description: - name: source_span - sha256: "254ee5351d6cb365c859e20ee823c3bb479bf4a293c22d17a9f1bf144ce86f7c" - url: "https://pub.dev" - source: hosted - version: "1.10.1" - sqflite: - dependency: transitive - description: - name: sqflite - sha256: "2d7299468485dca85efeeadf5d38986909c5eb0cd71fd3db2c2f000e6c9454bb" - url: "https://pub.dev" - source: hosted - version: "2.4.1" - sqflite_android: - dependency: transitive - description: - name: sqflite_android - sha256: "78f489aab276260cdd26676d2169446c7ecd3484bbd5fead4ca14f3ed4dd9ee3" - url: "https://pub.dev" - source: hosted - version: "2.4.0" - sqflite_common: - dependency: transitive - description: - name: sqflite_common - sha256: "761b9740ecbd4d3e66b8916d784e581861fd3c3553eda85e167bc49fdb68f709" - url: "https://pub.dev" - source: hosted - version: "2.5.4+6" - sqflite_common_ffi: - dependency: "direct dev" - description: - name: sqflite_common_ffi - sha256: "883dd810b2b49e6e8c3b980df1829ef550a94e3f87deab5d864917d27ca6bf36" - url: "https://pub.dev" - source: hosted - version: "2.3.4+4" - sqflite_darwin: - dependency: transitive - description: - name: sqflite_darwin - sha256: "22adfd9a2c7d634041e96d6241e6e1c8138ca6817018afc5d443fef91dcefa9c" - url: "https://pub.dev" - source: hosted - version: "2.4.1+1" - sqflite_platform_interface: - dependency: transitive - description: - name: sqflite_platform_interface - sha256: "8dd4515c7bdcae0a785b0062859336de775e8c65db81ae33dd5445f35be61920" - url: "https://pub.dev" - source: hosted - version: "2.4.0" - sqlite3: - dependency: transitive - description: - name: sqlite3 - sha256: f393d92c71bdcc118d6203d07c991b9be0f84b1a6f89dd4f7eed348131329924 - url: "https://pub.dev" - source: hosted - version: "2.9.0" - sqlite3_flutter_libs: - dependency: transitive - description: - name: sqlite3_flutter_libs - sha256: "69c80d812ef2500202ebd22002cbfc1b6565e9ff56b2f971e757fac5d42294df" - url: "https://pub.dev" - source: hosted - version: "0.5.40" - stack_trace: - dependency: transitive - description: - name: stack_trace - sha256: "8b27215b45d22309b5cddda1aa2b19bdfec9df0e765f2de506401c071d38d1b1" - url: "https://pub.dev" - source: hosted - version: "1.12.1" - stream_channel: - dependency: transitive - description: - name: stream_channel - sha256: "969e04c80b8bcdf826f8f16579c7b14d780458bd97f56d107d3950fdbeef059d" - url: "https://pub.dev" - source: hosted - version: "2.1.4" - stream_transform: - dependency: transitive - description: - name: stream_transform - sha256: ad47125e588cfd37a9a7f86c7d6356dde8dfe89d071d293f80ca9e9273a33871 - url: "https://pub.dev" - source: hosted - version: "2.1.1" - string_scanner: - dependency: transitive - description: - name: string_scanner - sha256: "921cd31725b72fe181906c6a94d987c78e3b98c2e205b397ea399d4054872b43" - url: "https://pub.dev" - source: hosted - version: "1.4.1" - synchronized: - dependency: transitive - description: - name: synchronized - sha256: "69fe30f3a8b04a0be0c15ae6490fc859a78ef4c43ae2dd5e8a623d45bfcf9225" - url: "https://pub.dev" - source: hosted - version: "3.3.0+3" - term_glyph: - dependency: transitive - description: - name: term_glyph - sha256: "7f554798625ea768a7518313e58f83891c7f5024f88e46e7182a4558850a4b8e" - url: "https://pub.dev" - source: hosted - version: "1.2.2" - test_api: - dependency: transitive - description: - name: test_api - sha256: fb31f383e2ee25fbbfe06b40fe21e1e458d14080e3c67e7ba0acfde4df4e0bbd - url: "https://pub.dev" - source: hosted - version: "0.7.4" - timing: - dependency: transitive - description: - name: timing - sha256: "70a3b636575d4163c477e6de42f247a23b315ae20e86442bebe32d3cabf61c32" - url: "https://pub.dev" - source: hosted - version: "1.0.1" - torch_dart: - dependency: transitive - description: - path: "../scripts/torch_dart" - relative: true - source: path - version: "0.0.1" - tuple: - dependency: transitive - description: - name: tuple - sha256: a97ce2013f240b2f3807bcbaf218765b6f301c3eff91092bcfa23a039e7dd151 - url: "https://pub.dev" - source: hosted - version: "2.0.2" - typed_data: - dependency: transitive - description: - name: typed_data - sha256: f9049c039ebfeb4cf7a7104a675823cd72dba8297f264b6637062516699fa006 - url: "https://pub.dev" - source: hosted - version: "1.4.0" - universal_ble: - dependency: transitive - description: - name: universal_ble - sha256: "1fad089150a29db82b3b7d60327e18c5ad6b3a5bb509defc1c690b0a76b9c098" - url: "https://pub.dev" - source: hosted - version: "0.15.0" - universal_platform: - dependency: transitive - description: - name: universal_platform - sha256: "64e16458a0ea9b99260ceb5467a214c1f298d647c659af1bff6d3bf82536b1ec" - url: "https://pub.dev" - source: hosted - version: "1.1.0" - unorm_dart: - dependency: transitive - description: - name: unorm_dart - sha256: "23d8bf65605401a6a32cff99435fed66ef3dab3ddcad3454059165df46496a3b" - url: "https://pub.dev" - source: hosted - version: "0.3.0" - vector_math: - dependency: transitive - description: - name: vector_math - sha256: "80b3257d1492ce4d091729e3a67a60407d227c27241d6927be0130c98e741803" - url: "https://pub.dev" - source: hosted - version: "2.1.4" - vm_service: - dependency: transitive - description: - name: vm_service - sha256: ddfa8d30d89985b96407efce8acbdd124701f96741f2d981ca860662f1c0dc02 - url: "https://pub.dev" - source: hosted - version: "15.0.0" - watcher: - dependency: "direct overridden" - description: - name: watcher - sha256: "69da27e49efa56a15f8afe8f4438c4ec02eff0a117df1b22ea4aad194fe1c104" - url: "https://pub.dev" - source: hosted - version: "1.1.1" - web: - dependency: transitive - description: - name: web - sha256: "868d88a33d8a87b18ffc05f9f030ba328ffefba92d6c127917a2ba740f9cfe4a" - url: "https://pub.dev" - source: hosted - version: "1.1.1" - web_socket: - dependency: transitive - description: - name: web_socket - sha256: "3c12d96c0c9a4eec095246debcea7b86c0324f22df69893d538fcc6f1b8cce83" - url: "https://pub.dev" - source: hosted - version: "0.1.6" - web_socket_channel: - dependency: transitive - description: - name: web_socket_channel - sha256: "0b8e2457400d8a859b7b2030786835a28a8e80836ef64402abef392ff4f1d0e5" - url: "https://pub.dev" - source: hosted - version: "3.0.2" - xdg_directories: - dependency: transitive - description: - name: xdg_directories - sha256: "7a3f37b05d989967cdddcbb571f1ea834867ae2faa29725fd085180e0883aa15" - url: "https://pub.dev" - source: hosted - version: "1.1.0" - xml: - dependency: transitive - description: - name: xml - sha256: b015a8ad1c488f66851d762d3090a21c600e479dc75e68328c52774040cf9226 - url: "https://pub.dev" - source: hosted - version: "6.5.0" - yaml: - dependency: transitive - description: - name: yaml - sha256: b9da305ac7c39faa3f030eccd175340f968459dae4af175130b3fc47e40d76ce - url: "https://pub.dev" - source: hosted - version: "3.1.3" -sdks: - dart: ">=3.7.0-0 <4.0.0" - flutter: ">=3.24.0" diff --git a/cw_monero/pubspec.yaml b/cw_monero/pubspec.yaml index 047e7e0fe..996167b7e 100644 --- a/cw_monero/pubspec.yaml +++ b/cw_monero/pubspec.yaml @@ -16,8 +16,8 @@ dependencies: ffi: ^2.0.1 http: ^1.1.0 path_provider: ^2.0.11 - mobx: ^2.0.7+4 - flutter_mobx: ^2.0.6+1 + mobx: ^2.6.0 + flutter_mobx: ^2.3.0 intl: any encrypt: ^5.0.1 polyseed: ^0.0.7 @@ -25,20 +25,25 @@ dependencies: path: ../cw_core monero: git: - url: https://github.com/mrcyjanek/monero_c - ref: bc8d1a0b75b97156d71579581b4cdfe58c777ed2 + url: https://github.com/mrcyjanek/monero_c.git + ref: 33671871de3d51696b66ffb3bd94f744e26974f6 path: impls/monero.dart mutex: ^3.1.0 ledger_flutter_plus: ^1.4.1 + trezor_flutter: + git: + url: https://github.com/cake-tech/trezor-flutter.git + ref: 591d4b8c821b9c10561f848b866324a9a8dcb489 + path: trezor-flutter dev_dependencies: flutter_test: sdk: flutter - build_runner: ^2.4.15 - build_resolvers: ^2.4.4 - mobx_codegen: ^2.0.7 + build_runner: ^2.15.0 + mobx_codegen: ^2.7.7 mockito: ^5.4.5 - hive_generator: ^2.0.1 +# hive_generator: ^2.0.1 + hive: 2.0.1 sqflite_common_ffi: ^2.3.4+4 dependency_overrides: diff --git a/cw_monero/pubspec_overrides.yaml b/cw_monero/pubspec_overrides.yaml new file mode 120000 index 000000000..d957432d3 --- /dev/null +++ b/cw_monero/pubspec_overrides.yaml @@ -0,0 +1 @@ +../pubspec_overrides.yaml \ No newline at end of file diff --git a/cw_mweb/.gitignore b/cw_mweb/.gitignore index 9d90768f5..0ec651641 100644 --- a/cw_mweb/.gitignore +++ b/cw_mweb/.gitignore @@ -22,8 +22,6 @@ migrate_working_dir/ #.vscode/ # Flutter/Dart/Pub related -# Libraries should not include pubspec.lock, per https://dart.dev/guides/libraries/private-files#pubspeclock. -/pubspec.lock **/doc/api/ .dart_tool/ .packages diff --git a/cw_mweb/analysis_options.yaml b/cw_mweb/analysis_options.yaml deleted file mode 100644 index a5744c1cf..000000000 --- a/cw_mweb/analysis_options.yaml +++ /dev/null @@ -1,4 +0,0 @@ -include: package:flutter_lints/flutter.yaml - -# Additional information about this file can be found at -# https://dart.dev/guides/language/analysis-options diff --git a/cw_mweb/pubspec.yaml b/cw_mweb/pubspec.yaml index ae4f4a2e9..058d76279 100644 --- a/cw_mweb/pubspec.yaml +++ b/cw_mweb/pubspec.yaml @@ -21,7 +21,7 @@ dev_dependencies: flutter_test: sdk: flutter flutter_lints: ^2.0.0 - ffigen: + ffigen: ^20.1.1 # For information on the generic Dart part of this file, see the # following page: https://dart.dev/tools/pub/pubspec diff --git a/cw_mweb/pubspec_overrides.yaml b/cw_mweb/pubspec_overrides.yaml new file mode 120000 index 000000000..d957432d3 --- /dev/null +++ b/cw_mweb/pubspec_overrides.yaml @@ -0,0 +1 @@ +../pubspec_overrides.yaml \ No newline at end of file diff --git a/cw_nano/build.yaml b/cw_nano/build.yaml new file mode 100644 index 000000000..81a9c96df --- /dev/null +++ b/cw_nano/build.yaml @@ -0,0 +1,5 @@ +targets: + $default: + sources: + exclude: + - "**/.secrets.g.dart" diff --git a/cw_nano/lib/banano_balance.dart b/cw_nano/lib/banano_balance.dart deleted file mode 100644 index ae62c0f12..000000000 --- a/cw_nano/lib/banano_balance.dart +++ /dev/null @@ -1,35 +0,0 @@ -import 'package:cw_core/balance.dart'; -import 'package:nanoutil/nanoutil.dart'; - -BigInt stringAmountToBigIntBanano(String amount) { - return BigInt.parse(NanoAmounts.getAmountAsRaw(amount, NanoAmounts.rawPerBanano)); -} - -class BananoBalance extends Balance { - final BigInt currentBalance; - final BigInt receivableBalance; - - BananoBalance({required this.currentBalance, required this.receivableBalance}) - : super(BigInt.zero, BigInt.zero); - - BananoBalance.fromFormattedString( - {required String formattedCurrentBalance, required String formattedReceivableBalance}) - : currentBalance = stringAmountToBigIntBanano(formattedCurrentBalance), - receivableBalance = stringAmountToBigIntBanano(formattedReceivableBalance), - super(BigInt.zero, BigInt.zero); - - BananoBalance.fromRawString({required String currentBalance, required String receivableBalance}) - : currentBalance = BigInt.parse(currentBalance), - receivableBalance = BigInt.parse(receivableBalance), - super(BigInt.zero, BigInt.zero); - - @override - String get formattedAvailableBalance { - return NanoAmounts.getRawAsUsableString(currentBalance.toString(), NanoAmounts.rawPerBanano); - } - - @override - String get formattedAdditionalBalance { - return NanoAmounts.getRawAsUsableString(receivableBalance.toString(), NanoAmounts.rawPerBanano); - } -} diff --git a/cw_nano/lib/nano_balance.dart b/cw_nano/lib/nano_balance.dart index e288cea79..c5559e6d3 100644 --- a/cw_nano/lib/nano_balance.dart +++ b/cw_nano/lib/nano_balance.dart @@ -1,37 +1,22 @@ +import 'package:cw_core/amount/money.dart'; import 'package:cw_core/balance.dart'; -import 'package:cw_core/utils/print_verbose.dart'; -import 'package:nanoutil/nanoutil.dart'; - -BigInt stringAmountToBigIntNano(String amount) { - return BigInt.parse(NanoAmounts.getAmountAsRaw(amount, NanoAmounts.rawPerNano)); -} +import 'package:cw_core/crypto_currency.dart'; class NanoBalance extends Balance { - final BigInt currentBalance; - final BigInt receivableBalance; + final Money currentBalance; + final Money receivableBalance; NanoBalance({required this.currentBalance, required this.receivableBalance}) : super(currentBalance, receivableBalance); - NanoBalance.fromFormattedString( - {required String formattedCurrentBalance, required String formattedReceivableBalance}) - : currentBalance = stringAmountToBigIntNano(formattedCurrentBalance), - receivableBalance = stringAmountToBigIntNano(formattedReceivableBalance), - super(stringAmountToBigIntNano(formattedCurrentBalance), - stringAmountToBigIntNano(formattedReceivableBalance)); - - NanoBalance.fromRawString({required String currentBalance, required String receivableBalance}) - : currentBalance = BigInt.parse(currentBalance), - receivableBalance = BigInt.parse(receivableBalance), - super(BigInt.parse(currentBalance), BigInt.parse(receivableBalance)); - - @override - String get formattedAvailableBalance { - return NanoAmounts.getRawAsUsableString(currentBalance.toString(), NanoAmounts.rawPerNano); - } - - @override - String get formattedAdditionalBalance { - return NanoAmounts.getRawAsUsableString(receivableBalance.toString(), NanoAmounts.rawPerNano); - } + factory NanoBalance.fromRawString({ + required String currentBalance, + required String receivableBalance, + }) => + NanoBalance( + currentBalance: + Money.tryParse(currentBalance, CryptoCurrency.nano) ?? Money.zero(CryptoCurrency.nano), + receivableBalance: Money.tryParse(receivableBalance, CryptoCurrency.nano) ?? + Money.zero(CryptoCurrency.nano), + ); } diff --git a/cw_nano/lib/nano_client.dart b/cw_nano/lib/nano_client.dart index 90f994759..fc68cf614 100644 --- a/cw_nano/lib/nano_client.dart +++ b/cw_nano/lib/nano_client.dart @@ -1,6 +1,8 @@ import 'dart:async'; import 'dart:convert'; +import 'package:cw_core/amount/money.dart'; +import 'package:cw_core/crypto_currency.dart'; import 'package:cw_core/nano_account_info_response.dart'; import 'package:cw_core/utils/print_verbose.dart'; import 'package:cw_core/utils/proxy_wrapper.dart'; @@ -85,10 +87,10 @@ class NanoClient { throw Exception( "Error while trying to get balance! ${data["error"] != null ? data["error"] : ""}"); } - final String currentBalance = data["balance"] as String; - final String receivableBalance = data["receivable"] as String; - final BigInt cur = BigInt.parse(currentBalance); - final BigInt rec = BigInt.parse(receivableBalance); + final currentBalance = data["balance"] as String; + final receivableBalance = data["receivable"] as String; + final cur = Money(BigInt.parse(currentBalance), CryptoCurrency.nano); + final rec = Money(BigInt.parse(receivableBalance), CryptoCurrency.nano); return NanoBalance(currentBalance: cur, receivableBalance: rec); } @@ -258,7 +260,7 @@ class NanoClient { // first get the current account balance: if (balanceAfterTx == null) { - final BigInt currentBalance = (await getBalance(publicAddress)).currentBalance; + final BigInt currentBalance = (await getBalance(publicAddress)).currentBalance.amount; final BigInt txAmount = BigInt.parse(amountRaw); balanceAfterTx = currentBalance - txAmount; } diff --git a/cw_nano/lib/nano_transaction_info.dart b/cw_nano/lib/nano_transaction_info.dart index 9195a06ef..66dd78344 100644 --- a/cw_nano/lib/nano_transaction_info.dart +++ b/cw_nano/lib/nano_transaction_info.dart @@ -1,13 +1,14 @@ +import 'package:cw_core/amount/money.dart'; +import 'package:cw_core/crypto_currency.dart'; import 'package:cw_core/format_amount.dart'; import 'package:cw_core/transaction_direction.dart'; import 'package:cw_core/transaction_info.dart'; -import 'package:nanoutil/nanoutil.dart'; class NanoTransactionInfo extends TransactionInfo { NanoTransactionInfo({ required this.id, required this.height, - required this.amountRaw, + required Money amountRaw, this.tokenSymbol = "XNO", required this.direction, required this.confirmed, @@ -15,12 +16,11 @@ class NanoTransactionInfo extends TransactionInfo { required this.confirmations, required this.to, required this.from, - }) : this.amount = amountRaw.toInt(); + }) : this.amount = amountRaw; final String id; final int height; - final int amount; - final BigInt amountRaw; + final Money amount; final TransactionDirection direction; final DateTime date; final bool confirmed; @@ -32,28 +32,17 @@ class NanoTransactionInfo extends TransactionInfo { bool get isPending => !this.confirmed; - @override - String amountFormatted() { - final String amt = - NanoAmounts.getRawAsUsableString(amountRaw.toString(), NanoAmounts.rawPerNano); - final String acc = NanoAmounts.getRawAccuracy(amountRaw.toString(), NanoAmounts.rawPerNano); - return "$acc$amt $tokenSymbol"; - } - @override String fiatAmount() => _fiatAmount ?? ''; @override void changeFiatAmount(String amount) => _fiatAmount = formatAmount(amount); - @override - String feeFormatted() => "0 XNO"; - factory NanoTransactionInfo.fromJson(Map data) { return NanoTransactionInfo( id: data['id'] as String, height: data['height'] as int, - amountRaw: BigInt.parse(data['amountRaw'] as String), + amountRaw: Money(BigInt.parse(data['amountRaw'] as String), CryptoCurrency.nano), direction: parseTransactionDirectionFromInt(data['direction'] as int), date: DateTime.fromMillisecondsSinceEpoch(data['date'] as int), confirmed: data['confirmed'] as bool, @@ -67,7 +56,7 @@ class NanoTransactionInfo extends TransactionInfo { Map toJson() => { 'id': id, 'height': height, - 'amountRaw': amountRaw.toString(), + 'amountRaw': amount.amount.toString(), 'direction': direction.index, 'date': date.millisecondsSinceEpoch, 'confirmed': confirmed, diff --git a/cw_nano/lib/nano_wallet.dart b/cw_nano/lib/nano_wallet.dart index 44b4a3c8a..b8472f2de 100644 --- a/cw_nano/lib/nano_wallet.dart +++ b/cw_nano/lib/nano_wallet.dart @@ -3,6 +3,7 @@ import 'dart:convert'; import 'dart:io'; import 'package:bip39/bip39.dart' as bip39; +import 'package:cw_core/amount/money.dart'; import 'package:cw_core/cake_hive.dart'; import 'package:cw_core/crypto_currency.dart'; import 'package:cw_core/encryption_file_utils.dart'; @@ -55,7 +56,10 @@ abstract class NanoWalletBase walletAddresses = NanoWalletAddresses(walletInfo), balance = ObservableMap.of({ CryptoCurrency.nano: initialBalance ?? - NanoBalance(currentBalance: BigInt.zero, receivableBalance: BigInt.zero) + NanoBalance( + currentBalance: Money.zero(CryptoCurrency.nano), + receivableBalance: Money.zero(CryptoCurrency.nano), + ) }), super(walletInfo, derivationInfo) { this.walletInfo = walletInfo; @@ -191,7 +195,7 @@ abstract class NanoWalletBase BigInt runningAmount = BigInt.zero; await _updateBalance(); - BigInt runningBalance = balance[currency]?.currentBalance ?? BigInt.zero; + BigInt runningBalance = balance[currency]?.currentBalance.amount ?? BigInt.zero; final List> blocks = []; String? previousHash; @@ -199,14 +203,13 @@ abstract class NanoWalletBase for (var txOut in credentials.outputs) { late BigInt amt; if (txOut.sendAll) { - amt = balance[currency]?.currentBalance ?? BigInt.zero; + amt = balance[currency]?.currentBalance.amount ?? BigInt.zero; } else { - amt = BigInt.tryParse(NanoAmounts.getAmountAsRaw( - txOut.cryptoAmount?.replaceAll(',', '.') ?? "0", NanoAmounts.rawPerNano)) ?? - BigInt.zero; + amt = txOut.cryptoAmount.amount; } - if (balance[currency]?.currentBalance != null && amt > balance[currency]!.currentBalance) { + if (balance[currency]?.currentBalance != null && + amt > balance[currency]!.currentBalance.amount) { throw Exception("Trying to send more than entire balance!"); } @@ -233,7 +236,8 @@ abstract class NanoWalletBase } try { - if (runningAmount > balance[currency]!.currentBalance || runningBalance < BigInt.zero) { + if (runningAmount > balance[currency]!.currentBalance.amount || + runningBalance < BigInt.zero) { throw Exception(("Trying to send more than entire balance!")); } } catch (e) { @@ -241,7 +245,7 @@ abstract class NanoWalletBase } return PendingNanoTransaction( - amount: runningAmount, + amount: Money(runningAmount, currency), id: "", nanoClient: _client, blocks: blocks, @@ -294,7 +298,7 @@ abstract class NanoWalletBase final bool isSend = transactionModel.type == "send"; result[transactionModel.hash] = NanoTransactionInfo( id: transactionModel.hash, - amountRaw: transactionModel.amount, + amountRaw: Money(transactionModel.amount, currency), height: transactionModel.height, direction: isSend ? TransactionDirection.outgoing : TransactionDirection.incoming, confirmed: transactionModel.confirmed, @@ -360,7 +364,7 @@ abstract class NanoWalletBase // get our balance: await _updateBalance(); // if we have anything to receive, process it: - if (balance[currency]!.receivableBalance > BigInt.zero) { + if (balance[currency]!.receivableBalance.amount > BigInt.zero) { await _receiveAll(); } }); @@ -465,8 +469,8 @@ abstract class NanoWalletBase // if we don't have a balance, we should at least create one, since it's a late binding // otherwise, it's better to just leave it as whatever it was before: if (balance[currency] == null) { - balance[currency] = - NanoBalance(currentBalance: BigInt.zero, receivableBalance: BigInt.zero); + balance[currency] = NanoBalance( + currentBalance: Money.zero(currency), receivableBalance: Money.zero(currency)); } } // don't save unnecessarily: diff --git a/cw_nano/lib/pending_nano_transaction.dart b/cw_nano/lib/pending_nano_transaction.dart index 298e39419..84789f801 100644 --- a/cw_nano/lib/pending_nano_transaction.dart +++ b/cw_nano/lib/pending_nano_transaction.dart @@ -1,6 +1,7 @@ +import 'package:cw_core/amount/money.dart'; +import 'package:cw_core/crypto_currency.dart'; import 'package:cw_core/pending_transaction.dart'; import 'package:cw_nano/nano_client.dart'; -import 'package:nanoutil/nanoutil.dart'; class PendingNanoTransaction with PendingTransaction { PendingNanoTransaction({ @@ -11,38 +12,26 @@ class PendingNanoTransaction with PendingTransaction { }); final NanoClient nanoClient; - final BigInt amount; final String id; final List> blocks; String hex = "unused"; @override - String get amountFormatted { - final String amt = NanoAmounts.getRawAsUsableString(amount.toString(), NanoAmounts.rawPerNano); - return amt; - } - - String get accurateAmountFormatted { - final String amt = NanoAmounts.getRawAsUsableString(amount.toString(), NanoAmounts.rawPerNano); - final String acc = NanoAmounts.getRawAccuracy(amount.toString(), NanoAmounts.rawPerNano); - return "$acc$amt"; - } + final Money amount; @override - String get feeFormatted => "$feeFormattedValue XNO"; + String get amountFormatted => amount.toString(); @override - String get feeFormattedValue => "0"; + Money get fee => Money.zero(CryptoCurrency.nano); @override Future commit() async { - for (var block in blocks) { + for (final block in blocks) { await nanoClient.processBlock(block, "send"); } } @override - Future> commitUR() { - throw UnimplementedError(); - } + Future> commitUR() => throw UnimplementedError(); } diff --git a/cw_nano/pubspec.lock b/cw_nano/pubspec.lock deleted file mode 100644 index 72684a8fc..000000000 --- a/cw_nano/pubspec.lock +++ /dev/null @@ -1,1060 +0,0 @@ -# Generated by pub -# See https://dart.dev/tools/pub/glossary#lockfile -packages: - _fe_analyzer_shared: - dependency: transitive - description: - name: _fe_analyzer_shared - sha256: "16e298750b6d0af7ce8a3ba7c18c69c3785d11b15ec83f6dcd0ad2a0009b3cab" - url: "https://pub.dev" - source: hosted - version: "76.0.0" - _macros: - dependency: transitive - description: dart - source: sdk - version: "0.3.3" - analyzer: - dependency: transitive - description: - name: analyzer - sha256: "1f14db053a8c23e260789e9b0980fa27f2680dd640932cae5e1137cce0e46e1e" - url: "https://pub.dev" - source: hosted - version: "6.11.0" - args: - dependency: transitive - description: - name: args - sha256: d0481093c50b1da8910eb0bb301626d4d8eb7284aa739614d2b394ee09e3ea04 - url: "https://pub.dev" - source: hosted - version: "2.7.0" - asn1lib: - dependency: transitive - description: - name: asn1lib - sha256: "1c296cd268f486cabcc3930e9b93a8133169305f18d722916e675959a88f6d2c" - url: "https://pub.dev" - source: hosted - version: "1.5.9" - async: - dependency: transitive - description: - name: async - sha256: "758e6d74e971c3e5aceb4110bfd6698efc7f501675bcfe0c775459a8140750eb" - url: "https://pub.dev" - source: hosted - version: "2.13.0" - bech32: - dependency: transitive - description: - path: "." - ref: HEAD - resolved-ref: "05755063b593aa6cca0a4820a318e0ce17de6192" - url: "https://github.com/cake-tech/bech32.git" - source: git - version: "0.2.2" - bip32: - dependency: "direct main" - description: - name: bip32 - sha256: "54787cd7a111e9d37394aabbf53d1fc5e2e0e0af2cd01c459147a97c0e3f8a97" - url: "https://pub.dev" - source: hosted - version: "2.0.0" - bip39: - dependency: "direct main" - description: - name: bip39 - sha256: de1ee27ebe7d96b84bb3a04a4132a0a3007dcdd5ad27dd14aa87a29d97c45edc - url: "https://pub.dev" - source: hosted - version: "1.0.6" - blockchain_utils: - dependency: transitive - description: - path: "." - ref: cake-update-v2 - resolved-ref: "59fdf29d72068e0522a96a8953ed7272833a9f57" - url: "https://github.com/cake-tech/blockchain_utils" - source: git - version: "3.3.0" - boolean_selector: - dependency: transitive - description: - name: boolean_selector - sha256: "8aab1771e1243a5063b8b0ff68042d67334e3feab9e95b9490f9a6ebf73b42ea" - url: "https://pub.dev" - source: hosted - version: "2.1.2" - bs58check: - dependency: transitive - description: - name: bs58check - sha256: c4a164d42b25c2f6bc88a8beccb9fc7d01440f3c60ba23663a20a70faf484ea9 - url: "https://pub.dev" - source: hosted - version: "1.0.2" - build: - dependency: transitive - description: - name: build - sha256: cef23f1eda9b57566c81e2133d196f8e3df48f244b317368d65c5943d91148f0 - url: "https://pub.dev" - source: hosted - version: "2.4.2" - build_config: - dependency: transitive - description: - name: build_config - sha256: "4ae2de3e1e67ea270081eaee972e1bd8f027d459f249e0f1186730784c2e7e33" - url: "https://pub.dev" - source: hosted - version: "1.1.2" - build_daemon: - dependency: transitive - description: - name: build_daemon - sha256: "8e928697a82be082206edb0b9c99c5a4ad6bc31c9e9b8b2f291ae65cd4a25daa" - url: "https://pub.dev" - source: hosted - version: "4.0.4" - build_resolvers: - dependency: transitive - description: - name: build_resolvers - sha256: b9e4fda21d846e192628e7a4f6deda6888c36b5b69ba02ff291a01fd529140f0 - url: "https://pub.dev" - source: hosted - version: "2.4.4" - build_runner: - dependency: "direct dev" - description: - name: build_runner - sha256: "058fe9dce1de7d69c4b84fada934df3e0153dd000758c4d65964d0166779aa99" - url: "https://pub.dev" - source: hosted - version: "2.4.15" - build_runner_core: - dependency: transitive - description: - name: build_runner_core - sha256: "22e3aa1c80e0ada3722fe5b63fd43d9c8990759d0a2cf489c8c5d7b2bdebc021" - url: "https://pub.dev" - source: hosted - version: "8.0.0" - built_collection: - dependency: transitive - description: - name: built_collection - sha256: "376e3dd27b51ea877c28d525560790aee2e6fbb5f20e2f85d5081027d94e2100" - url: "https://pub.dev" - source: hosted - version: "5.1.1" - built_value: - dependency: transitive - description: - name: built_value - sha256: "082001b5c3dc495d4a42f1d5789990505df20d8547d42507c29050af6933ee27" - url: "https://pub.dev" - source: hosted - version: "8.10.1" - cake_backup: - dependency: transitive - description: - path: "." - ref: main - resolved-ref: "3aba867dcab6737f6707782f5db15d71f303db38" - url: "https://github.com/cake-tech/cake_backup.git" - source: git - version: "1.0.0+1" - characters: - dependency: transitive - description: - name: characters - sha256: f71061c654a3380576a52b451dd5532377954cf9dbd272a78fc8479606670803 - url: "https://pub.dev" - source: hosted - version: "1.4.0" - checked_yaml: - dependency: transitive - description: - name: checked_yaml - sha256: feb6bed21949061731a7a75fc5d2aa727cf160b91af9a3e464c5e3a32e28b5ff - url: "https://pub.dev" - source: hosted - version: "2.0.3" - clock: - dependency: transitive - description: - name: clock - sha256: fddb70d9b5277016c77a80201021d40a2247104d9f4aa7bab7157b7e3f05b84b - url: "https://pub.dev" - source: hosted - version: "1.1.2" - code_builder: - dependency: transitive - description: - name: code_builder - sha256: "0ec10bf4a89e4c613960bf1e8b42c64127021740fb21640c29c909826a5eea3e" - url: "https://pub.dev" - source: hosted - version: "4.10.1" - collection: - dependency: transitive - description: - name: collection - sha256: "2f5709ae4d3d59dd8f7cd309b4e023046b57d8a6c82130785d2b0e5868084e76" - url: "https://pub.dev" - source: hosted - version: "1.19.1" - convert: - dependency: transitive - description: - name: convert - sha256: b30acd5944035672bc15c6b7a8b47d773e41e2f17de064350988c5d02adb1c68 - url: "https://pub.dev" - source: hosted - version: "3.1.2" - crypto: - dependency: transitive - description: - name: crypto - sha256: "1e445881f28f22d6140f181e07737b22f1e099a5e1ff94b0af2f9e4a463f4855" - url: "https://pub.dev" - source: hosted - version: "3.0.6" - cryptography: - dependency: transitive - description: - name: cryptography - sha256: d146b76d33d94548cf035233fbc2f4338c1242fa119013bead807d033fc4ae05 - url: "https://pub.dev" - source: hosted - version: "2.7.0" - cupertino_icons: - dependency: transitive - description: - name: cupertino_icons - sha256: ba631d1c7f7bef6b729a622b7b752645a2d076dba9976925b8f25725a30e1ee6 - url: "https://pub.dev" - source: hosted - version: "1.0.8" - cw_core: - dependency: "direct main" - description: - path: "../cw_core" - relative: true - source: path - version: "0.0.1" - dart_style: - dependency: transitive - description: - name: dart_style - sha256: "7306ab8a2359a48d22310ad823521d723acfed60ee1f7e37388e8986853b6820" - url: "https://pub.dev" - source: hosted - version: "2.3.8" - decimal: - dependency: "direct main" - description: - name: decimal - sha256: "24a261d5d5c87e86c7651c417a5dbdf8bcd7080dd592533910e8d0505a279f21" - url: "https://pub.dev" - source: hosted - version: "2.3.3" - ed25519_hd_key: - dependency: "direct main" - description: - name: ed25519_hd_key - sha256: "31e191ec97492873067e46dc9cc0c7d55170559c83a478400feffa0627acaccf" - url: "https://pub.dev" - source: hosted - version: "2.3.0" - encrypt: - dependency: transitive - description: - name: encrypt - sha256: "62d9aa4670cc2a8798bab89b39fc71b6dfbacf615de6cf5001fb39f7e4a996a2" - url: "https://pub.dev" - source: hosted - version: "5.0.3" - fake_async: - dependency: transitive - description: - name: fake_async - sha256: "5368f224a74523e8d2e7399ea1638b37aecfca824a3cc4dfdf77bf1fa905ac44" - url: "https://pub.dev" - source: hosted - version: "1.3.3" - ffi: - dependency: transitive - description: - name: ffi - sha256: "16ed7b077ef01ad6170a3d0c57caa4a112a38d7a2ed5602e0aca9ca6f3d98da6" - url: "https://pub.dev" - source: hosted - version: "2.1.3" - file: - dependency: transitive - description: - name: file - sha256: a3b4f84adafef897088c160faf7dfffb7696046cb13ae90b508c2cbc95d3b8d4 - url: "https://pub.dev" - source: hosted - version: "7.0.1" - fixnum: - dependency: transitive - description: - name: fixnum - sha256: b6dc7065e46c974bc7c5f143080a6764ec7a4be6da1285ececdc37be96de53be - url: "https://pub.dev" - source: hosted - version: "1.1.1" - fixnum_nanodart: - dependency: transitive - description: - name: fixnum_nanodart - sha256: "4b0132d11ecddc0d2ca64b6d7dee6726db432ed02cac1349d7532a08be5c54fc" - url: "https://pub.dev" - source: hosted - version: "1.0.0" - flutter: - dependency: "direct main" - description: flutter - source: sdk - version: "0.0.0" - flutter_mobx: - dependency: transitive - description: - name: flutter_mobx - sha256: ba5e93467866a2991259dc51cffd41ef45f695c667c2b8e7b087bf24118b50fe - url: "https://pub.dev" - source: hosted - version: "2.3.0" - flutter_test: - dependency: "direct dev" - description: flutter - source: sdk - version: "0.0.0" - flutter_web_plugins: - dependency: transitive - description: flutter - source: sdk - version: "0.0.0" - frontend_server_client: - dependency: transitive - description: - name: frontend_server_client - sha256: f64a0333a82f30b0cca061bc3d143813a486dc086b574bfb233b7c1372427694 - url: "https://pub.dev" - source: hosted - version: "4.0.0" - glob: - dependency: transitive - description: - name: glob - sha256: c3f1ee72c96f8f78935e18aa8cecced9ab132419e8625dc187e1c2408efc20de - url: "https://pub.dev" - source: hosted - version: "2.1.3" - graphs: - dependency: transitive - description: - name: graphs - sha256: "741bbf84165310a68ff28fe9e727332eef1407342fca52759cb21ad8177bb8d0" - url: "https://pub.dev" - source: hosted - version: "2.3.2" - hex: - dependency: "direct main" - description: - name: hex - sha256: "4e7cd54e4b59ba026432a6be2dd9d96e4c5205725194997193bf871703b82c4a" - url: "https://pub.dev" - source: hosted - version: "0.2.0" - hive: - dependency: transitive - description: - name: hive - sha256: "8dcf6db979d7933da8217edcec84e9df1bdb4e4edc7fc77dbd5aa74356d6d941" - url: "https://pub.dev" - source: hosted - version: "2.2.3" - hive_generator: - dependency: "direct dev" - description: - name: hive_generator - sha256: "06cb8f58ace74de61f63500564931f9505368f45f98958bd7a6c35ba24159db4" - url: "https://pub.dev" - source: hosted - version: "2.0.1" - http: - dependency: "direct main" - description: - name: http - sha256: "2c11f3f94c687ee9bad77c171151672986360b2b001d109814ee7140b2cf261b" - url: "https://pub.dev" - source: hosted - version: "1.4.0" - http_multi_server: - dependency: transitive - description: - name: http_multi_server - sha256: aa6199f908078bb1c5efb8d8638d4ae191aac11b311132c3ef48ce352fb52ef8 - url: "https://pub.dev" - source: hosted - version: "3.2.2" - http_parser: - dependency: transitive - description: - name: http_parser - sha256: "178d74305e7866013777bab2c3d8726205dc5a4dd935297175b19a23a2e66571" - url: "https://pub.dev" - source: hosted - version: "4.1.2" - intl: - dependency: transitive - description: - name: intl - sha256: d6f56758b7d3014a48af9701c085700aac781a92a87a62b1333b46d8879661cf - url: "https://pub.dev" - source: hosted - version: "0.19.0" - io: - dependency: transitive - description: - name: io - sha256: dfd5a80599cf0165756e3181807ed3e77daf6dd4137caaad72d0b7931597650b - url: "https://pub.dev" - source: hosted - version: "1.0.5" - js: - dependency: transitive - description: - name: js - sha256: f2c445dce49627136094980615a031419f7f3eb393237e4ecd97ac15dea343f3 - url: "https://pub.dev" - source: hosted - version: "0.6.7" - json_annotation: - dependency: transitive - description: - name: json_annotation - sha256: "1ce844379ca14835a50d2f019a3099f419082cfdd231cd86a142af94dd5c6bb1" - url: "https://pub.dev" - source: hosted - version: "4.9.0" - leak_tracker: - dependency: transitive - description: - name: leak_tracker - sha256: "6bb818ecbdffe216e81182c2f0714a2e62b593f4a4f13098713ff1685dfb6ab0" - url: "https://pub.dev" - source: hosted - version: "10.0.9" - leak_tracker_flutter_testing: - dependency: transitive - description: - name: leak_tracker_flutter_testing - sha256: f8b613e7e6a13ec79cfdc0e97638fddb3ab848452eff057653abd3edba760573 - url: "https://pub.dev" - source: hosted - version: "3.0.9" - leak_tracker_testing: - dependency: transitive - description: - name: leak_tracker_testing - sha256: "6ba465d5d76e67ddf503e1161d1f4a6bc42306f9d66ca1e8f079a47290fb06d3" - url: "https://pub.dev" - source: hosted - version: "3.0.1" - libcrypto: - dependency: "direct main" - description: - name: libcrypto - sha256: "18a97db8d88147b0b60d2755f29b5e4944181c4c1a9f52bd1ecbea1b0a5aab03" - url: "https://pub.dev" - source: hosted - version: "0.2.2" - logging: - dependency: transitive - description: - name: logging - sha256: c8245ada5f1717ed44271ed1c26b8ce85ca3228fd2ffdb75468ab01979309d61 - url: "https://pub.dev" - source: hosted - version: "1.3.0" - macros: - dependency: transitive - description: - name: macros - sha256: "1d9e801cd66f7ea3663c45fc708450db1fa57f988142c64289142c9b7ee80656" - url: "https://pub.dev" - source: hosted - version: "0.1.3-main.0" - matcher: - dependency: transitive - description: - name: matcher - sha256: dc58c723c3c24bf8d3e2d3ad3f2f9d7bd9cf43ec6feaa64181775e60190153f2 - url: "https://pub.dev" - source: hosted - version: "0.12.17" - material_color_utilities: - dependency: transitive - description: - name: material_color_utilities - sha256: f7142bb1154231d7ea5f96bc7bde4bda2a0945d2806bb11670e30b850d56bdec - url: "https://pub.dev" - source: hosted - version: "0.11.1" - meta: - dependency: transitive - description: - name: meta - sha256: e3641ec5d63ebf0d9b41bd43201a66e3fc79a65db5f61fc181f04cd27aab950c - url: "https://pub.dev" - source: hosted - version: "1.16.0" - mime: - dependency: transitive - description: - name: mime - sha256: "41a20518f0cb1256669420fdba0cd90d21561e560ac240f26ef8322e45bb7ed6" - url: "https://pub.dev" - source: hosted - version: "2.0.0" - mobx: - dependency: "direct main" - description: - name: mobx - sha256: bf1a90e5bcfd2851fc6984e20eef69557c65d9e4d0a88f5be4cf72c9819ce6b0 - url: "https://pub.dev" - source: hosted - version: "2.5.0" - mobx_codegen: - dependency: "direct dev" - description: - name: mobx_codegen - sha256: e0abbbc651a69550440f6b65c99ec222a1e2a4afd7baec8ba0f3088c7ca582a8 - url: "https://pub.dev" - source: hosted - version: "2.7.1" - nanodart: - dependency: transitive - description: - name: nanodart - sha256: "4b2f42d60307b54e8cf384d6193a567d07f8efd773858c0d5948246153c13282" - url: "https://pub.dev" - source: hosted - version: "2.0.0" - nanoutil: - dependency: "direct main" - description: - path: "." - ref: c01a9c552917008d8fbc6b540db657031625b04f - resolved-ref: c01a9c552917008d8fbc6b540db657031625b04f - url: "https://github.com/perishllc/nanoutil.git" - source: git - version: "1.0.3" - nested: - dependency: transitive - description: - name: nested - sha256: "03bac4c528c64c95c722ec99280375a6f2fc708eec17c7b3f07253b626cd2a20" - url: "https://pub.dev" - source: hosted - version: "1.0.0" - on_chain: - dependency: transitive - description: - path: "." - ref: "096865a8c6b89c260beadfec04f7e184c40a3273" - resolved-ref: "096865a8c6b89c260beadfec04f7e184c40a3273" - url: "https://github.com/cake-tech/on_chain.git" - source: git - version: "3.7.0" - package_config: - dependency: transitive - description: - name: package_config - sha256: f096c55ebb7deb7e384101542bfba8c52696c1b56fca2eb62827989ef2353bbc - url: "https://pub.dev" - source: hosted - version: "2.2.0" - path: - dependency: transitive - description: - name: path - sha256: "75cca69d1490965be98c73ceaea117e8a04dd21217b37b292c9ddbec0d955bc5" - url: "https://pub.dev" - source: hosted - version: "1.9.1" - path_provider: - dependency: transitive - description: - name: path_provider - sha256: "50c5dd5b6e1aaf6fb3a78b33f6aa3afca52bf903a8a5298f53101fdaee55bbcd" - url: "https://pub.dev" - source: hosted - version: "2.1.5" - path_provider_android: - dependency: transitive - description: - name: path_provider_android - sha256: d0d310befe2c8ab9e7f393288ccbb11b60c019c6b5afc21973eeee4dda2b35e9 - url: "https://pub.dev" - source: hosted - version: "2.2.17" - path_provider_foundation: - dependency: transitive - description: - name: path_provider_foundation - sha256: "4843174df4d288f5e29185bd6e72a6fbdf5a4a4602717eed565497429f179942" - url: "https://pub.dev" - source: hosted - version: "2.4.1" - path_provider_linux: - dependency: transitive - description: - name: path_provider_linux - sha256: f7a1fe3a634fe7734c8d3f2766ad746ae2a2884abe22e241a8b301bf5cac3279 - url: "https://pub.dev" - source: hosted - version: "2.2.1" - path_provider_platform_interface: - dependency: transitive - description: - name: path_provider_platform_interface - sha256: "88f5779f72ba699763fa3a3b06aa4bf6de76c8e5de842cf6f29e2e06476c2334" - url: "https://pub.dev" - source: hosted - version: "2.1.2" - path_provider_windows: - dependency: transitive - description: - name: path_provider_windows - sha256: bd6f00dbd873bfb70d0761682da2b3a2c2fccc2b9e84c495821639601d81afe7 - url: "https://pub.dev" - source: hosted - version: "2.3.0" - pinenacl: - dependency: transitive - description: - name: pinenacl - sha256: "57e907beaacbc3c024a098910b6240758e899674de07d6949a67b52fd984cbdf" - url: "https://pub.dev" - source: hosted - version: "0.6.0" - platform: - dependency: transitive - description: - name: platform - sha256: "5d6b1b0036a5f331ebc77c850ebc8506cbc1e9416c27e59b439f917a902a4984" - url: "https://pub.dev" - source: hosted - version: "3.1.6" - plugin_platform_interface: - dependency: transitive - description: - name: plugin_platform_interface - sha256: "4820fbfdb9478b1ebae27888254d445073732dae3d6ea81f0b7e06d5dedc3f02" - url: "https://pub.dev" - source: hosted - version: "2.1.8" - pointycastle: - dependency: transitive - description: - name: pointycastle - sha256: "4be0097fcf3fd3e8449e53730c631200ebc7b88016acecab2b0da2f0149222fe" - url: "https://pub.dev" - source: hosted - version: "3.9.1" - pool: - dependency: transitive - description: - name: pool - sha256: "20fe868b6314b322ea036ba325e6fc0711a22948856475e2c2b6306e8ab39c2a" - url: "https://pub.dev" - source: hosted - version: "1.5.1" - provider: - dependency: transitive - description: - name: provider - sha256: "4abbd070a04e9ddc287673bf5a030c7ca8b685ff70218720abab8b092f53dd84" - url: "https://pub.dev" - source: hosted - version: "6.1.5" - pub_semver: - dependency: transitive - description: - name: pub_semver - sha256: "5bfcf68ca79ef689f8990d1160781b4bad40a3bd5e5218ad4076ddb7f4081585" - url: "https://pub.dev" - source: hosted - version: "2.2.0" - pubspec_parse: - dependency: transitive - description: - name: pubspec_parse - sha256: "0560ba233314abbed0a48a2956f7f022cce7c3e1e73df540277da7544cad4082" - url: "https://pub.dev" - source: hosted - version: "1.5.0" - rational: - dependency: transitive - description: - name: rational - sha256: cb808fb6f1a839e6fc5f7d8cb3b0a10e1db48b3be102de73938c627f0b636336 - url: "https://pub.dev" - source: hosted - version: "2.2.3" - shared_preferences: - dependency: "direct main" - description: - name: shared_preferences - sha256: "6e8bf70b7fef813df4e9a36f658ac46d107db4b4cfe1048b477d4e453a8159f5" - url: "https://pub.dev" - source: hosted - version: "2.5.3" - shared_preferences_android: - dependency: transitive - description: - name: shared_preferences_android - sha256: "20cbd561f743a342c76c151d6ddb93a9ce6005751e7aa458baad3858bfbfb6ac" - url: "https://pub.dev" - source: hosted - version: "2.4.10" - shared_preferences_foundation: - dependency: transitive - description: - name: shared_preferences_foundation - sha256: "6a52cfcdaeac77cad8c97b539ff688ccfc458c007b4db12be584fbe5c0e49e03" - url: "https://pub.dev" - source: hosted - version: "2.5.4" - shared_preferences_linux: - dependency: transitive - description: - name: shared_preferences_linux - sha256: "580abfd40f415611503cae30adf626e6656dfb2f0cee8f465ece7b6defb40f2f" - url: "https://pub.dev" - source: hosted - version: "2.4.1" - shared_preferences_platform_interface: - dependency: transitive - description: - name: shared_preferences_platform_interface - sha256: "57cbf196c486bc2cf1f02b85784932c6094376284b3ad5779d1b1c6c6a816b80" - url: "https://pub.dev" - source: hosted - version: "2.4.1" - shared_preferences_web: - dependency: transitive - description: - name: shared_preferences_web - sha256: c49bd060261c9a3f0ff445892695d6212ff603ef3115edbb448509d407600019 - url: "https://pub.dev" - source: hosted - version: "2.4.3" - shared_preferences_windows: - dependency: transitive - description: - name: shared_preferences_windows - sha256: "94ef0f72b2d71bc3e700e025db3710911bd51a71cefb65cc609dd0d9a982e3c1" - url: "https://pub.dev" - source: hosted - version: "2.4.1" - shelf: - dependency: transitive - description: - name: shelf - sha256: e7dd780a7ffb623c57850b33f43309312fc863fb6aa3d276a754bb299839ef12 - url: "https://pub.dev" - source: hosted - version: "1.4.2" - shelf_web_socket: - dependency: transitive - description: - name: shelf_web_socket - sha256: "3632775c8e90d6c9712f883e633716432a27758216dfb61bd86a8321c0580925" - url: "https://pub.dev" - source: hosted - version: "3.0.0" - sky_engine: - dependency: transitive - description: flutter - source: sdk - version: "0.0.0" - socks5_proxy: - dependency: transitive - description: - path: "." - ref: "27ad7c2efae8d7460325c74b90f660085cbd0685" - resolved-ref: "27ad7c2efae8d7460325c74b90f660085cbd0685" - url: "https://github.com/LacticWhale/socks_dart" - source: git - version: "2.1.0" - socks_socket: - dependency: transitive - description: - path: "." - ref: e6232c53c1595469931ababa878759a067c02e94 - resolved-ref: e6232c53c1595469931ababa878759a067c02e94 - url: "https://github.com/sneurlax/socks_socket" - source: git - version: "1.1.1" - source_gen: - dependency: transitive - description: - name: source_gen - sha256: "14658ba5f669685cd3d63701d01b31ea748310f7ab854e471962670abcf57832" - url: "https://pub.dev" - source: hosted - version: "1.5.0" - source_helper: - dependency: transitive - description: - name: source_helper - sha256: "86d247119aedce8e63f4751bd9626fc9613255935558447569ad42f9f5b48b3c" - url: "https://pub.dev" - source: hosted - version: "1.3.5" - source_span: - dependency: transitive - description: - name: source_span - sha256: "254ee5351d6cb365c859e20ee823c3bb479bf4a293c22d17a9f1bf144ce86f7c" - url: "https://pub.dev" - source: hosted - version: "1.10.1" - sqflite: - dependency: transitive - description: - name: sqflite - sha256: "2d7299468485dca85efeeadf5d38986909c5eb0cd71fd3db2c2f000e6c9454bb" - url: "https://pub.dev" - source: hosted - version: "2.4.1" - sqflite_android: - dependency: transitive - description: - name: sqflite_android - sha256: "78f489aab276260cdd26676d2169446c7ecd3484bbd5fead4ca14f3ed4dd9ee3" - url: "https://pub.dev" - source: hosted - version: "2.4.0" - sqflite_common: - dependency: transitive - description: - name: sqflite_common - sha256: "761b9740ecbd4d3e66b8916d784e581861fd3c3553eda85e167bc49fdb68f709" - url: "https://pub.dev" - source: hosted - version: "2.5.4+6" - sqflite_common_ffi: - dependency: transitive - description: - name: sqflite_common_ffi - sha256: "883dd810b2b49e6e8c3b980df1829ef550a94e3f87deab5d864917d27ca6bf36" - url: "https://pub.dev" - source: hosted - version: "2.3.4+4" - sqflite_darwin: - dependency: transitive - description: - name: sqflite_darwin - sha256: "22adfd9a2c7d634041e96d6241e6e1c8138ca6817018afc5d443fef91dcefa9c" - url: "https://pub.dev" - source: hosted - version: "2.4.1+1" - sqflite_platform_interface: - dependency: transitive - description: - name: sqflite_platform_interface - sha256: "8dd4515c7bdcae0a785b0062859336de775e8c65db81ae33dd5445f35be61920" - url: "https://pub.dev" - source: hosted - version: "2.4.0" - sqlite3: - dependency: transitive - description: - name: sqlite3 - sha256: f393d92c71bdcc118d6203d07c991b9be0f84b1a6f89dd4f7eed348131329924 - url: "https://pub.dev" - source: hosted - version: "2.9.0" - sqlite3_flutter_libs: - dependency: transitive - description: - name: sqlite3_flutter_libs - sha256: "69c80d812ef2500202ebd22002cbfc1b6565e9ff56b2f971e757fac5d42294df" - url: "https://pub.dev" - source: hosted - version: "0.5.40" - stack_trace: - dependency: transitive - description: - name: stack_trace - sha256: "8b27215b45d22309b5cddda1aa2b19bdfec9df0e765f2de506401c071d38d1b1" - url: "https://pub.dev" - source: hosted - version: "1.12.1" - stream_channel: - dependency: transitive - description: - name: stream_channel - sha256: "969e04c80b8bcdf826f8f16579c7b14d780458bd97f56d107d3950fdbeef059d" - url: "https://pub.dev" - source: hosted - version: "2.1.4" - stream_transform: - dependency: transitive - description: - name: stream_transform - sha256: ad47125e588cfd37a9a7f86c7d6356dde8dfe89d071d293f80ca9e9273a33871 - url: "https://pub.dev" - source: hosted - version: "2.1.1" - string_scanner: - dependency: transitive - description: - name: string_scanner - sha256: "921cd31725b72fe181906c6a94d987c78e3b98c2e205b397ea399d4054872b43" - url: "https://pub.dev" - source: hosted - version: "1.4.1" - synchronized: - dependency: transitive - description: - name: synchronized - sha256: "69fe30f3a8b04a0be0c15ae6490fc859a78ef4c43ae2dd5e8a623d45bfcf9225" - url: "https://pub.dev" - source: hosted - version: "3.3.0+3" - term_glyph: - dependency: transitive - description: - name: term_glyph - sha256: "7f554798625ea768a7518313e58f83891c7f5024f88e46e7182a4558850a4b8e" - url: "https://pub.dev" - source: hosted - version: "1.2.2" - test_api: - dependency: transitive - description: - name: test_api - sha256: fb31f383e2ee25fbbfe06b40fe21e1e458d14080e3c67e7ba0acfde4df4e0bbd - url: "https://pub.dev" - source: hosted - version: "0.7.4" - timing: - dependency: transitive - description: - name: timing - sha256: "70a3b636575d4163c477e6de42f247a23b315ae20e86442bebe32d3cabf61c32" - url: "https://pub.dev" - source: hosted - version: "1.0.1" - torch_dart: - dependency: transitive - description: - path: "../scripts/torch_dart" - relative: true - source: path - version: "0.0.1" - tuple: - dependency: transitive - description: - name: tuple - sha256: a97ce2013f240b2f3807bcbaf218765b6f301c3eff91092bcfa23a039e7dd151 - url: "https://pub.dev" - source: hosted - version: "2.0.2" - typed_data: - dependency: transitive - description: - name: typed_data - sha256: f9049c039ebfeb4cf7a7104a675823cd72dba8297f264b6637062516699fa006 - url: "https://pub.dev" - source: hosted - version: "1.4.0" - unorm_dart: - dependency: transitive - description: - name: unorm_dart - sha256: "23d8bf65605401a6a32cff99435fed66ef3dab3ddcad3454059165df46496a3b" - url: "https://pub.dev" - source: hosted - version: "0.3.0" - vector_math: - dependency: transitive - description: - name: vector_math - sha256: "80b3257d1492ce4d091729e3a67a60407d227c27241d6927be0130c98e741803" - url: "https://pub.dev" - source: hosted - version: "2.1.4" - vm_service: - dependency: transitive - description: - name: vm_service - sha256: ddfa8d30d89985b96407efce8acbdd124701f96741f2d981ca860662f1c0dc02 - url: "https://pub.dev" - source: hosted - version: "15.0.0" - watcher: - dependency: "direct overridden" - description: - name: watcher - sha256: "69da27e49efa56a15f8afe8f4438c4ec02eff0a117df1b22ea4aad194fe1c104" - url: "https://pub.dev" - source: hosted - version: "1.1.1" - web: - dependency: transitive - description: - name: web - sha256: "868d88a33d8a87b18ffc05f9f030ba328ffefba92d6c127917a2ba740f9cfe4a" - url: "https://pub.dev" - source: hosted - version: "1.1.1" - web_socket: - dependency: transitive - description: - name: web_socket - sha256: "34d64019aa8e36bf9842ac014bb5d2f5586ca73df5e4d9bf5c936975cae6982c" - url: "https://pub.dev" - source: hosted - version: "1.0.1" - web_socket_channel: - dependency: transitive - description: - name: web_socket_channel - sha256: d645757fb0f4773d602444000a8131ff5d48c9e47adfe9772652dd1a4f2d45c8 - url: "https://pub.dev" - source: hosted - version: "3.0.3" - xdg_directories: - dependency: transitive - description: - name: xdg_directories - sha256: "7a3f37b05d989967cdddcbb571f1ea834867ae2faa29725fd085180e0883aa15" - url: "https://pub.dev" - source: hosted - version: "1.1.0" - yaml: - dependency: transitive - description: - name: yaml - sha256: b9da305ac7c39faa3f030eccd175340f968459dae4af175130b3fc47e40d76ce - url: "https://pub.dev" - source: hosted - version: "3.1.3" -sdks: - dart: ">=3.7.0-0 <4.0.0" - flutter: ">=3.27.0" diff --git a/cw_nano/pubspec.yaml b/cw_nano/pubspec.yaml index 836c30b71..4f45df12e 100644 --- a/cw_nano/pubspec.yaml +++ b/cw_nano/pubspec.yaml @@ -6,13 +6,13 @@ author: Hash Bags homepage: https://github.com/Such-Software/hash-wallet environment: - sdk: '>=2.18.2 <3.0.0' + sdk: '>=3.0.0 <4.0.0' flutter: ">=1.17.0" dependencies: flutter: sdk: flutter - mobx: ^2.0.7+4 + mobx: ^2.6.0 bip39: ^1.0.6 bip32: ^2.0.0 decimal: ^2.3.3 @@ -31,9 +31,10 @@ dependencies: dev_dependencies: flutter_test: sdk: flutter - build_runner: ^2.4.15 - mobx_codegen: ^2.0.7 - hive_generator: ^2.0.1 + build_runner: ^2.15.0 + mobx_codegen: ^2.7.7 +# hive_generator: ^2.0.1 + hive: 2.0.1 dependency_overrides: watcher: ^1.1.0 diff --git a/cw_nano/pubspec_overrides.yaml b/cw_nano/pubspec_overrides.yaml new file mode 120000 index 000000000..d957432d3 --- /dev/null +++ b/cw_nano/pubspec_overrides.yaml @@ -0,0 +1 @@ +../pubspec_overrides.yaml \ No newline at end of file diff --git a/cw_shared_external/pubspec.lock b/cw_shared_external/pubspec.lock deleted file mode 100644 index 203ecd5c3..000000000 --- a/cw_shared_external/pubspec.lock +++ /dev/null @@ -1,189 +0,0 @@ -# Generated by pub -# See https://dart.dev/tools/pub/glossary#lockfile -packages: - async: - dependency: transitive - description: - name: async - sha256: "947bfcf187f74dbc5e146c9eb9c0f10c9f8b30743e341481c1e2ed3ecc18c20c" - url: "https://pub.dev" - source: hosted - version: "2.11.0" - boolean_selector: - dependency: transitive - description: - name: boolean_selector - sha256: "6cfb5af12253eaf2b368f07bacc5a80d1301a071c73360d746b7f2e32d762c66" - url: "https://pub.dev" - source: hosted - version: "2.1.1" - characters: - dependency: transitive - description: - name: characters - sha256: "04a925763edad70e8443c99234dc3328f442e811f1d8fd1a72f1c8ad0f69a605" - url: "https://pub.dev" - source: hosted - version: "1.3.0" - clock: - dependency: transitive - description: - name: clock - sha256: cb6d7f03e1de671e34607e909a7213e31d7752be4fb66a86d29fe1eb14bfb5cf - url: "https://pub.dev" - source: hosted - version: "1.1.1" - collection: - dependency: transitive - description: - name: collection - sha256: ee67cb0715911d28db6bf4af1026078bd6f0128b07a5f66fb2ed94ec6783c09a - url: "https://pub.dev" - source: hosted - version: "1.18.0" - fake_async: - dependency: transitive - description: - name: fake_async - sha256: "511392330127add0b769b75a987850d136345d9227c6b94c96a04cf4a391bf78" - url: "https://pub.dev" - source: hosted - version: "1.3.1" - flutter: - dependency: "direct main" - description: flutter - source: sdk - version: "0.0.0" - flutter_test: - dependency: "direct dev" - description: flutter - source: sdk - version: "0.0.0" - leak_tracker: - dependency: transitive - description: - name: leak_tracker - sha256: "7f0df31977cb2c0b88585095d168e689669a2cc9b97c309665e3386f3e9d341a" - url: "https://pub.dev" - source: hosted - version: "10.0.4" - leak_tracker_flutter_testing: - dependency: transitive - description: - name: leak_tracker_flutter_testing - sha256: "06e98f569d004c1315b991ded39924b21af84cf14cc94791b8aea337d25b57f8" - url: "https://pub.dev" - source: hosted - version: "3.0.3" - leak_tracker_testing: - dependency: transitive - description: - name: leak_tracker_testing - sha256: "6ba465d5d76e67ddf503e1161d1f4a6bc42306f9d66ca1e8f079a47290fb06d3" - url: "https://pub.dev" - source: hosted - version: "3.0.1" - matcher: - dependency: transitive - description: - name: matcher - sha256: d2323aa2060500f906aa31a895b4030b6da3ebdcc5619d14ce1aada65cd161cb - url: "https://pub.dev" - source: hosted - version: "0.12.16+1" - material_color_utilities: - dependency: transitive - description: - name: material_color_utilities - sha256: "0e0a020085b65b6083975e499759762399b4475f766c21668c4ecca34ea74e5a" - url: "https://pub.dev" - source: hosted - version: "0.8.0" - meta: - dependency: transitive - description: - name: meta - sha256: "7687075e408b093f36e6bbf6c91878cc0d4cd10f409506f7bc996f68220b9136" - url: "https://pub.dev" - source: hosted - version: "1.12.0" - path: - dependency: transitive - description: - name: path - sha256: "087ce49c3f0dc39180befefc60fdb4acd8f8620e5682fe2476afd0b3688bb4af" - url: "https://pub.dev" - source: hosted - version: "1.9.0" - sky_engine: - dependency: transitive - description: flutter - source: sdk - version: "0.0.99" - source_span: - dependency: transitive - description: - name: source_span - sha256: "53e943d4206a5e30df338fd4c6e7a077e02254531b138a15aec3bd143c1a8b3c" - url: "https://pub.dev" - source: hosted - version: "1.10.0" - stack_trace: - dependency: transitive - description: - name: stack_trace - sha256: "73713990125a6d93122541237550ee3352a2d84baad52d375a4cad2eb9b7ce0b" - url: "https://pub.dev" - source: hosted - version: "1.11.1" - stream_channel: - dependency: transitive - description: - name: stream_channel - sha256: ba2aa5d8cc609d96bbb2899c28934f9e1af5cddbd60a827822ea467161eb54e7 - url: "https://pub.dev" - source: hosted - version: "2.1.2" - string_scanner: - dependency: transitive - description: - name: string_scanner - sha256: "556692adab6cfa87322a115640c11f13cb77b3f076ddcc5d6ae3c20242bedcde" - url: "https://pub.dev" - source: hosted - version: "1.2.0" - term_glyph: - dependency: transitive - description: - name: term_glyph - sha256: a29248a84fbb7c79282b40b8c72a1209db169a2e0542bce341da992fe1bc7e84 - url: "https://pub.dev" - source: hosted - version: "1.2.1" - test_api: - dependency: transitive - description: - name: test_api - sha256: "9955ae474176f7ac8ee4e989dadfb411a58c30415bcfb648fa04b2b8a03afa7f" - url: "https://pub.dev" - source: hosted - version: "0.7.0" - vector_math: - dependency: transitive - description: - name: vector_math - sha256: "80b3257d1492ce4d091729e3a67a60407d227c27241d6927be0130c98e741803" - url: "https://pub.dev" - source: hosted - version: "2.1.4" - vm_service: - dependency: transitive - description: - name: vm_service - sha256: "3923c89304b715fb1eb6423f017651664a03bf5f4b29983627c4da791f74a4ec" - url: "https://pub.dev" - source: hosted - version: "14.2.1" -sdks: - dart: ">=3.3.0 <4.0.0" - flutter: ">=3.18.0-18.0.pre.54" diff --git a/cw_shared_external/pubspec_overrides.yaml b/cw_shared_external/pubspec_overrides.yaml new file mode 120000 index 000000000..d957432d3 --- /dev/null +++ b/cw_shared_external/pubspec_overrides.yaml @@ -0,0 +1 @@ +../pubspec_overrides.yaml \ No newline at end of file diff --git a/cw_wownero/analysis_options.yaml b/cw_wownero/analysis_options.yaml deleted file mode 100644 index a5744c1cf..000000000 --- a/cw_wownero/analysis_options.yaml +++ /dev/null @@ -1,4 +0,0 @@ -include: package:flutter_lints/flutter.yaml - -# Additional information about this file can be found at -# https://dart.dev/guides/language/analysis-options diff --git a/cw_wownero/lib/pending_wownero_transaction.dart b/cw_wownero/lib/pending_wownero_transaction.dart index e30471ae7..f5c3b448c 100644 --- a/cw_wownero/lib/pending_wownero_transaction.dart +++ b/cw_wownero/lib/pending_wownero_transaction.dart @@ -1,7 +1,7 @@ +import 'package:cw_core/amount/money.dart'; import 'package:cw_wownero/api/structs/pending_transaction.dart'; import 'package:cw_wownero/api/transaction_history.dart' as wownero_transaction_history; import 'package:cw_core/crypto_currency.dart'; -import 'package:cw_core/amount_converter.dart'; import 'package:cw_core/pending_transaction.dart'; @@ -18,6 +18,10 @@ class PendingWowneroTransaction with PendingTransaction { final PendingTransactionDescription pendingTransactionDescription; + Money get amount => Money.fromInt(pendingTransactionDescription.amount, CryptoCurrency.wow); + + Money get fee => Money.fromInt(pendingTransactionDescription.fee, CryptoCurrency.wow); + @override String get id => pendingTransactionDescription.hash; @@ -27,15 +31,7 @@ class PendingWowneroTransaction with PendingTransaction { String get txKey => pendingTransactionDescription.txKey; @override - String get amountFormatted => - AmountConverter.amountIntToString(CryptoCurrency.wow, pendingTransactionDescription.amount); - - @override - String get feeFormatted => "$feeFormattedValue WOW"; - - @override - String get feeFormattedValue => - AmountConverter.amountIntToString(CryptoCurrency.wow, pendingTransactionDescription.fee); + String get amountFormatted => amount.toString(); @override Future commit() async { diff --git a/cw_wownero/lib/wownero_balance.dart b/cw_wownero/lib/wownero_balance.dart new file mode 100644 index 000000000..53d717757 --- /dev/null +++ b/cw_wownero/lib/wownero_balance.dart @@ -0,0 +1,17 @@ +import 'package:cw_core/amount/money.dart'; +import 'package:cw_core/balance.dart'; +import 'package:cw_core/crypto_currency.dart'; + +class WowneroBalance extends Balance { + WowneroBalance({ + required this.fullBalance, + required Money unlockedBalance, + Money? frozen, + }) : super( + unlockedBalance, + fullBalance - unlockedBalance, + frozen: frozen ?? Money.zero(CryptoCurrency.wow), + ); + + final Money fullBalance; +} diff --git a/cw_wownero/lib/wownero_transaction_info.dart b/cw_wownero/lib/wownero_transaction_info.dart index d7deae002..13df42684 100644 --- a/cw_wownero/lib/wownero_transaction_info.dart +++ b/cw_wownero/lib/wownero_transaction_info.dart @@ -1,3 +1,5 @@ +import 'package:cw_core/amount/money.dart'; +import 'package:cw_core/crypto_currency.dart'; import 'package:cw_core/transaction_info.dart'; import 'package:cw_core/wownero_amount_format.dart'; import 'package:cw_core/parseBoolFromString.dart'; @@ -20,12 +22,12 @@ class WowneroTransactionInfo extends TransactionInfo { date = DateTime.fromMillisecondsSinceEpoch( (int.tryParse(map['timestamp'] as String? ?? '') ?? 0) * 1000), isPending = parseBoolFromString(map['isPending'] as String), - amount = map['amount'] as int, + amount = Money.fromInt(map['amount'] as int, CryptoCurrency.wow), accountIndex = int.parse(map['accountIndex'] as String), addressIndex = map['addressIndex'] as int, confirmations = map['confirmations'] as int, key = getTxKey((map['hash'] ?? '') as String), - fee = map['fee'] as int? ?? 0 { + fee = Money.fromInt(map['fee'] as int? ?? 0, CryptoCurrency.wow) { additionalInfo = { 'key': key, 'accountIndex': accountIndex, @@ -40,23 +42,17 @@ class WowneroTransactionInfo extends TransactionInfo { final DateTime date; final int accountIndex; final bool isPending; - final int amount; - final int fee; + final Money amount; + final Money fee; final int addressIndex; final int confirmations; String? recipientAddress; String? key; String? _fiatAmount; - @override - String amountFormatted() => '${formatAmount(wowneroAmountToString(amount: amount))} WOW'; - @override String fiatAmount() => _fiatAmount ?? ''; @override void changeFiatAmount(String amount) => _fiatAmount = formatAmount(amount); - - @override - String feeFormatted() => '${formatAmount(wowneroAmountToString(amount: fee))} WOW'; } diff --git a/cw_wownero/lib/wownero_wallet.dart b/cw_wownero/lib/wownero_wallet.dart index c6c260ab6..27a8db424 100644 --- a/cw_wownero/lib/wownero_wallet.dart +++ b/cw_wownero/lib/wownero_wallet.dart @@ -5,6 +5,7 @@ import 'dart:io'; import 'dart:isolate'; import 'package:cw_core/account.dart'; +import 'package:cw_core/amount/money.dart'; import 'package:cw_core/crypto_currency.dart'; import 'package:cw_core/monero_transaction_priority.dart'; import 'package:cw_core/monero_wallet_keys.dart'; @@ -21,7 +22,6 @@ import 'package:cw_core/utils/proxy_wrapper.dart'; import 'package:cw_core/wallet_base.dart'; import 'package:cw_core/wallet_info.dart'; import 'package:cw_core/wownero_amount_format.dart'; -import 'package:cw_core/wownero_balance.dart'; import 'package:cw_wownero/api/account_list.dart'; import 'package:cw_wownero/api/coins_info.dart'; import 'package:cw_wownero/api/structs/pending_transaction.dart'; @@ -32,6 +32,7 @@ import 'package:cw_wownero/api/wownero_output.dart'; import 'package:cw_wownero/exceptions/wownero_transaction_creation_exception.dart'; import 'package:cw_wownero/exceptions/wownero_transaction_no_inputs_exception.dart'; import 'package:cw_wownero/pending_wownero_transaction.dart'; +import 'package:cw_wownero/wownero_balance.dart'; import 'package:cw_wownero/wownero_transaction_creation_credentials.dart'; import 'package:cw_wownero/wownero_transaction_history.dart'; import 'package:cw_wownero/wownero_transaction_info.dart'; @@ -60,8 +61,11 @@ abstract class WowneroWalletBase required String password}) : balance = ObservableMap.of({ CryptoCurrency.wow: WowneroBalance( - fullBalance: wownero_wallet.getFullBalance(accountIndex: 0), - unlockedBalance: wownero_wallet.getFullBalance(accountIndex: 0)) + fullBalance: + Money.fromInt(wownero_wallet.getFullBalance(accountIndex: 0), CryptoCurrency.wow), + unlockedBalance: + Money.fromInt(wownero_wallet.getFullBalance(accountIndex: 0), CryptoCurrency.wow), + ) }), _isTransactionUpdating = false, _hasSyncAfterStartup = false, @@ -79,8 +83,11 @@ abstract class WowneroWalletBase balance = ObservableMap.of({ currency: WowneroBalance( - fullBalance: wownero_wallet.getFullBalance(accountIndex: account.id), - unlockedBalance: wownero_wallet.getUnlockedBalance(accountIndex: account.id)) + fullBalance: Money.fromInt( + wownero_wallet.getFullBalance(accountIndex: account.id), CryptoCurrency.wow), + unlockedBalance: Money.fromInt( + wownero_wallet.getUnlockedBalance(accountIndex: account.id), CryptoCurrency.wow), + ) }); _updateSubAddress(isEnabledAutoGenerateSubaddress, account: account); _askForUpdateTransactionHistory(); @@ -131,6 +138,12 @@ abstract class WowneroWalletBase @override bool get hasRescan => true; + @override + bool get hasCoinControl => true; + + @override + bool get hasAccountsSupport => true; + @override MoneroWalletKeys get keys => MoneroWalletKeys( primaryAddress: wownero_wallet.getAddress(accountIndex: 0, addressIndex: 0), @@ -156,9 +169,13 @@ abstract class WowneroWalletBase await walletAddresses.init(); balance = ObservableMap.of({ currency: WowneroBalance( - fullBalance: wownero_wallet.getFullBalance(accountIndex: walletAddresses.account!.id), - unlockedBalance: - wownero_wallet.getUnlockedBalance(accountIndex: walletAddresses.account!.id)) + fullBalance: Money.fromInt( + wownero_wallet.getFullBalance(accountIndex: walletAddresses.account!.id), + CryptoCurrency.wow), + unlockedBalance: Money.fromInt( + wownero_wallet.getUnlockedBalance(accountIndex: walletAddresses.account!.id), + CryptoCurrency.wow), + ) }); _setListeners(); await updateTransactions(); @@ -320,13 +337,12 @@ abstract class WowneroWalletBase final spendAllCoins = inputs.length == unspentCoins.length; if (hasMultiDestination) { - if (outputs.any((item) => item.sendAll || (item.formattedCryptoAmount ?? 0) <= 0)) { + if (outputs.any((item) => item.sendAll || item.cryptoAmount.amount <= BigInt.zero)) { throw WowneroTransactionCreationException( 'You do not have enough WOW to send this amount.'); } - final int totalAmount = - outputs.fold(0, (acc, value) => acc + (value.formattedCryptoAmount ?? 0)); + final totalAmount = outputs.fold(0, (acc, value) => acc + value.cryptoAmount.amount.toInt()); final estimatedFee = calculateEstimatedFee(_credentials.priority, totalAmount); if (unlockedBalance < totalAmount) { @@ -341,8 +357,7 @@ abstract class WowneroWalletBase final wowneroOutputs = outputs.map((output) { final outputAddress = output.isParsedAddress ? output.extractedAddress : output.address; - return WowneroOutput( - address: outputAddress!, amount: output.cryptoAmount!.replaceAll(',', '.')); + return WowneroOutput(address: outputAddress!, amount: output.cryptoAmount.toString()); }).toList(); pendingTransactionDescription = await transaction_history.createTransactionMultDest( @@ -353,8 +368,8 @@ abstract class WowneroWalletBase } else { final output = outputs.first; final address = output.isParsedAddress ? output.extractedAddress : output.address; - final amount = output.sendAll ? null : output.cryptoAmount!.replaceAll(',', '.'); - final formattedAmount = output.sendAll ? null : output.formattedCryptoAmount; + final amount = output.sendAll ? null : output.cryptoAmount.toString(); + final formattedAmount = output.sendAll ? null : output.cryptoAmount.amount.toInt(); if ((formattedAmount != null && unlockedBalance < formattedAmount) || (formattedAmount == null && unlockedBalance <= 0)) { @@ -696,10 +711,10 @@ abstract class WowneroWalletBase row.isSpend ? TransactionDirection.outgoing : TransactionDirection.incoming, row.timeStamp, row.isPending, - row.amount, + Money.fromInt(row.amount, CryptoCurrency.wow), row.accountIndex, 0, - row.fee, + Money.fromInt(row.fee, CryptoCurrency.wow), row.confirmations, )..additionalInfo = { 'key': row.key, @@ -767,29 +782,31 @@ abstract class WowneroWalletBase final frozenBalance = _getFrozenBalance(); if (balance[currency]!.fullBalance != fullBalance || - balance[currency]!.unlockedBalance != unlockedBalance || - balance[currency]!.frozenBalance != frozenBalance) { + balance[currency]!.available != unlockedBalance || + balance[currency]!.frozen != frozenBalance) { balance[currency] = WowneroBalance( - fullBalance: fullBalance, unlockedBalance: unlockedBalance, frozenBalance: frozenBalance); + fullBalance: fullBalance, unlockedBalance: unlockedBalance, frozen: frozenBalance); } } Future _askForUpdateTransactionHistory() async => await updateTransactions(); - int _getFullBalance() => wownero_wallet.getFullBalance(accountIndex: walletAddresses.account!.id); + Money _getFullBalance() => Money.fromInt( + wownero_wallet.getFullBalance(accountIndex: walletAddresses.account!.id), CryptoCurrency.wow); - int _getUnlockedBalance() => - wownero_wallet.getUnlockedBalance(accountIndex: walletAddresses.account!.id); + Money _getUnlockedBalance() => Money.fromInt( + wownero_wallet.getUnlockedBalance(accountIndex: walletAddresses.account!.id), + CryptoCurrency.wow); - int _getFrozenBalance() { + Money _getFrozenBalance() { var frozenBalance = 0; - for (var coin in unspentCoinsInfo.values.where((element) => + for (final coin in unspentCoinsInfo.values.where((element) => element.walletId == id && element.accountIndex == walletAddresses.account!.id)) { if (coin.isFrozen) frozenBalance += coin.value; } - return frozenBalance; + return Money.fromInt(frozenBalance, CryptoCurrency.wow); } void _onNewBlock(int height, int blocksLeft, double ptc) async { diff --git a/cw_wownero/pubspec.lock b/cw_wownero/pubspec.lock deleted file mode 100644 index 52940e87d..000000000 --- a/cw_wownero/pubspec.lock +++ /dev/null @@ -1,959 +0,0 @@ -# Generated by pub -# See https://dart.dev/tools/pub/glossary#lockfile -packages: - _fe_analyzer_shared: - dependency: transitive - description: - name: _fe_analyzer_shared - sha256: "16e298750b6d0af7ce8a3ba7c18c69c3785d11b15ec83f6dcd0ad2a0009b3cab" - url: "https://pub.dev" - source: hosted - version: "76.0.0" - _macros: - dependency: transitive - description: dart - source: sdk - version: "0.3.3" - analyzer: - dependency: transitive - description: - name: analyzer - sha256: "1f14db053a8c23e260789e9b0980fa27f2680dd640932cae5e1137cce0e46e1e" - url: "https://pub.dev" - source: hosted - version: "6.11.0" - args: - dependency: transitive - description: - name: args - sha256: bf9f5caeea8d8fe6721a9c358dd8a5c1947b27f1cfaa18b39c301273594919e6 - url: "https://pub.dev" - source: hosted - version: "2.6.0" - asn1lib: - dependency: transitive - description: - name: asn1lib - sha256: "4bae5ae63e6d6dd17c4aac8086f3dec26c0236f6a0f03416c6c19d830c367cf5" - url: "https://pub.dev" - source: hosted - version: "1.5.8" - async: - dependency: transitive - description: - name: async - sha256: "758e6d74e971c3e5aceb4110bfd6698efc7f501675bcfe0c775459a8140750eb" - url: "https://pub.dev" - source: hosted - version: "2.13.0" - bech32: - dependency: transitive - description: - path: "." - ref: "05755063b593aa6cca0a4820a318e0ce17de6192" - resolved-ref: "05755063b593aa6cca0a4820a318e0ce17de6192" - url: "https://github.com/cake-tech/bech32.git" - source: git - version: "0.2.2" - blockchain_utils: - dependency: transitive - description: - path: "." - ref: cake-update-v2 - resolved-ref: "59fdf29d72068e0522a96a8953ed7272833a9f57" - url: "https://github.com/cake-tech/blockchain_utils" - source: git - version: "3.3.0" - boolean_selector: - dependency: transitive - description: - name: boolean_selector - sha256: "8aab1771e1243a5063b8b0ff68042d67334e3feab9e95b9490f9a6ebf73b42ea" - url: "https://pub.dev" - source: hosted - version: "2.1.2" - build: - dependency: transitive - description: - name: build - sha256: "80184af8b6cb3e5c1c4ec6d8544d27711700bc3e6d2efad04238c7b5290889f0" - url: "https://pub.dev" - source: hosted - version: "2.4.1" - build_config: - dependency: transitive - description: - name: build_config - sha256: bf80fcfb46a29945b423bd9aad884590fb1dc69b330a4d4700cac476af1708d1 - url: "https://pub.dev" - source: hosted - version: "1.1.1" - build_daemon: - dependency: transitive - description: - name: build_daemon - sha256: "79b2aef6ac2ed00046867ed354c88778c9c0f029df8a20fe10b5436826721ef9" - url: "https://pub.dev" - source: hosted - version: "4.0.2" - build_resolvers: - dependency: "direct dev" - description: - name: build_resolvers - sha256: b9e4fda21d846e192628e7a4f6deda6888c36b5b69ba02ff291a01fd529140f0 - url: "https://pub.dev" - source: hosted - version: "2.4.4" - build_runner: - dependency: "direct dev" - description: - name: build_runner - sha256: "058fe9dce1de7d69c4b84fada934df3e0153dd000758c4d65964d0166779aa99" - url: "https://pub.dev" - source: hosted - version: "2.4.15" - build_runner_core: - dependency: transitive - description: - name: build_runner_core - sha256: "22e3aa1c80e0ada3722fe5b63fd43d9c8990759d0a2cf489c8c5d7b2bdebc021" - url: "https://pub.dev" - source: hosted - version: "8.0.0" - built_collection: - dependency: transitive - description: - name: built_collection - sha256: "376e3dd27b51ea877c28d525560790aee2e6fbb5f20e2f85d5081027d94e2100" - url: "https://pub.dev" - source: hosted - version: "5.1.1" - built_value: - dependency: transitive - description: - name: built_value - sha256: "8b158ab94ec6913e480dc3f752418348b5ae099eb75868b5f4775f0572999c61" - url: "https://pub.dev" - source: hosted - version: "8.9.4" - cake_backup: - dependency: transitive - description: - path: "." - ref: "3aba867dcab6737f6707782f5db15d71f303db38" - resolved-ref: "3aba867dcab6737f6707782f5db15d71f303db38" - url: "https://github.com/cake-tech/cake_backup.git" - source: git - version: "1.0.0+1" - characters: - dependency: transitive - description: - name: characters - sha256: f71061c654a3380576a52b451dd5532377954cf9dbd272a78fc8479606670803 - url: "https://pub.dev" - source: hosted - version: "1.4.0" - checked_yaml: - dependency: transitive - description: - name: checked_yaml - sha256: feb6bed21949061731a7a75fc5d2aa727cf160b91af9a3e464c5e3a32e28b5ff - url: "https://pub.dev" - source: hosted - version: "2.0.3" - clock: - dependency: transitive - description: - name: clock - sha256: fddb70d9b5277016c77a80201021d40a2247104d9f4aa7bab7157b7e3f05b84b - url: "https://pub.dev" - source: hosted - version: "1.1.2" - code_builder: - dependency: transitive - description: - name: code_builder - sha256: "0ec10bf4a89e4c613960bf1e8b42c64127021740fb21640c29c909826a5eea3e" - url: "https://pub.dev" - source: hosted - version: "4.10.1" - collection: - dependency: transitive - description: - name: collection - sha256: "2f5709ae4d3d59dd8f7cd309b4e023046b57d8a6c82130785d2b0e5868084e76" - url: "https://pub.dev" - source: hosted - version: "1.19.1" - convert: - dependency: transitive - description: - name: convert - sha256: b30acd5944035672bc15c6b7a8b47d773e41e2f17de064350988c5d02adb1c68 - url: "https://pub.dev" - source: hosted - version: "3.1.2" - crypto: - dependency: transitive - description: - name: crypto - sha256: "1e445881f28f22d6140f181e07737b22f1e099a5e1ff94b0af2f9e4a463f4855" - url: "https://pub.dev" - source: hosted - version: "3.0.6" - cryptography: - dependency: transitive - description: - name: cryptography - sha256: d146b76d33d94548cf035233fbc2f4338c1242fa119013bead807d033fc4ae05 - url: "https://pub.dev" - source: hosted - version: "2.7.0" - cupertino_icons: - dependency: transitive - description: - name: cupertino_icons - sha256: ba631d1c7f7bef6b729a622b7b752645a2d076dba9976925b8f25725a30e1ee6 - url: "https://pub.dev" - source: hosted - version: "1.0.8" - cw_core: - dependency: "direct main" - description: - path: "../cw_core" - relative: true - source: path - version: "0.0.1" - dart_style: - dependency: transitive - description: - name: dart_style - sha256: "7306ab8a2359a48d22310ad823521d723acfed60ee1f7e37388e8986853b6820" - url: "https://pub.dev" - source: hosted - version: "2.3.8" - decimal: - dependency: transitive - description: - name: decimal - sha256: "24a261d5d5c87e86c7651c417a5dbdf8bcd7080dd592533910e8d0505a279f21" - url: "https://pub.dev" - source: hosted - version: "2.3.3" - encrypt: - dependency: "direct main" - description: - name: encrypt - sha256: "62d9aa4670cc2a8798bab89b39fc71b6dfbacf615de6cf5001fb39f7e4a996a2" - url: "https://pub.dev" - source: hosted - version: "5.0.3" - fake_async: - dependency: transitive - description: - name: fake_async - sha256: "5368f224a74523e8d2e7399ea1638b37aecfca824a3cc4dfdf77bf1fa905ac44" - url: "https://pub.dev" - source: hosted - version: "1.3.3" - ffi: - dependency: "direct main" - description: - name: ffi - sha256: "16ed7b077ef01ad6170a3d0c57caa4a112a38d7a2ed5602e0aca9ca6f3d98da6" - url: "https://pub.dev" - source: hosted - version: "2.1.3" - file: - dependency: transitive - description: - name: file - sha256: a3b4f84adafef897088c160faf7dfffb7696046cb13ae90b508c2cbc95d3b8d4 - url: "https://pub.dev" - source: hosted - version: "7.0.1" - fixnum: - dependency: transitive - description: - name: fixnum - sha256: b6dc7065e46c974bc7c5f143080a6764ec7a4be6da1285ececdc37be96de53be - url: "https://pub.dev" - source: hosted - version: "1.1.1" - flutter: - dependency: "direct main" - description: flutter - source: sdk - version: "0.0.0" - flutter_mobx: - dependency: "direct main" - description: - name: flutter_mobx - sha256: ba5e93467866a2991259dc51cffd41ef45f695c667c2b8e7b087bf24118b50fe - url: "https://pub.dev" - source: hosted - version: "2.3.0" - flutter_test: - dependency: "direct dev" - description: flutter - source: sdk - version: "0.0.0" - frontend_server_client: - dependency: transitive - description: - name: frontend_server_client - sha256: f64a0333a82f30b0cca061bc3d143813a486dc086b574bfb233b7c1372427694 - url: "https://pub.dev" - source: hosted - version: "4.0.0" - glob: - dependency: transitive - description: - name: glob - sha256: c3f1ee72c96f8f78935e18aa8cecced9ab132419e8625dc187e1c2408efc20de - url: "https://pub.dev" - source: hosted - version: "2.1.3" - graphs: - dependency: transitive - description: - name: graphs - sha256: "741bbf84165310a68ff28fe9e727332eef1407342fca52759cb21ad8177bb8d0" - url: "https://pub.dev" - source: hosted - version: "2.3.2" - hashlib: - dependency: transitive - description: - name: hashlib - sha256: e13e8237d93fb275cd1c55fc339bb90638994d1a4f140c7ee270173b51f3d169 - url: "https://pub.dev" - source: hosted - version: "1.21.1" - hashlib_codecs: - dependency: transitive - description: - name: hashlib_codecs - sha256: "8cea9ccafcfeaa7324d2ae52c61c69f7ff71f4237507a018caab31b9e416e3b1" - url: "https://pub.dev" - source: hosted - version: "2.6.0" - hive: - dependency: transitive - description: - name: hive - sha256: "8dcf6db979d7933da8217edcec84e9df1bdb4e4edc7fc77dbd5aa74356d6d941" - url: "https://pub.dev" - source: hosted - version: "2.2.3" - hive_generator: - dependency: "direct dev" - description: - name: hive_generator - sha256: "06cb8f58ace74de61f63500564931f9505368f45f98958bd7a6c35ba24159db4" - url: "https://pub.dev" - source: hosted - version: "2.0.1" - http: - dependency: "direct main" - description: - name: http - sha256: fe7ab022b76f3034adc518fb6ea04a82387620e19977665ea18d30a1cf43442f - url: "https://pub.dev" - source: hosted - version: "1.3.0" - http_multi_server: - dependency: transitive - description: - name: http_multi_server - sha256: aa6199f908078bb1c5efb8d8638d4ae191aac11b311132c3ef48ce352fb52ef8 - url: "https://pub.dev" - source: hosted - version: "3.2.2" - http_parser: - dependency: transitive - description: - name: http_parser - sha256: "2aa08ce0341cc9b354a498388e30986515406668dbcc4f7c950c3e715496693b" - url: "https://pub.dev" - source: hosted - version: "4.0.2" - intl: - dependency: "direct main" - description: - name: intl - sha256: d6f56758b7d3014a48af9701c085700aac781a92a87a62b1333b46d8879661cf - url: "https://pub.dev" - source: hosted - version: "0.19.0" - io: - dependency: transitive - description: - name: io - sha256: dfd5a80599cf0165756e3181807ed3e77daf6dd4137caaad72d0b7931597650b - url: "https://pub.dev" - source: hosted - version: "1.0.5" - js: - dependency: transitive - description: - name: js - sha256: f2c445dce49627136094980615a031419f7f3eb393237e4ecd97ac15dea343f3 - url: "https://pub.dev" - source: hosted - version: "0.6.7" - json_annotation: - dependency: transitive - description: - name: json_annotation - sha256: "1ce844379ca14835a50d2f019a3099f419082cfdd231cd86a142af94dd5c6bb1" - url: "https://pub.dev" - source: hosted - version: "4.9.0" - leak_tracker: - dependency: transitive - description: - name: leak_tracker - sha256: "6bb818ecbdffe216e81182c2f0714a2e62b593f4a4f13098713ff1685dfb6ab0" - url: "https://pub.dev" - source: hosted - version: "10.0.9" - leak_tracker_flutter_testing: - dependency: transitive - description: - name: leak_tracker_flutter_testing - sha256: f8b613e7e6a13ec79cfdc0e97638fddb3ab848452eff057653abd3edba760573 - url: "https://pub.dev" - source: hosted - version: "3.0.9" - leak_tracker_testing: - dependency: transitive - description: - name: leak_tracker_testing - sha256: "6ba465d5d76e67ddf503e1161d1f4a6bc42306f9d66ca1e8f079a47290fb06d3" - url: "https://pub.dev" - source: hosted - version: "3.0.1" - logging: - dependency: transitive - description: - name: logging - sha256: c8245ada5f1717ed44271ed1c26b8ce85ca3228fd2ffdb75468ab01979309d61 - url: "https://pub.dev" - source: hosted - version: "1.3.0" - macros: - dependency: transitive - description: - name: macros - sha256: "1d9e801cd66f7ea3663c45fc708450db1fa57f988142c64289142c9b7ee80656" - url: "https://pub.dev" - source: hosted - version: "0.1.3-main.0" - matcher: - dependency: transitive - description: - name: matcher - sha256: dc58c723c3c24bf8d3e2d3ad3f2f9d7bd9cf43ec6feaa64181775e60190153f2 - url: "https://pub.dev" - source: hosted - version: "0.12.17" - material_color_utilities: - dependency: transitive - description: - name: material_color_utilities - sha256: f7142bb1154231d7ea5f96bc7bde4bda2a0945d2806bb11670e30b850d56bdec - url: "https://pub.dev" - source: hosted - version: "0.11.1" - meta: - dependency: transitive - description: - name: meta - sha256: e3641ec5d63ebf0d9b41bd43201a66e3fc79a65db5f61fc181f04cd27aab950c - url: "https://pub.dev" - source: hosted - version: "1.16.0" - mime: - dependency: transitive - description: - name: mime - sha256: "41a20518f0cb1256669420fdba0cd90d21561e560ac240f26ef8322e45bb7ed6" - url: "https://pub.dev" - source: hosted - version: "2.0.0" - mobx: - dependency: "direct main" - description: - name: mobx - sha256: bf1a90e5bcfd2851fc6984e20eef69557c65d9e4d0a88f5be4cf72c9819ce6b0 - url: "https://pub.dev" - source: hosted - version: "2.5.0" - mobx_codegen: - dependency: "direct dev" - description: - name: mobx_codegen - sha256: e0abbbc651a69550440f6b65c99ec222a1e2a4afd7baec8ba0f3088c7ca582a8 - url: "https://pub.dev" - source: hosted - version: "2.7.1" - monero: - dependency: "direct main" - description: - path: "impls/monero.dart" - ref: bc8d1a0b75b97156d71579581b4cdfe58c777ed2 - resolved-ref: bc8d1a0b75b97156d71579581b4cdfe58c777ed2 - url: "https://github.com/mrcyjanek/monero_c" - source: git - version: "0.0.0" - mutex: - dependency: "direct main" - description: - name: mutex - sha256: "8827da25de792088eb33e572115a5eb0d61d61a3c01acbc8bcbe76ed78f1a1f2" - url: "https://pub.dev" - source: hosted - version: "3.1.0" - nested: - dependency: transitive - description: - name: nested - sha256: "03bac4c528c64c95c722ec99280375a6f2fc708eec17c7b3f07253b626cd2a20" - url: "https://pub.dev" - source: hosted - version: "1.0.0" - on_chain: - dependency: transitive - description: - path: "." - ref: "096865a8c6b89c260beadfec04f7e184c40a3273" - resolved-ref: "096865a8c6b89c260beadfec04f7e184c40a3273" - url: "https://github.com/cake-tech/on_chain.git" - source: git - version: "3.7.0" - package_config: - dependency: transitive - description: - name: package_config - sha256: "92d4488434b520a62570293fbd33bb556c7d49230791c1b4bbd973baf6d2dc67" - url: "https://pub.dev" - source: hosted - version: "2.1.1" - path: - dependency: transitive - description: - name: path - sha256: "75cca69d1490965be98c73ceaea117e8a04dd21217b37b292c9ddbec0d955bc5" - url: "https://pub.dev" - source: hosted - version: "1.9.1" - path_provider: - dependency: "direct main" - description: - name: path_provider - sha256: "50c5dd5b6e1aaf6fb3a78b33f6aa3afca52bf903a8a5298f53101fdaee55bbcd" - url: "https://pub.dev" - source: hosted - version: "2.1.5" - path_provider_android: - dependency: transitive - description: - name: path_provider_android - sha256: "4adf4fd5423ec60a29506c76581bc05854c55e3a0b72d35bb28d661c9686edf2" - url: "https://pub.dev" - source: hosted - version: "2.2.15" - path_provider_foundation: - dependency: transitive - description: - name: path_provider_foundation - sha256: "4843174df4d288f5e29185bd6e72a6fbdf5a4a4602717eed565497429f179942" - url: "https://pub.dev" - source: hosted - version: "2.4.1" - path_provider_linux: - dependency: transitive - description: - name: path_provider_linux - sha256: f7a1fe3a634fe7734c8d3f2766ad746ae2a2884abe22e241a8b301bf5cac3279 - url: "https://pub.dev" - source: hosted - version: "2.2.1" - path_provider_platform_interface: - dependency: transitive - description: - name: path_provider_platform_interface - sha256: "88f5779f72ba699763fa3a3b06aa4bf6de76c8e5de842cf6f29e2e06476c2334" - url: "https://pub.dev" - source: hosted - version: "2.1.2" - path_provider_windows: - dependency: transitive - description: - name: path_provider_windows - sha256: bd6f00dbd873bfb70d0761682da2b3a2c2fccc2b9e84c495821639601d81afe7 - url: "https://pub.dev" - source: hosted - version: "2.3.0" - platform: - dependency: transitive - description: - name: platform - sha256: "5d6b1b0036a5f331ebc77c850ebc8506cbc1e9416c27e59b439f917a902a4984" - url: "https://pub.dev" - source: hosted - version: "3.1.6" - plugin_platform_interface: - dependency: transitive - description: - name: plugin_platform_interface - sha256: "4820fbfdb9478b1ebae27888254d445073732dae3d6ea81f0b7e06d5dedc3f02" - url: "https://pub.dev" - source: hosted - version: "2.1.8" - pointycastle: - dependency: transitive - description: - name: pointycastle - sha256: "4be0097fcf3fd3e8449e53730c631200ebc7b88016acecab2b0da2f0149222fe" - url: "https://pub.dev" - source: hosted - version: "3.9.1" - polyseed: - dependency: "direct main" - description: - name: polyseed - sha256: ccc6ba0ed3976837e770a324a6295fbf3d7c0d05fc0fa1906e36479b07c2675b - url: "https://pub.dev" - source: hosted - version: "0.0.7" - pool: - dependency: transitive - description: - name: pool - sha256: "20fe868b6314b322ea036ba325e6fc0711a22948856475e2c2b6306e8ab39c2a" - url: "https://pub.dev" - source: hosted - version: "1.5.1" - provider: - dependency: transitive - description: - name: provider - sha256: c8a055ee5ce3fd98d6fc872478b03823ffdb448699c6ebdbbc71d59b596fd48c - url: "https://pub.dev" - source: hosted - version: "6.1.2" - pub_semver: - dependency: transitive - description: - name: pub_semver - sha256: "7b3cfbf654f3edd0c6298ecd5be782ce997ddf0e00531b9464b55245185bbbbd" - url: "https://pub.dev" - source: hosted - version: "2.1.5" - pubspec_parse: - dependency: transitive - description: - name: pubspec_parse - sha256: "81876843eb50dc2e1e5b151792c9a985c5ed2536914115ed04e9c8528f6647b0" - url: "https://pub.dev" - source: hosted - version: "1.4.0" - rational: - dependency: transitive - description: - name: rational - sha256: cb808fb6f1a839e6fc5f7d8cb3b0a10e1db48b3be102de73938c627f0b636336 - url: "https://pub.dev" - source: hosted - version: "2.2.3" - shelf: - dependency: transitive - description: - name: shelf - sha256: ad29c505aee705f41a4d8963641f91ac4cee3c8fad5947e033390a7bd8180fa4 - url: "https://pub.dev" - source: hosted - version: "1.4.1" - shelf_web_socket: - dependency: transitive - description: - name: shelf_web_socket - sha256: cc36c297b52866d203dbf9332263c94becc2fe0ceaa9681d07b6ef9807023b67 - url: "https://pub.dev" - source: hosted - version: "2.0.1" - sky_engine: - dependency: transitive - description: flutter - source: sdk - version: "0.0.0" - socks5_proxy: - dependency: transitive - description: - path: "." - ref: "27ad7c2efae8d7460325c74b90f660085cbd0685" - resolved-ref: "27ad7c2efae8d7460325c74b90f660085cbd0685" - url: "https://github.com/LacticWhale/socks_dart" - source: git - version: "2.1.0" - socks_socket: - dependency: transitive - description: - path: "." - ref: e6232c53c1595469931ababa878759a067c02e94 - resolved-ref: e6232c53c1595469931ababa878759a067c02e94 - url: "https://github.com/sneurlax/socks_socket" - source: git - version: "1.1.1" - source_gen: - dependency: transitive - description: - name: source_gen - sha256: "14658ba5f669685cd3d63701d01b31ea748310f7ab854e471962670abcf57832" - url: "https://pub.dev" - source: hosted - version: "1.5.0" - source_helper: - dependency: transitive - description: - name: source_helper - sha256: "86d247119aedce8e63f4751bd9626fc9613255935558447569ad42f9f5b48b3c" - url: "https://pub.dev" - source: hosted - version: "1.3.5" - source_span: - dependency: transitive - description: - name: source_span - sha256: "254ee5351d6cb365c859e20ee823c3bb479bf4a293c22d17a9f1bf144ce86f7c" - url: "https://pub.dev" - source: hosted - version: "1.10.1" - sqflite: - dependency: transitive - description: - name: sqflite - sha256: "2d7299468485dca85efeeadf5d38986909c5eb0cd71fd3db2c2f000e6c9454bb" - url: "https://pub.dev" - source: hosted - version: "2.4.1" - sqflite_android: - dependency: transitive - description: - name: sqflite_android - sha256: "78f489aab276260cdd26676d2169446c7ecd3484bbd5fead4ca14f3ed4dd9ee3" - url: "https://pub.dev" - source: hosted - version: "2.4.0" - sqflite_common: - dependency: transitive - description: - name: sqflite_common - sha256: "761b9740ecbd4d3e66b8916d784e581861fd3c3553eda85e167bc49fdb68f709" - url: "https://pub.dev" - source: hosted - version: "2.5.4+6" - sqflite_common_ffi: - dependency: transitive - description: - name: sqflite_common_ffi - sha256: "883dd810b2b49e6e8c3b980df1829ef550a94e3f87deab5d864917d27ca6bf36" - url: "https://pub.dev" - source: hosted - version: "2.3.4+4" - sqflite_darwin: - dependency: transitive - description: - name: sqflite_darwin - sha256: "22adfd9a2c7d634041e96d6241e6e1c8138ca6817018afc5d443fef91dcefa9c" - url: "https://pub.dev" - source: hosted - version: "2.4.1+1" - sqflite_platform_interface: - dependency: transitive - description: - name: sqflite_platform_interface - sha256: "8dd4515c7bdcae0a785b0062859336de775e8c65db81ae33dd5445f35be61920" - url: "https://pub.dev" - source: hosted - version: "2.4.0" - sqlite3: - dependency: transitive - description: - name: sqlite3 - sha256: f393d92c71bdcc118d6203d07c991b9be0f84b1a6f89dd4f7eed348131329924 - url: "https://pub.dev" - source: hosted - version: "2.9.0" - sqlite3_flutter_libs: - dependency: transitive - description: - name: sqlite3_flutter_libs - sha256: "69c80d812ef2500202ebd22002cbfc1b6565e9ff56b2f971e757fac5d42294df" - url: "https://pub.dev" - source: hosted - version: "0.5.40" - stack_trace: - dependency: transitive - description: - name: stack_trace - sha256: "8b27215b45d22309b5cddda1aa2b19bdfec9df0e765f2de506401c071d38d1b1" - url: "https://pub.dev" - source: hosted - version: "1.12.1" - stream_channel: - dependency: transitive - description: - name: stream_channel - sha256: "969e04c80b8bcdf826f8f16579c7b14d780458bd97f56d107d3950fdbeef059d" - url: "https://pub.dev" - source: hosted - version: "2.1.4" - stream_transform: - dependency: transitive - description: - name: stream_transform - sha256: ad47125e588cfd37a9a7f86c7d6356dde8dfe89d071d293f80ca9e9273a33871 - url: "https://pub.dev" - source: hosted - version: "2.1.1" - string_scanner: - dependency: transitive - description: - name: string_scanner - sha256: "921cd31725b72fe181906c6a94d987c78e3b98c2e205b397ea399d4054872b43" - url: "https://pub.dev" - source: hosted - version: "1.4.1" - synchronized: - dependency: transitive - description: - name: synchronized - sha256: "69fe30f3a8b04a0be0c15ae6490fc859a78ef4c43ae2dd5e8a623d45bfcf9225" - url: "https://pub.dev" - source: hosted - version: "3.3.0+3" - term_glyph: - dependency: transitive - description: - name: term_glyph - sha256: "7f554798625ea768a7518313e58f83891c7f5024f88e46e7182a4558850a4b8e" - url: "https://pub.dev" - source: hosted - version: "1.2.2" - test_api: - dependency: transitive - description: - name: test_api - sha256: fb31f383e2ee25fbbfe06b40fe21e1e458d14080e3c67e7ba0acfde4df4e0bbd - url: "https://pub.dev" - source: hosted - version: "0.7.4" - timing: - dependency: transitive - description: - name: timing - sha256: "70a3b636575d4163c477e6de42f247a23b315ae20e86442bebe32d3cabf61c32" - url: "https://pub.dev" - source: hosted - version: "1.0.1" - torch_dart: - dependency: transitive - description: - path: "../scripts/torch_dart" - relative: true - source: path - version: "0.0.1" - tuple: - dependency: transitive - description: - name: tuple - sha256: a97ce2013f240b2f3807bcbaf218765b6f301c3eff91092bcfa23a039e7dd151 - url: "https://pub.dev" - source: hosted - version: "2.0.2" - typed_data: - dependency: transitive - description: - name: typed_data - sha256: f9049c039ebfeb4cf7a7104a675823cd72dba8297f264b6637062516699fa006 - url: "https://pub.dev" - source: hosted - version: "1.4.0" - unorm_dart: - dependency: transitive - description: - name: unorm_dart - sha256: "23d8bf65605401a6a32cff99435fed66ef3dab3ddcad3454059165df46496a3b" - url: "https://pub.dev" - source: hosted - version: "0.3.0" - vector_math: - dependency: transitive - description: - name: vector_math - sha256: "80b3257d1492ce4d091729e3a67a60407d227c27241d6927be0130c98e741803" - url: "https://pub.dev" - source: hosted - version: "2.1.4" - vm_service: - dependency: transitive - description: - name: vm_service - sha256: ddfa8d30d89985b96407efce8acbdd124701f96741f2d981ca860662f1c0dc02 - url: "https://pub.dev" - source: hosted - version: "15.0.0" - watcher: - dependency: "direct overridden" - description: - name: watcher - sha256: "69da27e49efa56a15f8afe8f4438c4ec02eff0a117df1b22ea4aad194fe1c104" - url: "https://pub.dev" - source: hosted - version: "1.1.1" - web: - dependency: transitive - description: - name: web - sha256: "868d88a33d8a87b18ffc05f9f030ba328ffefba92d6c127917a2ba740f9cfe4a" - url: "https://pub.dev" - source: hosted - version: "1.1.1" - web_socket: - dependency: transitive - description: - name: web_socket - sha256: "3c12d96c0c9a4eec095246debcea7b86c0324f22df69893d538fcc6f1b8cce83" - url: "https://pub.dev" - source: hosted - version: "0.1.6" - web_socket_channel: - dependency: transitive - description: - name: web_socket_channel - sha256: "0b8e2457400d8a859b7b2030786835a28a8e80836ef64402abef392ff4f1d0e5" - url: "https://pub.dev" - source: hosted - version: "3.0.2" - xdg_directories: - dependency: transitive - description: - name: xdg_directories - sha256: "7a3f37b05d989967cdddcbb571f1ea834867ae2faa29725fd085180e0883aa15" - url: "https://pub.dev" - source: hosted - version: "1.1.0" - yaml: - dependency: transitive - description: - name: yaml - sha256: b9da305ac7c39faa3f030eccd175340f968459dae4af175130b3fc47e40d76ce - url: "https://pub.dev" - source: hosted - version: "3.1.3" -sdks: - dart: ">=3.7.0-0 <4.0.0" - flutter: ">=3.24.0" diff --git a/cw_wownero/pubspec.yaml b/cw_wownero/pubspec.yaml index 3bec1817e..f97f7b3ca 100644 --- a/cw_wownero/pubspec.yaml +++ b/cw_wownero/pubspec.yaml @@ -14,8 +14,8 @@ dependencies: ffi: ^2.0.1 http: ^1.1.0 path_provider: ^2.0.11 - mobx: ^2.0.7+4 - flutter_mobx: ^2.0.6+1 + mobx: ^2.6.0 + flutter_mobx: ^2.3.0 intl: any encrypt: ^5.0.1 polyseed: ^0.0.7 @@ -23,18 +23,18 @@ dependencies: path: ../cw_core monero: git: - url: https://github.com/mrcyjanek/monero_c - ref: bc8d1a0b75b97156d71579581b4cdfe58c777ed2 # monero_c hash + url: https://github.com/mrcyjanek/monero_c.git + ref: 33671871de3d51696b66ffb3bd94f744e26974f6 path: impls/monero.dart mutex: ^3.1.0 dev_dependencies: flutter_test: sdk: flutter - build_runner: ^2.4.15 - build_resolvers: ^2.4.4 - mobx_codegen: ^2.0.7 - hive_generator: ^2.0.1 + build_runner: ^2.15.0 + mobx_codegen: ^2.7.7 +# hive_generator: ^2.0.1 + hive: 2.0.1 dependency_overrides: watcher: ^1.1.0 diff --git a/cw_wownero/pubspec_overrides.yaml b/cw_wownero/pubspec_overrides.yaml new file mode 120000 index 000000000..d957432d3 --- /dev/null +++ b/cw_wownero/pubspec_overrides.yaml @@ -0,0 +1 @@ +../pubspec_overrides.yaml \ No newline at end of file diff --git a/docs/ADDING_EVM_L2_WALLET_NETWORK_TYPES.md b/docs/ADDING_EVM_L2_WALLET_NETWORK_TYPES.md index c5ef9747c..dd8cc92b8 100644 --- a/docs/ADDING_EVM_L2_WALLET_NETWORK_TYPES.md +++ b/docs/ADDING_EVM_L2_WALLET_NETWORK_TYPES.md @@ -275,8 +275,8 @@ static String getScanProviderPreferenceKey(int chainId) { return switch (chainId) { 1 => 'use_etherscan', 137 => 'use_polygonscan', - 8453 => 'use_basescan', - 42161 => 'use_arbiscan', + 8453 => 'use_base_scan', + 42161 => 'use_arbitrum_scan', 10 => 'use_optimismscan', // NEW _ => 'use_etherscan', // Default }; diff --git a/docs/ADDING_NEW_WALLET_TYPES.md b/docs/ADDING_NEW_WALLET_TYPES.md index 91a69b9f8..dac56e056 100644 --- a/docs/ADDING_NEW_WALLET_TYPES.md +++ b/docs/ADDING_NEW_WALLET_TYPES.md @@ -2,7 +2,7 @@ ## Important: EVM-Compatible Wallets -**If you're adding an EVM-compatible wallet (Ethereum, Polygon, Base, Arbitrum, Optimism, etc.)**, please refer to the **[Adding New L2 Network Guide](./adding_new_l2_network_guide.md)** instead. EVM wallets use a unified architecture and don't require the full setup described in this guide. +**If you're adding an EVM-compatible wallet (Ethereum, Polygon, Base, Arbitrum, Optimism, etc.)**, please refer to the **[Adding New L2 Network Guide](./ADDING_EVM_L2_WALLET_NETWORK_TYPES.md)** instead. EVM wallets use a unified architecture and don't require the full setup described in this guide. **For EVM wallets:** - Use `WalletType.evm` (unified type for all EVM chains) @@ -121,7 +121,6 @@ cd cw_walletx && flutter pub get && dart run build_runner build --delete-conflic dev_dependencies: build_runner: ^2.0.0 mobx_codegen: ^2.0.0 - hive_generator: ^2.0.0 ``` --- @@ -985,7 +984,7 @@ Add: dart run build_runner build --delete-conflicting-outputs ``` -4. **EVM Wallets**: If adding an EVM-compatible wallet, use the [L2 Network Guide](./adding_new_l2_network_guide.md) instead. +4. **EVM Wallets**: If adding an EVM-compatible wallet, use the [L2 Network Guide](./ADDING_EVM_L2_WALLET_NETWORK_TYPES.md) instead. 5. **Token Support**: If your wallet supports tokens (like ERC20, SPL, TRC20): - Add token checks in `exchange_trade_view_model.dart` @@ -1030,4 +1029,4 @@ Add: --- -Copyright (C) 2018-2023 Cake Labs LLC +Copyright (C) 2018-2025 Cake Labs LLC diff --git a/docs/INTEGRATION_TESTS.md b/docs/INTEGRATION_TESTS.md new file mode 100644 index 000000000..d405cf578 --- /dev/null +++ b/docs/INTEGRATION_TESTS.md @@ -0,0 +1,306 @@ +# Integration tests + +End to end tests that drive the real app on a device. They run on an Android emulator in CI +for every pull request into `dev`, and the funds suites run manually before releases. + +## Architecture + +Four layers, each one only talks to the layer below it: + +``` +suites/ the runnable tests, one file per user journey + tier0/ offline capable, no network assertions, the hard PR gate + tier1/ needs live network (nodes, swap providers), non blocking on PRs +funds_suites/ moves real funds, only ever run by the manual funds workflow +flows/ multi screen journeys shared between suites (onboarding, auth, wallet list) +robots/ one class per screen, knows that screen's keys and assertions +core/ the harness: BaseRobot primitives, AppLauncher, TestConfig, TestWallets +``` + +New robots extend `BaseRobot`. The older onboarding robots still hold a `CommonTestCases` +instead, which is the same idea with a weaker waiting story, migrate one over whenever you +touch it. + +Supporting pieces: + +- `test_driver/integration_test.dart` is the standard flutter drive driver. +- `integration_test_runner.sh` discovers and runs suites, see Running locally. +- `.github/workflows/integration_tests.yml` is the PR gate, + `reusable-integration-test.yml` holds the shared build and emulator pipeline. + +## Running locally + +Prerequisites, one time: + +1. A generated `lib/.secrets.g.dart` with the `*TestWalletSeeds` values populated. +2. App config for the cakewallet flavor: + `cd scripts/android && source ./app_env.sh cakewallet && ./app_config.sh` +3. Rename the app, every run wipes its data between suites: + + ``` + printf 'id=com.cakewallet.test_local\nname=local\n' > android/app.properties + ``` + + Step 2 writes the real `com.cakewallet.cake_wallet` id, and clearing that package takes + your own wallets with it. CI does this same rename after app_config for the same reason. + The runner refuses to clear a package that is not named for testing. +4. Compiled vector assets, both passes: + + ``` + ./compile_graphics.sh + while read -r dir; do + dart run vector_graphics_compiler --input-dir "$dir" --out-dir "$dir" + done < <(find assets/images -type d) + ``` + + `compile_graphics.sh` does not cover `assets/images`, and those `.svg.vec` files are + neither committed nor ignored, so a fresh checkout has none of them. Without this the + app throws "Unable to load asset" on the welcome screen and the first suite fails. +5. Native libs in `android/app/src/main/jniLibs/` for monero, wownero and zano + (prebuilts from the pinned monero_c release). +6. `~/.cargo/bin` on PATH so gradle can build the breez rust crates, and `protoc` installed + (`brew install protobuf`), the spark crate generates its protobuf bindings at build time. + +If the build sits at 0% CPU for minutes, close Android Studio or stop its Gradle daemon, +two daemons on this project deadlock on the same locks. + +Re-run steps 2 to 4 after every merge from dev, then `flutter pub get` at the root and the +codegen for `lib/`. The chain proxies in `lib//.dart` are generated and +gitignored, so a merge that adds a call like `bitcoin!.hasSelectedLightning` leaves them +behind and the build fails on a method that looks like it should exist. Step 2 also +rewrites `android/app.properties` with the real app id, so redo step 3 alongside it, and it +finishes by rewriting `pubspec.yaml` after its own last pub get, which is why the root pub +get has to come after it. Skipping step 4 shows up as "Unable to load asset" on whichever +screen dev added an icon to, one suite in, rather than at build time. + +Run one suite against a booted emulator: + +``` +flutter drive --driver=test_driver/integration_test.dart \ + --target=integration_test/suites/tier0/onboarding_create_test.dart \ + --dart-define=CI_BUILD=true -d +``` + +Run a whole tier through the runner: + +``` +TEST_TIER=tier0 FLUTTER_DEVICE= ./integration_test_runner.sh +``` + +`SUITE_DIR` also takes a single file, which is what you want while iterating on one suite: + +```bash +SUITE_DIR=integration_test/suites/tier0/fiat_currency_test.dart ./integration_test_runner.sh +``` + +Runner knobs (environment variables): `SUITE_DIR`, `TEST_TIER` (tier0, tier1, all), +`PLATFORM` (android, linux, macos, auto), `FLUTTER_DEVICE`, `RETRY_COUNT`, +`EXTRA_DART_DEFINES`, `REMOVE_DATA_DIRECTORY=N` to keep app data between suites, +`REMOVE_DESKTOP_DATA=Y` to let a desktop run wipe the wallet directories it shares with the +installed app, `VOID_GRACE` for how long a driver that cannot attach is given to recover. + +Test knobs (dart defines): `TEST_WALLET_TYPES=all` runs every available wallet type, +a comma separated list of type names runs just those, unset runs the representative set +(solana, ethereum, bitcoin, monero). + +## Which tier does my test go in + +- Creates wallets, navigates screens, checks local state: **tier0**. +- Needs a node, a provider API or any live network response: **tier1**. +- Signs and broadcasts a real transaction: **funds_suites**, never anywhere else. + +tier0 blocks merges. tier1 runs on every PR but reports without blocking until it has a +stable history. funds_suites run only from the manual funds workflow. + +## What is covered today + +Twenty seven suites. Add a line here when you add one. + +### tier0, no network needed + +| Suite | What it proves | +| --- | --- | +| `onboarding_create_test` | A wallet can be created for every configured type, and the app lands on the dashboard with that wallet open | +| `onboarding_restore_test` | A wallet restores from seed for every configured type, and the restored wallet reports the seed it was given | +| `invalid_seed_test` | A seed the wallet cannot parse leaves the user on the restore screen with nothing created | +| `duplicate_wallet_name_test` | A name already taken is refused instead of quietly overwriting the wallet holding it | +| `wallet_group_test` | A wallet added to an existing seed shares that seed, and both wallets stay reachable through the group | +| `wallet_rename_test` | A wallet can be renamed, and cannot be renamed onto a name another wallet already holds | +| `wallet_delete_test` | Deleting a wallet takes it off the list and out of storage, and leaves the others alone | +| `receive_address_test` | The receive sheet shows an address the opened wallet owns, not a leftover from the previous one | +| `seed_confirmation_test` | The seed and keys the app displays are the ones the opened wallet holds | +| `show_keys_auth_test` | The seed and keys page turns away a wrong pin and opens for the right one | +| `change_pin_test` | A changed pin is the one that opens what the old pin used to guard | +| `send_validation_test` | The send screen refuses an empty address, a malformed one, an empty amount and more than the balance | +| `address_book_test` | A saved contact keeps the address it was given | +| `send_from_address_book_test` | A contact picked on the send screen fills in the address that was saved for it | +| `settings_nav_test` | Every settings row opens its page and backs out of it | +| `language_test` | Changing the language changes what the settings screen shows | +| `fiat_currency_test` | Turning the fiat api off hides the currency setting, turning it back on returns it and the currency can be changed | + +### tier1, needs a node or a provider + +| Suite | What it proves | +| --- | --- | +| `sync_status_test` | A restored wallet reaches a node and starts syncing rather than sitting at connecting | +| `node_switching_test` | Confirming another node repoints the wallet at it | +| `transaction_history_test` | A wallet with known transactions renders them, both in the home preview and the full list | +| `transaction_details_test` | Tapping a transaction opens the details of that transaction and not another one | +| `swap_quote_test` | The swap sheet reaches a provider and returns a real quote, no trade is created | +| `monero_legacy_seed_test` | A 25 word monero seed restores through the legacy path, which takes a seed type and a restore height the polyseed path never asks for | + +### funds_suites, manual workflow only + +| Suite | What it proves | +| --- | --- | +| `send_dry_run_test` | Every funded chain builds and signs a transaction, then throws it away without broadcasting | +| `send_funds_test` | A real self transaction is broadcast on every funded chain | +| `swap_dry_run_test` | A real deposit is priced end to end, stopping before the trade is created | +| `swap_funds_test` | A swap is created and its deposit broadcast | + +The two dry runs need funded seeds because a quote and a signed transaction both depend on a +real balance, but neither spends anything. `SPEND=true` is what lets the two that do spend +past their guard. + +## Adding a test for your feature + +1. Give the widgets your test touches stable keys, inline in the widget: + `key: ValueKey('__key')`. Keys go on the widget the test taps or reads, + never on AnimatedSwitcher children where the key is the animation identity. +2. Create or extend the robot for the screen in `robots/`. One robot per screen, it + extends `BaseRobot` and implements `isDisplayed()`. Robots own every finder for their + screen, suites never call `find` directly. +3. If the journey spans several screens, add a flow to `flows/` composing the robots. +4. Copy `integration_test/templates/example_feature_test.dart.example` into the right + tier directory, rename it to `_test.dart` and fill it in. The runner only + picks up files ending in `_test.dart`. +5. Run it twice locally against an emulator before pushing. +6. Add it to the table above. + +## Harness rules + +- Suites use `integrationTest` from `core/app_launcher.dart`, not `testWidgets`. It contains + background async errors from app networking that would otherwise fail the test, awaited + assertion failures still fail the test normally. +- No `sleep` or fixed delays, use `pumpUntilFound` and `pumpUntil` with a timeout. +- No `pumpAndSettle` in new code, screens with endless animations never settle, + use the bounded `settle()` from BaseRobot. +- No `find.text` on localized strings, keys only. Wallet names and addresses are data, + matching on those is fine. +- `.secrets.g.dart` is only imported by `core/test_wallets.dart`. +- Every suite starts from a fresh install, the runner wipes app data between suites. +- Reading a view model through the page widget for a state assertion is fine, driving + the app through view models instead of the UI is not. + +## Secrets + +Test wallet seeds and receive addresses live in `lib/.secrets.g.dart` under names like +`solanaTestWalletSeeds`, defined in `tool/utils/secret_key.dart` and reachable through +`TestWallets`. CI injects the real values from the `MAIN_SECRETS_FILE` repository secret. + +Adding a new test secret means adding it in `tool/utils/secret_key.dart`, mapping it in +`TestWallets` and asking the team lead to add the value to the CI secret. + +The slack report uses two more repository secrets, `SLACK_TESTS_TOKEN` and +`SLACK_TESTS_CHANNEL`, which is a channel id rather than a webhook. Both the PR gate and +the funds workflow report through them, so every test result lands in one channel. + +That is a separate slack app from the one behind `SLACK_APP_TOKEN`, which uploads apks to +the builds channel. They are split so each token carries only what it needs, `chat:write` +for this one and the file scopes for that one, and so the name on a message says which of +the two sent it. A post coming back `missing_scope` means the app is short the +`chat:write` scope or was never reinstalled after it was added. + +## Funds suites + +`funds_suites/` restore funded wallets and move real funds: a small self send on every +funded chain, and one real swap with its deposit broadcast. They only run from the manual +`Funds Integration Tests` workflow, which the team lead dispatches from the Actions tab +before a release. The job runs in the `funds` environment, so add required reviewers to that +environment under Settings, Environments, or a dispatch still starts without an approval. + +Funded seeds live in the `FUNDS_SECRETS_FILE` secret, a base64 encoded replacement for +`integration_test/core/funded_wallets.dart` mapping wallet type names to that chain's +funded wallets, at least two per chain. A wallet with a passphrase carries it and the restore +flow ticks the passphrase box and enters it. A wallet without one leaves it out: + +```dart +import "funded_wallet.dart"; + +const Map> fundedWallets = { + "solana": [ + FundedWallet(seed: "first wallet seed words ...", passphrase: "first passphrase"), + FundedWallet(seed: "second wallet seed words ...", passphrase: "second passphrase"), + ], + "ethereum": [ + FundedWallet(seed: "seed words ..."), + ], +}; +``` + +A wrong passphrase restores an empty derivation, so it shows up the same way a drained +wallet does, as "no spendable balance". + +The suites restore a chain's funded wallets one by one and use the first that shows a +spendable balance, a wallet that finishes syncing while still empty counts as drained. +A chain where every funded wallet is empty fails with a message asking for a top up. + +The checked in default is an empty map and must stay empty. Only the funds workflow turns +on `write_funds_secrets`, and that input is the one thing keeping funded seeds out of a PR +build now that no environment scopes the secret. In auto mode the suites discover every +chain present in the map, so funding a new chain only means adding its entry to the +secret. The dispatch inputs narrow a run: `flows` picks send, swap or both, `chains` lists +the chains the send suites run on, `swap_from` picks the wallet type the swap deposits from +(empty takes the first funded chain) and `swap_to` the currency it receives (empty leaves +the swap page's own pairing, which is XMR for everything but a monero wallet). Whatever +`swap_to` names must have a fixture address in `TestWallets.receiveAddressFor`, that is +where the payout goes. Per chain send amounts are tuned in `TestConfig._fundsSendAmounts`. +The swap suite enters an amount just above the provider minimum, the deposit wallet's +balance has to cover that minimum plus fees. + +## How CI runs them + +The PR gate runs on GitHub hosted runners, the self-hosted android builders have no +usable KVM so the emulator cannot start there. Moving back is a one line change to +`runs-on` in `reusable-integration-test.yml` once a KVM capable builder exists. + +tier0 and tier1 each get their own emulator step and upload their logs separately, so a +runner that dies during tier1 cannot take the gate's own results with it. The gradle heap +is capped before the test phase because the daemon and the emulator together were +exhausting the runner. + +Every run posts to slack. The message names each tier's counts, its duration and any suite +that failed, and the full list of what passed goes in a reply on the same message so the +channel keeps the short version. A suite that failed because the driver never attached is +labelled as such rather than reported as a failing test, since in that case the suite never +ran at all. It needs `SLACK_TESTS_TOKEN` and `SLACK_TESTS_CHANNEL`, and +without either the step logs a notice and skips rather than failing the gate. Cancelled +runs stay quiet, since superseded runs are cancelled on purpose. The report is built from +the `SUMMARY_FILE` each tier writes, so anything the runner counts is available to it. + +Two environment quirks are worth knowing when a build fails in CI but not locally: the +deps docker image regenerates the mweb ffi bindings with an ffigen too old for the current +compiler and its rsync overwrites `cw_mweb/pubspec.yaml`, so the workflow restores that +file and regenerates the bindings itself. And `assets/images` svgs need compiling to +`.vec`, which `compile_graphics.sh` does not cover. + +## Flakiness playbook + +- A timeout in `pumpUntilFound` throws with the finder description. Diagnose through the + `integration-test-logs` artifact in CI, it holds the run log and a final logcat dump. + The timeout also takes a screenshot, it lands in `build/integration_test_screenshots/` + on the machine that ran the suite, named after the robot and the helper that timed out. + CI does not upload them. +- The app's own error handling ignores missing `.svg.vec` assets by design, the harness + filters those too. Any other FlutterError fails the test, that is intentional. +- Monero and wownero load native libraries on first open, give their steps generous + timeouts rather than retries. +- A driver that cannot attach is watched rather than waited out. Once flutter drive says + it is struggling, the attempt gets `VOID_GRACE` seconds (60 by default) to come back and + is then ended so the retry can start, instead of sitting there for the rest of + `TEST_TIMEOUT`. Run 31364715120 lost 20 minutes to two attempts that did exactly that. + 60 is well clear of a healthy attach, which took under a second on all 19 attempts that + worked in that same run, against 5 seconds for the warning to appear on the two that + did not. +- A suite that fails once in CI retries once with wiped app data. A red gate means the + same suite failed twice in a row. diff --git a/docs/SECURITY.md b/docs/SECURITY.md index e7c6baa02..b92493be5 100644 --- a/docs/SECURITY.md +++ b/docs/SECURITY.md @@ -1,12 +1,112 @@ # Security Policy -## Reporting a Vulnerability +Cake Wallet is developed by **Cake Labs LLC**. We take the security and privacy of +our users seriously and welcome reports from security researchers. -If you need to report a vulnerability, please either: +> **Cake Wallet is not affiliated with CAKE.com, Clockify, or security.cake.com.** +> Those services belong to unrelated companies. The only official vulnerability +> disclosure channels for Cake Wallet are the ones listed on this page and at +> https://cakewallet.com/security. -* Open a security advisory: https://github.com/cake-tech/cake_wallet/security/advisories/new -* Send an email to `dev@cakewallet.com` with details on the vulnerability +## Reporting a vulnerability -## Supported Versions +**Please do not open a public issue, pull request, or social-media post for a +security vulnerability.** Public disclosure before a fix is available puts users' +funds and privacy at risk. Use one of the private channels below and we will +coordinate a fix and disclosure with you. -As we don't maintain previous versions of the app, only the latest release for each platform is supported and any updates will bump the version number. +1. **GitHub private security advisory (preferred).** + [Report a vulnerability](https://github.com/cake-tech/cake_wallet/security/advisories/new). + This gives you a private, structured thread with the maintainers and is the + fastest way to reach us. +2. **Encrypted email.** Send details to **security@cakewallet.com**. For sensitive + reports, please encrypt with our PGP key: + - Key: https://cakewallet.com/.well-known/cakewallet-security.asc + - Fingerprint: `DC91 6520 0271 AC6A 0533 3D3C BFE7 D9A5 0E4D 3A0A` + +Both channels are monitored and automatically raise an alert in our internal +security channel, so reports will not be missed. + +### What to include + +- A clear description of the issue and its security impact. +- A working proof of concept is required. It must run directly against Cake + Wallet itself, using a release build or locally built version, and demonstrate + the reported behavior and security impact. Standalone Python code, + mathematical examples, or simulations that only reproduce the theory without + exercising Cake Wallet do not satisfy this requirement. +- Step-by-step reproduction instructions. +- Affected platforms (iOS, Android, macOS, Linux, Windows) and app version. +- Affected wallet types / chains, if applicable. +- Any relevant logs, addresses, or transaction IDs (for on-chain issues). + +If you used AI tooling to find or write up the report, please say so. + +### Communication expectations + +AI should not be used to generate comments when communicating with maintainers +and other contributors. Comments are expected to be written by humans. Comments +that are believed to be written by AI may be moderated. + +## Our commitment (safe harbor) + +We consider security research conducted in good faith under this policy to be +authorized. We will not pursue or support legal action against researchers who: + +- make a good-faith effort to avoid privacy violations, data destruction, and + interruption or degradation of our services; +- only interact with accounts they own or have explicit permission to access; and +- give us a reasonable opportunity to fix an issue before disclosing it publicly. + +If in doubt about whether an action is authorized, ask us first at +security@cakewallet.com. + +## What to expect + +- **Acknowledgement:** within **2 business days**. +- **Triage and initial assessment:** within **7 business days**. +- **Coordinated disclosure:** we aim to ship a fix and coordinate public + disclosure within **90 days** of the report. We will keep you updated on + progress and agree on a disclosure date with you. +- **Credit:** with your permission, we are happy to publicly credit you for the + report once a fix is released. + +## Scope + +**In scope:** the Cake Wallet and Monero.com applications and the code in this +repository and its sibling `cake-tech` repositories — anything that could lead to +loss of funds, exposure of keys or seeds, a privacy leak, or a +failed/incorrect transaction. + +**Out of scope:** issues in third-party services, exchange/swap providers, or +nodes we do not operate; reports generated solely by automated scanners without a +demonstrated impact; low-severity or informational issues on our marketing and +landing websites (for example reflected or self-XSS, missing security headers, +clickjacking on pages with no sensitive actions, or SPF/DMARC and cookie-flag +nitpicks) that do not affect the app or user funds; and social-engineering or +physical attacks. + +## Rewards + +At our **sole discretion**, we may offer a reward for a valid report. To be +eligible, a report must: + +- be submitted **privately** through one of the channels above (a GitHub private + security advisory or `security@cakewallet.com`) — anything disclosed publicly or + sent through other channels is not eligible; and +- identify a genuine vulnerability with real impact on users — typically loss of + funds, exposure of keys or seeds, a privacy leak, or a failed or incorrect + transaction. + +Trivial or low-impact findings are **not** eligible — for example, reflected XSS +or other low-severity issues on our marketing websites, missing security headers, +hardening or best-practice suggestions, automated-scanner output without a working +proof of concept, or already-known issues. There is no fixed bounty and no +guaranteed payout; whether a report qualifies, and any amount, are determined +solely by Cake Labs LLC. + +## Supported versions + +We do not maintain previous releases. Only the **latest release for each platform** +is supported; security fixes are delivered in new versions. Please keep Cake Wallet +up to date. diff --git a/docs/builds/ANDROID.md b/docs/builds/ANDROID.md index 4964cfec6..f74fb4d0a 100644 --- a/docs/builds/ANDROID.md +++ b/docs/builds/ANDROID.md @@ -18,8 +18,8 @@ In order to build the latest version of Cake Wallet, simply run the following: git clone --branch main https://github.com/cake-tech/cake_wallet.git # NOTE: Replace `main` with the latest release tag available at https://github.com/cake-tech/cake_wallet/releases/latest. cd cake_wallet -# docker build -t ghcr.io/cake-tech/cake_wallet:debian13-flutter3.32.0-ndkr28-go1.24.1-ruststablenightly . # Uncomment to build the docker image yourself instead of pulling it from the registry -docker run -v$(pwd):$(pwd) -w $(pwd) -i --rm ghcr.io/cake-tech/cake_wallet:debian13-flutter3.32.0-ndkr28-go1.24.1-ruststablenightly bash -x << EOF +# docker build -t ghcr.io/cake-tech/cake_wallet:debian13-flutter3.41.9-ndkr28-go1.24.1-ruststablenightly . # Uncomment to build the docker image yourself instead of pulling it from the registry +docker run -v$(pwd):$(pwd) -w $(pwd) -i --rm ghcr.io/cake-tech/cake_wallet:debian13-flutter3.41.9-ndkr28-go1.24.1-ruststablenightly bash -x << EOF set -x -e git config --global --add safe.directory '*' pushd scripts/android diff --git a/docs/builds/LINUX.md b/docs/builds/LINUX.md index 5f3367ffc..27f1f731e 100644 --- a/docs/builds/LINUX.md +++ b/docs/builds/LINUX.md @@ -20,8 +20,8 @@ In order to build the latest version of Cake Wallet, simply run the following: git clone --branch main https://github.com/cake-tech/cake_wallet.git # NOTE: Replace `main` with the latest release tag available at https://github.com/cake-tech/cake_wallet/releases/latest. cd cake_wallet -# docker build -t ghcr.io/cake-tech/cake_wallet:debian13-flutter3.32.0-ndkr28-go1.24.1-ruststablenightly . # Uncomment to build the docker image yourself instead of pulling it from the registry -docker run --privileged -v$(pwd):$(pwd) -w $(pwd) -i --rm ghcr.io/cake-tech/cake_wallet:debian13-flutter3.32.0-ndkr28-go1.24.1-ruststablenightly bash -x << EOF +# docker build -t ghcr.io/cake-tech/cake_wallet:debian13-flutter3.41.9-ndkr28-go1.24.1-ruststablenightly . # Uncomment to build the docker image yourself instead of pulling it from the registry +docker run --privileged -v$(pwd):$(pwd) -w $(pwd) -i --rm ghcr.io/cake-tech/cake_wallet:debian13-flutter3.41.9-ndkr28-go1.24.1-ruststablenightly bash -x << EOF set -x -e git config --global --add safe.directory '*' pushd scripts diff --git a/integration_test/components/common_test_cases.dart b/integration_test/components/common_test_cases.dart deleted file mode 100644 index e17fd5eb8..000000000 --- a/integration_test/components/common_test_cases.dart +++ /dev/null @@ -1,273 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:flutter_test/flutter_test.dart'; - -class CommonTestCases { - WidgetTester tester; - CommonTestCases(this.tester); - - Future isSpecificPage() async { - await tester.pumpAndSettle(); - hasType(); - } - - Future tapItemByKey( - String key, { - bool shouldPumpAndSettle = true, - int pumpDuration = 100, - Duration timeout = const Duration(seconds: 20), - }) async { - final widgetFinder = find.byKey(ValueKey(key)); - - // Wait for the widget to actually appear rather than pumping a flat 500ms - // and hoping. Screens that arrive behind an async transition (PIN setup's - // success button is the usual offender) are otherwise a coin flip, and the - // failure reads as "key missing" rather than "key not there YET". - // - // Deliberately a pump loop, not pumpAndSettle: the dashboard runs a - // perpetual sync-indicator animation, so settling never completes there. - final deadline = DateTime.now().add(timeout); - while (!tester.any(widgetFinder) && DateTime.now().isBefore(deadline)) { - await tester.pump(const Duration(milliseconds: 100)); - } - - expect(tester.any(widgetFinder), true, - reason: 'Widget with key "$key" should be visible ' - '(waited ${timeout.inSeconds}s)'); - - final widget = widgetFinder.first; - await tester.tap(widget, warnIfMissed: false); - shouldPumpAndSettle - ? await tester.pumpAndSettle(Duration(milliseconds: pumpDuration)) - : await tester.pump(); - } - - Future tapItemByFinder(Finder finder, {bool shouldPumpAndSettle = true}) async { - await tester.tap(finder); - shouldPumpAndSettle ? await tester.pumpAndSettle() : await tester.pump(); - } - - void hasText(String text, {bool hasWidget = true}) { - final textWidget = find.text(text); - expect(textWidget, hasWidget ? findsOneWidget : findsNothing); - } - - void hasTextAtLestOnce(String text, {bool hasWidget = true}) { - final textWidget = find.text(text); - expect(textWidget, hasWidget ? findsAny : findsNothing); - } - - void hasType() { - final typeWidget = find.byType(T); - expect(typeWidget, findsOneWidget); - } - - bool isKeyPresent(String key) { - final typeWidget = find.byKey(ValueKey(key)); - return typeWidget.tryEvaluate(); - } - - void hasValueKey(String key) { - final typeWidget = find.byKey(ValueKey(key)); - expect(typeWidget, findsOneWidget); - } - - Future swipePage({bool swipeRight = true}) async { - await tester.drag(find.byType(PageView), Offset(swipeRight ? -300 : 300, 0)); - await tester.pumpAndSettle(); - } - - Future swipeByPageKey({required String key, bool swipeRight = true}) async { - await tester.drag(find.byKey(ValueKey(key)), Offset(swipeRight ? -300 : 300, 0)); - await tester.pumpAndSettle(); - } - - Future goBack() async { - tester.printToConsole('Routing back to previous screen'); - final NavigatorState navigator = tester.state(find.byType(Navigator)); - navigator.pop(); - await tester.pumpAndSettle(); - } - - Future startGesture(String key, Offset gestureOffset) async { - await tester.pumpAndSettle(); - - final hasKey = isKeyPresent(key); - - tester.printToConsole("Has gestureKey: $hasKey"); - - if (!hasKey) return; - - final gesture = await tester.startGesture(tester.getCenter(find.byKey(ValueKey(key)))); - - // Drag to the left - await gesture.moveBy(gestureOffset); - - // End the gesture - await gesture.up(); - - await tester.pump(); - } - - Future dragUntilVisible( - String childKey, - String parentKey, { - int maxScrolls = 100, - double scrollStep = 50.0, - int maxReverseScrolls = 50, - }) async { - await tester.pumpAndSettle(); - - final itemFinder = find.byKey(ValueKey(childKey)); - final listFinder = find.byKey(ValueKey(parentKey)); - - // Check if the widget is already in the widget tree - if (tester.any(itemFinder)) { - // Widget is already built and in the tree - tester.printToConsole('Child is already present'); - return; - } - - // Find the Scrollable associated with the Parent Ad - final scrollableFinder = find.descendant( - of: listFinder, - matching: find.byType(Scrollable), - ); - - // Ensure that the Scrollable is found - expect( - scrollableFinder, - findsOneWidget, - reason: 'Scrollable descendant of the Parent Widget not found.', - ); - - // Get the initial scroll position - final scrollableState = tester.state(scrollableFinder); - double previousScrollPosition = scrollableState.position.pixels; - bool scrollDown = true; - bool reversedDirection = false; - bool found = false; - - int reverseScrollCount = 0; - - for (int scrolls = 0; scrolls < maxScrolls; scrolls++) { - await tester.pumpAndSettle(); - - // Check if the widget is visible - if (tester.any(itemFinder)) { - found = true; - break; - } - - // Log current state for debugging - tester.printToConsole('Scrolling ${scrollDown ? 'down' : 'up'}, attempt $scrolls'); - - // Stop if reverse scroll limit is exceeded - if (!scrollDown && reverseScrollCount >= maxReverseScrolls) { - tester.printToConsole('Maximum reverse scrolls reached. Widget not found.'); - break; - } - - // Perform scrolling in the current direction - await tester.drag( - scrollableFinder, - scrollDown ? Offset(0, -scrollStep) : Offset(0, scrollStep), - ); - await tester.pumpAndSettle(); - - // Update the scroll position after the drag - final currentScrollPosition = scrollableState.position.pixels; - - if (currentScrollPosition == previousScrollPosition) { - // Cannot scroll further in the current direction - if (reversedDirection) { - // We've already tried both directions - tester.printToConsole('Reached the scroll limit in both directions. Widget not found.'); - break; - } else { - // Reverse the scroll direction and reset reverse scroll count - scrollDown = !scrollDown; - reversedDirection = true; - reverseScrollCount = 0; - tester.printToConsole('Reached the end, reversing direction'); - } - } else { - // Update scroll position and reverse scroll count, incrementing only for reverse scrolling - previousScrollPosition = currentScrollPosition; - if (!scrollDown) reverseScrollCount++; - } - } - - if (!found) { - tester.printToConsole('Widget not found after $maxScrolls scrolls.'); - } - } - - Future scrollItemIntoView( - String itemKeyId, - double scrollPixels, - String scrollableFinderKey, - ) async { - final Finder itemFinder = find.byKey(ValueKey(itemKeyId)); - - final scrollableFinder = find.descendant( - of: find.byKey(ValueKey(scrollableFinderKey)), - matching: find.byType(Scrollable), - ); - - // Check if the item is already visible - if (tester.any(itemFinder)) { - tester.printToConsole('Item $itemKeyId is already visible'); - return; - } - - // Check if scrollable exists - if (!tester.any(scrollableFinder)) { - tester.printToConsole('Scrollable not found for $itemKeyId'); - return; - } - - try { - await tester.scrollUntilVisible( - itemFinder, - scrollPixels, - scrollable: scrollableFinder, - maxScrolls: 10, - ); - - // Wait for the scroll to complete - await tester.pumpAndSettle(Duration(milliseconds: 500)); - - // Verify the item is now visible - if (!tester.any(itemFinder)) { - tester.printToConsole('Item $itemKeyId not found after scrolling'); - } - } catch (e) { - tester.printToConsole('Could not scroll to $itemKeyId: $e'); - } - } - - Future enterText(String text, String editableTextKey) async { - final editableTextWidget = find.byKey(ValueKey((editableTextKey))); - - await tester.enterText(editableTextWidget, text); - - await tester.pumpAndSettle(); - } - - void findWidgetViaDescendant({ - required FinderBase of, - required FinderBase matching, - }) { - final textWidget = find.descendant(of: of, matching: matching); - - expect(textWidget, findsOneWidget); - } - - Future defaultSleepTime({int seconds = 2}) async => - await Future.delayed(Duration(seconds: seconds)); - - Future takeScreenshots(String screenshotName) async { - // Pausing this for now - // await (tester.binding as IntegrationTestWidgetsFlutterBinding).takeScreenshot(screenshotName); - } -} diff --git a/integration_test/components/common_test_constants.dart b/integration_test/components/common_test_constants.dart deleted file mode 100644 index 441b10f21..000000000 --- a/integration_test/components/common_test_constants.dart +++ /dev/null @@ -1,18 +0,0 @@ -import 'package:cw_core/crypto_currency.dart'; -import 'package:cw_core/wallet_type.dart'; - -class CommonTestConstants { - static final pin = [0, 8, 0, 1]; - static final String sendTestAmount = '0.00008'; - static final String sendTestFiatAmount = '1.00'; - static final String exchangeTestAmount = '0.01'; - // Hash Bags is Wownero-first and no longer ships Solana, so the defaults - // moved off SOL. WalletType.solana does not exist in this fork. - static final WalletType testWalletType = WalletType.wownero; - static final String testWalletName = 'Integrated Testing Wallet'; - static final CryptoCurrency sendTestReceiveCurrency = CryptoCurrency.wow; - static final CryptoCurrency exchangeTestReceiveCurrency = CryptoCurrency.btc; - static final CryptoCurrency exchangeTestDepositCurrency = CryptoCurrency.wow; - static final String testWalletAddress = - 'Wo3MWeKwtA918DU4c69hVSNgejdWFCRCuWjShRY66mJkU2Hv58eygJWDJS1MNa2Ge5M1WjUkGHJLAjaZYLBZnDh42Q2u1Es9J'; -} diff --git a/integration_test/components/common_test_flows.dart b/integration_test/components/common_test_flows.dart deleted file mode 100644 index 0821e0dd2..000000000 --- a/integration_test/components/common_test_flows.dart +++ /dev/null @@ -1,404 +0,0 @@ -import 'dart:io'; - -import 'package:hash_wallet/entities/seed_type.dart'; -import 'package:hash_wallet/reactions/wallet_utils.dart'; -import 'package:hash_wallet/wallet_types.g.dart'; -import 'package:cw_core/currency_for_wallet_type.dart'; -import 'package:cw_core/wallet_type.dart'; -import 'package:flutter/material.dart'; -import 'package:flutter_test/flutter_test.dart'; - -import 'package:hash_wallet/main.dart' as app; - -import '../robots/create_pin_welcome_page_robot.dart'; -import '../robots/new_wallet_page_robot.dart'; -import '../robots/new_wallet_type_page_robot.dart'; -import '../robots/pre_seed_page_robot.dart'; -import '../robots/restore_from_seed_or_key_robot.dart'; -import '../robots/restore_options_page_robot.dart'; -import '../robots/seed_verification_page_robot.dart'; -import '../robots/setup_pin_code_robot.dart'; -import '../robots/wallet_group_description_page_robot.dart'; -import '../robots/wallet_list_page_robot.dart'; -import '../robots/wallet_seed_page_robot.dart'; -import '../robots/welcome_page_robot.dart'; -import 'common_test_cases.dart'; -import 'package:hash_wallet/.secrets.g.dart' as secrets; - -import 'common_test_constants.dart'; - -class CommonTestFlows { - CommonTestFlows(this._tester) - : _commonTestCases = CommonTestCases(_tester), - _welcomePageRobot = WelcomePageRobot(_tester), - _preSeedPageRobot = PreSeedPageRobot(_tester), - _setupPinCodeRobot = SetupPinCodeRobot(_tester), - _newWalletPageRobot = NewWalletPageRobot(_tester), - _walletSeedPageRobot = WalletSeedPageRobot(_tester), - _walletListPageRobot = WalletListPageRobot(_tester), - _newWalletTypePageRobot = NewWalletTypePageRobot(_tester), - _restoreOptionsPageRobot = RestoreOptionsPageRobot(_tester), - _seedVerificationPageRobot = SeedVerificationPageRobot(_tester), - _createPinWelcomePageRobot = CreatePinWelcomePageRobot(_tester), - _restoreFromSeedOrKeysPageRobot = RestoreFromSeedOrKeysPageRobot(_tester), - _walletGroupDescriptionPageRobot = WalletGroupDescriptionPageRobot(_tester); - - final WidgetTester _tester; - final CommonTestCases _commonTestCases; - - final WelcomePageRobot _welcomePageRobot; - final PreSeedPageRobot _preSeedPageRobot; - final SetupPinCodeRobot _setupPinCodeRobot; - final NewWalletPageRobot _newWalletPageRobot; - final WalletSeedPageRobot _walletSeedPageRobot; - final WalletListPageRobot _walletListPageRobot; - final NewWalletTypePageRobot _newWalletTypePageRobot; - final RestoreOptionsPageRobot _restoreOptionsPageRobot; - final CreatePinWelcomePageRobot _createPinWelcomePageRobot; - final SeedVerificationPageRobot _seedVerificationPageRobot; - final RestoreFromSeedOrKeysPageRobot _restoreFromSeedOrKeysPageRobot; - final WalletGroupDescriptionPageRobot _walletGroupDescriptionPageRobot; - - //* ========== Handles flow to start the app afresh and accept disclaimer ============= - Future startAppFlow(Key key) async { - await app.main(topLevelKey: key); - - await _tester.pump(Duration(seconds: 2)); - - // // --------- Disclaimer Page ------------ - // // Tap checkbox to accept disclaimer - // await _disclaimerPageRobot.tapDisclaimerCheckbox(); - - // // Tap accept button - // await _disclaimerPageRobot.tapAcceptButton(); - } - - //* ========== Handles flow from welcome to creating a new wallet =============== - Future welcomePageToCreateNewWalletFlow( - WalletType walletTypeToCreate, - List walletPin, - ) async { - await _welcomeToCreateWalletPath(walletTypeToCreate, walletPin); - - await _generateNewWalletDetails(); - - await _confirmPreSeedInfo(); - - await _confirmWalletDetails(); - - await _verifyWalletSeed(); - } - - //* ========== Handles flow from welcome to restoring wallet from seeds =============== - Future welcomePageToRestoreWalletThroughSeedsFlow( - WalletType walletTypeToRestore, - String walletSeed, - List walletPin, - ) async { - await _welcomeToRestoreFromSeedsOrKeysPath(walletTypeToRestore, walletPin); - await _restoreFromSeeds(walletTypeToRestore, walletSeed); - } - - //* ========== Handles flow from welcome to restoring wallet from keys =============== - Future welcomePageToRestoreWalletThroughKeysFlow( - WalletType walletTypeToRestore, - List walletPin, - ) async { - await _welcomeToRestoreFromSeedsOrKeysPath(walletTypeToRestore, walletPin); - await _restoreFromKeys(); - } - - void confirmAllAvailableWalletTypeIconsDisplayCorrectly() { - for (var walletType in availableWalletTypes) { - final imageUrl = walletTypeToCryptoCurrency(walletType).iconPath; - - final walletIconFinder = find.image( - Image.asset( - imageUrl!, - width: 32, - height: 32, - ).image, - ); - - expect(walletIconFinder, findsAny); - } - } - - //* ========== Handles creating new wallet flow from wallet list/menu =============== - Future createNewWalletFromWalletMenu(WalletType walletTypeToCreate) async { - _tester.printToConsole('Creating ${walletTypeToCreate.name} Wallet'); - - await _tester.pumpAndSettle(Duration(milliseconds: 1000)); - - await _walletListPageRobot.navigateToCreateNewWalletPage(); - await _commonTestCases.defaultSleepTime(); - - await _selectWalletTypeForWallet(walletTypeToCreate); - await _commonTestCases.defaultSleepTime(); - - // ---- Wallet Group/New Seed Implementation Comes here - await _walletGroupDescriptionPageFlow(true, walletTypeToCreate); - - await _generateNewWalletDetails(); - - await _confirmPreSeedInfo(); - - await _confirmWalletDetails(); - - await _verifyWalletSeed(); - - await _commonTestCases.defaultSleepTime(); - } - - Future _walletGroupDescriptionPageFlow(bool isNewSeed, WalletType walletType) async { - if (!isBIP39Wallet(walletType)) return; - - await _walletGroupDescriptionPageRobot.isWalletGroupDescriptionPage(); - - if (isNewSeed) { - await _walletGroupDescriptionPageRobot.navigateToCreateNewSeedPage(); - } else { - await _walletGroupDescriptionPageRobot.navigateToChooseWalletGroup(); - } - } - - //* ========== Handles restore wallet flow from wallet list/menu =============== - Future restoreWalletFromWalletMenu(WalletType walletType, String walletSeed) async { - _tester.printToConsole('Restoring ${walletType.name} Wallet'); - await _walletListPageRobot.navigateToRestoreWalletOptionsPage(); - await _commonTestCases.defaultSleepTime(); - - if (!Platform.isLinux) await _restoreOptionsPageRobot.navigateToRestoreFromSeedsOrKeysPage(); - if (!Platform.isLinux) await _commonTestCases.defaultSleepTime(); - - await _selectWalletTypeForWallet(walletType); - await _commonTestCases.defaultSleepTime(); - - await _restoreFromSeeds(walletType, walletSeed); - await _commonTestCases.defaultSleepTime(); - } - - //* ========== Handles setting up pin code for wallet on first install =============== - Future setupPinCodeForWallet(List pin) async { - if (Platform.isLinux) return; - // ----------- SetupPinCode Page ------------- - // Confirm initial defaults - Widgets to be displayed etc - await _setupPinCodeRobot.isSetupPinCodePage(); - - await _setupPinCodeRobot.enterPinCode(pin); - await _setupPinCodeRobot.enterPinCode(pin); - await _setupPinCodeRobot.tapSuccessButton(); - } - - Future _welcomeToCreateWalletPath( - WalletType walletTypeToCreate, - List pin, - ) async { - await _createPinWelcomePageRobot.tapSetAPinButton(); - - await setupPinCodeForWallet(pin); - - await _welcomePageRobot.navigateToCreateNewWalletPage(); - - await _selectWalletTypeForWallet(walletTypeToCreate); - - if (_welcomePageRobot.hasNewSingleSeedButton()) { - await _welcomePageRobot.tapNewSingleSeed(); - } - } - - Future _welcomeToRestoreFromSeedsOrKeysPath( - WalletType walletTypeToRestore, - List pin, - ) async { - await _createPinWelcomePageRobot.tapSetAPinButton(); - - await setupPinCodeForWallet(pin); - - await _welcomePageRobot.navigateToRestoreWalletPage(); - - if (!Platform.isLinux) await _restoreOptionsPageRobot.navigateToRestoreFromSeedsOrKeysPage(); - - await _selectWalletTypeForWallet(walletTypeToRestore); - } - - //* ============ Handles New Wallet Type Page ================== - Future _selectWalletTypeForWallet(WalletType type) async { - // ----------- NewWalletType Page ------------- - _tester.printToConsole('Selecting wallet type: ${type.name}'); - - await _tester.pumpAndSettle(Duration(milliseconds: 1000)); - - await _newWalletTypePageRobot.findParticularWalletTypeInScrollableList(type); - - _tester.printToConsole('Tapping wallet type: ${type.name}'); - await _newWalletTypePageRobot.selectWalletType(type); - await _tester.pumpAndSettle(Duration(milliseconds: 500)); - - _tester.printToConsole('Pressing next button'); - await _newWalletTypePageRobot.onNextButtonPressed(); - - await _tester.pumpAndSettle(Duration(milliseconds: 2000)); - - _tester.printToConsole('Wallet type selection completed for: ${type.name}'); - } - - //* ============ Handles New Wallet Page ================== - Future _generateNewWalletDetails() async { - _tester.printToConsole('Attempting to find NewWalletPage...'); - - await _tester.pumpAndSettle(Duration(milliseconds: 1000)); - - await _newWalletPageRobot.isNewWalletPage(); - _tester.printToConsole('NewWalletPage found successfully'); - - await _newWalletPageRobot.generateWalletName(); - - if (Platform.isLinux) { - // manual pin input - await _restoreFromSeedOrKeysPageRobot - .enterPasswordForWalletRestore(CommonTestConstants.pin.join("")); - await _restoreFromSeedOrKeysPageRobot - .enterPasswordRepeatForWalletRestore(CommonTestConstants.pin.join("")); - } - - await _newWalletPageRobot.onNextButtonPressed(); - } - - //* ============ Handles Pre Seed Page ===================== - Future _confirmPreSeedInfo() async { - await _preSeedPageRobot.isPreSeedPage(); - - await _preSeedPageRobot.onConfirmButtonPressed(); - } - - //* ============ Handles Wallet Seed Page ================== - Future _confirmWalletDetails() async { - await _walletSeedPageRobot.isWalletSeedPage(); - - _walletSeedPageRobot.confirmWalletDetailsDisplayCorrectly(); - - _walletSeedPageRobot.confirmWalletSeedReminderDisplays(); - - // await _walletSeedPageRobot.onCopySeedsButtonPressed(); - - await _walletSeedPageRobot.onVerifySeedButtonPressed(); - } - - //* ============ Handles Wallet Seed Verification Page ================== - - Future _verifyWalletSeed() async { - await _seedVerificationPageRobot.isSeedVerificationPage(); - - _seedVerificationPageRobot.hasTitle(); - - await _seedVerificationPageRobot.verifyWalletSeeds(); - } - - //* Main Restore Actions - On the RestoreFromSeed/Keys Page - Restore from Seeds Action - Future _restoreFromSeeds(WalletType type, String walletSeed) async { - // ----------- RestoreFromSeedOrKeys Page ------------- - - await _restoreFromSeedOrKeysPageRobot.selectWalletNameFromAvailableOptions(); - await _restoreFromSeedOrKeysPageRobot.enterSeedPhraseForWalletRestore(walletSeed); - - final numberOfWords = walletSeed.split(' ').length; - - if (numberOfWords == 25 && (type == WalletType.monero)) { - await _restoreFromSeedOrKeysPageRobot - .chooseSeedTypeForMoneroOrWowneroWallets(MoneroSeedType.legacy); - - // Using a constant value of 2831400 for the blockheight as its the restore blockheight for our testing wallet - await _restoreFromSeedOrKeysPageRobot - .enterBlockHeightForWalletRestore(secrets.moneroTestWalletBlockHeight); - } - - if (Platform.isLinux) { - // manual pin input - await _restoreFromSeedOrKeysPageRobot - .enterPasswordForWalletRestore(CommonTestConstants.pin.join("")); - await _restoreFromSeedOrKeysPageRobot - .enterPasswordRepeatForWalletRestore(CommonTestConstants.pin.join("")); - } - - await _restoreFromSeedOrKeysPageRobot.onRestoreWalletButtonPressed(); - } - - //* Main Restore Actions - On the RestoreFromSeed/Keys Page - Restore from Keys Action - Future _restoreFromKeys() async { - await _commonTestCases.swipePage(); - await _commonTestCases.defaultSleepTime(); - - await _restoreFromSeedOrKeysPageRobot.selectWalletNameFromAvailableOptions( - isSeedFormEntry: false, - ); - - await _restoreFromSeedOrKeysPageRobot.enterSeedPhraseForWalletRestore(''); - await _restoreFromSeedOrKeysPageRobot.onRestoreWalletButtonPressed(); - } - - //* ====== Utility Function to get test wallet seeds for each wallet type ======== - String getWalletSeedsByWalletType(WalletType walletType) { - switch (walletType) { - case WalletType.monero: - return secrets.moneroTestWalletSeeds; - case WalletType.bitcoin: - return secrets.bitcoinTestWalletSeeds; - case WalletType.ethereum: - return secrets.ethereumTestWalletSeeds; - case WalletType.litecoin: - return secrets.litecoinTestWalletSeeds; - case WalletType.bitcoinCash: - return secrets.bitcoinCashTestWalletSeeds; - case WalletType.polygon: - return secrets.polygonTestWalletSeeds; - case WalletType.base: - return secrets.baseTestWalletSeeds; - case WalletType.arbitrum: - return secrets.arbitrumTestWalletSeeds; - case WalletType.bsc: - return secrets.bscTestWalletSeeds; - case WalletType.nano: - return secrets.nanoTestWalletSeeds; - case WalletType.wownero: - return secrets.wowneroTestWalletSeeds; - // Hash Bags does not ship SOL/TRX/ZEC/DCR/ZANO/Haven — those WalletType - // members no longer exist. dogecoin ships, but has no test seed in - // .secrets.g.dart. - case WalletType.dogecoin: - case WalletType.none: - case WalletType.banano: - throw Exception("Unable to get seeds for ${walletType}"); - } - } - - //* ====== Utility Function to get test receive address for each wallet type ======== - String getReceiveAddressByWalletType(WalletType walletType) { - switch (walletType) { - case WalletType.monero: - return secrets.moneroTestWalletReceiveAddress; - case WalletType.bitcoin: - return secrets.bitcoinTestWalletReceiveAddress; - case WalletType.ethereum: - return secrets.ethereumTestWalletReceiveAddress; - case WalletType.litecoin: - return secrets.litecoinTestWalletReceiveAddress; - case WalletType.bitcoinCash: - return secrets.bitcoinCashTestWalletReceiveAddress; - case WalletType.polygon: - return secrets.polygonTestWalletReceiveAddress; - case WalletType.base: - return secrets.baseTestWalletReceiveAddress; - case WalletType.arbitrum: - return secrets.arbitrumTestWalletReceiveAddress; - case WalletType.bsc: - return secrets.bscTestWalletReceiveAddress; - case WalletType.nano: - return secrets.nanoTestWalletReceiveAddress; - case WalletType.wownero: - return secrets.wowneroTestWalletReceiveAddress; - default: - return ''; - } - } -} diff --git a/integration_test/core/app_launcher.dart b/integration_test/core/app_launcher.dart new file mode 100644 index 000000000..eb84faf1b --- /dev/null +++ b/integration_test/core/app_launcher.dart @@ -0,0 +1,75 @@ +import "dart:async"; + +import "package:hash_wallet/main.dart" as app; +import "package:flutter/foundation.dart"; +import "package:flutter_test/flutter_test.dart"; + +import "benign_errors.dart"; + +void integrationTest(String description, Future Function(WidgetTester tester) body) { + testWidgets(description, (tester) async { + final completer = Completer(); + + final run = runZonedGuarded(() async { + try { + await body(tester); + + if (!completer.isCompleted) { + completer.complete(); + } + } catch (error, stack) { + if (!completer.isCompleted) { + completer.completeError(error, stack); + } + } + }, (error, stack) { + final kind = benignErrorKind(error.toString()); + + if (kind != null) { + debugPrint("Ignoring benign $kind background error: $error"); + return; + } + + if (completer.isCompleted) { + debugPrint("Background error after the test finished: $error"); + return; + } + + completer.completeError(error, stack); + }); + + unawaited(run); + + await completer.future; + }); +} + +class AppLauncher { + AppLauncher(this.tester); + + final WidgetTester tester; + + Future launchApp({required String testKey}) async { + final bindingHandler = FlutterError.onError; + + await app.main(topLevelKey: ValueKey(testKey)); + + _installTestErrorHandler(bindingHandler); + + await tester.pump(const Duration(seconds: 2)); + } + + void _installTestErrorHandler(FlutterExceptionHandler? bindingHandler) { + FlutterError.onError = (details) { + final reportedByTestFramework = details.library == "Flutter test framework"; + final kind = reportedByTestFramework ? null : benignErrorKind(details.exceptionAsString()); + + if (kind != null) { + debugPrint("Ignoring benign $kind error: ${details.exceptionAsString()}"); + return; + } + + bindingHandler?.call(details); + }; + } +} diff --git a/integration_test/core/base_robot.dart b/integration_test/core/base_robot.dart new file mode 100644 index 000000000..7ee2113d2 --- /dev/null +++ b/integration_test/core/base_robot.dart @@ -0,0 +1,238 @@ +import "dart:async"; + +import "package:flutter/material.dart"; +import "package:flutter_test/flutter_test.dart"; +import "package:integration_test/integration_test.dart"; + +import "benign_errors.dart"; + +abstract class BaseRobot { + BaseRobot(this.tester); + + final WidgetTester tester; + + Future isDisplayed(); + + void drainBackgroundErrors() { + final Object? pending = tester.takeException(); + + if (pending == null) { + return; + } + + final kind = benignErrorKind(pending.toString()); + + if (kind != null) { + tester.printToConsole("Ignoring benign $kind background error: $pending"); + return; + } + + throw TestFailure("Unhandled background error: $pending"); + } + + Future _pumpStep(Duration step) async { + await tester.pump(step); + + drainBackgroundErrors(); + } + + Future pumpUntilFound( + Finder finder, { + Duration timeout = const Duration(seconds: 30), + Duration step = const Duration(milliseconds: 100), + }) async { + final endTime = DateTime.now().add(timeout); + + while (DateTime.now().isBefore(endTime)) { + await _pumpStep(step); + + if (tester.any(finder)) { + return; + } + } + + await takeScreenshot("${runtimeType}_pump_until_found_timeout"); + throw TestFailure("Widget not found after ${timeout.inSeconds}s: $finder"); + } + + Future pumpUntilGone( + Finder finder, { + Duration timeout = const Duration(seconds: 30), + Duration step = const Duration(milliseconds: 100), + }) async { + final endTime = DateTime.now().add(timeout); + + while (DateTime.now().isBefore(endTime)) { + await _pumpStep(step); + + if (!tester.any(finder)) { + return; + } + } + + await takeScreenshot("${runtimeType}_pump_until_gone_timeout"); + throw TestFailure("Widget still present after ${timeout.inSeconds}s: $finder"); + } + + Future tapByKey(String key, {Duration timeout = const Duration(seconds: 30)}) async { + final finder = find.byKey(ValueKey(key)); + + await pumpUntilFound(finder, timeout: timeout); + + // A tap during a route transition lands on the animating overlay instead of the widget + await settle(max: const Duration(seconds: 2)); + + await tester.tap(finder.first, warnIfMissed: false); + await tester.pump(const Duration(milliseconds: 300)); + } + + Future tapWhenVisible( + Finder finder, { + Duration timeout = const Duration(seconds: 30), + }) async { + await pumpUntilFound(finder, timeout: timeout); + + await settle(max: const Duration(seconds: 2)); + + await tester.tap(finder.first, warnIfMissed: false); + await tester.pump(const Duration(milliseconds: 300)); + } + + Future enterTextByKey(String key, String text) async { + final finder = find.byKey(ValueKey(key)); + + await pumpUntilFound(finder); + + await tester.enterText(finder.first, text); + await tester.pump(const Duration(milliseconds: 300)); + } + + Future pumpUntil( + bool Function() condition, { + Duration timeout = const Duration(seconds: 30), + Duration step = const Duration(milliseconds: 100), + }) async { + final endTime = DateTime.now().add(timeout); + + while (DateTime.now().isBefore(endTime)) { + await _pumpStep(step); + + if (condition()) { + return true; + } + } + + return false; + } + + Future goBack() async { + final navigator = tester.state(find.byType(Navigator).last); + navigator.pop(); + + await tester.pump(const Duration(milliseconds: 500)); + } + + Future dismissModal() async { + final navigator = tester.state(find.byType(Navigator).first); + navigator.pop(); + + await tester.pump(const Duration(milliseconds: 500)); + } + + // We make this bounded instead of pumpAndSettle + Future settle({Duration max = const Duration(seconds: 3)}) async { + final endTime = DateTime.now().add(max); + + while (DateTime.now().isBefore(endTime)) { + await _pumpStep(const Duration(milliseconds: 100)); + + if (!tester.binding.hasScheduledFrame) { + return; + } + } + } + + Future isSpecificPage({Duration timeout = const Duration(seconds: 30)}) async { + await pumpUntilFound(find.byType(T), timeout: timeout); + + await settle(); + + hasType(); + } + + bool isKeyPresent(String key) => tester.any(find.byKey(ValueKey(key))); + + String describeScreen() { + final names = tester.allWidgets + .map((widget) => widget.runtimeType.toString()) + .where((name) => name.endsWith("Page") || name.endsWith("Sheet") || name.endsWith("Screen")) + .toSet() + .toList() + ..sort(); + + return names.join(", "); + } + + void hasType() { + expect(find.byType(T), findsOneWidget); + } + + void hasText(String text) { + expect(find.text(text), findsOneWidget); + } + + void hasTextAtLeastOnce(String text) { + expect(find.text(text), findsAny); + } + + String? textByKey(String key) { + final finder = find.byKey(ValueKey(key)); + + if (!tester.any(finder)) { + return null; + } + + return tester.widget(finder.first).data; + } + + static bool _surfaceConverted = false; + + Future takeScreenshot(String name) async { + try { + final binding = tester.binding; + + if (binding is! IntegrationTestWidgetsFlutterBinding) { + return; + } + + if (!_surfaceConverted) { + await binding.convertFlutterSurfaceToImage(); + _surfaceConverted = true; + } + + var finished = false; + + unawaited( + binding.takeScreenshot("${name}_${DateTime.now().millisecondsSinceEpoch}").then( + (_) => finished = true, + onError: (Object error) { + finished = true; + tester.printToConsole("Screenshot $name failed: $error"); + }, + ), + ); + + final deadline = DateTime.now().add(const Duration(seconds: 10)); + + while (!finished && DateTime.now().isBefore(deadline)) { + await tester.pump(const Duration(milliseconds: 100)); + } + + if (!finished) { + tester.printToConsole("Screenshot $name gave up after 10s"); + } + } catch (e) { + tester.printToConsole("Screenshot $name failed: $e"); + } + } +} diff --git a/integration_test/core/benign_errors.dart b/integration_test/core/benign_errors.dart new file mode 100644 index 000000000..4e812c4ef --- /dev/null +++ b/integration_test/core/benign_errors.dart @@ -0,0 +1,51 @@ +String? benignErrorKind(String message) { + if (_toleratedAssertions.any(message.contains)) { + return "assertion"; + } + + if (_toleratedAssets.any(message.contains)) { + return "asset"; + } + + if (_toleratedNetworkNoise.any(message.contains)) { + return "network"; + } + + return null; +} + +// Debug build assertions. The semantics ones are real accessibility bugs that already +// exist on dev, take them off this list once they are fixed. +const List _toleratedAssertions = [ + "overflowed by", + "minValue, and maxValue must be valid numbers", + "node.parent?._dirty", + "RenderBox was not laid out", +]; + +// HashImageWidget reports these before falling back to the svg +const List _toleratedAssets = [ + "Unable to load asset", + "NetworkImageLoadException", + "Failed to load network image", +]; + +// The same failures exception_handler.dart ignores +const List _toleratedNetworkNoise = [ + "SocketException", + "HttpException", + "ClientException", + "HandshakeException", + "TimeoutException", + "Failed host lookup", + "Connection closed", + "Connection reset by peer", + "Connection refused", + "Connection timed out", + "Operation timed out", + "Network is unreachable", + "No route to host", + "Broken pipe", + "Software caused connection abort", + "Connection closed before full header", +]; diff --git a/integration_test/core/funded_wallet.dart b/integration_test/core/funded_wallet.dart new file mode 100644 index 000000000..fdaf6871e --- /dev/null +++ b/integration_test/core/funded_wallet.dart @@ -0,0 +1,6 @@ +class FundedWallet { + const FundedWallet({required this.seed, this.passphrase = ""}); + + final String seed; + final String passphrase; +} diff --git a/integration_test/core/funded_wallets.dart b/integration_test/core/funded_wallets.dart new file mode 100644 index 000000000..6076fcec6 --- /dev/null +++ b/integration_test/core/funded_wallets.dart @@ -0,0 +1,5 @@ +import "funded_wallet.dart"; + +// Empty on purpose and it has to stay empty in git. The manual funds workflow overwrites +// this file from the FUNDS_SECRETS_FILE secret, keyed by WalletType name. +const Map> fundedWallets = {}; diff --git a/integration_test/core/funds_outcome.dart b/integration_test/core/funds_outcome.dart new file mode 100644 index 000000000..b8900fcd2 --- /dev/null +++ b/integration_test/core/funds_outcome.dart @@ -0,0 +1,46 @@ +import "package:flutter_test/flutter_test.dart"; + +class FundsOutcome { + static const marker = "FUNDS_RESULT"; + + static void ok(WidgetTester tester, String chain, String detail) => + _write(tester, chain, "ok", detail); + + static void empty(WidgetTester tester, String chain, String detail) => + _write(tester, chain, "empty", detail); + + static void waiting(WidgetTester tester, String chain, String detail) => + _write(tester, chain, "waiting", detail); + + static void failed(WidgetTester tester, String chain, String detail) => + _write(tester, chain, statusFor(detail), detail); + + static String statusFor(String detail) { + if (_isWalletBalanceLow(detail)) { + return "empty"; + } + + return _isChainTooSlowToSync(detail) ? "waiting" : "failed"; + } + + static bool needsFurtherReview(String detail) => statusFor(detail) == "failed"; + + static bool _isChainTooSlowToSync(String detail) => + detail.toLowerCase().contains("never became ready to send"); + + // Low wallet balance is not a failure, we just need to add fundsss + static bool _isWalletBalanceLow(String detail) { + final wording = detail.toLowerCase(); + + return wording.contains("not enough") || + wording.contains("wrong balance") || + wording.contains("insufficient") || + wording.contains("no spendable"); + } + + static void _write(WidgetTester tester, String chain, String status, String detail) { + final clean = detail.replaceAll("\n", " ").replaceAll("|", "/").trim(); + + tester.printToConsole("$marker|$chain|$status|$clean"); + } +} diff --git a/integration_test/core/test_config.dart b/integration_test/core/test_config.dart new file mode 100644 index 000000000..c93db5291 --- /dev/null +++ b/integration_test/core/test_config.dart @@ -0,0 +1,112 @@ +import "package:hash_wallet/wallet_types.g.dart"; +import "package:cw_core/wallet_type.dart"; + +import "test_wallets.dart"; + +class TestConfig { + static final List pin = [0, 8, 0, 1]; + + static const bool isCIBuild = bool.fromEnvironment("CI_BUILD"); + + static const String _walletTypesOverride = String.fromEnvironment("TEST_WALLET_TYPES"); + + // Solana and ethereum cover the cheap key-derivation chains, + // bitcoin covers the electrum family and monero covers the native-library chains. + static final List _representativeWalletTypes = [ + WalletType.solana, + WalletType.ethereum, + WalletType.bitcoin, + WalletType.monero, + ]; + + static List get walletTypesUnderTest { + if (_walletTypesOverride == "all") { + return availableWalletTypes; + } + + if (_walletTypesOverride.isNotEmpty) { + return _walletTypesOverride + .split(",") + .map((name) => WalletType.values.byName(name.trim())) + .where(availableWalletTypes.contains) + .toList(); + } + + return _representativeWalletTypes.where(availableWalletTypes.contains).toList(); + } + + static const String _fundsFlows = String.fromEnvironment("FLOWS", defaultValue: "all"); + + static const String _spend = String.fromEnvironment("SPEND", defaultValue: "false"); + + static const String _swapFrom = String.fromEnvironment("SWAP_FROM"); + + static const String swapReceive = String.fromEnvironment("SWAP_RECEIVE"); + + static WalletType swapDepositType(List funded) => + _swapFrom.isEmpty ? funded.first : _walletTypeByName(_swapFrom, "SWAP_FROM"); + + static bool shouldRunFundsFlow(String flow) => _fundsFlows == "all" || _fundsFlows == flow; + + static bool shouldDryRun(String flow) => shouldRunFundsFlow(flow); + + static bool shouldSpend(String flow) => _spend == "true" && shouldRunFundsFlow(flow); + + static const String _fundedChainsOverride = String.fromEnvironment( + "CHAINS", + defaultValue: "auto", + ); + + static List get fundedWalletTypesUnderTest { + if (_fundedChainsOverride == "auto") { + return TestWallets.fundedWalletTypes; + } + + return _fundedChainsOverride + .split(",") + .map((name) => _walletTypeByName(name, "CHAINS")) + .where((type) => TestWallets.fundedWalletsFor(type).isNotEmpty) + .toList(); + } + + static const Map _fundsSendAmounts = { + "solana": "0.0001", + "ethereum": "0.00001", + "polygon": "0.01", + "base": "0.00001", + "arbitrum": "0.00001", + "bsc": "0.000001", + "tron": "1", + "bitcoin": "0.00001", + "litecoin": "0.001", + "bitcoinCash": "0.0002", + "dogecoin": "2", + "monero": "0.0001", + "wownero": "0.1", + "zcash": "0.0001", + "nano": "0.000001", + "decred": "0.001", + "zano": "0.001", + }; + + static String fundsSendAmountFor(WalletType type) => _fundsSendAmounts[type.name] ?? "0.0001"; + + // Making this two hours to accommodate monero that takes a longgg time syncing + // also, this would only affect the funded tests which need actual funds to test + static const Duration walletSyncBudget = Duration(hours: 2); + + static WalletType _walletTypeByName(String name, String input) { + final trimmed = name.trim(); + + final type = WalletType.values.where((value) => value.name == trimmed).toList(); + + if (type.isEmpty) { + throw ArgumentError( + 'Unknown wallet type "$trimmed" in $input, ' + "valid names: ${WalletType.values.map((value) => value.name).join(", ")}", + ); + } + + return type.first; + } +} diff --git a/integration_test/core/test_wallets.dart b/integration_test/core/test_wallets.dart new file mode 100644 index 000000000..68ad98edb --- /dev/null +++ b/integration_test/core/test_wallets.dart @@ -0,0 +1,106 @@ +import "package:hash_wallet/.secrets.g.dart" as secrets; +import "package:hash_wallet/wallet_types.g.dart"; +import "package:cw_core/wallet_type.dart"; + +import "funded_wallet.dart"; +import "funded_wallets.dart"; + +class TestWallets { + static List get fundedWalletTypes => + availableWalletTypes.where((type) => fundedWalletsFor(type).isNotEmpty).toList(); + + static List fundedWalletsFor(WalletType type) => + (fundedWallets[type.name] ?? []).where((wallet) => wallet.seed.trim().isNotEmpty).toList(); + + static String seedFor(WalletType type) { + switch (type) { + case WalletType.monero: + return secrets.moneroTestWalletSeeds; + case WalletType.bitcoin: + return secrets.bitcoinTestWalletSeeds; + case WalletType.ethereum: + return secrets.ethereumTestWalletSeeds; + case WalletType.litecoin: + return secrets.litecoinTestWalletSeeds; + case WalletType.bitcoinCash: + return secrets.bitcoinCashTestWalletSeeds; + case WalletType.polygon: + return secrets.polygonTestWalletSeeds; + case WalletType.solana: + return secrets.solanaTestWalletSeeds; + case WalletType.base: + return secrets.baseTestWalletSeeds; + case WalletType.arbitrum: + return secrets.arbitrumTestWalletSeeds; + case WalletType.bsc: + return secrets.bscTestWalletSeeds; + case WalletType.robinhood: + return secrets.robinhoodTestWalletSeeds; + case WalletType.tron: + return secrets.tronTestWalletSeeds; + case WalletType.nano: + return secrets.nanoTestWalletSeeds; + case WalletType.wownero: + return secrets.wowneroTestWalletSeeds; + case WalletType.zano: + return secrets.zanoTestWalletSeeds; + case WalletType.decred: + return secrets.decredTestWalletSeeds; + case WalletType.dogecoin: + return secrets.dogeTestWalletSeeds; + case WalletType.zcash: + return secrets.zcashTestWalletSeeds; + case WalletType.none: + case WalletType.haven: + case WalletType.banano: + throw Exception("No test wallet seed available for ${type.name}"); + } + } + + static String receiveAddressFor(WalletType type) { + switch (type) { + case WalletType.monero: + return secrets.moneroTestWalletReceiveAddress; + case WalletType.bitcoin: + return secrets.bitcoinTestWalletReceiveAddress; + case WalletType.ethereum: + return secrets.ethereumTestWalletReceiveAddress; + case WalletType.litecoin: + return secrets.litecoinTestWalletReceiveAddress; + case WalletType.bitcoinCash: + return secrets.bitcoinCashTestWalletReceiveAddress; + case WalletType.polygon: + return secrets.polygonTestWalletReceiveAddress; + case WalletType.base: + return secrets.baseTestWalletReceiveAddress; + case WalletType.arbitrum: + return secrets.arbitrumTestWalletReceiveAddress; + case WalletType.bsc: + return secrets.bscTestWalletReceiveAddress; + case WalletType.robinhood: + return secrets.robinhoodTestWalletReceiveAddress; + case WalletType.solana: + return secrets.solanaTestWalletReceiveAddress; + case WalletType.tron: + return secrets.tronTestWalletReceiveAddress; + case WalletType.nano: + return secrets.nanoTestWalletReceiveAddress; + case WalletType.wownero: + return secrets.wowneroTestWalletReceiveAddress; + case WalletType.zano: + case WalletType.decred: + case WalletType.dogecoin: + case WalletType.zcash: + case WalletType.none: + case WalletType.haven: + case WalletType.banano: + return ""; + } + } + + static String get moneroLegacySeed => secrets.moneroLegacyTestWalletSeeds; + + static String get moneroRestoreBlockHeight => secrets.moneroTestWalletBlockHeight; + + static String get zcashRestoreHeight => secrets.zcashTestWalletRestoreHeight; +} diff --git a/integration_test/flows/auth_flows.dart b/integration_test/flows/auth_flows.dart new file mode 100644 index 000000000..259906870 --- /dev/null +++ b/integration_test/flows/auth_flows.dart @@ -0,0 +1,41 @@ +import "package:flutter_test/flutter_test.dart"; + +import "../core/test_config.dart"; +import "../robots/auth_page_robot.dart"; + +class AuthFlows { + AuthFlows(this.tester) : _authPageRobot = AuthPageRobot(tester); + + final WidgetTester tester; + final AuthPageRobot _authPageRobot; + + Future authenticateWithPin({List? pin, bool required = true}) async { + final prompted = await _waitForPinWidget( + timeout: required ? const Duration(seconds: 30) : const Duration(seconds: 5), + ); + + if (!prompted) { + expect(required, false, reason: "The pin was never asked for"); + + return; + } + + await _authPageRobot.enterPinCode(pin ?? TestConfig.pin); + } + + bool isPinWidgetShown() => _authPageRobot.onAuthPage(); + + Future _waitForPinWidget({Duration timeout = const Duration(seconds: 30)}) async { + final endTime = DateTime.now().add(timeout); + + while (DateTime.now().isBefore(endTime)) { + await tester.pump(const Duration(milliseconds: 100)); + + if (_authPageRobot.onAuthPage()) { + return true; + } + } + + return false; + } +} diff --git a/integration_test/flows/funds_flows.dart b/integration_test/flows/funds_flows.dart new file mode 100644 index 000000000..7969968fc --- /dev/null +++ b/integration_test/flows/funds_flows.dart @@ -0,0 +1,155 @@ +import "package:hash_wallet/di.dart"; +import "package:hash_wallet/store/app_store.dart"; +import "package:cw_core/sync_status.dart"; +import "package:cw_core/wallet_type.dart"; +import "package:flutter/material.dart"; +import "package:flutter_test/flutter_test.dart"; + +import "../core/test_config.dart"; +import "../core/test_wallets.dart"; +import "../robots/home_page_robot.dart"; +import "../robots/new_dashboard_robot.dart"; +import "../robots/new_send_page_robot.dart"; +import "onboarding_flows.dart"; + +class FundsFlows { + FundsFlows(this.tester) + : _onboardingFlows = OnboardingFlows(tester), + _dashboardRobot = NewDashboardRobot(tester), + _homePageRobot = HomePageRobot(tester), + _sendRobot = NewSendPageRobot(tester); + + final WidgetTester tester; + final OnboardingFlows _onboardingFlows; + final NewDashboardRobot _dashboardRobot; + final HomePageRobot _homePageRobot; + final NewSendPageRobot _sendRobot; + + bool _hasRestoredAnyWallet = false; + + Future openFundedWallet(WalletType type) async { + final wallets = TestWallets.fundedWalletsFor(type); + + for (int index = 0; index < wallets.length; index++) { + final wallet = wallets[index]; + + tester.printToConsole("Trying funded wallet ${index + 1}/${wallets.length} for ${type.name}"); + + if (_hasRestoredAnyWallet) { + await _dashboardRobot.openWalletsTab(); + + final isWalletListReady = await _dashboardRobot.pumpUntil( + () => + tester.any(find.byKey(const ValueKey("wallet_list_page_restore_wallet_button_key"))), + timeout: const Duration(seconds: 60), + ); + + expect( + isWalletListReady, + true, + reason: "The wallet list restore button didn't show before ${type.name}, " + "Currently have: ${_dashboardRobot.describeScreen()} on screen", + ); + + await _onboardingFlows.restoreAdditionalWalletFromWalletList( + type, + seed: wallet.seed, + passphrase: wallet.passphrase, + ); + } else { + await _onboardingFlows.restoreFirstWalletFromSeed( + type, + seed: wallet.seed, + passphrase: wallet.passphrase, + ); + } + + _hasRestoredAnyWallet = true; + + await _waitForDashboard(type); + await _homePageRobot.isDisplayed(); + + if (await _hasSpendableBalance()) { + return true; + } + + tester.printToConsole("Wallet ${index + 1} for ${type.name} has no spendable balance"); + } + + return false; + } + + Future returnToHome({int maxPops = 6}) async { + for (int attempt = 0; attempt <= maxPops; attempt++) { + if (await _homePageRobot.pumpUntil( + () => _isHomeInFront, + timeout: const Duration(seconds: 3), + )) { + return true; + } + + try { + await _homePageRobot.dismissModal(); + } catch (e) { + tester.printToConsole("Nothing left to pop while returning home: $e"); + break; + } + } + + return _isHomeInFront; + } + + Future _waitForDashboard(WalletType type) async { + final reached = await _dashboardRobot.pumpUntil( + () => tester.any(find.byKey(const ValueKey("new_dashboard_page_key"))), + timeout: TestConfig.walletSyncBudget, + ); + + if (reached) { + return; + } + + final status = getIt.get().wallet?.syncStatus; + final stillSyncing = status is SyncingSyncStatus || status is SyncronizingSyncStatus; + + throw TestFailure( + stillSyncing + ? "${type.name} never became ready to send, it is still syncing (${status.runtimeType})" + : "${type.name} never reached the dashboard, its status was ${status.runtimeType}", + ); + } + + bool get _isHomeInFront => _homePageRobot.isInFront && !_sendRobot.isSendFlowOpen; + + Future _hasSpendableBalance({Duration timeout = TestConfig.walletSyncBudget}) async { + final appStore = getIt.get(); + final endTime = DateTime.now().add(timeout); + DateTime? syncedSince; + + while (DateTime.now().isBefore(endTime)) { + await tester.pump(const Duration(milliseconds: 250)); + + final wallet = appStore.wallet; + + if (wallet == null) { + continue; + } + + if (wallet.balance.values.any((balance) => balance.available.sign > 0)) { + return true; + } + + if (wallet.syncStatus is SyncedSyncStatus) { + syncedSince ??= DateTime.now(); + + if (DateTime.now().difference(syncedSince) > const Duration(seconds: 15)) { + return false; + } + } else { + syncedSince = null; + } + } + + return false; + } +} diff --git a/integration_test/flows/onboarding_flows.dart b/integration_test/flows/onboarding_flows.dart new file mode 100644 index 000000000..10231fab8 --- /dev/null +++ b/integration_test/flows/onboarding_flows.dart @@ -0,0 +1,267 @@ +import "dart:io"; + +import "package:hash_wallet/entities/seed_type.dart"; +import "package:hash_wallet/reactions/wallet_utils.dart"; +import "package:cw_core/wallet_type.dart"; +import "package:flutter_test/flutter_test.dart"; + +import "../core/test_config.dart"; +import "../core/test_wallets.dart"; +import "../robots/create_pin_welcome_page_robot.dart"; +import "../robots/lightning_username_page_robot.dart"; +import "../robots/new_wallet_page_robot.dart"; +import "../robots/new_wallet_type_page_robot.dart"; +import "../robots/pre_seed_page_robot.dart"; +import "../robots/restore_from_seed_or_key_robot.dart"; +import "../robots/restore_options_page_robot.dart"; +import "../robots/seed_verification_page_robot.dart"; +import "../robots/setup_pin_code_robot.dart"; +import "../robots/wallet_group_description_page_robot.dart"; +import "../robots/wallet_group_existing_seed_page_robot.dart"; +import "../robots/wallet_groups_display_page_robot.dart"; +import "../robots/wallet_list_page_robot.dart"; +import "../robots/wallet_seed_page_robot.dart"; +import "../robots/welcome_page_robot.dart"; + +class OnboardingFlows { + OnboardingFlows(this.tester) + : _welcomePageRobot = WelcomePageRobot(tester), + _preSeedPageRobot = PreSeedPageRobot(tester), + _setupPinCodeRobot = SetupPinCodeRobot(tester), + _newWalletPageRobot = NewWalletPageRobot(tester), + _walletSeedPageRobot = WalletSeedPageRobot(tester), + _walletListPageRobot = WalletListPageRobot(tester), + _newWalletTypePageRobot = NewWalletTypePageRobot(tester), + _seedVerificationPageRobot = SeedVerificationPageRobot(tester), + _createPinWelcomePageRobot = CreatePinWelcomePageRobot(tester), + _restoreOptionsPageRobot = RestoreOptionsPageRobot(tester), + _restoreFromSeedOrKeysPageRobot = RestoreFromSeedOrKeysPageRobot(tester), + _lightningUsernamePageRobot = LightningUsernamePageRobot(tester), + _walletGroupDescriptionPageRobot = WalletGroupDescriptionPageRobot(tester), + _walletGroupsDisplayPageRobot = WalletGroupsDisplayPageRobot(tester), + _walletGroupExistingSeedPageRobot = WalletGroupExistingSeedPageRobot(tester); + + final WidgetTester tester; + + final WelcomePageRobot _welcomePageRobot; + final PreSeedPageRobot _preSeedPageRobot; + final SetupPinCodeRobot _setupPinCodeRobot; + final NewWalletPageRobot _newWalletPageRobot; + final WalletSeedPageRobot _walletSeedPageRobot; + final WalletListPageRobot _walletListPageRobot; + final NewWalletTypePageRobot _newWalletTypePageRobot; + final SeedVerificationPageRobot _seedVerificationPageRobot; + final CreatePinWelcomePageRobot _createPinWelcomePageRobot; + final RestoreOptionsPageRobot _restoreOptionsPageRobot; + final RestoreFromSeedOrKeysPageRobot _restoreFromSeedOrKeysPageRobot; + final LightningUsernamePageRobot _lightningUsernamePageRobot; + final WalletGroupDescriptionPageRobot _walletGroupDescriptionPageRobot; + final WalletGroupsDisplayPageRobot _walletGroupsDisplayPageRobot; + final WalletGroupExistingSeedPageRobot _walletGroupExistingSeedPageRobot; + + Future createFirstWallet(WalletType type, {List? pin}) async { + await _createPinWelcomePageRobot.tapSetAPinButton(); + + await setupPinCode(pin ?? TestConfig.pin); + + await _welcomePageRobot.navigateToCreateNewWalletPage(); + + await _selectWalletType(type); + + if (_welcomePageRobot.hasNewSingleSeedButton()) { + await _welcomePageRobot.tapNewSingleSeed(); + } + + await _completeWalletCreationSteps(type); + } + + Future createAdditionalWalletFromWalletList(WalletType type) async { + tester.printToConsole("Creating ${type.name} wallet"); + + await startCreatingWalletFromWalletList(type); + + await _completeWalletCreationSteps(type); + } + + Future createWalletInGroupOf(WalletType type, String existingWalletName) async { + tester.printToConsole("Adding a ${type.name} wallet to the seed of $existingWalletName"); + + await _walletListPageRobot.navigateToCreateNewWalletPage(); + + await _selectWalletType(type); + + await _walletGroupDescriptionPageRobot.isDisplayed(); + await _walletGroupDescriptionPageRobot.navigateToChooseWalletGroup(); + + await _walletGroupsDisplayPageRobot.isDisplayed(); + await _walletGroupsDisplayPageRobot.selectWallet(existingWalletName); + await _walletGroupsDisplayPageRobot.continueWithSelectedSeed(); + + await _newWalletPageRobot.isDisplayed(); + await _newWalletPageRobot.generateWalletName(); + await _newWalletPageRobot.onNextButtonPressed(); + + await _walletGroupExistingSeedPageRobot.isDisplayed(); + await _walletGroupExistingSeedPageRobot.openWallet(); + } + + Future startCreatingWalletFromWalletList(WalletType type) async { + await _walletListPageRobot.navigateToCreateNewWalletPage(); + + await _selectWalletType(type); + + if (isBIP39Wallet(type)) { + await _walletGroupDescriptionPageRobot.isDisplayed(); + await _walletGroupDescriptionPageRobot.navigateToCreateNewSeedPage(); + } + + await _newWalletPageRobot.isDisplayed(); + } + + Future restoreFirstWalletFromSeed( + WalletType type, { + String? seed, + String passphrase = "", + List? pin, + }) async { + await startRestoringFirstWallet(type, pin: pin); + + await _restoreFromSeed(type, seed ?? TestWallets.seedFor(type), passphrase); + } + + Future startRestoringFirstWallet(WalletType type, {List? pin}) async { + await _createPinWelcomePageRobot.tapSetAPinButton(); + + await setupPinCode(pin ?? TestConfig.pin); + + await _welcomePageRobot.navigateToRestoreWalletPage(); + + // Desktop skips the restore options page and goes straight to the type list. + if (!Platform.isLinux) { + await _restoreOptionsPageRobot.navigateToRestoreFromSeedsOrKeysPage(); + } + + await _selectWalletType(type); + + await _restoreFromSeedOrKeysPageRobot.isDisplayed(); + } + + Future restoreAdditionalWalletFromWalletList( + WalletType type, { + String? seed, + String passphrase = "", + }) async { + tester.printToConsole("Restoring ${type.name} wallet"); + + await _walletListPageRobot.navigateToRestoreWalletOptionsPage(); + + if (!Platform.isLinux) { + await _restoreOptionsPageRobot.navigateToRestoreFromSeedsOrKeysPage(); + } + + await _selectWalletType(type); + + await _restoreFromSeed(type, seed ?? TestWallets.seedFor(type), passphrase); + } + + Future _restoreFromSeed(WalletType type, String seed, String passphrase) async { + await _restoreFromSeedOrKeysPageRobot.selectWalletNameFromAvailableOptions(); + await _restoreFromSeedOrKeysPageRobot.enterSeedPhraseForWalletRestore(seed); + + if (passphrase.isNotEmpty) { + await _restoreFromSeedOrKeysPageRobot.enablePassphrase(); + } + + // 25 word monero seeds are legacy seeds and need their restore block height entered. + if (seed.split(" ").length == 25 && type == WalletType.monero) { + await _restoreFromSeedOrKeysPageRobot + .chooseSeedTypeForMoneroOrWowneroWallets(MoneroSeedType.legacy); + await _restoreFromSeedOrKeysPageRobot + .enterBlockHeightForWalletRestore(TestWallets.moneroRestoreBlockHeight); + } + + if (type == WalletType.zcash && TestWallets.zcashRestoreHeight.isNotEmpty) { + await _restoreFromSeedOrKeysPageRobot + .enterBlockHeightForWalletRestore(TestWallets.zcashRestoreHeight); + } + + if (Platform.isLinux) { + final password = TestConfig.pin.join(""); + await _restoreFromSeedOrKeysPageRobot.enterPasswordForWalletRestore(password); + await _restoreFromSeedOrKeysPageRobot.enterPasswordRepeatForWalletRestore(password); + } + + await _restoreFromSeedOrKeysPageRobot.onRestoreWalletButtonPressed(); + + if (passphrase.isNotEmpty) { + await _restoreFromSeedOrKeysPageRobot.enterPassphrase(passphrase); + } + } + + Future setupPinCode(List pin) async { + // Desktop builds take a wallet password on the new wallet form instead of a pin. + if (Platform.isLinux) { + return; + } + + await _setupPinCodeRobot.isDisplayed(); + + await _setupPinCodeRobot.enterPinCode(pin); + await _setupPinCodeRobot.enterPinCode(pin); + await _setupPinCodeRobot.tapSuccessButton(); + } + + Future _selectWalletType(WalletType type) async { + await _newWalletTypePageRobot.isDisplayed(); + + await _newWalletTypePageRobot.findParticularWalletTypeInScrollableList(type); + await _newWalletTypePageRobot.selectWalletType(type); + } + + Future _completeWalletCreationSteps(WalletType type) async { + await _generateNewWalletDetails(); + + await _preSeedPageRobot.isDisplayed(); + await _preSeedPageRobot.onConfirmButtonPressed(); + + await _confirmWalletSeedDetails(); + + await _verifyWalletSeed(); + + if (type == WalletType.bitcoin) { + await _lightningUsernamePageRobot.isDisplayed(); + await _lightningUsernamePageRobot.tapSkip(); + } + } + + Future _generateNewWalletDetails() async { + await _newWalletPageRobot.isDisplayed(); + + await _newWalletPageRobot.generateWalletName(); + + if (Platform.isLinux) { + final password = TestConfig.pin.join(""); + await _restoreFromSeedOrKeysPageRobot.enterPasswordForWalletRestore(password); + await _restoreFromSeedOrKeysPageRobot.enterPasswordRepeatForWalletRestore(password); + } + + await _newWalletPageRobot.onNextButtonPressed(); + } + + Future _confirmWalletSeedDetails() async { + await _walletSeedPageRobot.isDisplayed(); + + _walletSeedPageRobot.confirmWalletDetailsDisplayCorrectly(); + _walletSeedPageRobot.confirmWalletSeedReminderDisplays(); + + await _walletSeedPageRobot.onVerifySeedButtonPressed(); + } + + Future _verifyWalletSeed() async { + await _seedVerificationPageRobot.isDisplayed(); + + _seedVerificationPageRobot.hasTitle(); + + await _seedVerificationPageRobot.verifyWalletSeeds(); + } +} diff --git a/integration_test/flows/wallet_flows.dart b/integration_test/flows/wallet_flows.dart new file mode 100644 index 000000000..ec5f41981 --- /dev/null +++ b/integration_test/flows/wallet_flows.dart @@ -0,0 +1,59 @@ +import "package:hash_wallet/di.dart"; +import "package:hash_wallet/store/app_store.dart"; +import "package:flutter/material.dart"; +import "package:flutter_test/flutter_test.dart"; + +import "../robots/new_dashboard_robot.dart"; +import "auth_flows.dart"; + +class WalletFlows { + WalletFlows(this.tester) + : _authFlows = AuthFlows(tester), + _dashboardRobot = NewDashboardRobot(tester); + + final WidgetTester tester; + final AuthFlows _authFlows; + final NewDashboardRobot _dashboardRobot; + + Future _expandWalletGroups({int maxGroups = 10}) async { + for (int index = 0; index < maxGroups; index++) { + final tile = find.byKey(ValueKey("group_wallets_expansion_tile_widget_$index")); + + if (!tester.any(tile)) { + return; + } + + await tester.tap(tile, warnIfMissed: false); + await tester.pump(const Duration(milliseconds: 300)); + } + } + + Future switchToWallet(String name) async { + await _dashboardRobot.openWalletsTab(); + + final walletFinder = find.text(name); + + // Wallets sharing a seed are listed under their group's name, so the wallet itself is + // not on screen until that group is opened. + if (!tester.any(walletFinder)) { + await _expandWalletGroups(); + } + + await _dashboardRobot.pumpUntilFound(walletFinder); + + await tester.tap(walletFinder.first, warnIfMissed: false); + await tester.pump(const Duration(milliseconds: 500)); + + await _authFlows.authenticateWithPin(required: false); + + // The row does nothing at all when that wallet is already the open one, and the load + // itself runs behind a progress overlay, so the store is the only thing that tells us + // the switch happened. + final switched = await _dashboardRobot.pumpUntil( + () => getIt.get().wallet?.name == name, + timeout: const Duration(minutes: 2), + ); + + expect(switched, true, reason: "Wallet never became $name after tapping its row"); + } +} diff --git a/integration_test/funds_related_tests.dart b/integration_test/funds_related_tests.dart deleted file mode 100644 index 5aca6efc7..000000000 --- a/integration_test/funds_related_tests.dart +++ /dev/null @@ -1,96 +0,0 @@ -import 'package:flutter/foundation.dart'; -import 'package:flutter_test/flutter_test.dart'; -import 'package:integration_test/integration_test.dart'; - -import 'components/common_test_constants.dart'; -import 'components/common_test_flows.dart'; -import 'robots/auth_page_robot.dart'; -import 'robots/dashboard_page_robot.dart'; -import 'robots/exchange_confirm_page_robot.dart'; -import 'robots/exchange_page_robot.dart'; -import 'robots/exchange_trade_page_robot.dart'; -import 'package:hash_wallet/.secrets.g.dart' as secrets; - -void main() { - IntegrationTestWidgetsFlutterBinding.ensureInitialized(); - - DashboardPageRobot dashboardPageRobot; - ExchangePageRobot exchangePageRobot; - ExchangeConfirmPageRobot exchangeConfirmPageRobot; - AuthPageRobot authPageRobot; - ExchangeTradePageRobot exchangeTradePageRobot; - CommonTestFlows commonTestFlows; - - group('Startup Test', () { - testWidgets('Test for Exchange flow using Restore Wallet - Exchanging USDT(Sol) to SOL', - (tester) async { - authPageRobot = AuthPageRobot(tester); - exchangePageRobot = ExchangePageRobot(tester); - dashboardPageRobot = DashboardPageRobot(tester); - exchangeTradePageRobot = ExchangeTradePageRobot(tester); - exchangeConfirmPageRobot = ExchangeConfirmPageRobot(tester); - commonTestFlows = CommonTestFlows(tester); - - await commonTestFlows.startAppFlow(ValueKey('funds_exchange_test_app_key')); - - await commonTestFlows.welcomePageToRestoreWalletThroughSeedsFlow( - CommonTestConstants.testWalletType, - secrets.solanaTestWalletSeeds, - CommonTestConstants.pin, - ); - - // ----------- RestoreFromSeedOrKeys Page ------------- - await dashboardPageRobot.navigateToExchangePage(); - - // ----------- Exchange Page ------------- - await exchangePageRobot.isExchangePage(); - exchangePageRobot.hasResetButton(); - await exchangePageRobot.displayBothExchangeCards(); - exchangePageRobot.confirmRightComponentsDisplayOnDepositExchangeCards(); - exchangePageRobot.confirmRightComponentsDisplayOnReceiveExchangeCards(); - - await exchangePageRobot - .selectDepositCurrency(CommonTestConstants.exchangeTestDepositCurrency); - await exchangePageRobot - .selectReceiveCurrency(CommonTestConstants.exchangeTestReceiveCurrency); - - await exchangePageRobot.enterDepositAmount(CommonTestConstants.exchangeTestAmount); - await exchangePageRobot.enterDepositRefundAddress( - depositAddress: CommonTestConstants.testWalletAddress); - - await exchangePageRobot.enterReceiveAddress(CommonTestConstants.testWalletAddress); - - await exchangePageRobot.onExchangeButtonPressed(); - - await exchangePageRobot.handleErrors(CommonTestConstants.exchangeTestAmount); - - final onAuthPage = authPageRobot.onAuthPage(); - if (onAuthPage) { - await authPageRobot.enterPinCode(CommonTestConstants.pin); - } - - final onAuthPageDesktop = authPageRobot.onAuthPageDesktop(); - if (onAuthPageDesktop) { - await authPageRobot.enterPassword(CommonTestConstants.pin.join("")); - } - - // ----------- Exchange Confirm Page ------------- - await exchangeConfirmPageRobot.isExchangeConfirmPage(); - - exchangeConfirmPageRobot.confirmComponentsOfTradeDisplayProperly(); - await exchangeConfirmPageRobot.confirmCopyTradeIdToClipBoardWorksProperly(); - await exchangeConfirmPageRobot.onSavedTradeIdButtonPressed(); - - // ----------- Exchange Trade Page ------------- - await exchangeTradePageRobot.isExchangeTradePage(); - exchangeTradePageRobot.hasInformationDialog(); - await exchangeTradePageRobot.onGotItButtonPressed(); - - await exchangeTradePageRobot.onSendFromCakeButtonPressed(); - - await exchangeTradePageRobot.handleConfirmSendResult(); - - await exchangeTradePageRobot.onSendButtonOnConfirmSendingDialogPressed(); - }); - }); -} diff --git a/integration_test/funds_suites/send_dry_run_test.dart b/integration_test/funds_suites/send_dry_run_test.dart new file mode 100644 index 000000000..f2d15d7bd --- /dev/null +++ b/integration_test/funds_suites/send_dry_run_test.dart @@ -0,0 +1,107 @@ +import "package:hash_wallet/di.dart"; +import "package:hash_wallet/store/app_store.dart"; +import "package:flutter_test/flutter_test.dart"; +import "package:integration_test/integration_test.dart"; + +import "../core/app_launcher.dart"; +import "../core/funds_outcome.dart"; +import "../core/test_config.dart"; +import "../flows/auth_flows.dart"; +import "../flows/funds_flows.dart"; +import "../robots/home_page_robot.dart"; +import "../robots/new_send_page_robot.dart"; + +void main() { + IntegrationTestWidgetsFlutterBinding.ensureInitialized(); + + integrationTest("Every funded chain can build a transaction without sending it", (tester) async { + final appLauncher = AppLauncher(tester); + final authFlows = AuthFlows(tester); + final fundsFlows = FundsFlows(tester); + final homePageRobot = HomePageRobot(tester); + final sendRobot = NewSendPageRobot(tester); + + if (!TestConfig.shouldDryRun("send")) { + tester.printToConsole("FLOWS excludes send, skipping"); + return; + } + + final walletTypes = TestConfig.fundedWalletTypesUnderTest; + + expect(walletTypes.isNotEmpty, true, reason: "No funded wallet seeds configured"); + + await appLauncher.launchApp(testKey: "send_dry_run_test_app_key"); + + final appStore = getIt.get(); + final failures = []; + + for (final type in walletTypes) { + tester.printToConsole("Send dry run starting for ${type.name}"); + + try { + final opened = await fundsFlows.openFundedWallet(type); + + if (!opened) { + tester.printToConsole("${type.name} has nothing spendable, top it up"); + FundsOutcome.empty(tester, type.name, "every funded wallet is empty"); + continue; + } + + expect(appStore.wallet?.type, type); + + final ownAddress = appStore.wallet!.walletAddresses.address; + + await homePageRobot.openSendSheet(); + await sendRobot.isDisplayed(); + + await sendRobot.enterAddress(ownAddress); + await sendRobot.enterAmount(TestConfig.fundsSendAmountFor(type)); + await sendRobot.tapSendButtonWhenReady(); + + await authFlows.authenticateWithPin(required: false); + + await sendRobot.confirmTransactionBuilt(); + + await sendRobot.confirmSendButtonReplacesSwiper(); + + tester.printToConsole("${type.name} built a transaction, leaving it unsent"); + FundsOutcome.ok(tester, type.name, "built and signed, not broadcast"); + + expect( + await fundsFlows.returnToHome(), + true, + reason: "${type.name} finished but the send flow would not close", + ); + } catch (e) { + if (FundsOutcome.needsFurtherReview(e.toString())) { + failures.add("${type.name}: $e"); + } else { + tester.printToConsole("${type.name} needs attention rather than a fix: $e"); + } + + FundsOutcome.failed(tester, type.name, e.toString()); + + if (!await fundsFlows.returnToHome()) { + // The app is hanging or found itself somewhere we don't know, + // no need to continue running anything else + for (final skipped in walletTypes.skip(walletTypes.indexOf(type) + 1)) { + FundsOutcome.waiting( + tester, + skipped.name, + "never ran, ${type.name} left the app somewhere it could not come back from", + ); + } + + if (FundsOutcome.needsFurtherReview(e.toString())) { + failures.add("${type.name}: could not get back to the home page, " + "the chains after this one never ran"); + } + + break; + } + } + } + + expect(failures, isEmpty, reason: "Failed chains:\n${failures.join("\n")}"); + }); +} diff --git a/integration_test/funds_suites/send_funds_test.dart b/integration_test/funds_suites/send_funds_test.dart new file mode 100644 index 000000000..220164d4b --- /dev/null +++ b/integration_test/funds_suites/send_funds_test.dart @@ -0,0 +1,109 @@ +import "package:hash_wallet/di.dart"; +import "package:hash_wallet/store/app_store.dart"; +import "package:flutter_test/flutter_test.dart"; +import "package:integration_test/integration_test.dart"; + +import "../core/app_launcher.dart"; +import "../core/funds_outcome.dart"; +import "../core/test_config.dart"; +import "../flows/auth_flows.dart"; +import "../flows/funds_flows.dart"; +import "../robots/home_page_robot.dart"; +import "../robots/new_send_page_robot.dart"; + +void main() { + IntegrationTestWidgetsFlutterBinding.ensureInitialized(); + + // tests everything up to the point of swiping + integrationTest("Send a real self transaction on every funded chain", (tester) async { + final appLauncher = AppLauncher(tester); + final fundsFlows = FundsFlows(tester); + final authFlows = AuthFlows(tester); + final homePageRobot = HomePageRobot(tester); + final sendRobot = NewSendPageRobot(tester); + + if (!TestConfig.shouldSpend("send")) { + tester.printToConsole("FLOWS excludes send, skipping"); + return; + } + + final walletTypes = TestConfig.fundedWalletTypesUnderTest; + + expect(walletTypes.isNotEmpty, true, reason: "No funded wallet seeds configured"); + + await appLauncher.launchApp(testKey: "send_funds_test_app_key"); + + final appStore = getIt.get(); + final failures = []; + + for (final type in walletTypes) { + tester.printToConsole("Funds send starting for ${type.name}"); + + try { + final opened = await fundsFlows.openFundedWallet(type); + + if (!opened) { + tester.printToConsole("${type.name} has nothing spendable, top it up"); + FundsOutcome.empty(tester, type.name, "every funded wallet is empty"); + continue; + } + + expect(appStore.wallet?.type, type); + + // Making it a self send so the funds remain inside the funded wallet, only fees are spent. + final ownAddress = appStore.wallet!.walletAddresses.address; + + await homePageRobot.openSendSheet(); + await sendRobot.isDisplayed(); + + await sendRobot.enterAddress(ownAddress); + await sendRobot.enterAmount(TestConfig.fundsSendAmountFor(type)); + await sendRobot.tapSendButtonWhenReady(); + + await authFlows.authenticateWithPin(required: false); + + await sendRobot.swipeToConfirm(); + await sendRobot.confirmTransactionCommitted(); + + // The send page sits under the confirm sheet, so one pop leaves it up and the next + // chain would try to restore from behind it. + expect( + await fundsFlows.returnToHome(), + true, + reason: "${type.name} broadcast but the send flow would not close", + ); + + tester.printToConsole("FUNDS_SEND_OK: ${type.name}"); + FundsOutcome.ok(tester, type.name, "self send broadcast"); + } catch (e) { + if (FundsOutcome.needsFurtherReview(e.toString())) { + failures.add("${type.name}: $e"); + } else { + tester.printToConsole("${type.name} needs attention rather than a fix: $e"); + } + + FundsOutcome.failed(tester, type.name, e.toString()); + tester.printToConsole("FUNDS_SEND_FAIL: ${type.name}: $e"); + + if (!await fundsFlows.returnToHome()) { + for (final skipped in walletTypes.skip(walletTypes.indexOf(type) + 1)) { + FundsOutcome.waiting( + tester, + skipped.name, + "never ran, ${type.name} left the app somewhere it could not come back from", + ); + } + + if (FundsOutcome.needsFurtherReview(e.toString())) { + failures.add("${type.name}: could not get back to the home page, " + "the chains after this one never ran"); + } + + break; + } + } + } + + expect(failures, isEmpty, reason: "Failed chains:\n${failures.join("\n")}"); + }); +} diff --git a/integration_test/funds_suites/swap_dry_run_test.dart b/integration_test/funds_suites/swap_dry_run_test.dart new file mode 100644 index 000000000..9a1c73b04 --- /dev/null +++ b/integration_test/funds_suites/swap_dry_run_test.dart @@ -0,0 +1,69 @@ +import "package:hash_wallet/di.dart"; +import "package:hash_wallet/store/app_store.dart"; +import "package:cw_core/crypto_currency.dart"; +import "package:flutter_test/flutter_test.dart"; +import "package:integration_test/integration_test.dart"; + +import "../core/app_launcher.dart"; +import "../core/funds_outcome.dart"; +import "../core/test_config.dart"; +import "../flows/funds_flows.dart"; +import "../robots/home_page_robot.dart"; +import "../robots/new_swap_page_robot.dart"; + +void main() { + IntegrationTestWidgetsFlutterBinding.ensureInitialized(); + + // tests everything up to the point of creating the trade + integrationTest("Swap prices a real deposit without creating a trade", (tester) async { + final appLauncher = AppLauncher(tester); + final fundsFlows = FundsFlows(tester); + final homePageRobot = HomePageRobot(tester); + final swapRobot = NewSwapPageRobot(tester); + + if (!TestConfig.shouldDryRun("swap")) { + tester.printToConsole("FLOWS excludes swap, skipping"); + return; + } + + final walletTypes = TestConfig.fundedWalletTypesUnderTest; + + expect(walletTypes.isNotEmpty, true, reason: "No funded wallet seeds configured"); + + await appLauncher.launchApp(testKey: "swap_dry_run_test_app_key"); + + final appStore = getIt.get(); + + final type = TestConfig.swapDepositType(walletTypes); + + expect( + walletTypes, + contains(type), + reason: "SWAP_FROM=${type.name} is not among the funded wallets under test", + ); + + final opened = await fundsFlows.openFundedWallet(type); + + expect(opened, true, reason: "Every funded wallet for ${type.name} came up empty"); + expect(appStore.wallet?.type, type); + + await homePageRobot.openSwapSheet(); + await swapRobot.isDisplayed(); + + if (TestConfig.swapReceive.isNotEmpty) { + await swapRobot.chooseReceiveCurrency(CryptoCurrency.fromString(TestConfig.swapReceive)); + } + + await swapRobot.enterMinimumViableDepositAmount(); + await swapRobot.enterTestReceiveAddress(); + + await swapRobot.confirmQuoteReceived(); + await swapRobot.confirmSwapButtonEnabled(); + + tester.printToConsole("${type.name} got a quote and the swap button enabled, no trade created"); + FundsOutcome.ok(tester, type.name, "quoted, swap button enabled, no trade created"); + + await swapRobot.dismissModal(); + await homePageRobot.isDisplayed(); + }); +} diff --git a/integration_test/funds_suites/swap_funds_test.dart b/integration_test/funds_suites/swap_funds_test.dart new file mode 100644 index 000000000..be37fe3cc --- /dev/null +++ b/integration_test/funds_suites/swap_funds_test.dart @@ -0,0 +1,86 @@ +import "package:hash_wallet/di.dart"; +import "package:hash_wallet/store/app_store.dart"; +import "package:cw_core/crypto_currency.dart"; +import "package:flutter_test/flutter_test.dart"; +import "package:integration_test/integration_test.dart"; + +import "../core/app_launcher.dart"; +import "../core/funds_outcome.dart"; +import "../core/test_config.dart"; +import "../core/test_wallets.dart"; +import "../flows/auth_flows.dart"; +import "../flows/funds_flows.dart"; +import "../robots/home_page_robot.dart"; +import "../robots/new_send_page_robot.dart"; +import "../robots/new_swap_page_robot.dart"; + +void main() { + IntegrationTestWidgetsFlutterBinding.ensureInitialized(); + + integrationTest("Create a real swap and broadcast its deposit", (tester) async { + final appLauncher = AppLauncher(tester); + final fundsFlows = FundsFlows(tester); + final authFlows = AuthFlows(tester); + final homePageRobot = HomePageRobot(tester); + final sendRobot = NewSendPageRobot(tester); + final swapRobot = NewSwapPageRobot(tester); + + if (!TestConfig.shouldSpend("swap")) { + tester.printToConsole("FLOWS excludes swap, skipping"); + return; + } + + final walletTypes = TestConfig.fundedWalletTypesUnderTest; + + expect(walletTypes.isNotEmpty, true, reason: "No funded wallet seeds configured"); + + final type = TestConfig.swapDepositType(walletTypes); + + expect( + walletTypes, + contains(type), + reason: "SWAP_FROM=${type.name} is not among the funded wallets under test", + ); + + await appLauncher.launchApp(testKey: "swap_funds_test_app_key"); + + final opened = await fundsFlows.openFundedWallet(type); + + expect( + opened, + true, + reason: "${type.name}: none of the ${TestWallets.fundedWalletsFor(type).length} funded " + "wallets has a spendable balance, top them up before the next run", + ); + + final appStore = getIt.get(); + expect(appStore.wallet?.type, type); + + await homePageRobot.openSwapSheet(); + await swapRobot.isDisplayed(); + + if (TestConfig.swapReceive.isNotEmpty) { + await swapRobot.chooseReceiveCurrency(CryptoCurrency.fromString(TestConfig.swapReceive)); + } + + await swapRobot.enterMinimumViableDepositAmount(); + final payoutAddress = await swapRobot.enterTestReceiveAddress(); + await swapRobot.confirmQuoteReceived(); + await swapRobot.confirmSwapButtonEnabled(); + + await swapRobot.tapSwapButton(); + + await authFlows.authenticateWithPin(required: false); + + await swapRobot.confirmTradeCreated(); + swapRobot.confirmPayoutAddress(payoutAddress); + + await sendRobot.swipeToConfirm(); + await swapRobot.confirmDepositCommitted(); + + await homePageRobot.isDisplayed(); + + tester.printToConsole("FUNDS_SWAP_OK: ${type.name}"); + FundsOutcome.ok(tester, type.name, "trade created and deposit broadcast"); + }); +} diff --git a/integration_test/helpers/mocks.dart b/integration_test/helpers/mocks.dart deleted file mode 100644 index 817f1dde1..000000000 --- a/integration_test/helpers/mocks.dart +++ /dev/null @@ -1,27 +0,0 @@ -import 'package:hash_wallet/core/auth_service.dart'; -import 'package:hash_wallet/core/secure_storage.dart'; -import 'package:hash_wallet/store/app_store.dart'; -import 'package:hash_wallet/store/authentication_store.dart'; -import 'package:hash_wallet/store/settings_store.dart'; -import 'package:hash_wallet/store/wallet_list_store.dart'; -import 'package:hash_wallet/view_model/link_view_model.dart'; -import 'package:hive/hive.dart'; -import 'package:mocktail/mocktail.dart'; - -class MockAppStore extends Mock implements AppStore {} - -class MockAuthService extends Mock implements AuthService {} - -class MockSettingsStore extends Mock implements SettingsStore {} - -class MockAuthenticationStore extends Mock implements AuthenticationStore {} - -class MockWalletListStore extends Mock implements WalletListStore {} - -class MockLinkViewModel extends Mock implements LinkViewModel {} - -class MockHiveInterface extends Mock implements HiveInterface {} - -class MockHiveBox extends Mock implements Box {} - -class MockSecureStorage extends Mock implements SecureStorage {} diff --git a/integration_test/helpers/test_helpers.dart b/integration_test/helpers/test_helpers.dart deleted file mode 100644 index bc06a5355..000000000 --- a/integration_test/helpers/test_helpers.dart +++ /dev/null @@ -1,99 +0,0 @@ -import 'package:hash_wallet/core/auth_service.dart'; -import 'package:hash_wallet/core/secure_storage.dart'; -import 'package:hash_wallet/di.dart'; -import 'package:hash_wallet/store/app_store.dart'; -import 'package:hash_wallet/store/authentication_store.dart'; -import 'package:hash_wallet/store/settings_store.dart'; -import 'package:hash_wallet/store/wallet_list_store.dart'; -import 'package:hash_wallet/view_model/link_view_model.dart'; -import 'package:hive/hive.dart'; -import 'package:mocktail/mocktail.dart'; - -import 'mocks.dart'; - -class TestHelpers { - static void setup() { - // Fallback values can also be declared here - registerDependencies(); - } - - static void registerDependencies() { - getAndRegisterAppStore(); - getAndRegisterAuthService(); - getAndRegisterSettingsStore(); - getAndRegisterAuthenticationStore(); - getAndRegisterWalletListStore(); - - getAndRegisterLinkViewModel(); - getAndRegisterSecureStorage(); - getAndRegisterHiveInterface(); - } - - static MockSettingsStore getAndRegisterSettingsStore() { - _removeRegistrationIfExists(); - final service = MockSettingsStore(); - getIt.registerSingleton(service); - return service; - } - - static MockAppStore getAndRegisterAppStore() { - _removeRegistrationIfExists(); - final service = MockAppStore(); - final settingsStore = getAndRegisterSettingsStore(); - - when(() => service.settingsStore).thenAnswer((invocation) => settingsStore); - getIt.registerSingleton(service); - return service; - } - - static MockAuthService getAndRegisterAuthService() { - _removeRegistrationIfExists(); - final service = MockAuthService(); - getIt.registerSingleton(service); - return service; - } - - static MockAuthenticationStore getAndRegisterAuthenticationStore() { - _removeRegistrationIfExists(); - final service = MockAuthenticationStore(); - when(() => service.state).thenReturn(AuthenticationState.uninitialized); - getIt.registerSingleton(service); - return service; - } - - static MockWalletListStore getAndRegisterWalletListStore() { - _removeRegistrationIfExists(); - final service = MockWalletListStore(); - getIt.registerSingleton(service); - return service; - } - - static MockLinkViewModel getAndRegisterLinkViewModel() { - _removeRegistrationIfExists(); - final service = MockLinkViewModel(); - getIt.registerSingleton(service); - return service; - } - - static MockHiveInterface getAndRegisterHiveInterface() { - _removeRegistrationIfExists(); - final service = MockHiveInterface(); - getIt.registerSingleton(service); - return service; - } - - static MockSecureStorage getAndRegisterSecureStorage() { - _removeRegistrationIfExists(); - final service = MockSecureStorage(); - getIt.registerSingleton(service); - return service; - } - - static void _removeRegistrationIfExists() { - if (getIt.isRegistered()) { - getIt.unregister(); - } - } - - static void tearDown() => getIt.reset(); -} diff --git a/integration_test/integration_response_data.json b/integration_test/integration_response_data.json deleted file mode 100644 index ec747fa47..000000000 --- a/integration_test/integration_response_data.json +++ /dev/null @@ -1 +0,0 @@ -null \ No newline at end of file diff --git a/integration_test/robots/auth_page_robot.dart b/integration_test/robots/auth_page_robot.dart index 39014a9b8..5452fdafc 100644 --- a/integration_test/robots/auth_page_robot.dart +++ b/integration_test/robots/auth_page_robot.dart @@ -1,36 +1,20 @@ -import 'package:hash_wallet/generated/i18n.dart'; -import 'package:hash_wallet/src/screens/auth/auth_page.dart'; -import 'package:flutter/widgets.dart'; -import 'package:flutter_test/flutter_test.dart'; +import "package:hash_wallet/src/screens/auth/auth_page.dart"; +import "package:flutter/widgets.dart"; +import "package:flutter_test/flutter_test.dart"; -import '../components/common_test_cases.dart'; -import 'pin_code_widget_robot.dart'; +import "pin_code_widget_robot.dart"; class AuthPageRobot extends PinCodeWidgetRobot { - AuthPageRobot(this.tester) - : commonTestCases = CommonTestCases(tester), - super(tester); + AuthPageRobot(super.tester); - final WidgetTester tester; - late CommonTestCases commonTestCases; + @override + Future isDisplayed() async { + await isSpecificPage(); + } bool onAuthPage() { - final hasPinButtons = find.byKey(ValueKey('pin_code_button_3_key')); + final hasPinButtons = find.byKey(const ValueKey("pin_code_button_3_key")); final hasPin = hasPinButtons.tryEvaluate(); return hasPin; } - - bool onAuthPageDesktop() { - final hasWalletPasswordInput = find.byKey(ValueKey('enter_wallet_password')); - return hasWalletPasswordInput.tryEvaluate(); - } - - Future isAuthPage() async { - await commonTestCases.isSpecificPage(); - await commonTestCases.takeScreenshots('auth_page'); - } - - void hasTitle() { - commonTestCases.hasText(S.current.setup_pin); - } } diff --git a/integration_test/robots/connection_sync_page_robot.dart b/integration_test/robots/connection_sync_page_robot.dart new file mode 100644 index 000000000..da4698b6a --- /dev/null +++ b/integration_test/robots/connection_sync_page_robot.dart @@ -0,0 +1,40 @@ +import "package:hash_wallet/entities/fiat_api_mode.dart"; +import "package:hash_wallet/src/screens/settings/connection_sync_page.dart"; +import "package:flutter/material.dart"; +import "package:flutter_test/flutter_test.dart"; + +import "../core/base_robot.dart"; + +class ConnectionSyncPageRobot extends BaseRobot { + ConnectionSyncPageRobot(super.tester); + + @override + Future isDisplayed() async { + await isSpecificPage(); + } + + Future setFiatApiMode(FiatApiMode mode) async { + await tapByKey("fiat_api"); + + final unselected = find.byKey(ValueKey("picker_items_index_${mode.title}_button_key")); + final selected = + find.byKey(ValueKey("picker_items_index_${mode.title}_selected_item_button_key")); + + await pumpUntil(() => tester.any(unselected) || tester.any(selected)); + + await tester.tap(tester.any(unselected) ? unselected : selected); + + await settle(); + } + + void hasFiatApiMode(FiatApiMode mode) { + expect( + find.descendant( + of: find.byKey(const ValueKey("fiat_api")), + matching: find.text(mode.title), + ), + findsOneWidget, + reason: "The fiat api row does not show ${mode.title}", + ); + } +} diff --git a/integration_test/robots/contact_robot.dart b/integration_test/robots/contact_robot.dart new file mode 100644 index 000000000..f5ac1077b --- /dev/null +++ b/integration_test/robots/contact_robot.dart @@ -0,0 +1,66 @@ +import "package:hash_wallet/src/screens/contact/contact_list_page.dart"; +import "package:hash_wallet/src/screens/contact/contact_page.dart"; +import "package:flutter/material.dart"; +import "package:flutter_test/flutter_test.dart"; + +import "../core/base_robot.dart"; + +class ContactRobot extends BaseRobot { + ContactRobot(super.tester); + + @override + Future isDisplayed() async { + await pumpUntilFound(find.byType(ContactListPage)); + } + + Future openAddContact() async { + await tapByKey("contact_list_page_add_contact_button_key"); + + await pumpUntilFound(find.byType(ContactPage)); + } + + Future enterName(String name) async { + await enterTextByKey("contact_page_name_textfield_key", name); + } + + Future chooseCurrency(String currencyName) async { + await tapByKey("contact_page_currency_picker_button_key"); + + await tapByKey("picker_items_index_${currencyName}_button_key"); + + await settle(); + } + + Future enterAddress(String address) async { + final input = find.byKey(const ValueKey("contact_page_address_input_key")); + + await pumpUntilFound(input); + + final field = find.descendant(of: input, matching: find.byType(EditableText)); + + await tester.enterText(field.first, address); + await tester.pump(const Duration(milliseconds: 300)); + } + + Future save() async { + await tapByKey("contact_page_save_button_key"); + + await pumpUntilGone(find.byType(ContactPage)); + + await settle(); + } + + void expectContactSaved(String name, String address) { + final page = tester.widget(find.byType(ContactListPage)); + + final saved = page.contactListViewModel.contacts.where((c) => c.name == name).toList(); + + expect(saved, isNotEmpty, reason: "The contact $name was never saved"); + + expect( + saved.first.address, + address, + reason: "The address book stored a different address than it was given", + ); + } +} diff --git a/integration_test/robots/create_pin_welcome_page_robot.dart b/integration_test/robots/create_pin_welcome_page_robot.dart index a2e5600f3..4138be10e 100644 --- a/integration_test/robots/create_pin_welcome_page_robot.dart +++ b/integration_test/robots/create_pin_welcome_page_robot.dart @@ -1,46 +1,16 @@ -import 'package:hash_wallet/generated/i18n.dart'; -import 'package:hash_wallet/src/screens/welcome/create_pin_welcome_page.dart'; -import 'package:hash_wallet/wallet_type_utils.dart'; -import 'package:flutter_test/flutter_test.dart'; +import "package:hash_wallet/src/screens/welcome/create_pin_welcome_page.dart"; -import '../components/common_test_cases.dart'; +import "../core/base_robot.dart"; -class CreatePinWelcomePageRobot { - CreatePinWelcomePageRobot(this.tester) : commonTestCases = CommonTestCases(tester); +class CreatePinWelcomePageRobot extends BaseRobot { + CreatePinWelcomePageRobot(super.tester); - final WidgetTester tester; - late CommonTestCases commonTestCases; - - Future isCreatePinWelcomePage() async { - await commonTestCases.isSpecificPage(); - await commonTestCases.takeScreenshots('create_pin_welcome_page'); - } - - void hasTitle() { - String title; - if (isMoneroOnly) { - title = S.current.monero_com; - } - - title = S.current.cake_wallet; - - commonTestCases.hasText(title); - } - - void hasDescription() { - String description; - if (isMoneroOnly) { - description = S.current.monero_com_wallet_text; - } - - description = S.current.new_first_wallet_text; - - commonTestCases.hasText(description); + @override + Future isDisplayed() async { + await isSpecificPage(); } Future tapSetAPinButton() async { - await commonTestCases.tapItemByKey('create_pin_welcome_page_create_a_pin_button_key'); - - await commonTestCases.defaultSleepTime(); + await tapByKey("create_pin_welcome_page_create_a_pin_button_key"); } } diff --git a/integration_test/robots/dashboard_menu_widget_robot.dart b/integration_test/robots/dashboard_menu_widget_robot.dart deleted file mode 100644 index d6fcf9d71..000000000 --- a/integration_test/robots/dashboard_menu_widget_robot.dart +++ /dev/null @@ -1,40 +0,0 @@ -import 'package:hash_wallet/src/screens/dashboard/widgets/menu_widget.dart'; -import 'package:flutter_test/flutter_test.dart'; - -import '../components/common_test_cases.dart'; - -class DashboardMenuWidgetRobot { - DashboardMenuWidgetRobot(this.tester) : commonTestCases = CommonTestCases(tester); - - final WidgetTester tester; - late CommonTestCases commonTestCases; - - Future hasMenuWidget() async { - await commonTestCases.takeScreenshots('menu_widget_page'); - commonTestCases.hasType(); - } - - void displaysTheCorrectWalletNameAndSubName() { - final menuWidgetState = tester.state(find.byType(MenuWidget)); - - final walletName = menuWidgetState.widget.dashboardViewModel.name; - commonTestCases.hasText(walletName); - - final walletSubName = menuWidgetState.widget.dashboardViewModel.subname; - if (walletSubName.isNotEmpty) { - commonTestCases.hasText(walletSubName); - } - } - - Future navigateToWalletMenu() async { - await commonTestCases.tapItemByKey('dashboard_page_menu_widget_wallet_menu_button_key'); - await commonTestCases.defaultSleepTime(); - } - - Future navigateToSecurityAndBackupPage() async { - await commonTestCases.tapItemByKey( - 'dashboard_page_menu_widget_security_and_backup_button_key', - ); - await commonTestCases.defaultSleepTime(); - } -} diff --git a/integration_test/robots/dashboard_page_robot.dart b/integration_test/robots/dashboard_page_robot.dart deleted file mode 100644 index 250b80626..000000000 --- a/integration_test/robots/dashboard_page_robot.dart +++ /dev/null @@ -1,122 +0,0 @@ -import 'package:hash_wallet/generated/i18n.dart'; -import 'package:hash_wallet/src/screens/dashboard/dashboard_page.dart'; -import 'package:hash_wallet/src/screens/dashboard/pages/balance/crypto_balance_widget.dart'; -import 'package:cw_core/wallet_type.dart'; -import 'package:flutter_test/flutter_test.dart'; - -import '../components/common_test_cases.dart'; -import 'dashboard_menu_widget_robot.dart'; - -class DashboardPageRobot { - DashboardPageRobot(this.tester) - : commonTestCases = CommonTestCases(tester), - dashboardMenuWidgetRobot = DashboardMenuWidgetRobot(tester); - - final WidgetTester tester; - final DashboardMenuWidgetRobot dashboardMenuWidgetRobot; - late CommonTestCases commonTestCases; - - Future isDashboardPage() async { - await commonTestCases.isSpecificPage(); - await commonTestCases.takeScreenshots('dashboard_page'); - } - - Future confirmWalletTypeIsDisplayedCorrectly( - WalletType type, { - bool isHaven = false, - }) async { - final cryptoBalanceWidget = - tester.widget(find.byType(CryptoBalanceWidget)); - final hasAccounts = cryptoBalanceWidget.dashboardViewModel.balanceViewModel.hasAccounts; - - if (hasAccounts) { - final walletName = cryptoBalanceWidget.dashboardViewModel.name; - commonTestCases.hasText(walletName); - } else { - final walletName = walletTypeToString(type); - final assetName = isHaven ? '$walletName Assets' : walletName; - commonTestCases.hasText(assetName); - } - await commonTestCases.defaultSleepTime(seconds: 5); - } - - void confirmServiceUpdateButtonDisplays() { - commonTestCases.hasValueKey('dashboard_page_services_update_button_key'); - } - - void confirmSyncIndicatorButtonDisplays() { - commonTestCases.hasValueKey('dashboard_page_sync_indicator_button_key'); - } - - void confirmMenuButtonDisplays() { - commonTestCases.hasValueKey('dashboard_page_wallet_menu_button_key'); - } - - Future confirmRightCryptoAssetTitleDisplaysPerPageView( - WalletType type, { - bool isHaven = false, - }) async { - //Balance Page - await confirmWalletTypeIsDisplayedCorrectly(type, isHaven: isHaven); - - // Swipe to Cake features Page - await swipeDashboardTab(false); - commonTestCases.hasText('Cake ${S.current.features}'); - - // Swipe back to balance - await swipeDashboardTab(true); - - // Swipe to Transactions Page - await swipeDashboardTab(true); - commonTestCases.hasText(S.current.transactions); - - // Swipe back to balance - await swipeDashboardTab(false); - await commonTestCases.defaultSleepTime(seconds: 3); - } - - Future swipeDashboardTab(bool swipeRight) async { - await commonTestCases.swipeByPageKey( - key: 'dashboard_page_view_key', - swipeRight: swipeRight, - ); - await commonTestCases.defaultSleepTime(); - } - - Future openDrawerMenu() async { - await commonTestCases.tapItemByKey('dashboard_page_wallet_menu_button_key'); - await commonTestCases.defaultSleepTime(); - } - - Future navigateToBuyPage() async { - await commonTestCases.tapItemByKey('dashboard_page_buy_action_button_key'); - } - - Future navigateToWalletsListPage({bool isDrawer = false}) async { - await tester.pumpAndSettle(Duration(milliseconds: 1000)); - - await commonTestCases.tapItemByKey( - isDrawer - ? 'dashboard_page_menu_widget_wallet_menu_button_key' - : 'dashboard_page__wallet_list_button_key', - ); - - await tester.pumpAndSettle(Duration(milliseconds: 2000)); - } - - Future navigateToSendPage() async { - await commonTestCases.tapItemByKey('dashboard_page_send_action_button_key'); - } - - Future navigateToSellPage() async { - await commonTestCases.tapItemByKey('dashboard_page_sell_action_button_key'); - } - - Future navigateToReceivePage() async { - await commonTestCases.tapItemByKey('dashboard_page_receive_action_button_key'); - } - - Future navigateToExchangePage() async { - await commonTestCases.tapItemByKey('dashboard_page_swap_action_button_key'); - } -} diff --git a/integration_test/robots/disclaimer_page_robot.dart b/integration_test/robots/disclaimer_page_robot.dart deleted file mode 100644 index ed7cd7ee8..000000000 --- a/integration_test/robots/disclaimer_page_robot.dart +++ /dev/null @@ -1,40 +0,0 @@ -import 'package:hash_wallet/src/screens/disclaimer/disclaimer_page.dart'; -import 'package:flutter/material.dart'; -import 'package:flutter_test/flutter_test.dart'; - -import '../components/common_test_cases.dart'; - -class DisclaimerPageRobot { - DisclaimerPageRobot(this.tester) : commonTestCases = CommonTestCases(tester); - - final WidgetTester tester; - late CommonTestCases commonTestCases; - - Future isDisclaimerPage() async { - await commonTestCases.isSpecificPage(); - await commonTestCases.takeScreenshots('disclaimer_page'); - } - - void hasCheckIcon(bool hasBeenTapped) { - // The checked Icon should not be available initially, until user taps the checkbox - final checkIcon = find.byKey(ValueKey('disclaimer_check_icon_key')); - expect(checkIcon, hasBeenTapped ? findsOneWidget : findsNothing); - } - - void hasDisclaimerCheckbox() { - final checkBox = find.byKey(ValueKey('disclaimer_check_key')); - expect(checkBox, findsOneWidget); - } - - Future tapDisclaimerCheckbox() async { - await commonTestCases.tapItemByKey('disclaimer_check_key'); - - await commonTestCases.defaultSleepTime(); - } - - Future tapAcceptButton() async { - await commonTestCases.tapItemByKey('disclaimer_accept_button_key'); - - await commonTestCases.defaultSleepTime(); - } -} diff --git a/integration_test/robots/display_settings_page_robot.dart b/integration_test/robots/display_settings_page_robot.dart new file mode 100644 index 000000000..65499ff68 --- /dev/null +++ b/integration_test/robots/display_settings_page_robot.dart @@ -0,0 +1,78 @@ +import "package:hash_wallet/entities/fiat_currency.dart"; +import "package:hash_wallet/new-ui/widgets/currency_picker/currency_picker_search_field.dart"; +import "package:hash_wallet/new-ui/widgets/currency_picker/fiat_currency_picker_sheet.dart"; +import "package:hash_wallet/new-ui/widgets/currency_picker/fiat_currency_row.dart"; +import "package:hash_wallet/src/screens/settings/display_settings_page.dart"; +import "package:flutter/material.dart"; +import "package:flutter_test/flutter_test.dart"; + +import "../core/base_robot.dart"; + +class DisplaySettingsPageRobot extends BaseRobot { + DisplaySettingsPageRobot(super.tester); + + @override + Future isDisplayed() async { + await isSpecificPage(); + } + + Future openLanguagePicker() async { + await tapByKey("display_settings_language"); + } + + Future chooseLanguage(String code) async { + await tapByKey("picker_items_index_${code}_button_key"); + + await settle(); + } + + bool hasFiatCurrencyRow() => + tester.any(find.byKey(const ValueKey("display_settings_fiat_currency"))); + + Future openFiatCurrencyPicker() async { + await tapByKey("display_settings_fiat_currency"); + + await pumpUntilFound(find.byType(FiatCurrencyPickerSheet)); + } + + Future chooseFiatCurrency(FiatCurrency currency) async { + final searchField = find.descendant( + of: find.byType(CurrencyPickerSearchField), + matching: find.byType(TextFormField), + ); + + await tester.enterText(searchField, currency.fullName); + + final row = find.byWidgetPredicate( + (widget) => widget is FiatCurrencyRow && widget.currency == currency, + ); + + await pumpUntilFound(row); + + await tester.tap(row.first); + + await pumpUntilGone(find.byType(FiatCurrencyPickerSheet)); + } + + void expectFiatCurrencyShown(FiatCurrency currency) { + expect( + find.descendant( + of: find.byKey(const ValueKey("display_settings_fiat_currency")), + matching: find.text(currency.title), + ), + findsOneWidget, + reason: "The currency row still does not show ${currency.title}", + ); + } + + void expectLanguageShown(String displayName) { + expect( + find.descendant( + of: find.byKey(const ValueKey("display_settings_language")), + matching: find.text(displayName), + ), + findsOneWidget, + reason: "The language row still does not show $displayName", + ); + } +} diff --git a/integration_test/robots/exchange_confirm_page_robot.dart b/integration_test/robots/exchange_confirm_page_robot.dart deleted file mode 100644 index 5f5d25be1..000000000 --- a/integration_test/robots/exchange_confirm_page_robot.dart +++ /dev/null @@ -1,46 +0,0 @@ -import 'package:hash_wallet/generated/i18n.dart'; -import 'package:hash_wallet/src/screens/exchange_trade/exchange_confirm_page.dart'; -import 'package:flutter/services.dart'; -import 'package:flutter_test/flutter_test.dart'; - -import '../components/common_test_cases.dart'; - -class ExchangeConfirmPageRobot { - ExchangeConfirmPageRobot(this.tester) : commonTestCases = CommonTestCases(tester); - - final WidgetTester tester; - late CommonTestCases commonTestCases; - - Future isExchangeConfirmPage() async { - await commonTestCases.isSpecificPage(); - await commonTestCases.takeScreenshots('exchange_confirm_page'); - } - - void confirmComponentsOfTradeDisplayProperly() { - final ExchangeConfirmPage exchangeConfirmPage = tester.widget(find.byType(ExchangeConfirmPage)); - final trade = exchangeConfirmPage.trade; - - commonTestCases.hasText(trade.id); - commonTestCases.hasText('${trade.provider.title} ${S.current.trade_id}'); - - commonTestCases.hasValueKey('exchange_confirm_page_saved_id_button_key'); - commonTestCases.hasValueKey('exchange_confirm_page_copy_to_clipboard_button_key'); - } - - Future confirmCopyTradeIdToClipBoardWorksProperly() async { - final ExchangeConfirmPage exchangeConfirmPage = tester.widget(find.byType(ExchangeConfirmPage)); - final trade = exchangeConfirmPage.trade; - - await commonTestCases.tapItemByKey('exchange_confirm_page_copy_to_clipboard_button_key'); - - ClipboardData? clipboardData = await Clipboard.getData('text/plain'); - - expect(clipboardData?.text, trade.id); - } - - Future onSavedTradeIdButtonPressed() async { - await tester.pumpAndSettle(); - await commonTestCases.defaultSleepTime(); - await commonTestCases.tapItemByKey('exchange_confirm_page_saved_id_button_key'); - } -} diff --git a/integration_test/robots/exchange_page_robot.dart b/integration_test/robots/exchange_page_robot.dart deleted file mode 100644 index 97c63942f..000000000 --- a/integration_test/robots/exchange_page_robot.dart +++ /dev/null @@ -1,338 +0,0 @@ -import 'package:hash_wallet/generated/i18n.dart'; -import 'package:hash_wallet/src/screens/exchange/exchange_page.dart'; -import 'package:hash_wallet/src/screens/exchange/widgets/present_provider_picker.dart'; -import 'package:cw_core/crypto_currency.dart'; -import 'package:flutter/foundation.dart'; -import 'package:flutter_test/flutter_test.dart'; - -import '../components/common_test_cases.dart'; -import '../components/common_test_constants.dart'; -import 'auth_page_robot.dart'; - -class ExchangePageRobot { - ExchangePageRobot(this.tester) - : commonTestCases = CommonTestCases(tester), - authPageRobot = AuthPageRobot(tester); - - WidgetTester tester; - AuthPageRobot authPageRobot; - CommonTestCases commonTestCases; - - Future isExchangePage() async { - await commonTestCases.isSpecificPage(); - await commonTestCases.takeScreenshots('exchange_page'); - await commonTestCases.defaultSleepTime(); - } - - void hasResetButton() { - commonTestCases.hasText(S.current.reset); - } - - void displaysPresentProviderPicker() { - commonTestCases.hasType(); - } - - Future displayBothExchangeCards() async { - final ExchangePage exchangeCard = tester.widget( - find.byType(ExchangePage), - ); - - final depositKey = exchangeCard.depositKey; - final receiveKey = exchangeCard.receiveKey; - - final depositExchangeCard = find.byKey(depositKey); - expect(depositExchangeCard, findsOneWidget); - - final receiveExchangeCard = find.byKey(receiveKey); - expect(receiveExchangeCard, findsOneWidget); - } - - void confirmRightComponentsDisplayOnDepositExchangeCards() { - ExchangePage exchangePage = tester.widget(find.byType(ExchangePage)); - final exchangeViewModel = exchangePage.exchangeViewModel; - final depositCardPrefix = 'deposit_exchange_card'; - - commonTestCases.hasValueKey('${depositCardPrefix}_title_key'); - commonTestCases.hasValueKey('${depositCardPrefix}_currency_picker_button_key'); - commonTestCases.hasValueKey('${depositCardPrefix}_selected_currency_text_key'); - commonTestCases.hasValueKey('${depositCardPrefix}_amount_textfield_key'); - - exchangePage.depositKey.currentState!.changeLimits(min: '0.1'); - - commonTestCases.hasValueKey('${depositCardPrefix}_min_limit_text_key'); - - final initialCurrency = exchangeViewModel.depositCurrency; - if (initialCurrency.tag != null) { - commonTestCases.hasValueKey('${depositCardPrefix}_selected_currency_tag_text_key'); - } - - if (exchangeViewModel.hasAllAmount) { - commonTestCases.hasValueKey('${depositCardPrefix}_send_all_button_key'); - } - - if (exchangeViewModel.isMoneroWallet) { - commonTestCases.hasValueKey('${depositCardPrefix}_address_book_button_key'); - } - - if (exchangeViewModel.isDepositAddressEnabled) { - commonTestCases.hasValueKey('${depositCardPrefix}_editable_address_textfield_key'); - } else { - commonTestCases.hasValueKey('${depositCardPrefix}_non_editable_address_textfield_key'); - commonTestCases.hasValueKey('${depositCardPrefix}_copy_refund_address_button_key'); - } - - // commonTestCases.hasValueKey('${depositCardPrefix}_max_limit_text_key'); - } - - void confirmRightComponentsDisplayOnReceiveExchangeCards() { - ExchangePage exchangePage = tester.widget(find.byType(ExchangePage)); - final exchangeViewModel = exchangePage.exchangeViewModel; - final receiveCardPrefix = 'receive_exchange_card'; - - commonTestCases.hasValueKey('${receiveCardPrefix}_title_key'); - commonTestCases.hasValueKey('${receiveCardPrefix}_currency_picker_button_key'); - commonTestCases.hasValueKey('${receiveCardPrefix}_selected_currency_text_key'); - commonTestCases.hasValueKey('${receiveCardPrefix}_amount_textfield_key'); - commonTestCases.hasValueKey('${receiveCardPrefix}_min_limit_text_key'); - - final initialCurrency = exchangeViewModel.receiveCurrency; - if (initialCurrency.tag != null) { - commonTestCases.hasValueKey('${receiveCardPrefix}_selected_currency_tag_text_key'); - } - - if (exchangeViewModel.hasAllAmount) { - commonTestCases.hasValueKey('${receiveCardPrefix}_send_all_button_key'); - } - - if (exchangeViewModel.isMoneroWallet) { - commonTestCases.hasValueKey('${receiveCardPrefix}_address_book_button_key'); - } - - commonTestCases.hasValueKey('${receiveCardPrefix}_editable_address_textfield_key'); - } - - Future selectDepositCurrency(CryptoCurrency depositCurrency) async { - final depositPrefix = 'deposit_exchange_card'; - final currencyPickerKey = '${depositPrefix}_currency_picker_button_key'; - final currencyPickerDialogKey = '${depositPrefix}_currency_picker_dialog_button_key'; - - await commonTestCases.tapItemByKey(currencyPickerKey); - commonTestCases.hasValueKey(currencyPickerDialogKey); - - ExchangePage exchangePage = tester.widget(find.byType(ExchangePage)); - final exchangeViewModel = exchangePage.exchangeViewModel; - - if (depositCurrency == exchangeViewModel.depositCurrency) { - await commonTestCases.defaultSleepTime(); - await commonTestCases - .tapItemByKey('picker_items_index_${depositCurrency.name}_selected_item_button_key'); - return; - } - - await commonTestCases.enterText(depositCurrency.name, 'search_bar_widget_key'); - - await commonTestCases.defaultSleepTime(); - - await commonTestCases.tapItemByKey('picker_items_index_${depositCurrency.name}_button_key'); - } - - Future selectReceiveCurrency(CryptoCurrency receiveCurrency) async { - final receivePrefix = 'receive_exchange_card'; - final currencyPickerKey = '${receivePrefix}_currency_picker_button_key'; - final currencyPickerDialogKey = '${receivePrefix}_currency_picker_dialog_button_key'; - - await commonTestCases.tapItemByKey(currencyPickerKey); - commonTestCases.hasValueKey(currencyPickerDialogKey); - - ExchangePage exchangePage = tester.widget(find.byType(ExchangePage)); - final exchangeViewModel = exchangePage.exchangeViewModel; - - if (receiveCurrency == exchangeViewModel.receiveCurrency) { - await commonTestCases - .tapItemByKey('picker_items_index_${receiveCurrency.name}_selected_item_button_key'); - return; - } - - await commonTestCases.enterText(receiveCurrency.name, 'search_bar_widget_key'); - - await commonTestCases.defaultSleepTime(); - - await commonTestCases.tapItemByKey('picker_items_index_${receiveCurrency.name}_button_key'); - } - - Future enterDepositAmount(String amount) async { - await commonTestCases.enterText(amount, 'deposit_exchange_card_amount_textfield_key'); - } - - Future enterDepositRefundAddress({String? depositAddress}) async { - ExchangePage exchangePage = tester.widget(find.byType(ExchangePage)); - final exchangeViewModel = exchangePage.exchangeViewModel; - - if (exchangeViewModel.isDepositAddressEnabled && depositAddress != null) { - await commonTestCases.enterText( - depositAddress, 'deposit_exchange_card_editable_address_textfield_key'); - } - } - - Future enterReceiveAddress(String receiveAddress) async { - await commonTestCases.enterText( - receiveAddress, - 'receive_exchange_card_editable_address_textfield_key', - ); - await commonTestCases.defaultSleepTime(); - } - - Future onExchangeButtonPressed() async { - await commonTestCases.tapItemByKey('exchange_page_exchange_button_key'); - await commonTestCases.defaultSleepTime(); - } - - bool hasMaxLimitError() { - final maxErrorText = find.text(S.current.error_text_input_above_maximum_limit); - - bool hasMaxError = maxErrorText.tryEvaluate(); - - return hasMaxError; - } - - bool hasMinLimitError() { - final minErrorText = find.text(S.current.error_text_input_below_minimum_limit); - - bool hasMinError = minErrorText.tryEvaluate(); - - return hasMinError; - } - - bool hasTradeCreationFailureError() { - final tradeCreationFailureDialogButton = - find.byKey(ValueKey('exchange_page_trade_creation_failure_dialog_button_key')); - - bool hasTradeCreationFailure = tradeCreationFailureDialogButton.tryEvaluate(); - tester.printToConsole('Trade not created error: $hasTradeCreationFailure'); - return hasTradeCreationFailure; - } - - Future onTradeCreationFailureDialogButtonPressed() async { - await commonTestCases.tapItemByKey('exchange_page_trade_creation_failure_dialog_button_key'); - } - - /// Handling Trade Failure Errors or errors shown through the Failure Dialog. - /// - /// Simulating the user's flow and response when this error comes up. - /// Examples are: - /// - No provider can handle this trade error, - /// - Trade amount below limit error. - Future _handleTradeCreationFailureErrors() async { - tester.printToConsole('Inside trade creation failure handle'); - bool isTradeCreationFailure = false; - - isTradeCreationFailure = hasTradeCreationFailureError(); - - int maxRetries = 3; - int retries = 0; - - while (isTradeCreationFailure && retries < maxRetries) { - await tester.pumpAndSettle(); - - await onTradeCreationFailureDialogButtonPressed(); - - await commonTestCases.defaultSleepTime(seconds: 5); - - await onExchangeButtonPressed(); - - await _handleAuthPage(); - - isTradeCreationFailure = hasTradeCreationFailureError(); - retries++; - } - } - - /// Handles the min limit error. - /// - /// Simulates the user's flow and response when it comes up. - /// - /// Has a max retry of 20 times. - Future _handleMinLimitError(String initialAmount) async { - double amount; - - amount = double.parse(initialAmount); - - ExchangePage exchangePage = tester.widget(find.byType(ExchangePage)); - final exchangeViewModel = exchangePage.exchangeViewModel; - - amount = (exchangeViewModel.limits.min ?? 0.0) + 1.0; - - await enterDepositAmount(amount.toString()); - - await tester.pumpAndSettle(); - - await onExchangeButtonPressed(); - - await tester.pumpAndSettle(); - } - - /// Handles the max limit error. - /// - /// Simulates the user's flow and response when it comes up. - /// - /// Has a max retry of 20 times. - Future _handleMaxLimitError(String initialAmount) async { - double amount; - - amount = double.parse(initialAmount); - - ExchangePage exchangePage = tester.widget(find.byType(ExchangePage)); - final exchangeViewModel = exchangePage.exchangeViewModel; - - amount = (exchangeViewModel.limits.max ?? 0.0) - 1.0; - - await enterDepositAmount(amount.toString()); - - await tester.pumpAndSettle(); - - await onExchangeButtonPressed(); - - await tester.pumpAndSettle(); - } - - Future handleErrors(String initialAmount) async { - await tester.pumpAndSettle(); - - bool isMinLimitError = hasMinLimitError(); - - if (isMinLimitError) { - tester.printToConsole('Has min limit error'); - await _handleMinLimitError(initialAmount); - } - - bool isMaxLimitError = hasMaxLimitError(); - - if (isMaxLimitError) { - await _handleMaxLimitError(initialAmount); - } - - tester.printToConsole('No limits error, proceeding with flow'); - - await _handleAuthPage(); - - await tester.pumpAndSettle(); - - await _handleTradeCreationFailureErrors(); - - await tester.pumpAndSettle(); - - await commonTestCases.defaultSleepTime(); - } - - Future _handleAuthPage() async { - final onAuthPage = authPageRobot.onAuthPage(); - if (onAuthPage) { - await authPageRobot.enterPinCode(CommonTestConstants.pin); - } - - final onAuthPageDesktop = authPageRobot.onAuthPageDesktop(); - if (onAuthPageDesktop) { - await authPageRobot.enterPassword(CommonTestConstants.pin.join("")); - } - } -} diff --git a/integration_test/robots/exchange_trade_external_send_page_robot.dart b/integration_test/robots/exchange_trade_external_send_page_robot.dart deleted file mode 100644 index 18cfb1ccf..000000000 --- a/integration_test/robots/exchange_trade_external_send_page_robot.dart +++ /dev/null @@ -1,35 +0,0 @@ -import 'package:flutter_test/flutter_test.dart'; - -import 'package:hash_wallet/src/screens/exchange_trade/exchange_trade_external_send_page.dart'; - -import '../components/common_test_cases.dart'; - -class ExchangeTradeExternalSendPageRobot { - ExchangeTradeExternalSendPageRobot(this.tester) : commonTestCases = CommonTestCases(tester); - - final WidgetTester tester; - late CommonTestCases commonTestCases; - - Future isExchangeTradeExternalSendPage() async { - await commonTestCases.isSpecificPage(); - await commonTestCases.takeScreenshots('exchange_trade_external_send_page'); - } - - Future verifySendDetailsItemsDisplayProperly() async { - final widget = - tester.widget(find.byType(ExchangeTradeExternalSendPage)); - final exchangeTradeViewModel = widget.exchangeTradeViewModel; - final items = exchangeTradeViewModel.items.where((item) => item.isExternalSendDetail).toList(); - - for (var item in items) { - commonTestCases.hasValueKey('exchange_trade_external_send_page_send_item_${item.title}_key'); - tester.printToConsole('${item.title} present on screen'); - } - - commonTestCases.defaultSleepTime(); - } - - Future onContinueButtonPressed() async { - await commonTestCases.tapItemByKey('exchange_trade_external_send_page_continue_button_key'); - } -} diff --git a/integration_test/robots/exchange_trade_page_robot.dart b/integration_test/robots/exchange_trade_page_robot.dart deleted file mode 100644 index 162c59d23..000000000 --- a/integration_test/robots/exchange_trade_page_robot.dart +++ /dev/null @@ -1,161 +0,0 @@ -import 'dart:async'; - -import 'package:hash_wallet/core/execution_state.dart'; -import 'package:hash_wallet/generated/i18n.dart'; -import 'package:hash_wallet/src/screens/exchange_trade/exchange_trade_page.dart'; -import 'package:flutter/foundation.dart'; -import 'package:flutter_test/flutter_test.dart'; - -import '../components/common_test_cases.dart'; - -class ExchangeTradePageRobot { - ExchangeTradePageRobot(this.tester) : commonTestCases = CommonTestCases(tester); - - final WidgetTester tester; - late CommonTestCases commonTestCases; - - Future isExchangeTradePage() async { - await commonTestCases.isSpecificPage(); - await commonTestCases.takeScreenshots('exchange_trade_page'); - } - - void hasInformationDialog() { - commonTestCases.hasValueKey('information_page_dialog_key'); - } - - Future onGotItButtonPressed() async { - await commonTestCases.tapItemByKey('information_page_got_it_button_key'); - await commonTestCases.defaultSleepTime(); - } - - Future onSendFromExternalButtonPressed() async { - tester.printToConsole('Routing to send from external details page'); - - await commonTestCases.tapItemByKey( - 'exchange_trade_page_send_from_external_button_key', - ); - } - - Future onSendFromCakeButtonPressed() async { - tester.printToConsole('Now sending from cake'); - - await commonTestCases.tapItemByKey( - 'exchange_trade_page_send_from_cake_button_key', - shouldPumpAndSettle: false, - ); - - final Completer completer = Completer(); - - // Loop to wait for the async operation to complete - while (true) { - await Future.delayed(Duration(seconds: 1)); - - final ExchangeTradeState state = tester.state(find.byType(ExchangeTradeForm)); - final execState = state.widget.exchangeTradeViewModel.sendViewModel.state; - - bool isDone = execState is ExecutedSuccessfullyState; - bool isFailed = execState is FailureState; - - tester.printToConsole('isDone: $isDone'); - tester.printToConsole('isFailed: $isFailed'); - - if (isDone || isFailed) { - tester.printToConsole( - isDone ? 'Completer is done' : 'Completer is done though operation failed'); - completer.complete(); - await tester.pump(); - break; - } else { - tester.printToConsole('Completer is not done'); - await tester.pump(); - } - } - - await expectLater(completer.future, completes); - - tester.printToConsole('Done confirming sending'); - - await commonTestCases.defaultSleepTime(seconds: 4); - } - - Future onSendButtonOnConfirmSendingDialogPressed() async { - tester.printToConsole('Send Button on Confirm Dialog Triggered'); - await commonTestCases.defaultSleepTime(seconds: 4); - - final sendText = find.text(S.current.send); - bool hasText = sendText.tryEvaluate(); - - if (hasText) { - await commonTestCases.tapItemByFinder(sendText); - - await commonTestCases.defaultSleepTime(seconds: 4); - } - } - - Future onCancelButtonOnConfirmSendingDialogPressed() async { - tester.printToConsole('Cancel Button on Confirm Dialog Triggered'); - - await commonTestCases.tapItemByKey( - 'exchange_trade_page_confirm_sending_dialog_cancel_button_key', - ); - - await commonTestCases.defaultSleepTime(); - } - - Future onSendFailureDialogButtonPressed() async { - await commonTestCases.defaultSleepTime(seconds: 6); - - tester.printToConsole('Send Button Failure Dialog Triggered'); - - await commonTestCases.tapItemByKey('exchange_trade_page_send_failure_dialog_button_key'); - } - - Future hasErrorWhileSending() async { - await tester.pump(); - - tester.printToConsole('Checking if there is an error'); - - final errorDialog = find.byKey( - ValueKey('exchange_trade_page_send_failure_dialog_button_key'), - ); - - bool hasError = errorDialog.tryEvaluate(); - - tester.printToConsole('Has error: $hasError'); - - return hasError; - } - - Future handleConfirmSendResult() async { - bool hasError = false; - - hasError = await hasErrorWhileSending(); - - int maxRetries = 20; - int retries = 0; - - while (hasError && retries < maxRetries) { - tester.printToConsole('hasErrorInLoop: $hasError'); - await tester.pump(); - - await onSendFailureDialogButtonPressed(); - tester.printToConsole('Failure button tapped'); - - await commonTestCases.defaultSleepTime(); - - await onSendFromCakeButtonPressed(); - tester.printToConsole('Confirm sending button tapped'); - - hasError = await hasErrorWhileSending(); - - retries++; - } - - if (!hasError) { - tester.printToConsole('No error, proceeding with flow'); - await tester.pump(); - } - - await commonTestCases.defaultSleepTime(); - } -} diff --git a/integration_test/robots/home_page_robot.dart b/integration_test/robots/home_page_robot.dart new file mode 100644 index 000000000..ea030840c --- /dev/null +++ b/integration_test/robots/home_page_robot.dart @@ -0,0 +1,227 @@ +import "package:hash_wallet/new-ui/pages/home_page.dart"; +import "package:hash_wallet/new-ui/widgets/coins_page/assets_history/history_tile.dart"; +import "package:hash_wallet/new-ui/widgets/coins_page/assets_history/transaction_details_modal.dart"; +import "package:hash_wallet/new-ui/widgets/coins_page/top_bar_widget/sync_bar.dart"; +import "package:cw_core/sync_status.dart"; +import "package:flutter/material.dart"; +import "package:flutter_test/flutter_test.dart"; + +import "../core/base_robot.dart"; + +class HomePageRobot extends BaseRobot { + HomePageRobot(super.tester); + + @override + Future isDisplayed() async { + final shown = await pumpUntil(() => isInFront); + + expect(shown, true, reason: "The home tab never came to the front"); + } + + bool get isInFront { + final stackFinder = find.byType(IndexedStack); + + if (!tester.any(stackFinder)) { + return false; + } + + final stack = tester.widget(stackFinder.first); + final index = stack.children.indexWhere((child) => child is NewHomePage); + + return index >= 0 && stack.index == index; + } + + Future hasWalletName(String name) async { + final nameFinder = find.byKey(const ValueKey("home_page_wallet_name_text_key")); + final syncBarFinder = find.byType(SyncBar); + + final shown = await pumpUntil(() => tester.any(nameFinder) || tester.any(syncBarFinder)); + + expect(shown, true, reason: "Top bar showed neither the wallet name nor the sync bar"); + + if (!tester.any(nameFinder)) { + tester.printToConsole("Sync bar is in place of the wallet name, not checking it on screen"); + return; + } + + expect(textByKey("home_page_wallet_name_text_key"), name); + } + + Future openSendSheet() async { + await tapByKey("home_page_send_button_key"); + } + + Future openReceiveSheet() async { + await tapByKey("home_page_receive_button_key"); + } + + Future openSwapSheet() async { + await tapByKey("home_page_swap_button_key"); + } + + Future openSettingsSheet() async { + await tapByKey("home_page_settings_button_key"); + } + + Future openHistoryTab() async { + final tabFinder = find.byKey(const ValueKey("line_tab_switcher_1_key")); + + if (!tester.any(tabFinder)) { + return; + } + + await tapWhenVisible(tabFinder); + + await settle(); + } + + Future confirmTransactionHistoryVisible({ + Duration timeout = const Duration(minutes: 3), + }) async { + final hasTransactions = await pumpUntil( + () => _dashboardTransactionCount() > 0, + timeout: timeout, + ); + + expect( + hasTransactions, + true, + reason: "No transactions arrived within ${timeout.inSeconds}s", + ); + + await openHistoryTab(); + + await pumpUntilFound(find.byType(HistoryTile)); + + expect( + tester.widgetList(find.byType(HistoryTile)).length, + lessThanOrEqualTo(3), + reason: "The home page preview should never render more than the short history", + ); + } + + Future openAllTransactions() async { + final actionButton = find.byKey(const ValueKey("assets_history_action_button_key")); + final historyBar = find.byKey(const ValueKey("history_top_bar_key")); + + await tapWhenVisible(tester.any(actionButton) ? actionButton : historyBar); + + await pumpUntilFound(find.byKey(const ValueKey("history_modal_key"))); + } + + Future confirmAllTransactionsVisible() async { + await pumpUntilFound(_allViewTiles); + + final rendered = tester.widgetList(_allViewTiles).length; + + if (_dashboardTransactionCount() > 3) { + expect( + rendered, + greaterThan(3), + reason: "The All view rendered $rendered tiles, no more than the home preview does", + ); + + return; + } + + expect(_allViewTiles, findsWidgets); + } + + String firstTransactionIdInAllView() { + final tile = tester.widgetList(_allViewTiles).first; + final key = tile.key! as ValueKey; + + return key.value.substring("home_page_transaction_".length, key.value.length - "_key".length); + } + + Future openFirstTransactionDetails() async { + await pumpUntilFound(_allViewTiles); + + await tester.tap(_allViewTiles.first); + + await pumpUntilFound(find.byType(TransactionDetailsModal)); + + await settle(); + } + + String openedTransactionId() { + final modal = tester.widget(find.byType(TransactionDetailsModal)); + + return modal.transactionDetailsViewModel.transactionInfo.id; + } + + void hasTransactionIdRow() { + expect( + find.byKey(const ValueKey("standard_list_item_transaction_details_id_key")), + findsOneWidget, + reason: "The details opened without showing the transaction id", + ); + } + + // The home preview stays mounted underneath the modal, so its tiles have to be left out. + Finder get _allViewTiles => find.descendant( + of: find.byKey(const ValueKey("history_modal_key")), + matching: find.byType(HistoryTile), + ); + + Future confirmSyncIndicatorShown({Duration timeout = const Duration(minutes: 3)}) async { + final syncBar = find.byType(SyncBar); + final tick = find.descendant(of: syncBar, matching: find.byIcon(Icons.check)); + + SyncStatus? lastStatus; + var sawIndicator = false; + + final synced = await pumpUntil( + () { + lastStatus = _dashboardStatus(); + + if (lastStatus == null) { + return false; + } + + final keyed = find.descendant( + of: syncBar, + matching: find.byKey(ValueKey(lastStatus.runtimeType)), + ); + + sawIndicator = sawIndicator || tester.any(keyed) || tester.any(tick); + + return lastStatus is SyncedSyncStatus; + }, + timeout: timeout, + ); + + expect( + synced, + true, + reason: "The wallet never synced within ${timeout.inMinutes}m, " + "it reported ${lastStatus.runtimeType}", + ); + + expect( + sawIndicator, + true, + reason: "The sync bar never showed a status while the wallet synced", + ); + } + + SyncStatus? _dashboardStatus() { + final finder = find.byType(NewHomePage); + + if (!tester.any(finder)) { + return null; + } + + return tester.widget(finder.first).dashboardViewModel.status; + } + + int _dashboardTransactionCount() { + final finder = find.byType(NewHomePage); + + if (!tester.any(finder)) { + return 0; + } + + return tester.widget(finder.first).dashboardViewModel.transactions.length; + } +} diff --git a/integration_test/robots/lightning_username_page_robot.dart b/integration_test/robots/lightning_username_page_robot.dart new file mode 100644 index 000000000..69ffc3d0e --- /dev/null +++ b/integration_test/robots/lightning_username_page_robot.dart @@ -0,0 +1,17 @@ +import "package:flutter/material.dart"; +import "package:flutter_test/flutter_test.dart"; + +import "../core/base_robot.dart"; + +class LightningUsernamePageRobot extends BaseRobot { + LightningUsernamePageRobot(super.tester); + + @override + Future isDisplayed() async { + await pumpUntilFound(find.byKey(const ValueKey("lightning_username_page_skip_button_key"))); + } + + Future tapSkip() async { + await tapByKey("lightning_username_page_skip_button_key"); + } +} diff --git a/integration_test/robots/manage_nodes_page_robot.dart b/integration_test/robots/manage_nodes_page_robot.dart new file mode 100644 index 000000000..e6c5712dc --- /dev/null +++ b/integration_test/robots/manage_nodes_page_robot.dart @@ -0,0 +1,39 @@ +import "package:hash_wallet/generated/i18n.dart"; +import "package:hash_wallet/src/screens/nodes/widgets/node_list_row.dart"; +import "package:hash_wallet/src/screens/settings/manage_nodes_page.dart"; +import "package:flutter_test/flutter_test.dart"; + +import "../core/base_robot.dart"; + +class ManageNodesPageRobot extends BaseRobot { + ManageNodesPageRobot(super.tester); + + @override + Future isDisplayed() async { + await isSpecificPage(); + } + + String currentNodeUri() { + final page = tester.widget(find.byType(ManageNodesPage)); + + return page.nodeListViewModel.currentNode.uriRaw; + } + + Future switchToAnotherNode() async { + final rows = find.byType(NodeListRow); + + await pumpUntilFound(rows.first); + + expect( + tester.widgetList(rows).length, + greaterThan(1), + reason: "Only the current node is listed, there is nothing to switch to", + ); + + await tapWhenVisible(rows.at(1)); + + await tapWhenVisible(find.text(S.current.change)); + + await settle(); + } +} diff --git a/integration_test/robots/new_dashboard_robot.dart b/integration_test/robots/new_dashboard_robot.dart new file mode 100644 index 000000000..1aa332d17 --- /dev/null +++ b/integration_test/robots/new_dashboard_robot.dart @@ -0,0 +1,25 @@ +import "package:flutter/material.dart"; +import "package:flutter_test/flutter_test.dart"; + +import "../core/base_robot.dart"; + +class NewDashboardRobot extends BaseRobot { + NewDashboardRobot(super.tester); + + @override + Future isDisplayed({Duration timeout = const Duration(seconds: 30)}) async { + await pumpUntilFound(find.byKey(const ValueKey("new_dashboard_page_key")), timeout: timeout); + } + + Future openHomeTab() async { + await tapByKey("dashboard_page_home_action_button_key"); + } + + Future openWalletsTab() async { + await tapByKey("dashboard_page_wallets_action_button_key"); + } + + Future openContactsTab() async { + await tapByKey("dashboard_page_contacts_action_button_key"); + } +} diff --git a/integration_test/robots/new_receive_page_robot.dart b/integration_test/robots/new_receive_page_robot.dart new file mode 100644 index 000000000..b359e1edd --- /dev/null +++ b/integration_test/robots/new_receive_page_robot.dart @@ -0,0 +1,57 @@ +import "package:hash_wallet/new-ui/pages/receive_page.dart"; +import "package:flutter/material.dart"; +import "package:flutter_test/flutter_test.dart"; + +import "../core/base_robot.dart"; + +class NewReceivePageRobot extends BaseRobot { + NewReceivePageRobot(super.tester); + + @override + Future isDisplayed() async { + await pumpUntilFound(find.byType(NewReceivePage)); + } + + Future confirmAddressMatches(String expected) async { + final wanted = _normalise(expected); + + await pumpUntilFound(find.byKey(const ValueKey("receive_page_address_key"))); + + final matched = await pumpUntil(() => _renderedAddress() == wanted); + + expect( + matched, + true, + reason: "Receive page should show $wanted but showed ${_renderedAddress()}", + ); + } + + String _normalise(String address) => + address.replaceAll("bitcoincash:", "").replaceAll(RegExp(r"\s"), ""); + + String? _renderedAddress() { + final root = find.byKey(const ValueKey("receive_page_address_key")); + + if (!tester.any(root)) { + return null; + } + + final rich = find.descendant(of: root, matching: find.byType(RichText)); + + if (tester.any(rich)) { + final text = _normalise(tester.widget(rich.first).text.toPlainText()); + + if (text.isNotEmpty) { + return text; + } + } + + final plain = find.descendant(of: root, matching: find.byType(Text)); + + if (tester.any(plain)) { + return _normalise(tester.widget(plain.first).data ?? ""); + } + + return null; + } +} diff --git a/integration_test/robots/new_send_page_robot.dart b/integration_test/robots/new_send_page_robot.dart new file mode 100644 index 000000000..431f1bbe9 --- /dev/null +++ b/integration_test/robots/new_send_page_robot.dart @@ -0,0 +1,205 @@ +import "package:hash_wallet/core/execution_state.dart"; +import "package:hash_wallet/new-ui/pages/send_page.dart"; +import "package:hash_wallet/new-ui/widgets/confirm_swiper.dart"; +import "package:hash_wallet/new-ui/widgets/new_primary_button.dart"; +import "package:hash_wallet/new-ui/widgets/send_page/send_confirm_bottom_widget.dart"; +import "package:hash_wallet/new-ui/widgets/send_page/send_confirm_sheet.dart"; +import "package:hash_wallet/view_model/send/send_view_model.dart"; +import "package:flutter/material.dart"; +import "package:flutter_test/flutter_test.dart"; + +import "../core/base_robot.dart"; +import "../core/test_config.dart"; + +class NewSendPageRobot extends BaseRobot { + NewSendPageRobot(super.tester); + + @override + Future isDisplayed() async { + await pumpUntilFound(find.byType(NewSendPage)); + } + + Future enterAddress(String address) async { + await _enterTextInInput("send_page_address_input_key", address); + } + + Future enterAmount(String amount) async { + await _enterTextInInput("send_page_amount_input_key", amount); + } + + String enteredAddress() => _textInInput("send_page_address_input_key"); + + Future pickContactFromAddressBook(String contactName) async { + await tapByKey("send_page_address_book_button_key"); + + final tabs = find.byType(Tab); + + await pumpUntilFound(tabs); + + await tester.tap(tabs.at(1)); + + await settle(); + + final contactFinder = find.byKey(ValueKey(contactName)); + + await pumpUntilFound(contactFinder); + + await tester.tap(contactFinder.first); + + await pumpUntilGone(contactFinder); + } + + Future tapSendButton() async { + await tapByKey("send_page_send_button_key"); + } + + Future tapSendButtonWhenReady({Duration timeout = TestConfig.walletSyncBudget}) async { + final ready = await pumpUntil(() => _model()?.isReadyForSend ?? false, timeout: timeout); + + expect( + ready, + true, + reason: "The wallet never became ready to send within ${timeout.inMinutes}m, " + "so the send button stayed disabled", + ); + + await tapSendButton(); + } + + bool get isSendFlowOpen => + tester.any(find.byType(NewSendPage)) || tester.any(find.byType(SendConfirmSheet)); + + Future swipeToConfirm({Duration timeout = const Duration(seconds: 90)}) async { + final finder = find.byKey(const ValueKey("send_page_confirm_swiper_key")); + + await pumpUntilFound(finder, timeout: timeout); + + await settle(max: const Duration(seconds: 2)); + + final width = tester.view.physicalSize.width / tester.view.devicePixelRatio; + await tester.drag(finder.first, Offset(width, 0)); + + await tester.pump(const Duration(milliseconds: 500)); + } + + Future confirmSendButtonReplacesSwiper() async { + final swiper = find.byKey(const ValueKey("send_page_confirm_swiper_key")); + + await pumpUntilFound(swiper); + + tester.binding.platformDispatcher.accessibilityFeaturesTestValue = + const FakeAccessibilityFeatures(accessibleNavigation: true); + addTearDown(tester.binding.platformDispatcher.clearAccessibilityFeaturesTestValue); + + await settle(max: const Duration(seconds: 2)); + + final button = find.descendant(of: swiper, matching: find.byType(NewPrimaryButton)); + + expect( + tester.any(button), + true, + reason: "Accessible navigation is on but the sheet still only offers the swiper", + ); + + expect( + tester.widget(button.first).onPressed, + isNotNull, + reason: "The send button a screen reader gets is disabled", + ); + } + + Future confirmTransactionBuilt({Duration timeout = const Duration(minutes: 3)}) async { + await pumpUntil( + () => tester.any(find.byType(ConfirmSwiper)) || _model()?.state is FailureState, + timeout: timeout, + ); + + if (tester.any(find.byType(ConfirmSwiper))) { + return; + } + + fail("No confirm sheet after ${timeout.inSeconds}s. ${_describeSendState()}"); + } + + Future confirmTransactionCommitted({ + Duration timeout = const Duration(minutes: 3), + }) async { + await pumpUntilFound(find.byType(TransactionCommittedScreenActionButton), timeout: timeout); + } + + Future expectNoTransactionBuilt({ + Duration window = const Duration(seconds: 15), + }) async { + final offered = await pumpUntil( + () => tester.any(find.byType(ConfirmSwiper)), + timeout: window, + ); + + expect( + offered, + false, + reason: + "The send screen showed the swiper, wanting to broadcast a transaction it should have refused", + ); + } + + SendViewModel? _model() { + final sheet = find.byType(SendConfirmSheet); + + if (tester.any(sheet)) { + return tester.widget(sheet.first).sendViewModel; + } + + final page = find.byType(NewSendPage); + + if (tester.any(page)) { + return tester.widget(page.first).sendViewModel; + } + + return null; + } + + String _describeSendState() { + final model = _model(); + + final available = model?.wallet.balance.values + .map((balance) => balance.available) + .where((amount) => amount.sign > 0) + .join(", "); + + final errorFinder = find.byType(TransactionErrorActions); + final error = tester.any(errorFinder) + ? tester.widget(errorFinder.first).errorText + : model?.state is FailureState + ? (model!.state as FailureState).error + : null; + + return "state=${model?.state.runtimeType}, " + "wallet holds ${available == null || available.isEmpty ? "nothing" : available}" + "${error == null ? "" : ", the chain said: $error"}"; + } + + String _textInInput(String inputKey) { + final inputFinder = find.byKey(ValueKey(inputKey)); + + if (!tester.any(inputFinder)) { + return "unknown"; + } + + final field = find.descendant(of: inputFinder, matching: find.byType(EditableText)); + + return tester.widget(field.first).controller.text; + } + + Future _enterTextInInput(String inputKey, String text) async { + final inputFinder = find.byKey(ValueKey(inputKey)); + + await pumpUntilFound(inputFinder); + + final fieldFinder = find.descendant(of: inputFinder, matching: find.byType(EditableText)); + + await tester.enterText(fieldFinder.first, text); + + await tester.pump(const Duration(milliseconds: 300)); + } +} diff --git a/integration_test/robots/new_settings_page_robot.dart b/integration_test/robots/new_settings_page_robot.dart new file mode 100644 index 000000000..c4e28374d --- /dev/null +++ b/integration_test/robots/new_settings_page_robot.dart @@ -0,0 +1,45 @@ +import "package:hash_wallet/new-ui/pages/settings_page.dart"; +import "package:flutter/material.dart"; +import "package:flutter_test/flutter_test.dart"; + +import "../core/base_robot.dart"; + +class NewSettingsPageRobot extends BaseRobot { + NewSettingsPageRobot(super.tester); + + @override + Future isDisplayed() async { + await pumpUntilFound(find.byType(SettingsMainPage)); + } + + Future openRow(String route) async { + await scrollUntilRowVisible(route); + await tapByKey(route); + } + + Future scrollUntilRowVisible(String route) async { + final rowFinder = find.byKey(ValueKey(route)); + + if (tester.any(rowFinder)) { + return; + } + + final scrollableFinder = find.descendant( + of: find.byType(SettingsMainPage), + matching: find.byType(Scrollable), + ); + + await tester.scrollUntilVisible( + rowFinder, + 200, + scrollable: scrollableFinder.first, + maxScrolls: 30, + ); + + await tester.pump(const Duration(milliseconds: 300)); + } + + Future confirmLeafPageDisplayed(Type pageType) async { + await pumpUntilFound(find.byType(pageType)); + } +} diff --git a/integration_test/robots/new_swap_page_robot.dart b/integration_test/robots/new_swap_page_robot.dart new file mode 100644 index 000000000..53dda5756 --- /dev/null +++ b/integration_test/robots/new_swap_page_robot.dart @@ -0,0 +1,211 @@ +import "package:hash_wallet/core/execution_state.dart"; +import "package:hash_wallet/exchange/limits_state.dart"; +import "package:hash_wallet/new-ui/pages/swap_page.dart"; +import "package:hash_wallet/new-ui/widgets/currency_picker/currency_picker_row.dart"; +import "package:hash_wallet/new-ui/widgets/currency_picker/currency_picker_search_field.dart"; +import "package:hash_wallet/new-ui/widgets/swap_page/swap_amount_box.dart"; +import "package:hash_wallet/new-ui/widgets/swap_page/swap_confirm_sheet.dart"; +import "package:hash_wallet/src/widgets/primary_button.dart"; +import "package:hash_wallet/view_model/send/send_view_model_state.dart"; +import "package:cw_core/crypto_currency.dart"; +import "package:cw_core/wallet_type.dart"; +import "package:flutter/material.dart"; +import "package:flutter_test/flutter_test.dart"; + +import "../core/base_robot.dart"; +import "../core/test_config.dart"; +import "../core/test_wallets.dart"; + +class NewSwapPageRobot extends BaseRobot { + NewSwapPageRobot(super.tester); + + @override + Future isDisplayed() async { + await pumpUntilFound(find.byType(NewSwapPage)); + } + + Future enterDepositAmount(String amount) async { + await enterTextByKey("swap_page_deposit_amount_field_key", amount); + } + + Future chooseReceiveCurrency(CryptoCurrency currency) async { + await tapByKey("swap_amount_box_receive_currency_button_key"); + + final search = find.descendant( + of: find.byType(CurrencyPickerSearchField), + matching: find.byType(EditableText), + ); + + await pumpUntilFound(search); + + await tester.enterText(search.first, currency.title); + await settle(max: const Duration(seconds: 2)); + + await tapWhenVisible( + find.byWidgetPredicate( + (widget) => widget is CurrencyPickerRow && _isSameCurrency(widget.currency, currency), + ), + ); + + final chosen = await pumpUntil( + () => _isSameCurrency( + tester.widget(find.byType(NewSwapPage)).exchangeViewModel.receiveCurrency, + currency, + ), + ); + + expect( + chosen, + true, + reason: "The picker did not switch the receive currency to ${currency.title}", + ); + } + + static bool _isSameCurrency(CryptoCurrency one, CryptoCurrency other) => + one.title.toLowerCase() == other.title.toLowerCase() && + (one.tag ?? "").toLowerCase() == (other.tag ?? "").toLowerCase(); + + Future confirmQuoteReceived({Duration timeout = const Duration(seconds: 90)}) async { + final received = await pumpUntil(() => _bestRate() > 0, timeout: timeout); + + expect(received, true, reason: "No provider quote arrived within ${timeout.inSeconds}s"); + } + + Future enterMinimumViableDepositAmount() async { + final limitsLoaded = await pumpUntil( + () => _limitsState() is LimitsLoadedSuccessfully, + timeout: const Duration(seconds: 90), + ); + + expect(limitsLoaded, true, reason: "Provider limits never loaded"); + + final min = _limitsMin() ?? 0; + + // 5 percent over the minimum, so a small rate move does not invalidate the trade. + final amount = min > 0 ? (min * 1.05).toStringAsFixed(8) : "0.01"; + await enterDepositAmount(amount); + } + + Future enterTestReceiveAddress() async { + final exchangeViewModel = + tester.widget(find.byType(NewSwapPage)).exchangeViewModel; + final receiveType = cryptoCurrencyOrTokenToWalletType(exchangeViewModel.receiveCurrency); + final address = receiveType == null ? "" : TestWallets.receiveAddressFor(receiveType); + + expect( + address.isNotEmpty, + true, + reason: "No test receive address for ${exchangeViewModel.receiveCurrency.title}", + ); + + final receiveCard = find.byWidgetPredicate( + (widget) => widget is SwapAmountBox && widget.isReceiverCard, + ); + + tester.state(receiveCard).addressController.text = address; + await settle(); + + return address; + } + + Future confirmSwapButtonEnabled({Duration timeout = TestConfig.walletSyncBudget}) async { + final button = find.byKey(const ValueKey("exchange_page_exchange_button_key")); + + final enabled = await pumpUntil( + () => tester.any(button) && !tester.widget(button).isDisabled, + timeout: timeout, + ); + + expect( + enabled, + true, + reason: "The swap button stayed disabled with an amount, a quote and a receive address", + ); + } + + Future tapSwapButton() async { + await tapByKey("exchange_page_exchange_button_key"); + } + + Future confirmTradeCreated({Duration timeout = const Duration(minutes: 2)}) async { + await pumpUntilFound(find.byType(SwapConfirmSheet), timeout: timeout); + } + + void confirmPayoutAddress(String expected) { + final sheet = tester.widget(find.byType(SwapConfirmSheet)); + final payout = sheet.exchangeTradeViewModel.trade.payoutAddress ?? ""; + final isHex = expected.startsWith("0x"); + + expect( + isHex ? payout.toLowerCase() : payout, + isHex ? expected.toLowerCase() : expected, + reason: "The trade pays out somewhere other than the test wallet", + ); + } + + Future confirmDepositCommitted({Duration timeout = const Duration(minutes: 3)}) async { + ExecutionState? lastState; + + final committed = await pumpUntil( + () { + final sheet = find.byType(SwapConfirmSheet); + + if (!tester.any(sheet)) { + return true; + } + + lastState = + tester.widget(sheet).exchangeTradeViewModel.sendViewModel.state; + + return lastState is TransactionCommitted; + }, + timeout: timeout, + ); + + expect( + committed, + true, + reason: "The deposit never committed within ${timeout.inMinutes}m, " + "the sheet reported ${lastState.runtimeType}", + ); + + expect( + lastState, + isA(), + reason: "The confirm sheet closed before the deposit committed, " + "it last reported ${lastState.runtimeType}", + ); + + await pumpUntilGone(find.byType(NewSwapPage)); + } + + double? _limitsMin() { + final finder = find.byType(NewSwapPage); + + if (!tester.any(finder)) { + return null; + } + + return tester.widget(finder.first).exchangeViewModel.limits.min; + } + + LimitsState? _limitsState() { + final finder = find.byType(NewSwapPage); + + if (!tester.any(finder)) { + return null; + } + + return tester.widget(finder.first).exchangeViewModel.limitsState; + } + + double _bestRate() { + final finder = find.byType(NewSwapPage); + + if (!tester.any(finder)) { + return 0; + } + + return tester.widget(finder.first).exchangeViewModel.bestRate; + } +} diff --git a/integration_test/robots/new_wallet_page_robot.dart b/integration_test/robots/new_wallet_page_robot.dart index 00fbdabf4..7f8a254d2 100644 --- a/integration_test/robots/new_wallet_page_robot.dart +++ b/integration_test/robots/new_wallet_page_robot.dart @@ -1,36 +1,32 @@ -import 'package:hash_wallet/src/screens/new_wallet/new_wallet_page.dart'; -import 'package:flutter_test/flutter_test.dart'; +import "package:hash_wallet/src/screens/new_wallet/new_wallet_page.dart"; +import "package:hash_wallet/src/widgets/alert_with_one_action.dart"; +import "package:flutter_test/flutter_test.dart"; -import '../components/common_test_cases.dart'; +import "../core/base_robot.dart"; -class NewWalletPageRobot { - NewWalletPageRobot(this.tester) : commonTestCases = CommonTestCases(tester); +class NewWalletPageRobot extends BaseRobot { + NewWalletPageRobot(super.tester); - final WidgetTester tester; - late CommonTestCases commonTestCases; - - Future isNewWalletPage() async { - await commonTestCases.isSpecificPage(); - await commonTestCases.takeScreenshots('new_wallet_page'); + @override + Future isDisplayed() async { + await isSpecificPage(); } Future enterWalletName(String walletName) async { - await commonTestCases.enterText( - walletName, - 'new_wallet_page_wallet_name_textformfield_key', - ); - await commonTestCases.defaultSleepTime(); + await enterTextByKey("new_wallet_page_wallet_name_textformfield_key", walletName); } Future generateWalletName() async { - await commonTestCases.tapItemByKey( - 'new_wallet_page_wallet_name_textformfield_generate_name_button_key', - ); - await commonTestCases.defaultSleepTime(); + await tapByKey("new_wallet_page_wallet_name_textformfield_generate_name_button_key"); + } + + Future expectNameRejected() async { + await pumpUntilFound(find.byType(AlertWithOneAction)); + + hasType(); } Future onNextButtonPressed() async { - await commonTestCases.tapItemByKey('new_wallet_page_confirm_button_key'); - await commonTestCases.defaultSleepTime(); + await tapByKey("new_wallet_page_confirm_button_key"); } } diff --git a/integration_test/robots/new_wallet_type_page_robot.dart b/integration_test/robots/new_wallet_type_page_robot.dart index 7f1587427..7f269c6d5 100644 --- a/integration_test/robots/new_wallet_type_page_robot.dart +++ b/integration_test/robots/new_wallet_type_page_robot.dart @@ -1,55 +1,29 @@ -import 'package:hash_wallet/generated/i18n.dart'; -import 'package:hash_wallet/src/screens/new_wallet/new_wallet_type_page.dart'; -import 'package:hash_wallet/themes/core/material_base_theme.dart'; -import 'package:cw_core/wallet_type.dart'; -import 'package:flutter/material.dart'; -import 'package:flutter_test/flutter_test.dart'; +import "package:hash_wallet/src/screens/new_wallet/new_wallet_type_page.dart"; +import "package:cw_core/wallet_type.dart"; +import "package:flutter/material.dart"; +import "package:flutter_test/flutter_test.dart"; -import '../components/common_test_cases.dart'; +import "../core/base_robot.dart"; -class NewWalletTypePageRobot { - NewWalletTypePageRobot(this.tester) : commonTestCases = CommonTestCases(tester); +class NewWalletTypePageRobot extends BaseRobot { + NewWalletTypePageRobot(super.tester); - final WidgetTester tester; - late CommonTestCases commonTestCases; - - Future isNewWalletTypePage() async { - await commonTestCases.isSpecificPage(); - await commonTestCases.takeScreenshots('new_wallet_type_page'); - } - - void displaysCorrectTitle(bool isCreate) { - commonTestCases.hasText( - isCreate ? S.current.wallet_list_create_new_wallet : S.current.wallet_list_restore_wallet, - ); - } - - void hasWalletTypeForm() { - commonTestCases.hasType(); - } - - void displaysCorrectImage(ThemeType type) { - final walletTypeImage = Image.asset('assets/images/wallet_type.png').image; - final walletTypeLightImage = Image.asset('assets/images/wallet_type_light.png').image; - - find.image( - type == ThemeType.dark ? walletTypeImage : walletTypeLightImage, - ); + @override + Future isDisplayed() async { + await isSpecificPage(); } Future findParticularWalletTypeInScrollableList(WalletType type) async { - await tester.pump(Duration(milliseconds: 1000)); - final scrollableWidget = find.descendant( - of: find.byKey(Key('new_wallet_type_scrollable_key')), + of: find.byKey(const Key("new_wallet_type_scrollable_key")), matching: find.byType(Scrollable), ); - final targetWidget = find.byKey(ValueKey('new_wallet_type_${type.name}_button_key')); + final targetWidget = find.byKey(ValueKey("new_wallet_type_${type.name}_button_key")); - tester.printToConsole('Attempting to scroll to wallet type ${type.name}'); + tester.printToConsole("Attempting to scroll to wallet type ${type.name}"); - await tester.pump(Duration(milliseconds: 500)); + await pumpUntilFound(scrollableWidget.first); await tester.scrollUntilVisible( targetWidget, @@ -58,12 +32,14 @@ class NewWalletTypePageRobot { maxScrolls: 20, ); - await tester.pumpAndSettle(Duration(milliseconds: 1000)); + await settle(); - expect(tester.any(targetWidget), true, - reason: 'Wallet type ${type.name} should be visible after scrolling'); + expect( + tester.any(targetWidget), + true, + reason: "Wallet type ${type.name} should be visible after scrolling", + ); - // Additional check to ensure the widget is actually on screen final widgetRect = tester.getRect(targetWidget); final screenSize = tester.view.physicalSize; final screenRect = Rect.fromLTWH(0, 0, screenSize.width, screenSize.height); @@ -71,17 +47,13 @@ class NewWalletTypePageRobot { expect( screenRect.overlaps(widgetRect), true, - reason: 'Wallet type ${type.name} should be within screen bounds', + reason: "Wallet type ${type.name} should be within screen bounds", ); - tester.printToConsole('Wallet type ${type.name} is now visible and tappable'); + tester.printToConsole("Wallet type ${type.name} is now visible and tappable"); } Future selectWalletType(WalletType type) async { - await commonTestCases.tapItemByKey('new_wallet_type_${type.name}_button_key'); - } - - Future onNextButtonPressed() async { - await commonTestCases.tapItemByKey('new_wallet_type_next_button_key'); + await tapByKey("new_wallet_type_${type.name}_button_key"); } } diff --git a/integration_test/robots/pin_code_widget_robot.dart b/integration_test/robots/pin_code_widget_robot.dart index c961c8ff6..18d9ad008 100644 --- a/integration_test/robots/pin_code_widget_robot.dart +++ b/integration_test/robots/pin_code_widget_robot.dart @@ -1,52 +1,24 @@ -import 'package:hash_wallet/src/screens/pin_code/pin_code_widget.dart'; -import 'package:flutter_test/flutter_test.dart'; +import "package:hash_wallet/src/screens/pin_code/pin_code_widget.dart"; +import "package:flutter_test/flutter_test.dart"; -import '../components/common_test_cases.dart'; +import "../core/base_robot.dart"; -class PinCodeWidgetRobot { - PinCodeWidgetRobot(this.tester) : commonTestCases = CommonTestCases(tester); +class PinCodeWidgetRobot extends BaseRobot { + PinCodeWidgetRobot(super.tester); - final WidgetTester tester; - late CommonTestCases commonTestCases; + @override + Future isDisplayed() async => hasPinCodeWidget(); void hasPinCodeWidget() { final pinCodeWidget = find.bySubtype(); expect(pinCodeWidget, findsOneWidget); } - void hasNumberButtonsVisible() { - // Confirmation for buttons 1-9 - for (var i = 1; i < 10; i++) { - commonTestCases.hasValueKey('pin_code_button_${i}_key'); + Future enterPinCode(List pinCode) async { + for (final pin in pinCode) { + await tapByKey("pin_code_button_${pin}_key"); } - // Confirmation for 0 button - commonTestCases.hasValueKey('pin_code_button_0_key'); - } - - Future enterPassword(String password, {int pumpDuration = 100}) async { - await commonTestCases.enterText( - password, - 'enter_wallet_password', - ); - await tester.pumpAndSettle(); - await commonTestCases.tapItemByKey( - 'unlock', - ); - await tester.pumpAndSettle(); - - await commonTestCases.defaultSleepTime(); - } - - Future enterPinCode(List pinCode, {int pumpDuration = 100}) async { - for (int pin in pinCode) { - await commonTestCases.tapItemByKey( - 'pin_code_button_${pin}_key', - pumpDuration: pumpDuration, - ); - } - - await commonTestCases.takeScreenshots('pin_code_widget'); - await commonTestCases.defaultSleepTime(); + await settle(); } } diff --git a/integration_test/robots/pre_seed_page_robot.dart b/integration_test/robots/pre_seed_page_robot.dart index c5984564f..9fd71aa86 100644 --- a/integration_test/robots/pre_seed_page_robot.dart +++ b/integration_test/robots/pre_seed_page_robot.dart @@ -1,21 +1,20 @@ -import 'package:hash_wallet/src/screens/seed/pre_seed_page.dart'; -import 'package:flutter_test/flutter_test.dart'; +import "package:hash_wallet/new-ui/pages/seed/pre_seed_page.dart"; -import '../components/common_test_cases.dart'; +import "../core/base_robot.dart"; -class PreSeedPageRobot { - PreSeedPageRobot(this.tester) : commonTestCases = CommonTestCases(tester); +class PreSeedPageRobot extends BaseRobot { + PreSeedPageRobot(super.tester); - final WidgetTester tester; - late CommonTestCases commonTestCases; - - Future isPreSeedPage() async { - await commonTestCases.isSpecificPage(); - await commonTestCases.takeScreenshots('pre_seed_page'); + @override + Future isDisplayed() async { + await isSpecificPage(); } + // The button is only built once all three boxes are ticked Future onConfirmButtonPressed() async { - await commonTestCases.tapItemByKey('pre_seed_page_button_key'); - await commonTestCases.defaultSleepTime(); + await tapByKey("pre_seed_page_only_way_checkbox_key"); + await tapByKey("pre_seed_page_write_down_checkbox_key"); + await tapByKey("pre_seed_page_never_share_checkbox_key"); + await tapByKey("pre_seed_page_button_key"); } } diff --git a/integration_test/robots/restore_from_seed_or_key_robot.dart b/integration_test/robots/restore_from_seed_or_key_robot.dart index 0b2f34413..5baa27c67 100644 --- a/integration_test/robots/restore_from_seed_or_key_robot.dart +++ b/integration_test/robots/restore_from_seed_or_key_robot.dart @@ -1,123 +1,101 @@ -import 'package:hash_wallet/entities/seed_type.dart'; -import 'package:hash_wallet/generated/i18n.dart'; -import 'package:hash_wallet/src/screens/restore/wallet_restore_page.dart'; -import 'package:hash_wallet/src/widgets/validable_annotated_editable_text.dart'; -import 'package:flutter_test/flutter_test.dart'; +import "package:hash_wallet/entities/seed_type.dart"; +import "package:hash_wallet/src/screens/restore/wallet_restore_page.dart"; +import "package:hash_wallet/src/widgets/primary_button.dart"; +import "package:hash_wallet/src/widgets/validable_annotated_editable_text.dart"; +import "package:flutter/material.dart"; +import "package:flutter_test/flutter_test.dart"; -import '../components/common_test_cases.dart'; +import "../core/base_robot.dart"; -class RestoreFromSeedOrKeysPageRobot { - RestoreFromSeedOrKeysPageRobot(this.tester) : commonTestCases = CommonTestCases(tester); +class RestoreFromSeedOrKeysPageRobot extends BaseRobot { + RestoreFromSeedOrKeysPageRobot(super.tester); - final WidgetTester tester; - late CommonTestCases commonTestCases; - - Future isRestoreFromSeedKeyPage() async { - await commonTestCases.isSpecificPage(); - await commonTestCases.takeScreenshots('wallet_restore_page'); - } - - Future confirmViewComponentsDisplayProperlyPerPageView() async { - commonTestCases.hasText(S.current.wallet_name); - commonTestCases.hasText(S.current.enter_seed_phrase); - commonTestCases.hasText(S.current.restore_title_from_seed); - - commonTestCases.hasValueKey('wallet_restore_from_seed_wallet_name_textfield_key'); - commonTestCases.hasValueKey('wallet_restore_from_seed_wallet_name_refresh_button_key'); - commonTestCases.hasValueKey('wallet_restore_from_seed_wallet_seeds_paste_button_key'); - commonTestCases.hasValueKey('wallet_restore_from_seed_wallet_seeds_textfield_key'); - - commonTestCases.hasText(S.current.private_key, hasWidget: false); - commonTestCases.hasText(S.current.restore_title_from_keys, hasWidget: false); - - await commonTestCases.swipePage(); - await commonTestCases.defaultSleepTime(); - - commonTestCases.hasText(S.current.wallet_name); - commonTestCases.hasText(S.current.private_key); - commonTestCases.hasText(S.current.restore_title_from_keys); - - commonTestCases.hasText(S.current.enter_seed_phrase, hasWidget: false); - commonTestCases.hasText(S.current.restore_title_from_seed, hasWidget: false); - - await commonTestCases.swipePage(swipeRight: false); - } - - void confirmRestoreButtonDisplays() { - commonTestCases.hasValueKey('wallet_restore_seed_or_key_restore_button_key'); - } - - void confirmAdvancedSettingButtonDisplays() { - commonTestCases.hasValueKey('wallet_restore_advanced_settings_button_key'); - } - - Future enterWalletNameText(String walletName, {bool isSeedFormEntry = true}) async { - await commonTestCases.enterText( - walletName, - 'wallet_restore_from_${isSeedFormEntry ? 'seed' : 'keys'}_wallet_name_textfield_key', - ); + @override + Future isDisplayed() async { + await isSpecificPage(); } Future selectWalletNameFromAvailableOptions({bool isSeedFormEntry = true}) async { - await commonTestCases.tapItemByKey( + await tapByKey( 'wallet_restore_from_${isSeedFormEntry ? 'seed' : 'keys'}_wallet_name_refresh_button_key', ); } Future enterSeedPhraseForWalletRestore(String text) async { - ValidatableAnnotatedEditableTextState seedTextState = - await tester.state(find.byType(ValidatableAnnotatedEditableText)); + final ValidatableAnnotatedEditableTextState seedTextState = + tester.state(find.byType(ValidatableAnnotatedEditableText)); seedTextState.widget.controller.text = text; - await tester.pumpAndSettle(); + await settle(); } Future enterPasswordForWalletRestore(String text) async { - await commonTestCases.enterText( - text, - 'password', - ); - await tester.pumpAndSettle(); + await enterTextByKey("password", text); + await settle(); } Future enterPasswordRepeatForWalletRestore(String text) async { - await commonTestCases.enterText( - text, - 'repeat_wallet_password', - ); - await tester.pumpAndSettle(); + await enterTextByKey("repeat_wallet_password", text); + await settle(); } Future enterBlockHeightForWalletRestore(String blockHeight) async { - await commonTestCases.enterText( - blockHeight, - 'wallet_restore_from_seed_blockheight_textfield_key', - ); - await tester.pumpAndSettle(); + await enterTextByKey("wallet_restore_from_seed_blockheight_textfield_key", blockHeight); + await settle(); } Future chooseSeedTypeForMoneroOrWowneroWallets(MoneroSeedType selectedType) async { - await commonTestCases.tapItemByKey('wallet_restore_from_seed_seedtype_picker_button_key'); + await tapByKey("wallet_restore_from_seed_seedtype_picker_button_key"); - await commonTestCases.defaultSleepTime(); - - await commonTestCases.tapItemByKey('picker_items_index_${selectedType.title}_button_key'); + await tapByKey("picker_items_index_${selectedType.title}_button_key"); } - Future onPasteSeedPhraseButtonPressed() async { - await commonTestCases.tapItemByKey('wallet_restore_from_seed_wallet_seeds_paste_button_key'); - } - - Future enterPrivateKeyForWalletRestore(String privateKey) async { - await commonTestCases.enterText( - privateKey, - 'wallet_restore_from_key_private_key_textfield_key', + Future expectRestoreRefused({Duration window = const Duration(seconds: 10)}) async { + final button = tester.widget( + find.byKey(const ValueKey("wallet_restore_seed_or_key_restore_button_key")), ); - await tester.pumpAndSettle(); + + expect( + button.isDisabled, + true, + reason: "The restore button accepted a seed the wallet cannot parse", + ); + + final left = await pumpUntil( + () => !tester.any(find.byType(WalletRestorePage)), + timeout: window, + ); + + expect(left, false, reason: "A seed the wallet cannot parse still restored something"); } Future onRestoreWalletButtonPressed() async { - await commonTestCases.tapItemByKey('wallet_restore_seed_or_key_restore_button_key'); - await commonTestCases.defaultSleepTime(); + await tapByKey("wallet_restore_seed_or_key_restore_button_key"); + } + + Future enablePassphrase() async { + await tapByKey("wallet_restore_has_passphrase_checkbox_key"); + await settle(); + + final page = tester.widget(find.byType(WalletRestorePage)); + + expect( + page.walletRestoreViewModel.hasPassphrase, + true, + reason: "The passphrase box did not tick, the wallet would restore without it", + ); + } + + Future enterPassphrase(String passphrase) async { + await enterTextByKey( + "add_passphrase_bottom_sheet_widget_passphrase_textfield_key", + passphrase, + ); + await enterTextByKey( + "add_passphrase_bottom_sheet_widget_confirm_passphrase_textfield_key", + passphrase, + ); + + await tapByKey("add_passphrase_bottom_sheet_widget_restore_button_key"); } } diff --git a/integration_test/robots/restore_options_page_robot.dart b/integration_test/robots/restore_options_page_robot.dart index 86a04d878..03aef93c0 100644 --- a/integration_test/robots/restore_options_page_robot.dart +++ b/integration_test/robots/restore_options_page_robot.dart @@ -1,43 +1,16 @@ -import 'package:hash_wallet/src/screens/restore/restore_options_page.dart'; -import 'package:flutter_test/flutter_test.dart'; +import "package:hash_wallet/src/screens/restore/restore_options_page.dart"; -import '../components/common_test_cases.dart'; +import "../core/base_robot.dart"; -class RestoreOptionsPageRobot { - RestoreOptionsPageRobot(this.tester) : commonTestCases = CommonTestCases(tester); +class RestoreOptionsPageRobot extends BaseRobot { + RestoreOptionsPageRobot(super.tester); - final WidgetTester tester; - late CommonTestCases commonTestCases; - - Future isRestoreOptionsPage() async { - await commonTestCases.isSpecificPage(); - await commonTestCases.takeScreenshots('restore_options'); - } - - void hasRestoreOptionsButton() { - commonTestCases.hasValueKey('restore_options_from_seeds_or_keys_button_key'); - commonTestCases.hasValueKey('restore_options_from_backup_button_key'); - commonTestCases.hasValueKey('restore_options_from_hardware_wallet_button_key'); - commonTestCases.hasValueKey('restore_options_from_qr_button_key'); + @override + Future isDisplayed() async { + await isSpecificPage(); } Future navigateToRestoreFromSeedsOrKeysPage() async { - await commonTestCases.tapItemByKey('restore_options_from_seeds_or_keys_button_key'); - await commonTestCases.defaultSleepTime(); - } - - Future navigateToRestoreFromBackupPage() async { - await commonTestCases.tapItemByKey('restore_options_from_backup_button_key'); - await commonTestCases.defaultSleepTime(); - } - - Future navigateToRestoreFromHardwareWalletPage() async { - await commonTestCases.tapItemByKey('restore_options_from_hardware_wallet_button_key'); - await commonTestCases.defaultSleepTime(); - } - - Future backAndVerify() async { - await commonTestCases.goBack(); - await isRestoreOptionsPage(); + await tapByKey("restore_options_from_seeds_or_keys_button_key"); } } diff --git a/integration_test/robots/security_and_backup_page_robot.dart b/integration_test/robots/security_and_backup_page_robot.dart deleted file mode 100644 index b867f871a..000000000 --- a/integration_test/robots/security_and_backup_page_robot.dart +++ /dev/null @@ -1,28 +0,0 @@ -import 'package:hash_wallet/generated/i18n.dart'; -import 'package:hash_wallet/src/screens/settings/security_backup_page.dart'; -import 'package:flutter_test/flutter_test.dart'; - -import '../components/common_test_cases.dart'; - -class SecurityAndBackupPageRobot { - SecurityAndBackupPageRobot(this.tester) : commonTestCases = CommonTestCases(tester); - - final WidgetTester tester; - final CommonTestCases commonTestCases; - - Future isSecurityAndBackupPage() async { - await commonTestCases.isSpecificPage(); - await commonTestCases.takeScreenshots('security_backup_page'); - } - - void hasTitle() { - commonTestCases.hasText(S.current.security_and_backup); - } - - Future navigateToShowKeysPage() async { - await tester.pumpAndSettle(Duration(milliseconds: 1000)); - - await commonTestCases.tapItemByKey('security_backup_page_show_keys_button_key'); - await tester.pumpAndSettle(Duration(milliseconds: 1000)); - } -} diff --git a/integration_test/robots/security_backup_page_robot.dart b/integration_test/robots/security_backup_page_robot.dart new file mode 100644 index 000000000..c040cfe6d --- /dev/null +++ b/integration_test/robots/security_backup_page_robot.dart @@ -0,0 +1,16 @@ +import "package:hash_wallet/src/screens/settings/security_backup_page.dart"; + +import "../core/base_robot.dart"; + +class SecurityBackupPageRobot extends BaseRobot { + SecurityBackupPageRobot(super.tester); + + @override + Future isDisplayed() async { + await isSpecificPage(); + } + + Future openChangePin() async { + await tapByKey("security_backup_page_change_pin_button_key"); + } +} diff --git a/integration_test/robots/seed_verification_page_robot.dart b/integration_test/robots/seed_verification_page_robot.dart index 34bd41065..7fad726e2 100644 --- a/integration_test/robots/seed_verification_page_robot.dart +++ b/integration_test/robots/seed_verification_page_robot.dart @@ -1,22 +1,19 @@ -import 'package:hash_wallet/generated/i18n.dart'; -import 'package:hash_wallet/src/screens/seed/seed_verification/seed_verification_page.dart'; -import 'package:flutter_test/flutter_test.dart'; +import "package:hash_wallet/generated/i18n.dart"; +import "package:hash_wallet/src/screens/seed/seed_verification/seed_verification_page.dart"; +import "package:flutter_test/flutter_test.dart"; -import '../components/common_test_cases.dart'; +import "../core/base_robot.dart"; -class SeedVerificationPageRobot { - SeedVerificationPageRobot(this.tester) : commonTestCases = CommonTestCases(tester); +class SeedVerificationPageRobot extends BaseRobot { + SeedVerificationPageRobot(super.tester); - final WidgetTester tester; - final CommonTestCases commonTestCases; - - Future isSeedVerificationPage() async { - await commonTestCases.isSpecificPage(); - await commonTestCases.takeScreenshots('seed_verification_page'); + @override + Future isDisplayed() async { + await isSpecificPage(); } void hasTitle() { - commonTestCases.hasText(S.current.verify_seed); + hasText(S.current.verify_seed); } Future verifyWalletSeeds() async { @@ -28,18 +25,23 @@ class SeedVerificationPageRobot { while (!walletSeedViewModel.isVerificationComplete && walletSeedViewModel.verificationWordCount != 0) { final currentCorrectWord = walletSeedViewModel.currentCorrectWord; + final currentStep = walletSeedViewModel.currentStepIndex; - commonTestCases.hasTextAtLestOnce(currentCorrectWord); + hasTextAtLeastOnce(currentCorrectWord); - await commonTestCases.tapItemByKey( - 'seed_verification_option_${currentCorrectWord}_button_key', + await tapByKey("seed_verification_option_${currentCorrectWord}_button_key"); + + final hasMovedOn = await pumpUntil( + () => + walletSeedViewModel.isVerificationComplete || + walletSeedViewModel.currentStepIndex != currentStep, ); - await commonTestCases.defaultSleepTime(seconds: 1); + if (!hasMovedOn) { + throw TestFailure("Seed verification never moved past step $currentStep"); + } } - await commonTestCases.tapItemByKey('wallet_seed_page_open_wallet_button_key'); - - await commonTestCases.defaultSleepTime(); + await tapByKey("wallet_seed_page_open_wallet_button_key"); } } diff --git a/integration_test/robots/send_page_robot.dart b/integration_test/robots/send_page_robot.dart deleted file mode 100644 index fb40214f8..000000000 --- a/integration_test/robots/send_page_robot.dart +++ /dev/null @@ -1,716 +0,0 @@ -import 'dart:async'; - -import 'package:hash_wallet/core/execution_state.dart'; -import 'package:hash_wallet/generated/i18n.dart'; -import 'package:hash_wallet/src/screens/send/send_page.dart'; -import 'package:hash_wallet/src/widgets/base_text_form_field.dart'; -import 'package:hash_wallet/src/widgets/primary_button.dart'; -import 'package:hash_wallet/view_model/send/send_view_model_state.dart'; -import 'package:cw_core/crypto_currency.dart'; -import 'package:cw_core/transaction_priority.dart'; -import 'package:flutter/material.dart'; -import 'package:flutter_test/flutter_test.dart'; - -import '../components/common_test_cases.dart'; -import '../components/common_test_constants.dart'; -import 'auth_page_robot.dart'; -import 'package:hash_wallet/src/widgets/standard_slide_button_widget.dart'; -import 'package:hash_wallet/src/screens/dashboard/dashboard_page.dart'; - -class SendPageRobot { - SendPageRobot({required this.tester}) - : commonTestCases = CommonTestCases(tester), - authPageRobot = AuthPageRobot(tester); - - WidgetTester tester; - CommonTestCases commonTestCases; - AuthPageRobot authPageRobot; - - Future isSendPage() async { - await commonTestCases.isSpecificPage(); - await commonTestCases.takeScreenshots('send_page'); - } - - Future waitForSendPage() async { - tester.printToConsole('Waiting for SendPage to be available'); - - final stopwatch = Stopwatch()..start(); - final maxDuration = Duration(seconds: 30); - - while (stopwatch.elapsed < maxDuration) { - await tester.pump(Duration(milliseconds: 500)); - - // Check if we're still on auth page - if (authPageRobot.onAuthPage()) { - tester.printToConsole('Still on auth page, waiting...'); - continue; - } - - // Check if SendPage is available - final sendPageFinder = find.byType(SendPage); - if (sendPageFinder.tryEvaluate()) { - tester.printToConsole('SendPage found!'); - return; - } - - tester.printToConsole('SendPage not found yet, waiting...'); - } - - throw Exception('SendPage not found after ${maxDuration.inSeconds} seconds'); - } - - Future checkIfSendPageIsVisible() async { - tester.printToConsole('Confirming SendPage is visible'); - - if (authPageRobot.onAuthPage()) { - tester.printToConsole('Auth page is currently visible'); - await _handleAuthPage(); - await commonTestCases.defaultSleepTime(); - } - - await waitForSendPage(); - } - - void hasTitle() { - commonTestCases.hasText(S.current.send); - } - - void confirmViewComponentsDisplayProperly() { - SendPage sendPage = tester.widget(find.byType(SendPage)); - final sendViewModel = sendPage.sendViewModel; - - commonTestCases.hasValueKey('send_page_address_textfield_key'); - commonTestCases.hasValueKey('send_page_note_textfield_key'); - commonTestCases.hasValueKey('send_page_amount_textfield_key'); - commonTestCases.hasValueKey('send_page_add_template_button_key'); - - if (sendViewModel.hasMultipleTokens) { - commonTestCases.hasValueKey('send_page_currency_picker_button_key'); - } - - if (!sendViewModel.isBatchSending) { - commonTestCases.hasValueKey('send_page_send_all_button_key'); - } - - if (!sendViewModel.isFiatDisabled) { - commonTestCases.hasValueKey('send_page_fiat_amount_textfield_key'); - } - - if (sendViewModel.feesViewModel.hasFees) { - commonTestCases.hasValueKey('send_page_select_fee_priority_button_key'); - } - - if (sendViewModel.hasCoinControl) { - commonTestCases.hasValueKey('send_page_unspent_coin_button_key'); - } - - if (sendViewModel.sendTemplateViewModel.hasMultiRecipient) { - commonTestCases.hasValueKey('send_page_add_receiver_button_key'); - } - } - - Future selectReceiveCurrency(CryptoCurrency receiveCurrency) async { - final currencyPickerKey = 'send_page_currency_picker_button_key'; - final currencyPickerDialogKey = 'send_page_currency_picker_dialog_button_key'; - - await commonTestCases.tapItemByKey(currencyPickerKey); - await commonTestCases.defaultSleepTime(); - - // Check if picker dialog is present - if (!commonTestCases.isKeyPresent(currencyPickerDialogKey)) { - tester.printToConsole('Currency picker dialog not found, may already be selected'); - return; - } - - SendPage sendPage = tester.widget(find.byType(SendPage)); - final sendViewModel = sendPage.sendViewModel; - - if (receiveCurrency == sendViewModel.selectedCryptoCurrency) { - await commonTestCases - .tapItemByKey('picker_items_index_${receiveCurrency.name}_selected_item_button_key'); - return; - } - - await commonTestCases.enterText(receiveCurrency.title, 'search_bar_widget_key'); - - await commonTestCases.defaultSleepTime(); - - await commonTestCases.tapItemByKey('picker_items_index_${receiveCurrency.fullName}_button_key'); - } - - Future enterReceiveAddress(String receiveAddress) async { - await commonTestCases.enterText(receiveAddress, 'send_page_address_textfield_key'); - await commonTestCases.defaultSleepTime(); - } - - Future enterSendAmount(String amount, {bool isFiat = false}) async { - await commonTestCases.enterText( - amount, - isFiat ? 'send_page_fiat_amount_textfield_key' : 'send_page_amount_textfield_key', - ); - } - - String _getTextFromField(ValueKey key) { - final field = find.byKey(key); - if (!field.tryEvaluate()) return ''; - final baseTextFormField = tester.widget(field); - return baseTextFormField.controller?.text ?? ''; - } - - /// Validates wallet balance for $1 send by checking against the converted crypto amount - /// Returns true if wallet has sufficient balance, false otherwise - Future validateWalletBalanceForOneDollarSend() async { - SendPage sendPage = tester.widget(find.byType(SendPage)); - final sendViewModel = sendPage.sendViewModel; - - final balance = await sendViewModel.sendingBalance; - - await setupOneDollarSend(); - - // Get the crypto amount that was set for the $1 send - String cryptoAmount = _getTextFromField(ValueKey('send_page_amount_textfield_key')); - if (cryptoAmount.isEmpty || cryptoAmount == '0' || cryptoAmount == '0.0') { - cryptoAmount = '0.001'; // fallback amount - } - - final amount = double.tryParse(cryptoAmount) ?? 0.0; - - tester.printToConsole( - 'Wallet balance: $balance, sending amount for \$${CommonTestConstants.sendTestFiatAmount}: $cryptoAmount', - ); - - if (balance.isEmpty || double.tryParse(balance) == null) { - tester.printToConsole('Invalid wallet balance: $balance'); - return false; - } - - final balanceValue = double.parse(balance); - if (balanceValue < amount) { - tester.printToConsole( - 'Insufficient balance for \$${CommonTestConstants.sendTestFiatAmount} send: $balanceValue < $amount', - ); - return false; - } - - tester.printToConsole( - 'Wallet has sufficient balance for \$${CommonTestConstants.sendTestFiatAmount} send', - ); - return true; - } - - Future selectTransactionPriority({TransactionPriority? priority}) async { - SendPage sendPage = tester.widget(find.byType(SendPage)); - final sendViewModel = sendPage.sendViewModel; - - if (!sendViewModel.feesViewModel.hasFees || priority == null) return; - - final transactionPriorityPickerKey = 'send_page_select_fee_priority_button_key'; - await commonTestCases.tapItemByKey(transactionPriorityPickerKey); - - if (priority == sendViewModel.feesViewModel.transactionPriority) { - await commonTestCases - .tapItemByKey('picker_items_index_${priority.title}_selected_item_button_key'); - return; - } - - await commonTestCases.dragUntilVisible( - 'picker_items_index_${priority.title}_button_key', - 'picker_scrollbar_key', - ); - await commonTestCases.defaultSleepTime(); - - await commonTestCases.tapItemByKey('picker_items_index_${priority.title}_button_key'); - } - - Future onSendButtonPressed() async { - tester.printToConsole('Pressing send'); - - await checkIfSendPageIsVisible(); - - await tester.pumpAndSettle(); - final sendPage = tester.widget(find.byType(SendPage)); - - while (true) { - bool isReadyForSend = sendPage.sendViewModel.isReadyForSend; - await tester.pump(); - if (isReadyForSend) { - tester.printToConsole('Is ready for send'); - break; - } else { - await commonTestCases.defaultSleepTime(); - await tester.pumpAndSettle(); - tester.printToConsole('not yet ready for send'); - } - } - await commonTestCases.tapItemByKey( - 'send_page_send_button_key', - shouldPumpAndSettle: false, - ); - - await _waitForSendTransactionCompletion(); - - await commonTestCases.defaultSleepTime(); - } - - Future _waitForSendTransactionCompletion() async { - await tester.pump(); - final Completer completer = Completer(); - - // Loop to wait for the async operation to complete - while (true) { - await Future.delayed(Duration(seconds: 1)); - - tester.printToConsole('Before _handleAuth'); - - await _handleAuthPage(); - - await commonTestCases.defaultSleepTime(); - - tester.printToConsole('After _handleAuth'); - - await tester.pump(); - - if (authPageRobot.onAuthPage()) { - tester.printToConsole('Still on auth page, continuing to wait'); - continue; - } - - final sendPageFinder = find.byType(SendPage); - if (!sendPageFinder.tryEvaluate()) { - tester.printToConsole('SendPage not found yet, continuing to wait'); - continue; - } - - final sendPage = tester.widget(sendPageFinder); - final state = sendPage.sendViewModel.state; - - await tester.pump(); - - bool isDone = state is ExecutedSuccessfullyState || state is TransactionCommitted; - bool isFailed = state is FailureState; - - tester.printToConsole('isDone: $isDone'); - tester.printToConsole('isFailed: $isFailed'); - - if (isDone || isFailed) { - tester.printToConsole( - isDone ? 'Completer is done' : 'Completer is done though operation failed', - ); - completer.complete(); - await tester.pump(); - break; - } else { - tester.printToConsole('Completer is not done'); - await tester.pump(); - } - } - - await expectLater(completer.future, completes); - - tester.printToConsole('Done confirming sending operation'); - } - - Future _handleAuthPage() async { - tester.printToConsole('Inside _handleAuth'); - - final onAuthPageDesktop = authPageRobot.onAuthPageDesktop(); - if (onAuthPageDesktop) { - await authPageRobot.enterPassword(CommonTestConstants.pin.join("")); - await commonTestCases.defaultSleepTime(); - return; - } - - await tester.pump(); - tester.printToConsole('starting auth checks'); - - final authPage = authPageRobot.onAuthPage(); - - tester.printToConsole('hasAuth:$authPage'); - - if (authPage) { - await tester.pump(); - tester.printToConsole('Starting inner _handleAuth loop checks'); - - try { - await authPageRobot.enterPinCode(CommonTestConstants.pin, pumpDuration: 500); - tester.printToConsole('Auth done'); - - await tester.pump(Duration(seconds: 3)); - - tester.printToConsole('Auth pump done'); - - await commonTestCases.defaultSleepTime(); - } catch (e) { - tester.printToConsole('Auth failed, retrying: $e'); - await tester.pump(); - - await commonTestCases.defaultSleepTime(seconds: 1); - await _handleAuthPage(); - } - } else { - tester.printToConsole('No auth page detected, proceeding'); - } - await tester.pump(); - } - - //* ------ On Sending Failure ------------ - Future hasErrorWhileSending() async { - await tester.pump(); - - tester.printToConsole('Checking if there is an error'); - - final errorDialog = find.byKey(ValueKey('send_page_send_failure_dialog_button_key')); - - bool hasError = errorDialog.tryEvaluate(); - - tester.printToConsole('Has error: $hasError'); - - return hasError; - } - - Future onSendFailureDialogButtonPressed() async { - await commonTestCases.defaultSleepTime(); - - tester.printToConsole('Send Button Failure Dialog Triggered'); - - await commonTestCases.tapItemByKey('send_page_send_failure_dialog_button_key'); - } - - //* ------ On Sending Success ------------ - Future onSendSliderOnConfirmSendingBottomSheetDragged() async { - await commonTestCases.defaultSleepTime(); - await tester.pump(); - - if (commonTestCases.isKeyPresent('send_page_confirm_sending_bottom_sheet_key')) { - tester.printToConsole('Found confirm sending bottom sheet, starting slider drag'); - - final accessibleButton = find.byType(PrimaryButton); - if (accessibleButton.tryEvaluate()) { - tester.printToConsole('Found accessible navigation button, tapping it'); - await tester.tap(accessibleButton); - await tester.pumpAndSettle(); - } else { - await _performSendSliderDrag(); - } - - tester.printToConsole('Slider/button action completed, waiting for transaction completion'); - - // Check if bottom sheet is dismissed - if (commonTestCases.isKeyPresent('send_page_confirm_sending_bottom_sheet_key')) { - tester.printToConsole('Bottom sheet still present, waiting a bit more for dismissal'); - await commonTestCases.defaultSleepTime(seconds: 3); - - // If still present, try one more manual dismissal attempt - if (commonTestCases.isKeyPresent('send_page_confirm_sending_bottom_sheet_key')) { - tester.printToConsole('Bottom sheet still present, attempting final manual dismissal'); - await tester.tapAt(Offset(200, 200)); // Tap outside - await tester.pumpAndSettle(); - } - } - - // Wait for transaction completion - await _waitForCommitTransactionCompletion(); - await commonTestCases.defaultSleepTime(seconds: 2); - } else { - tester.printToConsole('Confirm sending bottom sheet not found, waiting and retrying'); - await commonTestCases.defaultSleepTime(); - await tester.pump(); - await onSendSliderOnConfirmSendingBottomSheetDragged(); - } - } - - Future _performSendSliderDrag() async { - final sliderFinder = find.byKey(const ValueKey('standard_slide_button_widget_slider_key')); - expect(sliderFinder, findsOneWidget, reason: 'Slider should be found'); - - // Find the StandardSlideButton widget to get the main container - final slideButtonFinder = find.byType(StandardSlideButton); - expect(slideButtonFinder, findsOneWidget, reason: 'StandardSlideButton should be found'); - - // Get the main container bounds (the outer container, not the slider container) - final mainContainerRect = tester.getRect(slideButtonFinder); - final containerWidth = mainContainerRect.width; - - final sideMargin = 4.0; - final sliderWidth = 42.0; - final effectiveMaxWidth = containerWidth - 2 * sideMargin; - final threshold = effectiveMaxWidth - sliderWidth - 10; - - // Add a small buffer to ensure we exceed the threshold - final dragDistance = threshold + 20; - - tester.printToConsole( - 'Main container width: $containerWidth, Threshold: $threshold, Drag distance: $dragDistance', - ); - - // Start the drag - await tester.drag(sliderFinder, Offset(dragDistance, 0)); - - // Wait for the drag to complete and trigger onHorizontalDragEnd - await tester.pump(Duration(milliseconds: 100)); - - // Release the drag (this should trigger onHorizontalDragEnd) - await tester.pump(Duration(seconds: 2)); - - tester.printToConsole('Drag completed, waiting for callback'); - - // Wait for the slide completion callback to trigger and bottom sheet to dismiss - await _waitForBottomSheetDismissal(); - } - - Future _waitForBottomSheetDismissal() async { - tester.printToConsole('Waiting for bottom sheet dismissal'); - - final stopwatch = Stopwatch()..start(); - final maxDuration = Duration(seconds: 10); - - while (stopwatch.elapsed < maxDuration) { - await tester.pump(Duration(milliseconds: 500)); - - // Check if the confirm bottom sheet is still present - if (!commonTestCases.isKeyPresent('send_page_confirm_sending_bottom_sheet_key')) { - tester.printToConsole('Bottom sheet dismissed successfully!'); - return; - } - - // Check if transaction has started (this indicates slider was successful) - if (_isTransactionStarted()) { - tester.printToConsole( - 'Transaction started, slider was successful even if bottom sheet is still visible'); - return; - } - - tester.printToConsole('Bottom sheet still present, waiting'); - } - - tester.printToConsole('Bottom sheet dismissal timeout reached, trying manual dismissal'); - - // If the bottom sheet is still present after timeout, try to manually dismiss it - // This might happen if the slider drag didn't trigger the onSlideComplete properly - if (commonTestCases.isKeyPresent('send_page_confirm_sending_bottom_sheet_key')) { - tester.printToConsole('Attempting manual bottom sheet dismissal'); - - // Try to find and tap a close button or back button - final closeButton = find.byIcon(Icons.close); - if (closeButton.tryEvaluate()) { - await tester.tap(closeButton); - await tester.pumpAndSettle(); - tester.printToConsole('Manual close button tapped'); - } else { - // Try to pop the bottom sheet by tapping outside or using back gesture - await tester.tapAt(Offset(100, 100)); // Tap outside the bottom sheet - await tester.pumpAndSettle(); - tester.printToConsole('Tapped outside bottom sheet'); - } - } - } - - bool _isTransactionStarted() { - try { - // Check if SendPage is available and transaction state has changed - final sendPageFinder = find.byType(SendPage); - if (!sendPageFinder.tryEvaluate()) { - return false; - } - - final sendPage = tester.widget(sendPageFinder); - final state = sendPage.sendViewModel.state; - - // Check if we're in a transaction-related state - return state is TransactionCommitting || - state is IsExecutingState || - state is TransactionCommitted || - state is ExecutedSuccessfullyState; - } catch (e) { - return false; - } - } - - Future _waitForCommitTransactionCompletion() async { - tester.printToConsole('Starting to wait for transaction completion'); - - final stopwatch = Stopwatch()..start(); - final maxDuration = Duration(seconds: 60); - - while (stopwatch.elapsed < maxDuration) { - await Future.delayed(Duration(seconds: 1)); - - // Check if the confirm bottom sheet is gone (transaction started) - if (!commonTestCases.isKeyPresent('send_page_confirm_sending_bottom_sheet_key')) { - tester.printToConsole('Confirm bottom sheet disappeared, transaction may be processing'); - } - - // Check if we've navigated back to the dashboard (for cases of successful transaction) - final dashboardFinder = find.byType(DashboardPage); - if (dashboardFinder.tryEvaluate()) { - tester.printToConsole('Dashboard detected, transaction completed successfully!'); - await tester.pump(); - return; - } - - // Check if SendPage is still available (transaction still processing) - final sendPageFinder = find.byType(SendPage); - if (sendPageFinder.tryEvaluate()) { - final sendPage = tester.widget(sendPageFinder); - final state = sendPage.sendViewModel.state; - - bool isDone = state is ExecutedSuccessfullyState || state is TransactionCommitted; - bool isFailed = state is FailureState; - - tester.printToConsole('Transaction state: $state'); - tester.printToConsole('isDone: $isDone'); - tester.printToConsole('isFailed: $isFailed'); - - if (isDone) { - tester.printToConsole('Transaction committed successfully!'); - await tester.pump(); - return; - } else if (isFailed) { - tester.printToConsole('Transaction failed: $state'); - await tester.pump(); - return; - } else { - tester.printToConsole('Transaction still processing'); - await tester.pump(); - } - } else { - // SendPage not found, check if we're on dashboard or still processing - if (dashboardFinder.tryEvaluate()) { - tester.printToConsole('Dashboard detected - transaction completed successfully!'); - await tester.pump(); - return; - } else { - tester.printToConsole( - 'SendPage not found, but dashboard not yet visible - continuing to wait...'); - } - } - } - - if (stopwatch.elapsed >= maxDuration) { - tester.printToConsole('Transaction completion timeout reached'); - } - - tester.printToConsole('Done waiting for transaction completion'); - } - - //* ---- Handle Transaction Success Flow ----- - Future handleTransactionSuccessFlow() async { - await commonTestCases.defaultSleepTime(); - - // Wait for any success dialogs to appear - await tester.pump(Duration(seconds: 2)); - - // Check for contact addition dialog first (if new contact address exists) - final contactDialog = find.byKey(ValueKey('send_page_add_contact_bottom_sheet_yes_button_key')); - if (contactDialog.tryEvaluate()) { - tester.printToConsole('Found contact addition dialog, selecting Yes'); - - // Check if the button is actually visible and tappable - final buttonRect = tester.getRect(contactDialog); - final screenSize = tester.view.physicalSize / tester.view.devicePixelRatio; - - if (buttonRect.bottom <= screenSize.height && buttonRect.top >= 0) { - await commonTestCases.tapItemByKey('send_page_add_contact_bottom_sheet_yes_button_key'); - await commonTestCases.defaultSleepTime(); - } else { - tester.printToConsole('Contact dialog button is off-screen, skipping'); - } - } - - // Check for the main success dialog - final successDialog = find.byKey(ValueKey('send_page_sent_dialog_ok_button_key')); - if (successDialog.tryEvaluate()) { - tester.printToConsole('Found transaction success dialog, closing it'); - await commonTestCases.tapItemByKey('send_page_sent_dialog_ok_button_key'); - await commonTestCases.defaultSleepTime(); - } - } - - //* ---- Fiat/Crypto Amount Validation ----- - Future testFiatAmountEntry() async { - tester.printToConsole('Testing fiat amount entry...'); - - await enterSendAmount(''); - await enterSendAmount('', isFiat: true); - await commonTestCases.defaultSleepTime(); - - await enterSendAmount(CommonTestConstants.sendTestFiatAmount, isFiat: true); - await commonTestCases.defaultSleepTime(); - - // Wait for conversion to complete - await tester.pump(Duration(seconds: 3)); - await tester.pumpAndSettle(); - - // Next we get the crypto amount value and validate it's not 0 - final cryptoAmount = _getTextFromField(ValueKey('send_page_amount_textfield_key')); - - tester.printToConsole( - 'Crypto amount after entering \$${CommonTestConstants.sendTestFiatAmount}: $cryptoAmount', - ); - - if (cryptoAmount.isNotEmpty && cryptoAmount != '0' && cryptoAmount != '0.0') { - tester.printToConsole('Fiat to crypto conversion working - crypto amount: $cryptoAmount'); - } else { - tester.printToConsole( - 'Fiat to crypto conversion may not be working - crypto amount: $cryptoAmount', - ); - } - } - - Future testCryptoAmountEntry() async { - tester.printToConsole('Testing crypto amount entry...'); - - await enterSendAmount(''); - await enterSendAmount('', isFiat: true); - await commonTestCases.defaultSleepTime(); - - String cryptoAmount = '0.001'; - - await enterSendAmount(cryptoAmount); - await commonTestCases.defaultSleepTime(); - - // Wait for conversion to complete - await tester.pump(Duration(seconds: 3)); - await tester.pumpAndSettle(); - - // Get the fiat amount value and validate it's not 0 - final fiatAmount = _getTextFromField(ValueKey('send_page_fiat_amount_textfield_key')); - - tester.printToConsole('Fiat amount after entering $cryptoAmount: $fiatAmount'); - - if (fiatAmount.isNotEmpty && fiatAmount != '0' && fiatAmount != '0.0') { - tester.printToConsole('Crypto to fiat conversion working, fiat amount: $fiatAmount'); - } else { - tester.printToConsole( - 'Crypto to fiat conversion may not be working - fiat amount: $fiatAmount'); - } - } - - Future setupOneDollarSend() async { - // Clear existing amounts - await enterSendAmount(''); - await enterSendAmount('', isFiat: true); - await commonTestCases.defaultSleepTime(); - - await enterSendAmount(CommonTestConstants.sendTestFiatAmount, isFiat: true); - await commonTestCases.defaultSleepTime(); - - // Wait for conversion to complete - await tester.pump(Duration(seconds: 3)); - await tester.pumpAndSettle(); - - // Get the converted crypto amount - String cryptoAmount = _getTextFromField(ValueKey('send_page_amount_textfield_key')); - if (cryptoAmount.isEmpty || cryptoAmount == '0' || cryptoAmount == '0.0') { - cryptoAmount = '0.001'; // fallback amount - } - - tester.printToConsole( - 'Sending $cryptoAmount (equivalent to \$${CommonTestConstants.sendTestFiatAmount}) to test wallet', - ); - - // Update the amount field with the converted value - await enterSendAmount(cryptoAmount); - await commonTestCases.defaultSleepTime(); - } -} diff --git a/integration_test/robots/setup_pin_code_robot.dart b/integration_test/robots/setup_pin_code_robot.dart index 83e30a8b0..28d358c14 100644 --- a/integration_test/robots/setup_pin_code_robot.dart +++ b/integration_test/robots/setup_pin_code_robot.dart @@ -1,29 +1,16 @@ -import 'package:hash_wallet/generated/i18n.dart'; -import 'package:hash_wallet/src/screens/setup_pin_code/setup_pin_code.dart'; -import 'package:flutter_test/flutter_test.dart'; +import "package:hash_wallet/src/screens/setup_pin_code/setup_pin_code.dart"; -import '../components/common_test_cases.dart'; -import 'pin_code_widget_robot.dart'; +import "pin_code_widget_robot.dart"; class SetupPinCodeRobot extends PinCodeWidgetRobot { - SetupPinCodeRobot(this.tester) - : commonTestCases = CommonTestCases(tester), - super(tester); + SetupPinCodeRobot(super.tester); - final WidgetTester tester; - late CommonTestCases commonTestCases; - - Future isSetupPinCodePage() async { - await commonTestCases.isSpecificPage(); - await commonTestCases.takeScreenshots('setup_pin_code_page'); - } - - void hasTitle() { - commonTestCases.hasText(S.current.setup_pin); + @override + Future isDisplayed() async { + await isSpecificPage(); } Future tapSuccessButton() async { - await commonTestCases.tapItemByKey('setup_pin_code_success_button_key'); - await commonTestCases.defaultSleepTime(); + await tapByKey("setup_pin_code_success_button_key"); } } diff --git a/integration_test/robots/show_keys_disclaimer_page_robot.dart b/integration_test/robots/show_keys_disclaimer_page_robot.dart new file mode 100644 index 000000000..3d931abe9 --- /dev/null +++ b/integration_test/robots/show_keys_disclaimer_page_robot.dart @@ -0,0 +1,16 @@ +import "package:hash_wallet/new-ui/pages/seed/show_keys_disclaimer_page.dart"; + +import "../core/base_robot.dart"; + +class ShowKeysDisclaimerPageRobot extends BaseRobot { + ShowKeysDisclaimerPageRobot(super.tester); + + @override + Future isDisplayed() async { + await isSpecificPage(); + } + + Future tapShowKeys() async { + await tapByKey("show_keys_disclaimer_page_button_key"); + } +} diff --git a/integration_test/robots/transaction_success_info_robot.dart b/integration_test/robots/transaction_success_info_robot.dart deleted file mode 100644 index b07a5e8ef..000000000 --- a/integration_test/robots/transaction_success_info_robot.dart +++ /dev/null @@ -1,21 +0,0 @@ -import 'package:hash_wallet/src/screens/send/transaction_success_info_page.dart'; -import 'package:flutter_test/flutter_test.dart'; - -import '../components/common_test_cases.dart'; - -class TransactionSuccessInfoRobot { - TransactionSuccessInfoRobot(this.tester) : commonTestCases = CommonTestCases(tester); - - final WidgetTester tester; - late CommonTestCases commonTestCases; - - Future isTransactionSuccessInfoPage() async { - await commonTestCases.isSpecificPage(); - await commonTestCases.takeScreenshots('transaction_success_info_page'); - } - - Future onConfirmButtonPressed() async { - await commonTestCases.tapItemByKey('transaction_success_info_page_button_key'); - await commonTestCases.defaultSleepTime(); - } -} diff --git a/integration_test/robots/transactions_page_robot.dart b/integration_test/robots/transactions_page_robot.dart deleted file mode 100644 index cc446886e..000000000 --- a/integration_test/robots/transactions_page_robot.dart +++ /dev/null @@ -1,439 +0,0 @@ -import 'package:hash_wallet/generated/i18n.dart'; -import 'package:hash_wallet/src/screens/dashboard/pages/transactions_page.dart'; -import 'package:hash_wallet/view_model/dashboard/anonpay_transaction_list_item.dart'; -import 'package:hash_wallet/view_model/dashboard/dashboard_view_model.dart'; -import 'package:hash_wallet/view_model/dashboard/date_section_item.dart'; -import 'package:hash_wallet/view_model/dashboard/order_list_item.dart'; -import 'package:hash_wallet/view_model/dashboard/trade_list_item.dart'; -import 'package:hash_wallet/view_model/dashboard/transaction_list_item.dart'; -import 'package:cw_core/crypto_currency.dart'; -import 'package:cw_core/sync_status.dart'; -import 'package:cw_core/transaction_direction.dart'; -import 'package:flutter/material.dart'; -import 'package:flutter_test/flutter_test.dart'; -import 'package:intl/intl.dart'; - -import '../components/common_test_cases.dart'; - -class TransactionsPageRobot { - TransactionsPageRobot(this.tester) : commonTestCases = CommonTestCases(tester); - - final WidgetTester tester; - late CommonTestCases commonTestCases; - - Future isTransactionsPage() async { - await commonTestCases.isSpecificPage(); - await commonTestCases.takeScreenshots('transactions_page'); - } - - Future confirmTransactionsPageConstantsDisplayProperly() async { - await commonTestCases.defaultSleepTime(); - - final transactionsPage = tester.widget(find.byType(TransactionsPage)); - final dashboardViewModel = transactionsPage.dashboardViewModel; - if (dashboardViewModel.status is SyncingSyncStatus) { - commonTestCases.hasValueKey('transactions_page_syncing_alert_card_key'); - commonTestCases.hasText(S.current.syncing_wallet_alert_title); - commonTestCases.hasText(S.current.syncing_wallet_alert_content); - } - - commonTestCases.hasValueKey('transactions_page_header_row_key'); - commonTestCases.hasText(S.current.transactions); - commonTestCases.hasValueKey('transactions_page_header_row_transaction_filter_button_key'); - } - - Future confirmTransactionHistoryListDisplaysCorrectly(bool hasTxHistoryWhileSyncing) async { - try { - final transactionsPage = tester.widget(find.byType(TransactionsPage)); - final dashboardViewModel = transactionsPage.dashboardViewModel; - - await _waitForListToBeReady(); - - if (!hasTxHistoryWhileSyncing) { - await _waitForSyncToComplete(dashboardViewModel); - } else { - await tester.pump(Duration(seconds: 2)); - await tester.pumpAndSettle(); - } - - await _performComprehensiveItemCheck(dashboardViewModel); - } catch (e) { - tester.printToConsole('Error in transaction history check: $e'); - } - } - - Future _waitForSyncToComplete(DashboardViewModel dashboardViewModel) async { - const maxWaitTime = Duration(minutes: 2); - final endTime = DateTime.now().add(maxWaitTime); - - tester.printToConsole('Waiting for wallet to sync...'); - - while (DateTime.now().isBefore(endTime)) { - await tester.pump(Duration(seconds: 2)); - await tester.pumpAndSettle(); - - if (dashboardViewModel.status is SyncedSyncStatus) { - tester.printToConsole('Wallet synced successfully'); - return; - } - - // Check if we have now have transaction items available - if (dashboardViewModel.items.isNotEmpty) { - tester.printToConsole('Items available while syncing, proceeding with test'); - return; - } - - tester.printToConsole('Sync status: ${dashboardViewModel.status.runtimeType}'); - } - - tester.printToConsole('Warning: Wallet did not sync within expected time, proceeding anyway'); - } - - Future _performComprehensiveItemCheck(DashboardViewModel dashboardViewModel) async { - try { - await _waitForItemsToLoad(dashboardViewModel); - - final itemsToProcess = dashboardViewModel.items.where((item) { - if (item is DateSectionItem) return false; - if (item is TransactionListItem) { - return !(item.hasTokens && item.assetOfTransaction == null); - } - return true; - }).toList(); - - if (itemsToProcess.isEmpty) { - tester.printToConsole('No transaction items to process - checking for placeholder'); - _verifyPlaceholder(); - return; - } - - // This is a temporary limit to prevent the test from taking too long - final maxItemsToProcess = 100; - final itemsToCheck = itemsToProcess.take(maxItemsToProcess).toList(); - - tester.printToConsole( - 'Processing ${itemsToCheck.length} items out of ${itemsToProcess.length} total items', - ); - - await _processVisibleItems(itemsToCheck, dashboardViewModel); - - // Try to scroll and process more items if needed - await _processItemsWithScrolling(itemsToCheck, dashboardViewModel); - } catch (e) { - tester.printToConsole('Error in comprehensive item check: $e'); - try { - _verifyPlaceholder(); - } catch (placeholderError) { - tester.printToConsole('Could not verify placeholder either: $placeholderError'); - } - } - } - - Future _waitForItemsToLoad(DashboardViewModel dashboardViewModel) async { - const maxWaitTime = Duration(seconds: 30); - final endTime = DateTime.now().add(maxWaitTime); - - while (DateTime.now().isBefore(endTime)) { - if (dashboardViewModel.items.isNotEmpty) { - tester.printToConsole('Items loaded: ${dashboardViewModel.items.length}'); - return; - } - - // Check if placeholder is shown - if (tester.any(find.byKey(ValueKey('transactions_page_placeholder_transactions_text_key')))) { - tester.printToConsole('Placeholder is shown - no items to load'); - return; - } - - await tester.pump(Duration(seconds: 1)); - await tester.pumpAndSettle(); - } - - tester.printToConsole('Warning: No items loaded and no placeholder shown within expected time'); - } - - Future _processVisibleItems( - List items, - DashboardViewModel dashboardViewModel, - ) async { - try { - int processedCount = 0; - - for (var item in items) { - try { - final keyId = (item.key as ValueKey).value; - - // Check if item is already visible - if (tester.any(find.byKey(ValueKey(keyId)))) { - tester.printToConsole('Processing visible item: $keyId'); - await _verifyItemDisplay(item, dashboardViewModel); - processedCount++; - } - } catch (itemError) { - tester.printToConsole('Error processing item: $itemError'); - continue; - } - } - - tester.printToConsole('Processed $processedCount visible items\n'); - } catch (e) { - tester.printToConsole('Error in visible items processing: $e'); - } - } - - Future _processItemsWithScrolling( - List items, - DashboardViewModel dashboardViewModel, - ) async { - try { - final scrollableFinder = find.descendant( - of: find.byKey(ValueKey('transactions_page_list_view_builder_key')), - matching: find.byType(Scrollable), - ); - - if (!tester.any(scrollableFinder)) { - tester.printToConsole('No scrollable found, skipping scroll processing'); - return; - } - - int processedCount = 0; - const maxScrollAttempts = 10; - - for (var item in items) { - try { - final keyId = (item.key as ValueKey).value; - - // Skip if already processed - if (tester.any(find.byKey(ValueKey(keyId)))) { - continue; - } - - // Try to scroll to the item - bool found = false; - for (int attempt = 0; attempt < maxScrollAttempts; attempt++) { - await tester.pumpAndSettle(Duration(milliseconds: 200)); - - if (tester.any(find.byKey(ValueKey(keyId)))) { - tester.printToConsole('Found item after scrolling: $keyId'); - await _verifyItemDisplay(item, dashboardViewModel); - processedCount++; - found = true; - break; - } - - // Perform scroll - try { - await tester.drag(scrollableFinder, Offset(0, -100)); - await tester.pumpAndSettle(Duration(milliseconds: 300)); - } catch (scrollError) { - tester.printToConsole('Scroll failed: $scrollError'); - break; - } - } - - if (!found) { - tester.printToConsole('Could not find item after scrolling: $keyId'); - } - } catch (itemError) { - tester.printToConsole('Error processing item with scrolling: $itemError'); - continue; - } - } - - tester.printToConsole('Processed $processedCount items with scrolling'); - } catch (e) { - tester.printToConsole('Error in scroll processing: $e'); - } - } - - void _verifyPlaceholder() { - commonTestCases.hasValueKey('transactions_page_placeholder_transactions_text_key'); - commonTestCases.hasText(S.current.placeholder_transactions); - } - - Future _waitForListToBeReady() async { - const maxWaitAttempts = 10; - int attempts = 0; - - while (attempts < maxWaitAttempts) { - await tester.pumpAndSettle(Duration(milliseconds: 500)); - - // Check if the list view is present - final listViewFinder = find.byKey(ValueKey('transactions_page_list_view_builder_key')); - if (tester.any(listViewFinder)) { - tester.printToConsole('List view is ready'); - return; - } - - attempts++; - tester.printToConsole('Waiting for list view to be ready, attempt $attempts\n'); - } - - tester.printToConsole('List view did not become ready within expected time'); - } - - Future _verifyItemDisplay(dynamic item, DashboardViewModel dashboardViewModel) async { - // Execute the proper check depending on item type. - switch (item.runtimeType) { - case TransactionListItem: - final transactionItem = item as TransactionListItem; - tester.printToConsole(transactionItem.formattedTitle); - tester.printToConsole(transactionItem.formattedFiatAmount); - tester.printToConsole('\n'); - await _verifyTransactionListItemDisplay(transactionItem, dashboardViewModel); - break; - - case AnonpayTransactionListItem: - await _verifyAnonpayTransactionListItemDisplay(item as AnonpayTransactionListItem); - break; - - case TradeListItem: - await _verifyTradeListItemDisplay(item as TradeListItem); - break; - - case OrderListItem: - await _verifyOrderListItemDisplay(item as OrderListItem); - break; - - default: - tester.printToConsole('Unhandled item type: ${item.runtimeType}'); - } - } - - Future _verifyTransactionListItemDisplay( - TransactionListItem item, - DashboardViewModel dashboardViewModel, - ) async { - final keyId = - '${dashboardViewModel.type.name}_transaction_history_item_${item.transaction.id}_key'; - - if (!tester.any(find.byKey(ValueKey(keyId)))) { - tester.printToConsole( - 'Could not find transaction item with key: $keyId for transaction ${item.transaction.id}', - ); - return; - } - - if (item.hasTokens && item.assetOfTransaction == null) return; - - try { - //* ==============Confirm it has the right key for this item ======== - commonTestCases.hasValueKey(keyId); - - //* ======Confirm it displays the properly formatted amount========== - commonTestCases.findWidgetViaDescendant( - of: find.byKey(ValueKey(keyId)), - matching: find.text(item.formattedCryptoAmount), - ); - - //* ======Confirm it displays the properly formatted date============ - final formattedDate = DateFormat('HH:mm').format(item.transaction.date); - commonTestCases.findWidgetViaDescendant( - of: find.byKey(ValueKey(keyId)), - matching: find.text(formattedDate), - ); - - //* ======Confirm it displays the properly formatted fiat amount===== - final formattedFiatAmount = - dashboardViewModel.balanceViewModel.isFiatDisabled ? '' : item.formattedFiatAmount; - if (formattedFiatAmount.isNotEmpty) { - commonTestCases.findWidgetViaDescendant( - of: find.byKey(ValueKey(keyId)), - matching: find.text(formattedFiatAmount), - ); - } - - //* ======Confirm it displays the right image based on the transaction direction===== - final imageToUse = item.transaction.direction == TransactionDirection.incoming - ? 'assets/images/down_arrow.png' - : 'assets/images/up_arrow.png'; - - find.widgetWithImage(Container, AssetImage(imageToUse)); - } catch (e) { - tester.printToConsole('Error verifying transaction item ${item.transaction.id}: $e'); - } - } - - Future _verifyAnonpayTransactionListItemDisplay(AnonpayTransactionListItem item) async { - final keyId = 'anonpay_invoice_transaction_list_item_${item.transaction.invoiceId}_key'; - - //* ==============Confirm it has the right key for this item ======== - commonTestCases.hasValueKey(keyId); - - //* ==============Confirm it displays the correct provider ========================= - commonTestCases.hasText(item.transaction.provider); - - //* ===========Confirm it displays the properly formatted amount with currency ======== - final currency = item.transaction.fiatAmount != null - ? item.transaction.fiatEquiv ?? '' - : CryptoCurrency.fromFullName(item.transaction.coinTo).name.toUpperCase(); - - final amount = - item.transaction.fiatAmount?.toString() ?? (item.transaction.amountTo?.toString() ?? ''); - - final amountCurrencyText = amount + ' ' + currency; - - commonTestCases.hasText(amountCurrencyText); - - //* ======Confirm it displays the properly formatted date================= - final formattedDate = DateFormat('HH:mm').format(item.transaction.createdAt); - commonTestCases.hasText(formattedDate); - - //* ===============Confirm it displays the right image==================== - find.widgetWithImage(ClipRRect, AssetImage('assets/images/trocador.png')); - } - - Future _verifyTradeListItemDisplay(TradeListItem item) async { - final keyId = 'trade_list_item_${item.trade.id}_key'; - final from = item.trade.from?.toString() ?? ''; - final to = item.trade.to?.toString() ?? ''; - - //* ==============Confirm it has the right key for this item ======== - commonTestCases.hasValueKey(keyId); - - //* ==============Confirm it displays the correct provider ========================= - final conversionFlow = '$from → $to'; - - commonTestCases.hasText(conversionFlow); - - //* ===========Confirm it displays the properly formatted amount with its crypto tag ======== - - final amountCryptoText = item.tradeFormattedAmount + ' ' + from; - - commonTestCases.hasText(amountCryptoText); - - //* ======Confirm it displays the properly formatted date================= - final createdAtFormattedDate = - item.trade.createdAt != null ? DateFormat('HH:mm').format(item.trade.createdAt!) : null; - - if (createdAtFormattedDate != null) { - commonTestCases.hasText(createdAtFormattedDate); - } - - //* ===============Confirm it displays the right image==================== - commonTestCases.hasValueKey(item.trade.provider.image); - } - - Future _verifyOrderListItemDisplay(OrderListItem item) async { - final keyId = 'order_list_item_${item.order.id}_key'; - - //* ==============Confirm it has the right key for this item ======== - commonTestCases.hasValueKey(keyId); - - //* ==============Confirm it displays the correct provider ========================= - final orderFlow = '${item.order.from!} → ${item.order.to}'; - - commonTestCases.hasText(orderFlow); - - //* ===========Confirm it displays the properly formatted amount with its crypto tag ======== - - final amountCryptoText = item.orderFormattedAmount + ' ' + item.order.to!; - - commonTestCases.hasText(amountCryptoText); - - //* ======Confirm it displays the properly formatted date================= - final createdAtFormattedDate = DateFormat('HH:mm').format(item.order.createdAt); - - commonTestCases.hasText(createdAtFormattedDate); - } -} diff --git a/integration_test/robots/wallet_edit_page_robot.dart b/integration_test/robots/wallet_edit_page_robot.dart new file mode 100644 index 000000000..12543d884 --- /dev/null +++ b/integration_test/robots/wallet_edit_page_robot.dart @@ -0,0 +1,50 @@ +import "package:hash_wallet/src/screens/wallet/wallet_edit_page.dart"; +import "package:flutter/material.dart"; +import "package:flutter_test/flutter_test.dart"; + +import "../core/base_robot.dart"; + +class WalletEditPageRobot extends BaseRobot { + WalletEditPageRobot(super.tester); + + @override + Future isDisplayed() async { + await isSpecificPage(); + } + + Future enterName(String name) async { + final input = find.byKey(const ValueKey("wallet_edit_page_name_input_key")); + + await pumpUntilFound(input); + + final field = find.descendant(of: input, matching: find.byType(EditableText)); + + await tester.enterText(field.first, name); + + await tester.pump(const Duration(milliseconds: 300)); + } + + Future save() async { + await tapByKey("wallet_edit_page_save_button_key"); + } + + Future tapDelete() async { + await tapByKey("wallet_edit_page_delete_button_key"); + } + + Future confirmDelete() async { + await tapByKey("wallet_edit_page_confirm_delete_button_key"); + } + + Future expectNameTakenRefusal() async { + await pumpUntilFound(find.byKey(const ValueKey("wallet_edit_page_name_taken_ok_button_key"))); + + hasType(); + } + + Future dismissNameTakenRefusal() async { + await tapByKey("wallet_edit_page_name_taken_ok_button_key"); + + await settle(); + } +} diff --git a/integration_test/robots/wallet_group_description_page_robot.dart b/integration_test/robots/wallet_group_description_page_robot.dart index 1e1cbc1ed..9c48ace01 100644 --- a/integration_test/robots/wallet_group_description_page_robot.dart +++ b/integration_test/robots/wallet_group_description_page_robot.dart @@ -1,39 +1,25 @@ -import 'package:hash_wallet/generated/i18n.dart'; -import 'package:hash_wallet/src/screens/new_wallet/wallet_group_description_page.dart'; -import 'package:flutter_test/flutter_test.dart'; +import "package:hash_wallet/src/screens/new_wallet/wallet_group_description_page.dart"; -import '../components/common_test_cases.dart'; +import "../core/base_robot.dart"; -class WalletGroupDescriptionPageRobot { - WalletGroupDescriptionPageRobot(this.tester) : commonTestCases = CommonTestCases(tester); +class WalletGroupDescriptionPageRobot extends BaseRobot { + WalletGroupDescriptionPageRobot(super.tester); - final WidgetTester tester; - final CommonTestCases commonTestCases; - - Future isWalletGroupDescriptionPage() async { - await commonTestCases.isSpecificPage(); - await commonTestCases.takeScreenshots('wallet_group_description_page'); + @override + Future isDisplayed() async { + await isSpecificPage(); } - void hasTitle() { - commonTestCases.hasText(S.current.wallet_group); - } - - bool hasNewSingleSeedButton() { - return commonTestCases.isKeyPresent('wallet_group_description_page_create_new_seed_button_key'); - } + bool hasNewSingleSeedButton() => + isKeyPresent("wallet_group_description_page_create_new_seed_button_key"); Future navigateToCreateNewSeedPage() async { if (hasNewSingleSeedButton()) { - await commonTestCases.tapItemByKey( - 'wallet_group_description_page_create_new_seed_button_key', - ); + await tapByKey("wallet_group_description_page_create_new_seed_button_key"); } } Future navigateToChooseWalletGroup() async { - await commonTestCases.tapItemByKey( - 'wallet_group_description_page_choose_wallet_group_button_key', - ); + await tapByKey("wallet_group_description_page_choose_wallet_group_button_key"); } } diff --git a/integration_test/robots/wallet_group_existing_seed_page_robot.dart b/integration_test/robots/wallet_group_existing_seed_page_robot.dart new file mode 100644 index 000000000..ad8ab5f50 --- /dev/null +++ b/integration_test/robots/wallet_group_existing_seed_page_robot.dart @@ -0,0 +1,16 @@ +import "package:hash_wallet/src/screens/new_wallet/wallet_group_existing_seed_description_page.dart"; + +import "../core/base_robot.dart"; + +class WalletGroupExistingSeedPageRobot extends BaseRobot { + WalletGroupExistingSeedPageRobot(super.tester); + + @override + Future isDisplayed() async { + await isSpecificPage(); + } + + Future openWallet() async { + await tapByKey("wallet_group_existing_seed_description_page_open_wallet_button_key"); + } +} diff --git a/integration_test/robots/wallet_groups_display_page_robot.dart b/integration_test/robots/wallet_groups_display_page_robot.dart new file mode 100644 index 000000000..0fc710d61 --- /dev/null +++ b/integration_test/robots/wallet_groups_display_page_robot.dart @@ -0,0 +1,21 @@ +import "package:hash_wallet/src/screens/new_wallet/wallet_group_display_page.dart"; +import "package:flutter_test/flutter_test.dart"; + +import "../core/base_robot.dart"; + +class WalletGroupsDisplayPageRobot extends BaseRobot { + WalletGroupsDisplayPageRobot(super.tester); + + @override + Future isDisplayed() async { + await isSpecificPage(); + } + + Future selectWallet(String name) async { + await tapWhenVisible(find.text(name)); + } + + Future continueWithSelectedSeed() async { + await tapByKey("wallet_group_display_page_next_button_key"); + } +} diff --git a/integration_test/robots/wallet_keys_robot.dart b/integration_test/robots/wallet_keys_robot.dart index ffd77c3f5..75d59fec6 100644 --- a/integration_test/robots/wallet_keys_robot.dart +++ b/integration_test/robots/wallet_keys_robot.dart @@ -1,48 +1,38 @@ -import 'package:hash_wallet/generated/i18n.dart'; -import 'package:hash_wallet/reactions/wallet_connect.dart'; -import 'package:hash_wallet/src/screens/wallet_keys/wallet_keys_page.dart'; -import 'package:hash_wallet/store/app_store.dart'; -import 'package:cw_core/monero_wallet_keys.dart'; -import 'package:cw_core/wallet_type.dart'; -import 'package:cw_monero/monero_wallet.dart'; -import 'package:flutter_test/flutter_test.dart'; -import 'package:polyseed/polyseed.dart'; +import "package:hash_wallet/reactions/wallet_connect.dart"; +import "package:hash_wallet/src/screens/wallet_keys/wallet_keys_page.dart"; +import "package:hash_wallet/store/app_store.dart"; +import "package:cw_core/monero_wallet_keys.dart"; +import "package:cw_core/wallet_type.dart"; +import "package:cw_monero/monero_wallet.dart"; +import "package:flutter_test/flutter_test.dart"; +import "package:polyseed/polyseed.dart"; -import '../components/common_test_cases.dart'; +import "../core/base_robot.dart"; -class WalletKeysAndSeedPageRobot { - WalletKeysAndSeedPageRobot(this.tester) : commonTestCases = CommonTestCases(tester); +class WalletKeysAndSeedPageRobot extends BaseRobot { + WalletKeysAndSeedPageRobot(super.tester); - final WidgetTester tester; - final CommonTestCases commonTestCases; - - Future isWalletKeysAndSeedPage() async { - await commonTestCases.isSpecificPage(); - await commonTestCases.takeScreenshots('wallet_keys_page'); + @override + Future isDisplayed() async { + await isSpecificPage(); } - void hasTitle() { - final walletKeysPage = tester.widget(find.byType(WalletKeysPage)); - final walletKeysViewModel = walletKeysPage.walletKeysViewModel; - commonTestCases.hasText(walletKeysViewModel.title); - } - - void hasShareWarning() { - commonTestCases.hasText(S.current.do_not_share_warning_text.toUpperCase()); - } + int _verifiedCredentials = 0; Future confirmWalletCredentials(WalletType walletType) async { + _verifiedCredentials = 0; + final walletKeysPage = tester.widget(find.byType(WalletKeysPage)); final walletKeysViewModel = walletKeysPage.walletKeysViewModel; final appStore = walletKeysViewModel.appStore; final walletName = walletType.name; - bool hasSeed = appStore.wallet!.seed != null; - bool hasHexSeed = appStore.wallet!.hexSeed != null; - bool hasPrivateKey = appStore.wallet!.privateKey != null; + final hasSeed = appStore.wallet!.seed != null; + final hasHexSeed = appStore.wallet!.hexSeed != null; + final hasPrivateKey = appStore.wallet!.privateKey != null; if (walletType == WalletType.monero) { - final moneroWallet = appStore.wallet as MoneroWalletBase; + final moneroWallet = appStore.wallet! as MoneroWalletBase; final lang = PolyseedLang.getByPhrase(moneroWallet.seed); final legacySeed = moneroWallet.seedLegacy(lang.nameEnglish); @@ -57,51 +47,89 @@ class WalletKeysAndSeedPageRobot { if (walletType == WalletType.bitcoin || walletType == WalletType.litecoin || walletType == WalletType.bitcoinCash) { - final seedWords = appStore.wallet!.seed!.split(" "); - for (var seedWord in seedWords) { - commonTestCases.hasTextAtLestOnce(seedWord); - } - tester.printToConsole('$walletName wallet has seeds properly displayed'); + _showsSeedWords(appStore.wallet!.seed!); + _verifiedCredentials++; + tester.printToConsole("$walletName wallet has seeds properly displayed"); } // Hash Bags does not ship Solana or Tron; those WalletType members were // removed with the chains, so only the EVM chains remain here. if (isEVMCompatibleChain(walletType)) { if (hasSeed) { - final seedWords = appStore.wallet!.seed!.split(" "); - for (var seedWord in seedWords) { - commonTestCases.hasTextAtLestOnce(seedWord); - } - tester.printToConsole('$walletName wallet has seeds properly displayed'); + _showsSeedWords(appStore.wallet!.seed!); + _verifiedCredentials++; + tester.printToConsole("$walletName wallet has seeds properly displayed"); } if (hasPrivateKey) { - await commonTestCases.tapItemByKey('wallet_keys_page_keys'); - commonTestCases.hasText(appStore.wallet!.privateKey!); - tester.printToConsole('$walletName wallet has private key properly displayed'); + await _openKeysTab(); + _showsCredentialOnce(appStore.wallet!.privateKey!, "private key"); + _verifiedCredentials++; + tester.printToConsole("$walletName wallet has private key properly displayed"); } } if (walletType == WalletType.nano || walletType == WalletType.banano) { if (hasSeed) { - final seedWords = appStore.wallet!.seed!.split(" "); - for (var seedWord in seedWords) { - commonTestCases.hasTextAtLestOnce(seedWord); - } - tester.printToConsole('$walletName wallet has seeds properly displayed'); + _showsSeedWords(appStore.wallet!.seed!); + _verifiedCredentials++; + tester.printToConsole("$walletName wallet has seeds properly displayed"); } if (hasHexSeed) { - await commonTestCases.tapItemByKey('wallet_keys_page_keys'); - commonTestCases.hasText(appStore.wallet!.hexSeed!); - tester.printToConsole('$walletName wallet has hexSeed properly displayed'); + await _openKeysTab(); + _showsCredentialOnce(appStore.wallet!.hexSeed!, "hex seed"); + _verifiedCredentials++; + tester.printToConsole("$walletName wallet has hexSeed properly displayed"); } if (hasPrivateKey) { - await commonTestCases.tapItemByKey('wallet_keys_page_keys'); - commonTestCases.hasText(appStore.wallet!.privateKey!); - tester.printToConsole('$walletName wallet has private key properly displayed'); + await _openKeysTab(); + _showsCredentialOnce(appStore.wallet!.privateKey!, "private key"); + _verifiedCredentials++; + tester.printToConsole("$walletName wallet has private key properly displayed"); } } - await commonTestCases.defaultSleepTime(seconds: 5); + _expectSomethingWasVerified(walletType); + } + + void _expectSomethingWasVerified(WalletType walletType) { + expect( + _verifiedCredentials, + greaterThan(0), + reason: "Nothing was verified for ${walletType.name}, this suite covers no credential " + "for that type so it cannot tell a working keys page from a broken one", + ); + } + + Future _openKeysTab() async { + await tapByKey("wallet_keys_page_keys"); + + await settle(); + } + + void _showsCredential(String value, String label) { + expect( + tester.any(find.text(value, skipOffstage: false)), + true, + reason: "The keys page did not show the wallet's $label", + ); + } + + void _showsCredentialOnce(String value, String label) { + final rows = tester.widgetList(find.text(value, skipOffstage: false)).length; + + expect( + rows, + 1, + reason: "The keys page showed $rows rows for the wallet's $label, expected one", + ); + } + + void _showsSeedWords(String seed) { + final words = seed.split(" "); + + for (var index = 0; index < words.length; index++) { + _showsCredential(words[index], "seed word ${index + 1}"); + } } Future _confirmMoneroWalletCredentials( @@ -111,68 +139,42 @@ class WalletKeysAndSeedPageRobot { String legacySeed, ) async { final keys = appStore.wallet!.keys as MoneroWalletKeys; - - final hasPublicSpendKey = commonTestCases.isKeyPresent( - '${walletName}_wallet_public_spend_key_item_key', - ); - final hasPrivateSpendKey = commonTestCases.isKeyPresent( - '${walletName}_wallet_private_spend_key_item_key', - ); - final hasPublicViewKey = commonTestCases.isKeyPresent( - '${walletName}_wallet_public_view_key_item_key', - ); - final hasPrivateViewKey = commonTestCases.isKeyPresent( - '${walletName}_wallet_private_view_key_item_key', - ); final hasSeeds = seed.isNotEmpty; final hasSeedLegacy = Polyseed.isValidSeed(seed); - if (hasPublicSpendKey) { - await commonTestCases.tapItemByKey('wallet_keys_page_keys'); - commonTestCases.hasText(keys.publicSpendKey); - tester.printToConsole('$walletName wallet has public spend key properly displayed'); - } - if (hasPrivateSpendKey) { - await commonTestCases.tapItemByKey('wallet_keys_page_keys'); - commonTestCases.hasText(keys.privateSpendKey); - tester.printToConsole('$walletName wallet has private spend key properly displayed'); - } - if (hasPublicViewKey) { - await commonTestCases.tapItemByKey('wallet_keys_page_keys'); - commonTestCases.hasText(keys.publicViewKey); - tester.printToConsole('$walletName wallet has public view key properly displayed'); - } - if (hasPrivateViewKey) { - await commonTestCases.tapItemByKey('wallet_keys_page_keys'); - commonTestCases.hasText(keys.privateViewKey); - tester.printToConsole('$walletName wallet has private view key properly displayed'); - } + await _openKeysTab(); + + _showsCredentialOnce(keys.publicSpendKey, "public spend key"); + _verifiedCredentials++; + tester.printToConsole("$walletName wallet has public spend key properly displayed"); + + _showsCredentialOnce(keys.privateSpendKey, "private spend key"); + _verifiedCredentials++; + tester.printToConsole("$walletName wallet has private spend key properly displayed"); + + _showsCredentialOnce(keys.publicViewKey, "public view key"); + _verifiedCredentials++; + tester.printToConsole("$walletName wallet has public view key properly displayed"); + + _showsCredentialOnce(keys.privateViewKey, "private view key"); + _verifiedCredentials++; + tester.printToConsole("$walletName wallet has private view key properly displayed"); + if (hasSeeds) { - await commonTestCases.tapItemByKey('wallet_keys_page_seed'); - final seedWords = seed.split(" "); - for (var seedWord in seedWords) { - commonTestCases.hasTextAtLestOnce(seedWord); - } - tester.printToConsole('$walletName wallet has seeds properly displayed'); + await tapByKey("wallet_keys_page_seed"); + await settle(); + + _showsSeedWords(seed); + _verifiedCredentials++; + tester.printToConsole("$walletName wallet has seeds properly displayed"); } if (hasSeedLegacy) { - await commonTestCases.tapItemByKey('wallet_keys_page_seed_legacy'); - final seedWords = legacySeed.split(" "); - for (var seedWord in seedWords) { - commonTestCases.hasTextAtLestOnce(seedWord); - } - tester.printToConsole('$walletName wallet has legacy seeds properly displayed'); + await tapByKey("wallet_keys_page_seed_legacy"); + await settle(); + + _showsSeedWords(legacySeed); + _verifiedCredentials++; + tester.printToConsole("$walletName wallet has legacy seeds properly displayed"); } } - - Future backToDashboard() async { - tester.printToConsole('Going back to dashboard from credentials page'); - await tester.pumpAndSettle(Duration(milliseconds: 1000)); - - await commonTestCases.goBack(); - await tester.pumpAndSettle(Duration(milliseconds: 1000)); - - await commonTestCases.goBack(); - await tester.pumpAndSettle(Duration(milliseconds: 2000)); - } } diff --git a/integration_test/robots/wallet_list_page_robot.dart b/integration_test/robots/wallet_list_page_robot.dart index 0eb5bf32f..67b63ce8a 100644 --- a/integration_test/robots/wallet_list_page_robot.dart +++ b/integration_test/robots/wallet_list_page_robot.dart @@ -1,28 +1,48 @@ -import 'package:hash_wallet/generated/i18n.dart'; -import 'package:flutter_test/flutter_test.dart'; +import "package:hash_wallet/src/screens/wallet_list/wallet_list_page.dart"; +import "package:flutter/material.dart"; +import "package:flutter_test/flutter_test.dart"; -import '../components/common_test_cases.dart'; +import "../core/base_robot.dart"; -class WalletListPageRobot { - WalletListPageRobot(this.tester) : commonTestCases = CommonTestCases(tester); +class WalletListPageRobot extends BaseRobot { + WalletListPageRobot(super.tester); - final WidgetTester tester; - late CommonTestCases commonTestCases; + @override + Future isDisplayed() async { + final shown = await pumpUntil(_isShowing); - Future isWalletListPage() async { - await commonTestCases.isSpecificPage(); - await commonTestCases.takeScreenshots('wallet_list_page'); + expect(shown, true, reason: "The wallets tab never came to the front"); } - void displaysCorrectTitle() { - commonTestCases.hasText(S.current.wallets); + Future openEditFor(String walletName) async { + await tapByKey("wallet_list_edit_${walletName}_button_key"); } + bool hasWallet(String walletName) => tester.any( + find.descendant( + of: find.byType(WalletListPage), + matching: find.text(walletName), + ), + ); + Future navigateToCreateNewWalletPage() async { - await commonTestCases.tapItemByKey('wallet_list_page_create_new_wallet_button_key'); + await tapByKey("wallet_list_page_create_new_wallet_button_key"); } Future navigateToRestoreWalletOptionsPage() async { - await commonTestCases.tapItemByKey('wallet_list_page_restore_wallet_button_key'); + await tapByKey("wallet_list_page_restore_wallet_button_key"); + } + + bool _isShowing() { + final stackFinder = find.byType(IndexedStack); + + if (!tester.any(stackFinder)) { + return false; + } + + final stack = tester.widget(stackFinder.first); + final index = stack.children.indexWhere((child) => child is WalletListPage); + + return index >= 0 && stack.index == index; } } diff --git a/integration_test/robots/wallet_seed_page_robot.dart b/integration_test/robots/wallet_seed_page_robot.dart index a85e87576..f896c9465 100644 --- a/integration_test/robots/wallet_seed_page_robot.dart +++ b/integration_test/robots/wallet_seed_page_robot.dart @@ -1,38 +1,19 @@ -import 'package:hash_wallet/generated/i18n.dart'; -import 'package:hash_wallet/src/screens/seed/wallet_seed_page.dart'; -import 'package:flutter_test/flutter_test.dart'; +import "package:hash_wallet/generated/i18n.dart"; +import "package:hash_wallet/src/screens/seed/wallet_seed_page.dart"; +import "package:flutter_test/flutter_test.dart"; -import '../components/common_test_cases.dart'; +import "../core/base_robot.dart"; -class WalletSeedPageRobot { - WalletSeedPageRobot(this.tester) : commonTestCases = CommonTestCases(tester); +class WalletSeedPageRobot extends BaseRobot { + WalletSeedPageRobot(super.tester); - final WidgetTester tester; - late CommonTestCases commonTestCases; - - Future isWalletSeedPage() async { - await commonTestCases.isSpecificPage(); - await commonTestCases.takeScreenshots('wallet_seed_page'); + @override + Future isDisplayed() async { + await isSpecificPage(); } Future onVerifySeedButtonPressed() async { - await commonTestCases.tapItemByKey('wallet_seed_page_verify_seed_button_key'); - await commonTestCases.defaultSleepTime(); - } - - Future onSaveSeedButtonPressed() async { - await commonTestCases.tapItemByKey('wallet_seed_page_save_seeds_button_key'); - await commonTestCases.defaultSleepTime(); - } - - Future onConfirmButtonOnSeedAlertDialogPressed() async { - await commonTestCases.tapItemByKey('wallet_seed_page_seed_alert_confirm_button_key'); - await commonTestCases.defaultSleepTime(); - } - - Future onBackButtonOnSeedAlertDialogPressed() async { - await commonTestCases.tapItemByKey('wallet_seed_page_seed_alert_back_button_key'); - await commonTestCases.defaultSleepTime(); + await tapByKey("wallet_seed_page_verify_seed_button_key"); } void confirmWalletDetailsDisplayCorrectly() { @@ -42,23 +23,18 @@ class WalletSeedPageRobot { final walletName = walletSeedViewModel.name; final walletSeeds = walletSeedViewModel.seedSplit; - commonTestCases.hasText(walletName); - for (var seed in walletSeeds) { - commonTestCases.hasTextAtLestOnce(seed); + hasText(walletName); + + for (var index = 0; index < walletSeeds.length; index++) { + expect( + tester.any(find.text(walletSeeds[index])), + true, + reason: "The seed page did not show seed word ${index + 1}", + ); } } void confirmWalletSeedReminderDisplays() { - commonTestCases.hasText(S.current.cake_seeds_save_disclaimer); - } - - Future onSaveSeedsButtonPressed() async { - await commonTestCases.tapItemByKey('wallet_seed_page_save_seeds_button_key'); - await commonTestCases.defaultSleepTime(); - } - - Future onCopySeedsButtonPressed() async { - await commonTestCases.tapItemByKey('wallet_seed_page_copy_seeds_button_key'); - await commonTestCases.defaultSleepTime(); + hasText(S.current.cake_seeds_save_disclaimer); } } diff --git a/integration_test/robots/welcome_page_robot.dart b/integration_test/robots/welcome_page_robot.dart index 8336f6c09..c73f4ea49 100644 --- a/integration_test/robots/welcome_page_robot.dart +++ b/integration_test/robots/welcome_page_robot.dart @@ -1,50 +1,28 @@ -import 'package:hash_wallet/src/screens/welcome/welcome_page.dart'; -import 'package:flutter/material.dart'; -import 'package:flutter_test/flutter_test.dart'; +import "package:hash_wallet/src/screens/welcome/welcome_page.dart"; +import "package:flutter_test/flutter_test.dart"; -import '../components/common_test_cases.dart'; +import "../core/base_robot.dart"; -class WelcomePageRobot { - WelcomePageRobot(this.tester) : commonTestCases = CommonTestCases(tester); +class WelcomePageRobot extends BaseRobot { + WelcomePageRobot(super.tester); - final WidgetTester tester; - late CommonTestCases commonTestCases; - - Future isWelcomePage() async { - await commonTestCases.isSpecificPage(); - await commonTestCases.takeScreenshots('welcome_page'); - } - - void confirmActionButtonsDisplay() { - final createNewWalletButton = find.byKey(ValueKey('welcome_page_create_new_wallet_button_key')); - - final restoreWalletButton = find.byKey(ValueKey('welcome_page_restore_wallet_button_key')); - - expect(createNewWalletButton, findsOneWidget); - expect(restoreWalletButton, findsOneWidget); + @override + Future isDisplayed() async { + await isSpecificPage(); } Future navigateToCreateNewWalletPage() async { - await commonTestCases.tapItemByKey('welcome_page_create_new_wallet_button_key'); - await commonTestCases.defaultSleepTime(); + await tapByKey("welcome_page_create_new_wallet_button_key"); } - bool hasNewSingleSeedButton() { - return commonTestCases.isKeyPresent('wallet_group_description_page_create_new_seed_button_key'); - } + bool hasNewSingleSeedButton() => + isKeyPresent("wallet_group_description_page_create_new_seed_button_key"); Future tapNewSingleSeed() async { - await commonTestCases.tapItemByKey('wallet_group_description_page_create_new_seed_button_key'); - await commonTestCases.defaultSleepTime(); + await tapByKey("wallet_group_description_page_create_new_seed_button_key"); } Future navigateToRestoreWalletPage() async { - await commonTestCases.tapItemByKey('welcome_page_restore_wallet_button_key'); - await commonTestCases.defaultSleepTime(); - } - - Future backAndVerify() async { - await commonTestCases.goBack(); - await isWelcomePage(); + await tapByKey("welcome_page_restore_wallet_button_key"); } } diff --git a/integration_test/suites/tier0/address_book_test.dart b/integration_test/suites/tier0/address_book_test.dart new file mode 100644 index 000000000..b45841384 --- /dev/null +++ b/integration_test/suites/tier0/address_book_test.dart @@ -0,0 +1,46 @@ +import "package:cw_core/wallet_type.dart"; +import "package:integration_test/integration_test.dart"; + +import "../../core/app_launcher.dart"; +import "../../core/test_wallets.dart"; +import "../../flows/onboarding_flows.dart"; +import "../../robots/contact_robot.dart"; +import "../../robots/home_page_robot.dart"; +import "../../robots/new_dashboard_robot.dart"; + +void main() { + IntegrationTestWidgetsFlutterBinding.ensureInitialized(); + + integrationTest("A saved contact keeps the address it was given", (tester) async { + final appLauncher = AppLauncher(tester); + final onboardingFlows = OnboardingFlows(tester); + final dashboardRobot = NewDashboardRobot(tester); + final homePageRobot = HomePageRobot(tester); + final contactRobot = ContactRobot(tester); + + const contactName = "Integration Test Contact"; + final contactAddress = TestWallets.receiveAddressFor(WalletType.solana); + + await appLauncher.launchApp(testKey: "address_book_test_app_key"); + + await onboardingFlows.createFirstWallet(WalletType.solana); + + await dashboardRobot.isDisplayed(); + await homePageRobot.isDisplayed(); + + await dashboardRobot.openContactsTab(); + await contactRobot.isDisplayed(); + + await contactRobot.openAddContact(); + + await contactRobot.enterName(contactName); + await contactRobot.chooseCurrency("sol"); + await contactRobot.enterAddress(contactAddress); + + await contactRobot.save(); + + await contactRobot.isDisplayed(); + + contactRobot.expectContactSaved(contactName, contactAddress); + }); +} diff --git a/integration_test/suites/tier0/change_pin_test.dart b/integration_test/suites/tier0/change_pin_test.dart new file mode 100644 index 000000000..8e63575fc --- /dev/null +++ b/integration_test/suites/tier0/change_pin_test.dart @@ -0,0 +1,73 @@ +import "package:hash_wallet/routes.dart"; +import "package:hash_wallet/src/screens/wallet_keys/wallet_keys_page.dart"; +import "package:cw_core/wallet_type.dart"; +import "package:flutter_test/flutter_test.dart"; +import "package:integration_test/integration_test.dart"; + +import "../../core/app_launcher.dart"; +import "../../core/test_config.dart"; +import "../../flows/auth_flows.dart"; +import "../../flows/onboarding_flows.dart"; +import "../../robots/home_page_robot.dart"; +import "../../robots/new_dashboard_robot.dart"; +import "../../robots/new_settings_page_robot.dart"; +import "../../robots/security_backup_page_robot.dart"; +import "../../robots/setup_pin_code_robot.dart"; +import "../../robots/show_keys_disclaimer_page_robot.dart"; +import "../../robots/wallet_keys_robot.dart"; + +void main() { + IntegrationTestWidgetsFlutterBinding.ensureInitialized(); + + integrationTest("Changing the pin replaces the one that unlocks the wallet", (tester) async { + final appLauncher = AppLauncher(tester); + final onboardingFlows = OnboardingFlows(tester); + final authFlows = AuthFlows(tester); + final dashboardRobot = NewDashboardRobot(tester); + final homePageRobot = HomePageRobot(tester); + final settingsRobot = NewSettingsPageRobot(tester); + final securityRobot = SecurityBackupPageRobot(tester); + final setupPinRobot = SetupPinCodeRobot(tester); + final walletKeysRobot = WalletKeysAndSeedPageRobot(tester); + final disclaimerRobot = ShowKeysDisclaimerPageRobot(tester); + + final newPin = [1, 2, 3, 4]; + + expect(newPin, isNot(TestConfig.pin), reason: "The new pin has to differ from the old one"); + + await appLauncher.launchApp(testKey: "change_pin_test_app_key"); + + await onboardingFlows.createFirstWallet(WalletType.solana); + + await dashboardRobot.isDisplayed(); + await homePageRobot.isDisplayed(); + + await homePageRobot.openSettingsSheet(); + await settingsRobot.isDisplayed(); + + await settingsRobot.openRow(Routes.securityBackupPage); + await securityRobot.isDisplayed(); + + await securityRobot.openChangePin(); + await authFlows.authenticateWithPin(); + + await setupPinRobot.isDisplayed(); + await setupPinRobot.enterPinCode(newPin); + await setupPinRobot.enterPinCode(newPin); + await setupPinRobot.tapSuccessButton(); + + await securityRobot.isDisplayed(); + + await securityRobot.goBack(); + await settingsRobot.isDisplayed(); + + await settingsRobot.openRow(Routes.showKeysDisclaimer); + await disclaimerRobot.isDisplayed(); + await disclaimerRobot.tapShowKeys(); + await authFlows.authenticateWithPin(pin: newPin); + + await walletKeysRobot.isDisplayed(); + + expect(find.byType(WalletKeysPage), findsOneWidget); + }); +} diff --git a/integration_test/suites/tier0/duplicate_wallet_name_test.dart b/integration_test/suites/tier0/duplicate_wallet_name_test.dart new file mode 100644 index 000000000..7b6306b41 --- /dev/null +++ b/integration_test/suites/tier0/duplicate_wallet_name_test.dart @@ -0,0 +1,42 @@ +import "package:hash_wallet/di.dart"; +import "package:hash_wallet/store/app_store.dart"; +import "package:cw_core/wallet_type.dart"; +import "package:integration_test/integration_test.dart"; + +import "../../core/app_launcher.dart"; +import "../../flows/onboarding_flows.dart"; +import "../../robots/home_page_robot.dart"; +import "../../robots/new_dashboard_robot.dart"; +import "../../robots/new_wallet_page_robot.dart"; + +void main() { + IntegrationTestWidgetsFlutterBinding.ensureInitialized(); + + integrationTest("A wallet name already in use is refused", (tester) async { + final appLauncher = AppLauncher(tester); + final onboardingFlows = OnboardingFlows(tester); + final dashboardRobot = NewDashboardRobot(tester); + final homePageRobot = HomePageRobot(tester); + final newWalletPageRobot = NewWalletPageRobot(tester); + + const walletType = WalletType.solana; + + await appLauncher.launchApp(testKey: "duplicate_wallet_name_test_app_key"); + + await onboardingFlows.createFirstWallet(walletType); + + await dashboardRobot.isDisplayed(); + await homePageRobot.isDisplayed(); + + final existingName = getIt.get().wallet!.name; + + await dashboardRobot.openWalletsTab(); + + await onboardingFlows.startCreatingWalletFromWalletList(walletType); + + await newWalletPageRobot.enterWalletName(existingName); + await newWalletPageRobot.onNextButtonPressed(); + + await newWalletPageRobot.expectNameRejected(); + }); +} diff --git a/integration_test/suites/tier0/fiat_currency_test.dart b/integration_test/suites/tier0/fiat_currency_test.dart new file mode 100644 index 000000000..43a7b7f1e --- /dev/null +++ b/integration_test/suites/tier0/fiat_currency_test.dart @@ -0,0 +1,107 @@ +import "package:hash_wallet/di.dart"; +import "package:hash_wallet/entities/fiat_api_mode.dart"; +import "package:hash_wallet/entities/fiat_currency.dart"; +import "package:hash_wallet/routes.dart"; +import "package:hash_wallet/store/settings_store.dart"; +import "package:cw_core/wallet_type.dart"; +import "package:flutter_test/flutter_test.dart"; +import "package:integration_test/integration_test.dart"; + +import "../../core/app_launcher.dart"; +import "../../flows/onboarding_flows.dart"; +import "../../robots/connection_sync_page_robot.dart"; +import "../../robots/display_settings_page_robot.dart"; +import "../../robots/home_page_robot.dart"; +import "../../robots/new_dashboard_robot.dart"; +import "../../robots/new_settings_page_robot.dart"; + +void main() { + IntegrationTestWidgetsFlutterBinding.ensureInitialized(); + + integrationTest("Turning the fiat api off hides the currency setting, turning it on returns it", + (tester) async { + final appLauncher = AppLauncher(tester); + final onboardingFlows = OnboardingFlows(tester); + final dashboardRobot = NewDashboardRobot(tester); + final homePageRobot = HomePageRobot(tester); + final settingsRobot = NewSettingsPageRobot(tester); + final connectionRobot = ConnectionSyncPageRobot(tester); + final displayRobot = DisplaySettingsPageRobot(tester); + + const wanted = FiatCurrency.eur; + + await appLauncher.launchApp(testKey: "fiat_currency_test_app_key"); + + await onboardingFlows.createFirstWallet(WalletType.solana); + + await dashboardRobot.isDisplayed(); + await homePageRobot.isDisplayed(); + + final settingsStore = getIt.get(); + + expect( + settingsStore.fiatCurrency, + isNot(wanted), + reason: "The wallet already starts on $wanted, so changing to it would prove nothing", + ); + + await homePageRobot.openSettingsSheet(); + await settingsRobot.isDisplayed(); + + if (settingsStore.fiatApiMode != FiatApiMode.disabled) { + await settingsRobot.openRow(Routes.connectionSync); + await connectionRobot.isDisplayed(); + + await connectionRobot.setFiatApiMode(FiatApiMode.disabled); + connectionRobot.hasFiatApiMode(FiatApiMode.disabled); + + expect(settingsStore.fiatApiMode, FiatApiMode.disabled); + + await connectionRobot.goBack(); + await settingsRobot.isDisplayed(); + } + + await settingsRobot.openRow(Routes.displaySettingsPage); + await displayRobot.isDisplayed(); + + expect( + displayRobot.hasFiatCurrencyRow(), + false, + reason: "The currency setting is still showing while the fiat api is off", + ); + + await displayRobot.goBack(); + await settingsRobot.isDisplayed(); + + await settingsRobot.openRow(Routes.connectionSync); + await connectionRobot.isDisplayed(); + + await connectionRobot.setFiatApiMode(FiatApiMode.enabled); + connectionRobot.hasFiatApiMode(FiatApiMode.enabled); + + expect(settingsStore.fiatApiMode, FiatApiMode.enabled); + + await connectionRobot.goBack(); + await settingsRobot.isDisplayed(); + + await settingsRobot.openRow(Routes.displaySettingsPage); + await displayRobot.isDisplayed(); + + expect( + displayRobot.hasFiatCurrencyRow(), + true, + reason: "The currency setting did not come back after the fiat api was turned on", + ); + + await displayRobot.openFiatCurrencyPicker(); + await displayRobot.chooseFiatCurrency(wanted); + + displayRobot.expectFiatCurrencyShown(wanted); + + expect( + settingsStore.fiatCurrency, + wanted, + reason: "The row shows ${wanted.title} but the setting behind it did not change", + ); + }); +} diff --git a/integration_test/suites/tier0/invalid_seed_test.dart b/integration_test/suites/tier0/invalid_seed_test.dart new file mode 100644 index 000000000..74c929ab3 --- /dev/null +++ b/integration_test/suites/tier0/invalid_seed_test.dart @@ -0,0 +1,51 @@ +import "package:hash_wallet/di.dart"; +import "package:hash_wallet/store/app_store.dart"; +import "package:cw_core/wallet_type.dart"; +import "package:flutter_test/flutter_test.dart"; +import "package:integration_test/integration_test.dart"; + +import "../../core/app_launcher.dart"; +import "../../core/test_wallets.dart"; +import "../../flows/onboarding_flows.dart"; +import "../../robots/home_page_robot.dart"; +import "../../robots/new_dashboard_robot.dart"; +import "../../robots/restore_from_seed_or_key_robot.dart"; + +void main() { + IntegrationTestWidgetsFlutterBinding.ensureInitialized(); + + integrationTest("A seed the wallet cannot parse restores nothing", (tester) async { + final appLauncher = AppLauncher(tester); + final onboardingFlows = OnboardingFlows(tester); + final restoreRobot = RestoreFromSeedOrKeysPageRobot(tester); + final dashboardRobot = NewDashboardRobot(tester); + final homePageRobot = HomePageRobot(tester); + + const walletType = WalletType.solana; + + await appLauncher.launchApp(testKey: "invalid_seed_test_app_key"); + + await onboardingFlows.startRestoringFirstWallet(walletType); + + // Twelve words that are not in the wordlist. Restoring from this would either fail + // outright or, worse it derives some other wallet the user has no keys for. + await restoreRobot.enterSeedPhraseForWalletRestore( + "alpha bravo charlie delta echo foxtrot golf hotel india juliet kilo lima", + ); + + await restoreRobot.onRestoreWalletButtonPressed(); + + await restoreRobot.expectRestoreRefused(); + + // The same form with the real seed goes through. Without this the test above would pass + // just as happily if the form were broken and restored nothing for any seed at all. + await restoreRobot.selectWalletNameFromAvailableOptions(); + await restoreRobot.enterSeedPhraseForWalletRestore(TestWallets.seedFor(walletType)); + await restoreRobot.onRestoreWalletButtonPressed(); + + await dashboardRobot.isDisplayed(); + await homePageRobot.isDisplayed(); + + expect(getIt.get().wallet?.type, walletType); + }); +} diff --git a/integration_test/suites/tier0/language_test.dart b/integration_test/suites/tier0/language_test.dart new file mode 100644 index 000000000..9cd60e90c --- /dev/null +++ b/integration_test/suites/tier0/language_test.dart @@ -0,0 +1,46 @@ +import "package:hash_wallet/routes.dart"; +import "package:cw_core/wallet_type.dart"; +import "package:integration_test/integration_test.dart"; + +import "../../core/app_launcher.dart"; +import "../../flows/onboarding_flows.dart"; +import "../../robots/display_settings_page_robot.dart"; +import "../../robots/home_page_robot.dart"; +import "../../robots/new_dashboard_robot.dart"; +import "../../robots/new_settings_page_robot.dart"; + +void main() { + IntegrationTestWidgetsFlutterBinding.ensureInitialized(); + + integrationTest("Changing the language changes what the settings screen shows", (tester) async { + final appLauncher = AppLauncher(tester); + final onboardingFlows = OnboardingFlows(tester); + final dashboardRobot = NewDashboardRobot(tester); + final homePageRobot = HomePageRobot(tester); + final settingsRobot = NewSettingsPageRobot(tester); + final displayRobot = DisplaySettingsPageRobot(tester); + + // Not the default, so seeing it afterwards means the change took rather than the screen + // having always said so. + const languageCode = "de"; + const languageName = "Deutsch (German)"; + + await appLauncher.launchApp(testKey: "language_test_app_key"); + + await onboardingFlows.createFirstWallet(WalletType.solana); + + await dashboardRobot.isDisplayed(); + await homePageRobot.isDisplayed(); + + await homePageRobot.openSettingsSheet(); + await settingsRobot.isDisplayed(); + + await settingsRobot.openRow(Routes.displaySettingsPage); + await displayRobot.isDisplayed(); + + await displayRobot.openLanguagePicker(); + await displayRobot.chooseLanguage(languageCode); + + displayRobot.expectLanguageShown(languageName); + }); +} diff --git a/integration_test/suites/tier0/onboarding_create_test.dart b/integration_test/suites/tier0/onboarding_create_test.dart new file mode 100644 index 000000000..0315dd500 --- /dev/null +++ b/integration_test/suites/tier0/onboarding_create_test.dart @@ -0,0 +1,53 @@ +import "package:hash_wallet/di.dart"; +import "package:hash_wallet/store/app_store.dart"; +import "package:flutter_test/flutter_test.dart"; +import "package:integration_test/integration_test.dart"; + +import "../../core/app_launcher.dart"; +import "../../core/test_config.dart"; +import "../../flows/onboarding_flows.dart"; +import "../../robots/home_page_robot.dart"; +import "../../robots/new_dashboard_robot.dart"; + +void main() { + IntegrationTestWidgetsFlutterBinding.ensureInitialized(); + + integrationTest("Create a wallet for each configured wallet type", (tester) async { + final appLauncher = AppLauncher(tester); + final onboardingFlows = OnboardingFlows(tester); + final dashboardRobot = NewDashboardRobot(tester); + final homePageRobot = HomePageRobot(tester); + + await appLauncher.launchApp(testKey: "onboarding_create_test_app_key"); + + final walletTypes = TestConfig.walletTypesUnderTest; + final firstType = walletTypes.first; + + tester.printToConsole("Creating first wallet: ${firstType.name}"); + + await onboardingFlows.createFirstWallet(firstType); + + await dashboardRobot.isDisplayed(); + await homePageRobot.isDisplayed(); + + final appStore = getIt.get(); + expect(appStore.wallet?.type, firstType); + + await homePageRobot.hasWalletName(appStore.wallet!.name); + + for (final type in walletTypes.skip(1)) { + tester.printToConsole("Creating additional wallet: ${type.name}"); + + await dashboardRobot.openWalletsTab(); + + await onboardingFlows.createAdditionalWalletFromWalletList(type); + + await dashboardRobot.isDisplayed(); + await homePageRobot.isDisplayed(); + + expect(appStore.wallet?.type, type); + + await homePageRobot.hasWalletName(appStore.wallet!.name); + } + }); +} diff --git a/integration_test/suites/tier0/onboarding_restore_test.dart b/integration_test/suites/tier0/onboarding_restore_test.dart new file mode 100644 index 000000000..6d2a7281d --- /dev/null +++ b/integration_test/suites/tier0/onboarding_restore_test.dart @@ -0,0 +1,94 @@ +import "package:hash_wallet/di.dart"; +import "package:hash_wallet/store/app_store.dart"; +import "package:cw_core/wallet_type.dart"; +import "package:flutter_test/flutter_test.dart"; +import "package:integration_test/integration_test.dart"; + +import "../../core/app_launcher.dart"; +import "../../core/test_config.dart"; +import "../../core/test_wallets.dart"; +import "../../flows/onboarding_flows.dart"; +import "../../robots/home_page_robot.dart"; +import "../../robots/new_dashboard_robot.dart"; + +void main() { + IntegrationTestWidgetsFlutterBinding.ensureInitialized(); + + integrationTest("Restore a wallet from seed for each configured wallet type", (tester) async { + final appLauncher = AppLauncher(tester); + final onboardingFlows = OnboardingFlows(tester); + final dashboardRobot = NewDashboardRobot(tester); + final homePageRobot = HomePageRobot(tester); + + await appLauncher.launchApp(testKey: "onboarding_restore_test_app_key"); + + final walletTypes = TestConfig.walletTypesUnderTest; + final firstType = walletTypes.first; + + tester.printToConsole("Restoring first wallet: ${firstType.name}"); + + await onboardingFlows.restoreFirstWalletFromSeed(firstType); + + await dashboardRobot.isDisplayed(); + await homePageRobot.isDisplayed(); + + final appStore = getIt.get(); + expect(appStore.wallet?.type, firstType); + + await homePageRobot.hasWalletName(appStore.wallet!.name); + + await _expectRestoredTheRightWallet(homePageRobot, appStore, firstType); + + for (final type in walletTypes.skip(1)) { + tester.printToConsole("Restoring additional wallet: ${type.name}"); + + await dashboardRobot.openWalletsTab(); + + await onboardingFlows.restoreAdditionalWalletFromWalletList(type); + + await dashboardRobot.isDisplayed(); + await homePageRobot.isDisplayed(); + + expect(appStore.wallet?.type, type); + + await homePageRobot.hasWalletName(appStore.wallet!.name); + + await _expectRestoredTheRightWallet(homePageRobot, appStore, type); + } + }); +} + +Future _expectRestoredTheRightWallet( + HomePageRobot robot, + AppStore appStore, + WalletType type, +) async { + final expectedAddress = TestWallets.receiveAddressFor(type); + + if (expectedAddress.isEmpty) { + robot.tester.printToConsole("No known address for ${type.name}, derivation not verified"); + return; + } + + final settled = await robot.pumpUntil( + () { + final addresses = appStore.wallet?.walletAddresses; + + if (addresses == null) { + return false; + } + + return addresses.address == expectedAddress || + addresses.addressesMap.containsKey(expectedAddress) || + addresses.allAddressesMap.containsKey(expectedAddress); + }, + timeout: const Duration(seconds: 60), + ); + + expect( + settled, + true, + reason: "${type.name} restored from a known seed derived " + "${appStore.wallet?.walletAddresses.address} and does not know $expectedAddress", + ); +} diff --git a/integration_test/suites/tier0/receive_address_test.dart b/integration_test/suites/tier0/receive_address_test.dart new file mode 100644 index 000000000..b637a1963 --- /dev/null +++ b/integration_test/suites/tier0/receive_address_test.dart @@ -0,0 +1,43 @@ +import "package:hash_wallet/di.dart"; +import "package:hash_wallet/store/app_store.dart"; +import "package:cw_core/wallet_type.dart"; +import "package:flutter_test/flutter_test.dart"; +import "package:integration_test/integration_test.dart"; + +import "../../core/app_launcher.dart"; +import "../../flows/onboarding_flows.dart"; +import "../../robots/home_page_robot.dart"; +import "../../robots/new_dashboard_robot.dart"; +import "../../robots/new_receive_page_robot.dart"; + +void main() { + IntegrationTestWidgetsFlutterBinding.ensureInitialized(); + + integrationTest("Receive sheet shows the opened wallet's own address", (tester) async { + final appLauncher = AppLauncher(tester); + final onboardingFlows = OnboardingFlows(tester); + final dashboardRobot = NewDashboardRobot(tester); + final homePageRobot = HomePageRobot(tester); + final receiveRobot = NewReceivePageRobot(tester); + + const walletType = WalletType.solana; + + await appLauncher.launchApp(testKey: "receive_address_test_app_key"); + + await onboardingFlows.restoreFirstWalletFromSeed(walletType); + + await dashboardRobot.isDisplayed(); + await homePageRobot.isDisplayed(); + + final expectedAddress = getIt.get().wallet!.walletAddresses.address; + expect(expectedAddress.isNotEmpty, true); + + await homePageRobot.openReceiveSheet(); + + await receiveRobot.isDisplayed(); + await receiveRobot.confirmAddressMatches(expectedAddress); + + await receiveRobot.dismissModal(); + await homePageRobot.isDisplayed(); + }); +} diff --git a/integration_test/suites/tier0/seed_confirmation_test.dart b/integration_test/suites/tier0/seed_confirmation_test.dart new file mode 100644 index 000000000..e07bac0d1 --- /dev/null +++ b/integration_test/suites/tier0/seed_confirmation_test.dart @@ -0,0 +1,70 @@ +import "package:hash_wallet/di.dart"; +import "package:hash_wallet/routes.dart"; +import "package:hash_wallet/store/app_store.dart"; +import "package:cw_core/wallet_type.dart"; +import "package:flutter_test/flutter_test.dart"; +import "package:integration_test/integration_test.dart"; + +import "../../core/app_launcher.dart"; +import "../../flows/auth_flows.dart"; +import "../../flows/onboarding_flows.dart"; +import "../../robots/home_page_robot.dart"; +import "../../robots/new_dashboard_robot.dart"; +import "../../robots/new_settings_page_robot.dart"; +import "../../robots/show_keys_disclaimer_page_robot.dart"; +import "../../robots/wallet_keys_robot.dart"; + +void main() { + IntegrationTestWidgetsFlutterBinding.ensureInitialized(); + + integrationTest("Displayed seed and keys match the opened wallet", (tester) async { + final appLauncher = AppLauncher(tester); + final onboardingFlows = OnboardingFlows(tester); + final authFlows = AuthFlows(tester); + final dashboardRobot = NewDashboardRobot(tester); + final homePageRobot = HomePageRobot(tester); + final settingsRobot = NewSettingsPageRobot(tester); + final walletKeysRobot = WalletKeysAndSeedPageRobot(tester); + final disclaimerRobot = ShowKeysDisclaimerPageRobot(tester); + + // These three cover the different key display paths on the wallet keys page. + final walletTypes = [WalletType.solana, WalletType.bitcoin, WalletType.monero]; + + await appLauncher.launchApp(testKey: "seed_confirmation_test_app_key"); + + await onboardingFlows.createFirstWallet(walletTypes.first); + + await dashboardRobot.isDisplayed(); + await homePageRobot.isDisplayed(); + + final appStore = getIt.get(); + + for (final type in walletTypes) { + if (type != walletTypes.first) { + await dashboardRobot.openWalletsTab(); + await onboardingFlows.createAdditionalWalletFromWalletList(type); + await homePageRobot.isDisplayed(); + } + + expect(appStore.wallet?.type, type); + + tester.printToConsole("Confirming displayed credentials for ${type.name}"); + + await homePageRobot.openSettingsSheet(); + await settingsRobot.isDisplayed(); + + await settingsRobot.openRow(Routes.showKeysDisclaimer); + await disclaimerRobot.isDisplayed(); + await disclaimerRobot.tapShowKeys(); + await authFlows.authenticateWithPin(); + + await walletKeysRobot.isDisplayed(); + await walletKeysRobot.confirmWalletCredentials(type); + + await settingsRobot.goBack(); + await settingsRobot.dismissModal(); + + await homePageRobot.isDisplayed(); + } + }); +} diff --git a/integration_test/suites/tier0/send_from_address_book_test.dart b/integration_test/suites/tier0/send_from_address_book_test.dart new file mode 100644 index 000000000..24779f4f2 --- /dev/null +++ b/integration_test/suites/tier0/send_from_address_book_test.dart @@ -0,0 +1,59 @@ +import "package:cw_core/wallet_type.dart"; +import "package:flutter_test/flutter_test.dart"; +import "package:integration_test/integration_test.dart"; + +import "../../core/app_launcher.dart"; +import "../../core/test_wallets.dart"; +import "../../flows/onboarding_flows.dart"; +import "../../robots/contact_robot.dart"; +import "../../robots/home_page_robot.dart"; +import "../../robots/new_dashboard_robot.dart"; +import "../../robots/new_send_page_robot.dart"; + +void main() { + IntegrationTestWidgetsFlutterBinding.ensureInitialized(); + + integrationTest("A contact picked on the send screen fills in its own address", (tester) async { + final appLauncher = AppLauncher(tester); + final onboardingFlows = OnboardingFlows(tester); + final dashboardRobot = NewDashboardRobot(tester); + final homePageRobot = HomePageRobot(tester); + final contactRobot = ContactRobot(tester); + final sendRobot = NewSendPageRobot(tester); + + const contactName = "Send Book Contact"; + final contactAddress = TestWallets.receiveAddressFor(WalletType.solana); + + await appLauncher.launchApp(testKey: "send_from_address_book_test_app_key"); + + await onboardingFlows.createFirstWallet(WalletType.solana); + + await dashboardRobot.isDisplayed(); + await homePageRobot.isDisplayed(); + + await dashboardRobot.openContactsTab(); + await contactRobot.isDisplayed(); + + await contactRobot.openAddContact(); + await contactRobot.enterName(contactName); + await contactRobot.chooseCurrency("sol"); + await contactRobot.enterAddress(contactAddress); + await contactRobot.save(); + + await contactRobot.isDisplayed(); + + await dashboardRobot.openHomeTab(); + await homePageRobot.isDisplayed(); + + await homePageRobot.openSendSheet(); + await sendRobot.isDisplayed(); + + await sendRobot.pickContactFromAddressBook(contactName); + + expect( + sendRobot.enteredAddress(), + contactAddress, + reason: "The send screen was filled with an address the contact does not hold", + ); + }); +} diff --git a/integration_test/suites/tier0/send_validation_test.dart b/integration_test/suites/tier0/send_validation_test.dart new file mode 100644 index 000000000..1f347e0ec --- /dev/null +++ b/integration_test/suites/tier0/send_validation_test.dart @@ -0,0 +1,53 @@ +import "package:cw_core/wallet_type.dart"; +import "package:integration_test/integration_test.dart"; + +import "../../core/app_launcher.dart"; +import "../../core/test_wallets.dart"; +import "../../flows/onboarding_flows.dart"; +import "../../robots/home_page_robot.dart"; +import "../../robots/new_dashboard_robot.dart"; +import "../../robots/new_send_page_robot.dart"; + +void main() { + IntegrationTestWidgetsFlutterBinding.ensureInitialized(); + + integrationTest("Send screen refuses what it cannot send", (tester) async { + final appLauncher = AppLauncher(tester); + final onboardingFlows = OnboardingFlows(tester); + final dashboardRobot = NewDashboardRobot(tester); + final homePageRobot = HomePageRobot(tester); + final sendRobot = NewSendPageRobot(tester); + + const walletType = WalletType.solana; + + await appLauncher.launchApp(testKey: "send_validation_test_app_key"); + + await onboardingFlows.createFirstWallet(walletType); + + await dashboardRobot.isDisplayed(); + await homePageRobot.isDisplayed(); + + await homePageRobot.openSendSheet(); + await sendRobot.isDisplayed(); + + await sendRobot.tapSendButtonWhenReady(timeout: const Duration(minutes: 5)); + await sendRobot.expectNoTransactionBuilt(); + + // test that no transaction is built when the address is wrong or cannot be parsed + await sendRobot.enterAddress("not-a-real-address"); + await sendRobot.enterAmount("0.0001"); + await sendRobot.tapSendButton(); + + await sendRobot.expectNoTransactionBuilt(); + + // test that no transaction is built even if the address is valid but no balance + await sendRobot.enterAddress(TestWallets.receiveAddressFor(walletType)); + await sendRobot.enterAmount("0.0001"); + await sendRobot.tapSendButton(); + + await sendRobot.expectNoTransactionBuilt(); + + await sendRobot.dismissModal(); + await homePageRobot.isDisplayed(); + }); +} diff --git a/integration_test/suites/tier0/settings_nav_test.dart b/integration_test/suites/tier0/settings_nav_test.dart new file mode 100644 index 000000000..1da0df620 --- /dev/null +++ b/integration_test/suites/tier0/settings_nav_test.dart @@ -0,0 +1,61 @@ +import "package:hash_wallet/new-ui/pages/about_page.dart"; +import "package:hash_wallet/routes.dart"; +import "package:hash_wallet/src/screens/settings/connection_sync_page.dart"; +import "package:hash_wallet/src/screens/settings/display_settings_page.dart"; +import "package:hash_wallet/src/screens/settings/manage_nodes_page.dart"; +import "package:hash_wallet/src/screens/settings/other_settings_page.dart"; +import "package:hash_wallet/src/screens/settings/privacy_page.dart"; +import "package:hash_wallet/src/screens/settings/security_backup_page.dart"; +import "package:cw_core/wallet_type.dart"; +import "package:integration_test/integration_test.dart"; + +import "../../core/app_launcher.dart"; +import "../../flows/onboarding_flows.dart"; +import "../../robots/home_page_robot.dart"; +import "../../robots/new_dashboard_robot.dart"; +import "../../robots/new_settings_page_robot.dart"; + +void main() { + IntegrationTestWidgetsFlutterBinding.ensureInitialized(); + + integrationTest("Every settings row opens its page and backs out", (tester) async { + final appLauncher = AppLauncher(tester); + final onboardingFlows = OnboardingFlows(tester); + final dashboardRobot = NewDashboardRobot(tester); + final homePageRobot = HomePageRobot(tester); + final settingsRobot = NewSettingsPageRobot(tester); + + final rowsUnderTest = { + Routes.manageNodes: ManageNodesPage, + Routes.privacyPage: PrivacyPage, + Routes.otherSettingsPage: OtherSettingsPage, + Routes.connectionSync: ConnectionSyncPage, + Routes.displaySettingsPage: DisplaySettingsPage, + Routes.securityBackupPage: SecurityBackupPage, + Routes.aboutPage: AboutPage, + }; + + await appLauncher.launchApp(testKey: "settings_nav_test_app_key"); + + await onboardingFlows.createFirstWallet(WalletType.solana); + + await dashboardRobot.isDisplayed(); + await homePageRobot.isDisplayed(); + + await homePageRobot.openSettingsSheet(); + await settingsRobot.isDisplayed(); + + for (final entry in rowsUnderTest.entries) { + tester.printToConsole("Opening settings row ${entry.key}"); + + await settingsRobot.openRow(entry.key); + await settingsRobot.confirmLeafPageDisplayed(entry.value); + + await settingsRobot.goBack(); + await settingsRobot.isDisplayed(); + } + + await settingsRobot.dismissModal(); + await homePageRobot.isDisplayed(); + }); +} diff --git a/integration_test/suites/tier0/show_keys_auth_test.dart b/integration_test/suites/tier0/show_keys_auth_test.dart new file mode 100644 index 000000000..0f9ecaf34 --- /dev/null +++ b/integration_test/suites/tier0/show_keys_auth_test.dart @@ -0,0 +1,61 @@ +import "package:hash_wallet/routes.dart"; +import "package:hash_wallet/src/screens/wallet_keys/wallet_keys_page.dart"; +import "package:cw_core/wallet_type.dart"; +import "package:flutter_test/flutter_test.dart"; +import "package:integration_test/integration_test.dart"; + +import "../../core/app_launcher.dart"; +import "../../flows/auth_flows.dart"; +import "../../flows/onboarding_flows.dart"; +import "../../robots/home_page_robot.dart"; +import "../../robots/new_dashboard_robot.dart"; +import "../../robots/new_settings_page_robot.dart"; +import "../../robots/show_keys_disclaimer_page_robot.dart"; +import "../../robots/wallet_keys_robot.dart"; + +void main() { + IntegrationTestWidgetsFlutterBinding.ensureInitialized(); + + integrationTest("Seed and keys page rejects a wrong pin and accepts the right one", + (tester) async { + final appLauncher = AppLauncher(tester); + final onboardingFlows = OnboardingFlows(tester); + final authFlows = AuthFlows(tester); + final dashboardRobot = NewDashboardRobot(tester); + final homePageRobot = HomePageRobot(tester); + final settingsRobot = NewSettingsPageRobot(tester); + final walletKeysRobot = WalletKeysAndSeedPageRobot(tester); + final disclaimerRobot = ShowKeysDisclaimerPageRobot(tester); + + final wrongPin = [9, 9, 9, 9]; + + await appLauncher.launchApp(testKey: "show_keys_auth_test_app_key"); + + await onboardingFlows.createFirstWallet(WalletType.solana); + + await dashboardRobot.isDisplayed(); + await homePageRobot.isDisplayed(); + + await homePageRobot.openSettingsSheet(); + await settingsRobot.isDisplayed(); + + await settingsRobot.openRow(Routes.showKeysDisclaimer); + await disclaimerRobot.isDisplayed(); + await disclaimerRobot.tapShowKeys(); + + await authFlows.authenticateWithPin(pin: wrongPin); + + // A wrong pin must keep the keys page locked away. + await settingsRobot.settle(max: const Duration(seconds: 3)); + expect(find.byType(WalletKeysPage), findsNothing); + expect(authFlows.isPinWidgetShown(), true); + + await authFlows.authenticateWithPin(); + + await walletKeysRobot.isDisplayed(); + + await settingsRobot.goBack(); + await settingsRobot.dismissModal(); + await homePageRobot.isDisplayed(); + }); +} diff --git a/integration_test/suites/tier0/wallet_delete_test.dart b/integration_test/suites/tier0/wallet_delete_test.dart new file mode 100644 index 000000000..7e139185d --- /dev/null +++ b/integration_test/suites/tier0/wallet_delete_test.dart @@ -0,0 +1,85 @@ +import "package:hash_wallet/di.dart"; +import "package:hash_wallet/store/app_store.dart"; +import "package:cw_core/wallet_info.dart"; +import "package:cw_core/wallet_type.dart"; +import "package:flutter_test/flutter_test.dart"; +import "package:integration_test/integration_test.dart"; + +import "../../core/app_launcher.dart"; +import "../../flows/auth_flows.dart"; +import "../../flows/onboarding_flows.dart"; +import "../../robots/home_page_robot.dart"; +import "../../robots/new_dashboard_robot.dart"; +import "../../robots/wallet_edit_page_robot.dart"; +import "../../robots/wallet_list_page_robot.dart"; + +void main() { + IntegrationTestWidgetsFlutterBinding.ensureInitialized(); + + integrationTest("Deleting a wallet takes it off the list and out of storage", (tester) async { + final appLauncher = AppLauncher(tester); + final onboardingFlows = OnboardingFlows(tester); + final authFlows = AuthFlows(tester); + final dashboardRobot = NewDashboardRobot(tester); + final homePageRobot = HomePageRobot(tester); + final walletListRobot = WalletListPageRobot(tester); + final walletEditRobot = WalletEditPageRobot(tester); + + await appLauncher.launchApp(testKey: "wallet_delete_test_app_key"); + + await onboardingFlows.createFirstWallet(WalletType.solana); + + await dashboardRobot.isDisplayed(); + await homePageRobot.isDisplayed(); + + final appStore = getIt.get(); + final deletedWallet = appStore.wallet!.name; + + await dashboardRobot.openWalletsTab(); + await onboardingFlows.createAdditionalWalletFromWalletList(WalletType.ethereum); + + await homePageRobot.isDisplayed(); + + final keptWallet = appStore.wallet!.name; + + expect(keptWallet, isNot(deletedWallet), reason: "The second wallet never became the open one"); + + await dashboardRobot.openWalletsTab(); + await walletListRobot.isDisplayed(); + + await walletListRobot.openEditFor(deletedWallet); + await walletEditRobot.isDisplayed(); + + await walletEditRobot.tapDelete(); + await authFlows.authenticateWithPin(required: false); + + await walletEditRobot.confirmDelete(); + + await walletListRobot.isDisplayed(); + + final removed = await walletListRobot.pumpUntil( + () => !walletListRobot.hasWallet(deletedWallet), + timeout: const Duration(minutes: 1), + ); + + expect(removed, true, reason: "$deletedWallet is still on the list after being deleted"); + + expect( + walletListRobot.hasWallet(keptWallet), + true, + reason: "Deleting $deletedWallet took $keptWallet with it", + ); + + final stored = await WalletInfo.getAll(); + final names = stored.map((wallet) => wallet.name).toList(); + + expect(names, isNot(contains(deletedWallet))); + expect(names, contains(keptWallet)); + + expect( + appStore.wallet?.name, + keptWallet, + reason: "Deleting another wallet changed which one is open", + ); + }); +} diff --git a/integration_test/suites/tier0/wallet_group_test.dart b/integration_test/suites/tier0/wallet_group_test.dart new file mode 100644 index 000000000..6b8dd9e7d --- /dev/null +++ b/integration_test/suites/tier0/wallet_group_test.dart @@ -0,0 +1,70 @@ +import "package:hash_wallet/di.dart"; +import "package:hash_wallet/store/app_store.dart"; +import "package:cw_core/wallet_type.dart"; +import "package:flutter_test/flutter_test.dart"; +import "package:integration_test/integration_test.dart"; + +import "../../core/app_launcher.dart"; +import "../../flows/onboarding_flows.dart"; +import "../../flows/wallet_flows.dart"; +import "../../robots/home_page_robot.dart"; +import "../../robots/new_dashboard_robot.dart"; + +void main() { + IntegrationTestWidgetsFlutterBinding.ensureInitialized(); + + integrationTest("A wallet added to an existing seed shares it and both stay reachable", + (tester) async { + final appLauncher = AppLauncher(tester); + final onboardingFlows = OnboardingFlows(tester); + final walletFlows = WalletFlows(tester); + final dashboardRobot = NewDashboardRobot(tester); + final homePageRobot = HomePageRobot(tester); + + const firstType = WalletType.solana; + const secondType = WalletType.ethereum; + + await appLauncher.launchApp(testKey: "wallet_group_test_app_key"); + + await onboardingFlows.createFirstWallet(firstType); + + await dashboardRobot.isDisplayed(); + await homePageRobot.isDisplayed(); + + final appStore = getIt.get(); + final firstWalletName = appStore.wallet!.name; + final sharedSeed = appStore.wallet!.seed; + + expect(sharedSeed, isNotNull, reason: "The first wallet has no seed to share"); + + await dashboardRobot.openWalletsTab(); + + await onboardingFlows.createWalletInGroupOf(secondType, firstWalletName); + + await dashboardRobot.isDisplayed(); + await homePageRobot.isDisplayed(); + + final secondWalletName = appStore.wallet!.name; + + expect( + secondWalletName, + isNot(firstWalletName), + reason: "Adding to a group opened the wallet that was already there", + ); + + expect(appStore.wallet?.type, secondType); + + expect( + appStore.wallet!.seed, + sharedSeed, + reason: "The wallet added to the group derived its own seed instead of sharing one", + ); + + await walletFlows.switchToWallet(firstWalletName); + + await homePageRobot.isDisplayed(); + await homePageRobot.hasWalletName(firstWalletName); + + expect(appStore.wallet?.seed, sharedSeed); + }); +} diff --git a/integration_test/suites/tier0/wallet_rename_test.dart b/integration_test/suites/tier0/wallet_rename_test.dart new file mode 100644 index 000000000..16c983ebe --- /dev/null +++ b/integration_test/suites/tier0/wallet_rename_test.dart @@ -0,0 +1,86 @@ +import "package:hash_wallet/di.dart"; +import "package:hash_wallet/store/app_store.dart"; +import "package:cw_core/wallet_info.dart"; +import "package:cw_core/wallet_type.dart"; +import "package:flutter_test/flutter_test.dart"; +import "package:integration_test/integration_test.dart"; + +import "../../core/app_launcher.dart"; +import "../../flows/onboarding_flows.dart"; +import "../../robots/home_page_robot.dart"; +import "../../robots/new_dashboard_robot.dart"; +import "../../robots/wallet_edit_page_robot.dart"; +import "../../robots/wallet_list_page_robot.dart"; + +void main() { + IntegrationTestWidgetsFlutterBinding.ensureInitialized(); + + integrationTest("A wallet can be renamed, but not onto a name already in use", (tester) async { + final appLauncher = AppLauncher(tester); + final onboardingFlows = OnboardingFlows(tester); + final dashboardRobot = NewDashboardRobot(tester); + final homePageRobot = HomePageRobot(tester); + final walletListRobot = WalletListPageRobot(tester); + final walletEditRobot = WalletEditPageRobot(tester); + + const renamedTo = "Renamed Test Wallet"; + + await appLauncher.launchApp(testKey: "wallet_rename_test_app_key"); + + await onboardingFlows.createFirstWallet(WalletType.solana); + + await dashboardRobot.isDisplayed(); + await homePageRobot.isDisplayed(); + + final appStore = getIt.get(); + final renamedWallet = appStore.wallet!.name; + + await dashboardRobot.openWalletsTab(); + await onboardingFlows.createAdditionalWalletFromWalletList(WalletType.ethereum); + + await homePageRobot.isDisplayed(); + + final openWallet = appStore.wallet!.name; + + expect(openWallet, isNot(renamedWallet), reason: "The second wallet never became the open one"); + + await dashboardRobot.openWalletsTab(); + await walletListRobot.isDisplayed(); + + await walletListRobot.openEditFor(renamedWallet); + await walletEditRobot.isDisplayed(); + + await walletEditRobot.enterName(openWallet); + await walletEditRobot.save(); + + await walletEditRobot.expectNameTakenRefusal(); + await walletEditRobot.dismissNameTakenRefusal(); + + await walletEditRobot.enterName(renamedTo); + await walletEditRobot.save(); + + await walletListRobot.isDisplayed(); + + final renamed = await walletListRobot.pumpUntil(() => walletListRobot.hasWallet(renamedTo)); + + expect(renamed, true, reason: "The list never showed $renamedTo after the rename was saved"); + + expect( + walletListRobot.hasWallet(renamedWallet), + false, + reason: "The list still shows $renamedWallet next to the name it was renamed to", + ); + + final stored = await WalletInfo.getAll(); + final names = stored.map((wallet) => wallet.name).toList(); + + expect(names, contains(renamedTo)); + expect(names, isNot(contains(renamedWallet))); + + expect( + appStore.wallet?.name, + openWallet, + reason: "Renaming another wallet changed which one is open", + ); + }); +} diff --git a/integration_test/suites/tier1/monero_legacy_seed_test.dart b/integration_test/suites/tier1/monero_legacy_seed_test.dart new file mode 100644 index 000000000..cf58d4695 --- /dev/null +++ b/integration_test/suites/tier1/monero_legacy_seed_test.dart @@ -0,0 +1,47 @@ +import "package:hash_wallet/di.dart"; +import "package:hash_wallet/store/app_store.dart"; +import "package:cw_core/wallet_type.dart"; +import "package:flutter_test/flutter_test.dart"; +import "package:integration_test/integration_test.dart"; + +import "../../core/app_launcher.dart"; +import "../../core/test_wallets.dart"; +import "../../flows/onboarding_flows.dart"; +import "../../robots/home_page_robot.dart"; +import "../../robots/new_dashboard_robot.dart"; + +void main() { + IntegrationTestWidgetsFlutterBinding.ensureInitialized(); + + integrationTest("A 25 word monero seed restores through the legacy path", (tester) async { + final appLauncher = AppLauncher(tester); + final onboardingFlows = OnboardingFlows(tester); + final dashboardRobot = NewDashboardRobot(tester); + final homePageRobot = HomePageRobot(tester); + + final legacySeed = TestWallets.moneroLegacySeed; + + expect( + legacySeed.split(" ").length, + 25, + reason: "The legacy seed secret is not a 25 word seed, this suite would restore a polyseed", + ); + + await appLauncher.launchApp(testKey: "monero_legacy_seed_test_app_key"); + + await onboardingFlows.restoreFirstWalletFromSeed(WalletType.monero, seed: legacySeed); + + await dashboardRobot.isDisplayed(); + await homePageRobot.isDisplayed(); + + final appStore = getIt.get(); + + expect(appStore.wallet?.type, WalletType.monero); + + expect( + appStore.wallet?.seed?.split(" ").length, + 25, + reason: "The wallet came back with a seed of a different length than the one restored", + ); + }); +} diff --git a/integration_test/suites/tier1/node_switching_test.dart b/integration_test/suites/tier1/node_switching_test.dart new file mode 100644 index 000000000..e72f316ca --- /dev/null +++ b/integration_test/suites/tier1/node_switching_test.dart @@ -0,0 +1,49 @@ +import "package:hash_wallet/routes.dart"; +import "package:cw_core/wallet_type.dart"; +import "package:flutter_test/flutter_test.dart"; +import "package:integration_test/integration_test.dart"; + +import "../../core/app_launcher.dart"; +import "../../flows/onboarding_flows.dart"; +import "../../robots/home_page_robot.dart"; +import "../../robots/manage_nodes_page_robot.dart"; +import "../../robots/new_dashboard_robot.dart"; +import "../../robots/new_settings_page_robot.dart"; + +void main() { + IntegrationTestWidgetsFlutterBinding.ensureInitialized(); + + integrationTest("Switching nodes changes which one the wallet is pointed at", (tester) async { + final appLauncher = AppLauncher(tester); + final onboardingFlows = OnboardingFlows(tester); + final dashboardRobot = NewDashboardRobot(tester); + final homePageRobot = HomePageRobot(tester); + final settingsRobot = NewSettingsPageRobot(tester); + final nodesRobot = ManageNodesPageRobot(tester); + + await appLauncher.launchApp(testKey: "node_switching_test_app_key"); + + await onboardingFlows.createFirstWallet(WalletType.solana); + + await dashboardRobot.isDisplayed(); + await homePageRobot.isDisplayed(); + + await homePageRobot.openSettingsSheet(); + await settingsRobot.isDisplayed(); + + await settingsRobot.openRow(Routes.manageNodes); + await nodesRobot.isDisplayed(); + + final initialNodeUri = nodesRobot.currentNodeUri(); + + expect(initialNodeUri, isNotEmpty, reason: "The wallet started with no node at all"); + + await nodesRobot.switchToAnotherNode(); + + expect( + nodesRobot.currentNodeUri(), + isNot(initialNodeUri), + reason: "The node was confirmed but the wallet is still pointed at $initialNodeUri", + ); + }); +} diff --git a/integration_test/suites/tier1/swap_quote_test.dart b/integration_test/suites/tier1/swap_quote_test.dart new file mode 100644 index 000000000..d7aa90a80 --- /dev/null +++ b/integration_test/suites/tier1/swap_quote_test.dart @@ -0,0 +1,37 @@ +import "package:cw_core/wallet_type.dart"; +import "package:integration_test/integration_test.dart"; + +import "../../core/app_launcher.dart"; +import "../../flows/onboarding_flows.dart"; +import "../../robots/home_page_robot.dart"; +import "../../robots/new_dashboard_robot.dart"; +import "../../robots/new_swap_page_robot.dart"; + +void main() { + IntegrationTestWidgetsFlutterBinding.ensureInitialized(); + + integrationTest("Swap sheet fetches a provider quote, no trade is created", (tester) async { + final appLauncher = AppLauncher(tester); + final onboardingFlows = OnboardingFlows(tester); + final dashboardRobot = NewDashboardRobot(tester); + final homePageRobot = HomePageRobot(tester); + final swapRobot = NewSwapPageRobot(tester); + + await appLauncher.launchApp(testKey: "swap_quote_test_app_key"); + + await onboardingFlows.restoreFirstWalletFromSeed(WalletType.solana); + + await dashboardRobot.isDisplayed(); + await homePageRobot.isDisplayed(); + + await homePageRobot.openSwapSheet(); + await swapRobot.isDisplayed(); + + await swapRobot.enterDepositAmount("1"); + + await swapRobot.confirmQuoteReceived(); + + await swapRobot.dismissModal(); + await homePageRobot.isDisplayed(); + }); +} diff --git a/integration_test/suites/tier1/sync_status_test.dart b/integration_test/suites/tier1/sync_status_test.dart new file mode 100644 index 000000000..902e91f40 --- /dev/null +++ b/integration_test/suites/tier1/sync_status_test.dart @@ -0,0 +1,27 @@ +import "package:cw_core/wallet_type.dart"; +import "package:integration_test/integration_test.dart"; + +import "../../core/app_launcher.dart"; +import "../../flows/onboarding_flows.dart"; +import "../../robots/home_page_robot.dart"; +import "../../robots/new_dashboard_robot.dart"; + +void main() { + IntegrationTestWidgetsFlutterBinding.ensureInitialized(); + + integrationTest("Restored wallet connects to a node and starts syncing", (tester) async { + final appLauncher = AppLauncher(tester); + final onboardingFlows = OnboardingFlows(tester); + final dashboardRobot = NewDashboardRobot(tester); + final homePageRobot = HomePageRobot(tester); + + await appLauncher.launchApp(testKey: "sync_status_test_app_key"); + + await onboardingFlows.restoreFirstWalletFromSeed(WalletType.bitcoin); + + await dashboardRobot.isDisplayed(); + await homePageRobot.isDisplayed(); + + await homePageRobot.confirmSyncIndicatorShown(); + }); +} diff --git a/integration_test/suites/tier1/transaction_details_test.dart b/integration_test/suites/tier1/transaction_details_test.dart new file mode 100644 index 000000000..317793319 --- /dev/null +++ b/integration_test/suites/tier1/transaction_details_test.dart @@ -0,0 +1,47 @@ +import "package:cw_core/wallet_type.dart"; +import "package:flutter_test/flutter_test.dart"; +import "package:integration_test/integration_test.dart"; + +import "../../core/app_launcher.dart"; +import "../../flows/onboarding_flows.dart"; +import "../../robots/home_page_robot.dart"; +import "../../robots/new_dashboard_robot.dart"; + +void main() { + IntegrationTestWidgetsFlutterBinding.ensureInitialized(); + + integrationTest("Tapping a transaction opens the details of that transaction", (tester) async { + final appLauncher = AppLauncher(tester); + final onboardingFlows = OnboardingFlows(tester); + final dashboardRobot = NewDashboardRobot(tester); + final homePageRobot = HomePageRobot(tester); + + await appLauncher.launchApp(testKey: "transaction_details_test_app_key"); + + await onboardingFlows.restoreFirstWalletFromSeed(WalletType.solana); + + await dashboardRobot.isDisplayed(); + await homePageRobot.isDisplayed(); + + await homePageRobot.confirmTransactionHistoryVisible(); + + await homePageRobot.openAllTransactions(); + await homePageRobot.confirmAllTransactionsVisible(); + + final tapped = homePageRobot.firstTransactionIdInAllView(); + + expect(tapped, isNotEmpty, reason: "The tile carries no transaction id to check against"); + + await homePageRobot.openFirstTransactionDetails(); + + // A list that hands the wrong transaction to the details screen shows one person's + // amounts under another's id, and nothing on the screen would say so. + expect( + homePageRobot.openedTransactionId(), + tapped, + reason: "The details opened for a different transaction than the one tapped", + ); + + homePageRobot.hasTransactionIdRow(); + }); +} diff --git a/integration_test/suites/tier1/transaction_history_test.dart b/integration_test/suites/tier1/transaction_history_test.dart new file mode 100644 index 000000000..54ac88554 --- /dev/null +++ b/integration_test/suites/tier1/transaction_history_test.dart @@ -0,0 +1,37 @@ +import "package:hash_wallet/di.dart"; +import "package:hash_wallet/store/app_store.dart"; +import "package:cw_core/wallet_type.dart"; +import "package:flutter_test/flutter_test.dart"; +import "package:integration_test/integration_test.dart"; + +import "../../core/app_launcher.dart"; +import "../../flows/onboarding_flows.dart"; +import "../../robots/home_page_robot.dart"; +import "../../robots/new_dashboard_robot.dart"; + +void main() { + IntegrationTestWidgetsFlutterBinding.ensureInitialized(); + + integrationTest("Transaction history renders for a wallet with known transactions", + (tester) async { + final appLauncher = AppLauncher(tester); + final onboardingFlows = OnboardingFlows(tester); + final dashboardRobot = NewDashboardRobot(tester); + final homePageRobot = HomePageRobot(tester); + + await appLauncher.launchApp(testKey: "transaction_history_test_app_key"); + + await onboardingFlows.restoreFirstWalletFromSeed(WalletType.solana); + + await dashboardRobot.isDisplayed(); + await homePageRobot.isDisplayed(); + + expect(getIt.get().wallet?.type, WalletType.solana); + + await homePageRobot.confirmTransactionHistoryVisible(); + + await homePageRobot.openAllTransactions(); + + await homePageRobot.confirmAllTransactionsVisible(); + }); +} diff --git a/integration_test/templates/example_feature_test.dart.example b/integration_test/templates/example_feature_test.dart.example new file mode 100644 index 000000000..84799cbce --- /dev/null +++ b/integration_test/templates/example_feature_test.dart.example @@ -0,0 +1,55 @@ +// Copy this file into integration_test/suites/tier0/ or tier1/, rename it to +// _test.dart and replace the example steps with your journey. +// The runner only picks up files ending in _test.dart, this template never runs. + +import "package:cw_core/wallet_type.dart"; +import "package:flutter_test/flutter_test.dart"; +import "package:integration_test/integration_test.dart"; + +import "../../core/app_launcher.dart"; +import "../../flows/onboarding_flows.dart"; +import "../../robots/home_page_robot.dart"; +import "../../robots/new_dashboard_robot.dart"; + +void main() { + // Required first line in every suite, integrationTest contains background app errors + // that testWidgets would turn into failures. + IntegrationTestWidgetsFlutterBinding.ensureInitialized(); + + integrationTest("One sentence describing the journey under test", (tester) async { + // Robots and flows take the tester, create the ones your journey needs. + final appLauncher = AppLauncher(tester); + final onboardingFlows = OnboardingFlows(tester); + final dashboardRobot = NewDashboardRobot(tester); + final homePageRobot = HomePageRobot(tester); + + // Boots the real app, the key names the suite in failure output. + await appLauncher.launchApp(testKey: "example_feature_test_app_key"); + + // Every suite starts from a fresh install, so get to a working wallet first. + // Use restoreFirstWalletFromSeed instead when your feature needs a known wallet. + await onboardingFlows.createFirstWallet(WalletType.solana); + + await dashboardRobot.isDisplayed(); + await homePageRobot.isDisplayed(); + + // Drive your feature through its robot from here. Robots own the finders and + // assertions for their screen, the suite only sequences them: + // + // await homePageRobot.openSettingsSheet(); + // await mySettingsRobot.isDisplayed(); + // await mySettingsRobot.doTheThing(); + // + // Rules of thumb: + // - Wait with pumpUntilFound and pumpUntil, never with sleeps or pumpAndSettle. + // - Tap and read widgets by ValueKey, add keys to lib/ widgets when they are missing. + // - Assert app state through getIt (AppStore) or the page widget's view model. + // - A widget being in the tree does not mean it is on screen. Every dashboard tab + // stays mounted in an IndexedStack, so find.byType on a tab passes from any other + // tab. Check what is in front, the way WalletListPageRobot does. + // - A button being findable does not mean it is enabled. Tapping a disabled button + // does nothing at all and reads later as a missing widget somewhere else, so wait + // on whatever the screen itself gates on. + // - Add the finished suite to the table in docs/INTEGRATION_TESTS.md. + }); +} diff --git a/integration_test/test_suites/confirm_seeds_flow_test.dart b/integration_test/test_suites/confirm_seeds_flow_test.dart deleted file mode 100644 index bc313f6ae..000000000 --- a/integration_test/test_suites/confirm_seeds_flow_test.dart +++ /dev/null @@ -1,122 +0,0 @@ -import 'package:hash_wallet/wallet_types.g.dart'; -import 'package:cw_core/wallet_type.dart'; -import 'package:flutter/foundation.dart'; -import 'package:flutter/material.dart'; -import 'package:flutter_test/flutter_test.dart'; -import 'package:integration_test/integration_test.dart'; - -import '../components/common_test_constants.dart'; -import '../components/common_test_flows.dart'; -import '../robots/auth_page_robot.dart'; -import '../robots/dashboard_page_robot.dart'; -import '../robots/security_and_backup_page_robot.dart'; -import '../robots/wallet_keys_robot.dart'; - -void main() { - IntegrationTestWidgetsFlutterBinding.ensureInitialized(); - - AuthPageRobot authPageRobot; - CommonTestFlows commonTestFlows; - DashboardPageRobot dashboardPageRobot; - WalletKeysAndSeedPageRobot walletKeysAndSeedPageRobot; - SecurityAndBackupPageRobot securityAndBackupPageRobot; - - testWidgets( - 'Confirm if the seeds display properly', - (tester) async { - // Store the original FlutterError.onError handler - final originalOnError = FlutterError.onError; - - FlutterError.onError = (FlutterErrorDetails details) { - debugPrint('FlutterError caught: ${details.exception}'); - }; - - try { - authPageRobot = AuthPageRobot(tester); - commonTestFlows = CommonTestFlows(tester); - dashboardPageRobot = DashboardPageRobot(tester); - walletKeysAndSeedPageRobot = WalletKeysAndSeedPageRobot(tester); - securityAndBackupPageRobot = SecurityAndBackupPageRobot(tester); - - await commonTestFlows.startAppFlow( - ValueKey('confirm_creds_display_correctly_flow_app_key'), - ); - - await commonTestFlows.welcomePageToCreateNewWalletFlow( - WalletType.solana, - CommonTestConstants.pin, - ); - - await dashboardPageRobot.confirmWalletTypeIsDisplayedCorrectly(WalletType.solana); - - await _confirmSeedsFlowForWalletType( - WalletType.solana, - authPageRobot, - dashboardPageRobot, - securityAndBackupPageRobot, - walletKeysAndSeedPageRobot, - tester, - ); - - // Do the same for other available wallet types - for (var walletType in availableWalletTypes) { - if (walletType == WalletType.solana) { - continue; - } - - await dashboardPageRobot.navigateToWalletsListPage(isDrawer: true); - - await commonTestFlows.createNewWalletFromWalletMenu(walletType); - - await dashboardPageRobot.confirmWalletTypeIsDisplayedCorrectly(walletType); - - await _confirmSeedsFlowForWalletType( - walletType, - authPageRobot, - dashboardPageRobot, - securityAndBackupPageRobot, - walletKeysAndSeedPageRobot, - tester, - ); - } - - await Future.delayed(Duration(seconds: 15)); - } finally { - FlutterError.onError = originalOnError; - } - }, - ); -} - -Future _confirmSeedsFlowForWalletType( - WalletType walletType, - AuthPageRobot authPageRobot, - DashboardPageRobot dashboardPageRobot, - SecurityAndBackupPageRobot securityAndBackupPageRobot, - WalletKeysAndSeedPageRobot walletKeysAndSeedPageRobot, - WidgetTester tester, -) async { - await dashboardPageRobot.openDrawerMenu(); - await dashboardPageRobot.dashboardMenuWidgetRobot.navigateToSecurityAndBackupPage(); - - await securityAndBackupPageRobot.navigateToShowKeysPage(); - - final onAuthPage = authPageRobot.onAuthPage(); - if (onAuthPage) { - await authPageRobot.enterPinCode(CommonTestConstants.pin); - } - - final onAuthPageDesktop = authPageRobot.onAuthPageDesktop(); - if (onAuthPageDesktop) { - await authPageRobot.enterPassword(CommonTestConstants.pin.join("")); - } - await tester.pumpAndSettle(); - - await walletKeysAndSeedPageRobot.isWalletKeysAndSeedPage(); - walletKeysAndSeedPageRobot.hasTitle(); - walletKeysAndSeedPageRobot.hasShareWarning(); - - await walletKeysAndSeedPageRobot.confirmWalletCredentials(walletType); - - await walletKeysAndSeedPageRobot.backToDashboard(); -} diff --git a/integration_test/test_suites/create_wallet_flow_test.dart b/integration_test/test_suites/create_wallet_flow_test.dart deleted file mode 100644 index 36bad9169..000000000 --- a/integration_test/test_suites/create_wallet_flow_test.dart +++ /dev/null @@ -1,60 +0,0 @@ -import 'package:hash_wallet/wallet_types.g.dart'; -import 'package:cw_core/wallet_type.dart'; -import 'package:flutter/material.dart'; -import 'package:flutter_test/flutter_test.dart'; -import 'package:integration_test/integration_test.dart'; - -import '../components/common_test_constants.dart'; -import '../components/common_test_flows.dart'; -import '../robots/dashboard_page_robot.dart'; - -void main() { - IntegrationTestWidgetsFlutterBinding.ensureInitialized(); - - CommonTestFlows commonTestFlows; - DashboardPageRobot dashboardPageRobot; - - testWidgets( - 'Create Wallet Flow', - (tester) async { - FlutterError.onError = (FlutterErrorDetails details) { - debugPrint('FlutterError caught: ${details.exception}'); - }; - commonTestFlows = CommonTestFlows(tester); - dashboardPageRobot = DashboardPageRobot(tester); - - // Start the app - await commonTestFlows.startAppFlow( - ValueKey('create_wallets_through_seeds_test_app_key'), - ); - - await commonTestFlows.welcomePageToCreateNewWalletFlow( - WalletType.solana, - CommonTestConstants.pin, - ); - - // Confirm it actually restores a solana wallet - await dashboardPageRobot.confirmWalletTypeIsDisplayedCorrectly(WalletType.solana); - - // Do the same for other available wallet types - for (var walletType in availableWalletTypes) { - if (walletType == WalletType.solana) { - continue; - } - - await dashboardPageRobot.navigateToWalletsListPage(); - - await commonTestFlows.createNewWalletFromWalletMenu(walletType); - - await dashboardPageRobot.confirmWalletTypeIsDisplayedCorrectly(walletType); - } - - // Goes to the wallet menu and provides a confirmation that all the wallets were correctly restored - await dashboardPageRobot.navigateToWalletsListPage(); - - commonTestFlows.confirmAllAvailableWalletTypeIconsDisplayCorrectly(); - - await Future.delayed(Duration(seconds: 5)); - }, - ); -} diff --git a/integration_test/test_suites/exchange_flow_test.dart b/integration_test/test_suites/exchange_flow_test.dart deleted file mode 100644 index e61d60fa4..000000000 --- a/integration_test/test_suites/exchange_flow_test.dart +++ /dev/null @@ -1,65 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:flutter_test/flutter_test.dart'; -import 'package:integration_test/integration_test.dart'; - -import '../components/common_test_constants.dart'; -import '../components/common_test_flows.dart'; -import '../robots/dashboard_page_robot.dart'; -import '../robots/exchange_confirm_page_robot.dart'; -import '../robots/exchange_page_robot.dart'; -import '../robots/exchange_trade_external_send_page_robot.dart'; -import '../robots/exchange_trade_page_robot.dart'; -import 'package:hash_wallet/.secrets.g.dart' as secrets; - -void main() { - IntegrationTestWidgetsFlutterBinding.ensureInitialized(); - - CommonTestFlows commonTestFlows; - ExchangePageRobot exchangePageRobot; - DashboardPageRobot dashboardPageRobot; - ExchangeTradePageRobot exchangeTradePageRobot; - ExchangeConfirmPageRobot exchangeConfirmPageRobot; - ExchangeTradeExternalSendPageRobot exchangeTradeExternalSendPageRobot; - - testWidgets('Exchange flow', (tester) async { - FlutterError.onError = (FlutterErrorDetails details) { - debugPrint('FlutterError caught: ${details.exception}'); - }; - - commonTestFlows = CommonTestFlows(tester); - exchangePageRobot = ExchangePageRobot(tester); - dashboardPageRobot = DashboardPageRobot(tester); - exchangeTradePageRobot = ExchangeTradePageRobot(tester); - exchangeConfirmPageRobot = ExchangeConfirmPageRobot(tester); - exchangeTradeExternalSendPageRobot = ExchangeTradeExternalSendPageRobot(tester); - - await commonTestFlows.startAppFlow(ValueKey('exchange_app_test_key')); - await commonTestFlows.welcomePageToRestoreWalletThroughSeedsFlow( - CommonTestConstants.testWalletType, - secrets.solanaTestWalletSeeds, - CommonTestConstants.pin, - ); - await dashboardPageRobot.navigateToExchangePage(); - - // ----------- Exchange Page ------------- - await exchangePageRobot.selectDepositCurrency(CommonTestConstants.exchangeTestDepositCurrency); - await exchangePageRobot.selectReceiveCurrency(CommonTestConstants.exchangeTestReceiveCurrency); - - await exchangePageRobot.enterDepositAmount(CommonTestConstants.exchangeTestAmount); - await exchangePageRobot.enterDepositRefundAddress( - depositAddress: CommonTestConstants.testWalletAddress, - ); - await exchangePageRobot.enterReceiveAddress(CommonTestConstants.testWalletAddress); - await exchangePageRobot.onExchangeButtonPressed(); - await exchangePageRobot.handleErrors(CommonTestConstants.exchangeTestAmount); - - await exchangeConfirmPageRobot.onSavedTradeIdButtonPressed(); - - await exchangeTradePageRobot.onGotItButtonPressed(); - await exchangeTradePageRobot.onSendFromExternalButtonPressed(); - - await exchangeTradeExternalSendPageRobot.isExchangeTradeExternalSendPage(); - await exchangeTradeExternalSendPageRobot.verifySendDetailsItemsDisplayProperly(); - await exchangeTradeExternalSendPageRobot.onContinueButtonPressed(); - }); -} diff --git a/integration_test/test_suites/restore_wallet_through_seeds_flow_test.dart b/integration_test/test_suites/restore_wallet_through_seeds_flow_test.dart deleted file mode 100644 index 50957bcfb..000000000 --- a/integration_test/test_suites/restore_wallet_through_seeds_flow_test.dart +++ /dev/null @@ -1,75 +0,0 @@ -import 'package:hash_wallet/wallet_types.g.dart'; -import 'package:cw_core/wallet_type.dart'; -import 'package:flutter/material.dart'; -import 'package:flutter_test/flutter_test.dart'; -import 'package:integration_test/integration_test.dart'; - -import '../components/common_test_constants.dart'; -import '../components/common_test_flows.dart'; -import '../robots/dashboard_page_robot.dart'; -import 'package:hash_wallet/.secrets.g.dart' as secrets; - -void main() { - IntegrationTestWidgetsFlutterBinding.ensureInitialized(); - - CommonTestFlows commonTestFlows; - DashboardPageRobot dashboardPageRobot; - - testWidgets( - 'Restoring Wallets Through Seeds', - (tester) async { - FlutterError.onError = (FlutterErrorDetails details) { - debugPrint('FlutterError caught: ${details.exception}'); - }; - - commonTestFlows = CommonTestFlows(tester); - dashboardPageRobot = DashboardPageRobot(tester); - - // Start the app - await commonTestFlows.startAppFlow( - ValueKey('restore_wallets_through_seeds_test_app_key'), - ); - - // Restore the first wallet type: Solana - await commonTestFlows.welcomePageToRestoreWalletThroughSeedsFlow( - WalletType.solana, - secrets.solanaTestWalletSeeds, - CommonTestConstants.pin, - ); - - // Confirm it actually restores a solana wallet - await dashboardPageRobot.confirmWalletTypeIsDisplayedCorrectly(WalletType.solana); - - // Do the same for other available wallet types - for (var walletType in availableWalletTypes) { - if (walletType == WalletType.solana) { - continue; - } - final seed = commonTestFlows.getWalletSeedsByWalletType(walletType); - if (seed.isEmpty) { - tester.printToConsole("----------------------------"); - tester.printToConsole("- Skipped wallet: ${walletType}"); - tester.printToConsole("- Make sure to add seed to secrets"); - tester.printToConsole("----------------------------"); - continue; - } - - await dashboardPageRobot.navigateToWalletsListPage(); - - await commonTestFlows.restoreWalletFromWalletMenu( - walletType, - seed, - ); - - await dashboardPageRobot.confirmWalletTypeIsDisplayedCorrectly(walletType); - } - - // Goes to the wallet menu and provides a visual confirmation that all the wallets were correctly restored - await dashboardPageRobot.navigateToWalletsListPage(); - - commonTestFlows.confirmAllAvailableWalletTypeIconsDisplayCorrectly(); - - await Future.delayed(Duration(seconds: 5)); - }, - ); -} diff --git a/integration_test/test_suites/send_flow_test.dart b/integration_test/test_suites/send_flow_test.dart deleted file mode 100644 index 649548dac..000000000 --- a/integration_test/test_suites/send_flow_test.dart +++ /dev/null @@ -1,133 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:flutter_test/flutter_test.dart'; -import 'package:integration_test/integration_test.dart'; -import 'package:cw_core/wallet_type.dart'; - -import '../components/common_test_constants.dart'; -import '../components/common_test_flows.dart'; -import '../components/common_test_cases.dart'; -import '../robots/dashboard_page_robot.dart'; -import '../robots/send_page_robot.dart'; -import 'package:hash_wallet/.secrets.g.dart' as secrets; - -void main() { - IntegrationTestWidgetsFlutterBinding.ensureInitialized(); - - SendPageRobot sendPageRobot; - CommonTestFlows commonTestFlows; - DashboardPageRobot dashboardPageRobot; - CommonTestCases commonTestCases; - - testWidgets('Send flow with wallet switching for insufficient balance - testing \$1 send', - (tester) async { - FlutterError.onError = (FlutterErrorDetails details) { - debugPrint('FlutterError caught: ${details.exception}'); - }; - - commonTestFlows = CommonTestFlows(tester); - sendPageRobot = SendPageRobot(tester: tester); - dashboardPageRobot = DashboardPageRobot(tester); - commonTestCases = CommonTestCases(tester); - - await commonTestFlows.startAppFlow(ValueKey('send_test_app_key')); - - // Various wallet options we want to use to test the send flow - final walletConfigs = [ - { - 'type': WalletType.solana, - 'seed': secrets.solanaTestWalletSeeds2, - 'name': 'Solana Wallet 1', - }, - { - 'type': WalletType.solana, - 'seed': secrets.solanaTestWalletSeeds, - 'name': 'Solana Wallet 2', - }, - { - 'type': WalletType.ethereum, - 'seed': secrets.ethereumTestWalletSeeds, - 'name': 'Ethereum Wallet', - }, - ]; - - bool hasSufficientBalance = false; - WalletType? successfulWalletType; - - // We try each wallet configuration until we find one with sufficient balance - for (int i = 0; i < walletConfigs.length; i++) { - final config = walletConfigs[i]; - - if ((config['seed'] as String).isEmpty) { - tester.printToConsole('Skipping ${config['name']}, seed is empty'); - continue; - } - - tester.printToConsole('Trying ${config['name']}'); - - // For first wallet, we restore wallet afresh, for subsequent ones, we do a wallet switch - if (i == 0) { - await commonTestFlows.welcomePageToRestoreWalletThroughSeedsFlow( - config['type'] as WalletType, - config['seed'] as String, - CommonTestConstants.pin, - ); - } else { - await commonTestCases.goBack(); - await commonTestCases.defaultSleepTime(); - - await dashboardPageRobot.navigateToWalletsListPage(); - await commonTestCases.defaultSleepTime(); - - await commonTestFlows.restoreWalletFromWalletMenu( - config['type'] as WalletType, - config['seed'] as String, - ); - } - - await dashboardPageRobot.confirmWalletTypeIsDisplayedCorrectly(config['type'] as WalletType); - - // Navigate to send page - await dashboardPageRobot.navigateToSendPage(); - await sendPageRobot.checkIfSendPageIsVisible(); - - await sendPageRobot.enterReceiveAddress(CommonTestConstants.testWalletAddress); - await sendPageRobot.selectReceiveCurrency(CommonTestConstants.sendTestReceiveCurrency); - - await sendPageRobot.selectTransactionPriority(); - - // Main check to see if this wallet has sufficient balance for $1 send - final hasBalance = await sendPageRobot.validateWalletBalanceForOneDollarSend(); - if (hasBalance) { - hasSufficientBalance = true; - successfulWalletType = config['type'] as WalletType; - tester.printToConsole( - '${config['name']} has sufficient balance for \$${CommonTestConstants.sendTestFiatAmount} send, proceeding with send', - ); - break; - } else { - tester.printToConsole( - '${config['name']} has insufficient balance for \$${CommonTestConstants.sendTestFiatAmount} send', - ); - if (i < walletConfigs.length - 1) { - tester.printToConsole('Switching to next wallet...'); - } - } - } - - // We only proceed with the send if we have sufficient balance - if (hasSufficientBalance && successfulWalletType != null) { - tester.printToConsole('Performing send transaction with $successfulWalletType wallet'); - - await sendPageRobot.testFiatAmountEntry(); - await sendPageRobot.testCryptoAmountEntry(); - - await sendPageRobot.onSendButtonPressed(); - await sendPageRobot.onSendSliderOnConfirmSendingBottomSheetDragged(); - await sendPageRobot.handleTransactionSuccessFlow(); - } else { - tester.printToConsole( - 'Test completed without sending - all available wallets have insufficient balance for \$${CommonTestConstants.sendTestFiatAmount} send', - ); - } - }); -} diff --git a/integration_test/test_suites/transaction_history_flow_test.dart b/integration_test/test_suites/transaction_history_flow_test.dart deleted file mode 100644 index 9fd7b627c..000000000 --- a/integration_test/test_suites/transaction_history_flow_test.dart +++ /dev/null @@ -1,82 +0,0 @@ -import 'package:cw_core/wallet_type.dart'; -import 'package:flutter/material.dart'; -import 'package:flutter_test/flutter_test.dart'; -import 'package:integration_test/integration_test.dart'; - -import '../components/common_test_constants.dart'; -import '../components/common_test_flows.dart'; -import '../robots/dashboard_page_robot.dart'; -import '../robots/transactions_page_robot.dart'; -import 'package:hash_wallet/.secrets.g.dart' as secrets; - -void main() { - IntegrationTestWidgetsFlutterBinding.ensureInitialized(); - - CommonTestFlows commonTestFlows; - DashboardPageRobot dashboardPageRobot; - TransactionsPageRobot transactionsPageRobot; - - /// Two Test Scenarios - /// - Fully Synchronizes and display the transaction history either immediately or few seconds after fully synchronizing - /// - Displays the transaction history progressively as synchronizing happens - testWidgets('Transaction history flow', (tester) async { - FlutterError.onError = (FlutterErrorDetails details) { - debugPrint('FlutterError caught: ${details.exception}'); - }; - - commonTestFlows = CommonTestFlows(tester); - dashboardPageRobot = DashboardPageRobot(tester); - transactionsPageRobot = TransactionsPageRobot(tester); - - await commonTestFlows.startAppFlow( - ValueKey('confirm_creds_display_correctly_flow_app_key'), - ); - - /// Test Scenario 1 - Displays transaction history list while synchronizing. - /// - /// For Solana/Tron WalletTypes. - await commonTestFlows.welcomePageToRestoreWalletThroughSeedsFlow( - WalletType.solana, - secrets.solanaTestWalletSeeds, - CommonTestConstants.pin, - ); - - await dashboardPageRobot.confirmWalletTypeIsDisplayedCorrectly(WalletType.solana); - - await dashboardPageRobot.swipeDashboardTab(true); - - await transactionsPageRobot.isTransactionsPage(); - - await transactionsPageRobot.confirmTransactionsPageConstantsDisplayProperly(); - - // Wait time for the first scenario to ensure proper loading - await tester.pump(Duration(seconds: 3)); - await tester.pumpAndSettle(); - - await transactionsPageRobot.confirmTransactionHistoryListDisplaysCorrectly(true); - - /// Test Scenario 2 - Displays transaction history list after fully synchronizing. - /// - /// For Bitcoin/Monero/Wownero WalletTypes. - await dashboardPageRobot.navigateToWalletsListPage(); - - await commonTestFlows.restoreWalletFromWalletMenu( - WalletType.bitcoin, - secrets.bitcoinTestWalletSeeds, - ); - - await dashboardPageRobot.confirmWalletTypeIsDisplayedCorrectly(WalletType.bitcoin); - - await dashboardPageRobot.swipeDashboardTab(true); - - await transactionsPageRobot.isTransactionsPage(); - - await transactionsPageRobot.confirmTransactionsPageConstantsDisplayProperly(); - - // Wait time for the second scenario to ensure proper loading - await tester.pump(Duration(seconds: 3)); - await tester.pumpAndSettle(); - - await transactionsPageRobot.confirmTransactionHistoryListDisplaysCorrectly(false); - }); -} diff --git a/integration_test_runner.sh b/integration_test_runner.sh index e00475b3f..9b43252aa 100755 --- a/integration_test_runner.sh +++ b/integration_test_runner.sh @@ -1,14 +1,44 @@ #!/bin/bash -# Exit on any error set -euo pipefail -# Configurations -export DESKTOP_FORCE_MOBILE="Y" -MAX_INACTIVITY=${MAX_INACTIVITY:-180} -MAX_PARALLEL_TESTS=${MAX_PARALLEL_TESTS:-2} -TEST_TIMEOUT=${TEST_TIMEOUT:-600} +# Runner params/knobs, all overridable from the environment: +# SUITE_DIR directory to collect *_test.dart suites from, or a single suite file +# (default integration_test/suites) +# TEST_TIER tier0, tier1 or all, narrows SUITE_DIR to a tier subdirectory +# PLATFORM android, linux, macos or auto, picks the between-suite data reset +# ANDROID_APP_ID package cleared between suites on android (default read from android/app.properties) +# PREBUILT_APK prebuilt apk passed to flutter drive, its dart defines are already baked in +# EXTRA_DART_DEFINES semicolon separated KEY=VALUE pairs +# FLUTTER_DEVICE device id passed to flutter drive, needed when several devices are attached +# TEST_TIMEOUT per-attempt timeout in seconds (default 900) +# RETRY_COUNT retries per suite after a failure (default 1) +# MAX_VOID_ATTEMPTS extra attempts when the driver never attached (default 2) +# MAX_TOTAL_VOID_ATTEMPTS give up on the whole run after this many, the environment is broken (default 4) +# REMOVE_DATA_DIRECTORY set to N to keep app data between suites (default wipes it) +# REMOVE_DESKTOP_DATA set to Y to let a desktop run wipe the wallet directories below, +# they belong to the installed wallet so this is off by default +# SUMMARY_FILE path to write a key=value summary of the run to, for reporting +# VOID_GRACE seconds to let a driver that cannot attach recover before the +# attempt is ended early (default 60) + +SUITE_DIR=${SUITE_DIR:-integration_test/suites} +TEST_TIER=${TEST_TIER:-all} +PLATFORM=${PLATFORM:-auto} +TEST_TIMEOUT=${TEST_TIMEOUT:-900} RETRY_COUNT=${RETRY_COUNT:-1} +MAX_VOID_ATTEMPTS=${MAX_VOID_ATTEMPTS:-2} +MAX_TOTAL_VOID_ATTEMPTS=${MAX_TOTAL_VOID_ATTEMPTS:-4} +REMOVE_DATA_DIRECTORY=${REMOVE_DATA_DIRECTORY:-Y} +REMOVE_DESKTOP_DATA=${REMOVE_DESKTOP_DATA:-N} +EXTRA_DART_DEFINES=${EXTRA_DART_DEFINES:-} +SUMMARY_FILE=${SUMMARY_FILE:-} +VOID_GRACE=${VOID_GRACE:-60} + +# Using this to capture as many of these annoying flutter drive errors as possible, +# so we can handle them, and the test doesn't just hang forever +VOID_MARKERS="unusually long time to connect to the VM|taking unusually long time to initialize|Service has disappeared|Flutter Driver extension is taking a long time to become available" +PREBUILT_APK=${PREBUILT_APK:-} # Data directories to clean (current app dirs plus legacy pre-rename ones, # since root_dir.dart migrates legacy data into the current dir on startup) @@ -21,22 +51,19 @@ DATA_DIRS=( "$HOME/.config/cake_wallet" ) -# Global state -declare -a targets -declare -a passed_tests -declare -a failed_tests -declare -a test_durations -declare -a test_start_times +targets=() +passed_tests=() +failed_tests=() +funds_results=() +total_void_attempts=0 -# Signal handling cleanup() { - echo "🛑 Received interrupt signal, cleaning up..." + echo "Received interrupt signal, cleaning up..." exit 1 } trap cleanup SIGINT SIGTERM -# Utility functions log() { echo "[$(date '+%Y-%m-%d %H:%M:%S')] $1" } @@ -45,7 +72,6 @@ error() { echo "[$(date '+%Y-%m-%d %H:%M:%S')] ERROR: $1" >&2 } -# Format seconds into hours, minutes, seconds format_duration() { local seconds=$1 local hours=$((seconds / 3600)) @@ -61,101 +87,356 @@ format_duration() { fi } -clean_data_directories() { - if [[ "${REMOVE_DATA_DIRECTORY:-}" == "Y" ]]; then - log "Cleaning data directories..." - for dir in "${DATA_DIRS[@]}"; do - if [[ -d "$dir" ]]; then - rm -rf "$dir" || error "Failed to remove $dir" - fi - done +resolve_platform() { + if [[ "$PLATFORM" != "auto" ]]; then + return + fi + + if command -v adb > /dev/null 2>&1 && adb devices 2>/dev/null | grep -q "device$"; then + PLATFORM="android" + elif [[ "$(uname -s)" == "Darwin" ]]; then + PLATFORM="macos" + else + PLATFORM="linux" + fi + + log "Resolved platform: $PLATFORM" +} + +resolve_android_app_id() { + if [[ -n "${ANDROID_APP_ID:-}" ]]; then + return + fi + + if [[ -f "android/app.properties" ]]; then + ANDROID_APP_ID=$(grep '^id=' android/app.properties | cut -d'=' -f2) + fi + + ANDROID_APP_ID=${ANDROID_APP_ID:-com.cakewallet.cake_wallet} +} + +restart_adb() { + if [[ "$PLATFORM" != "android" ]]; then + return + fi + + bounded 30 adb kill-server + bounded 30 adb start-server + bounded 60 adb wait-for-device + + if ! bounded 30 adb devices | grep -q "device$"; then + error "No device is attached after restarting adb" + return 1 fi } -# Run a single test with retry logic +bounded() { + local limit="$1" + shift + + if command -v timeout > /dev/null 2>&1; then + timeout "$limit" "$@" 2>/dev/null || return 1 + elif command -v perl > /dev/null 2>&1; then + perl -e 'alarm shift; exec @ARGV' "$limit" "$@" 2>/dev/null || return 1 + else + "$@" 2>/dev/null || return 1 + fi +} + +capture_failure_logcat() { + local test_name="$1" + + if [[ "$PLATFORM" != "android" ]]; then + return + fi + + echo "===== logcat after failed attempt: $test_name =====" + bounded 60 adb logcat -d -t 400 || echo "logcat unavailable" + echo "===== end logcat =====" +} + +clean_data_directories() { + if [[ "$REMOVE_DATA_DIRECTORY" != "Y" ]]; then + return + fi + + if [[ "$PLATFORM" == "android" ]]; then + if ! command -v adb > /dev/null 2>&1; then + error "adb is not on PATH, every suite would run on whatever data is already installed" + exit 1 + fi + + if [[ "$ANDROID_APP_ID" != *".test_"* ]]; then + error "Refusing to clear $ANDROID_APP_ID, it is not a test build. Rename the app first: printf 'id=com.cakewallet.test_local\\nname=local\\n' > android/app.properties" + exit 1 + fi + + log "Cleaning app data..." + adb shell pm clear "$ANDROID_APP_ID" > /dev/null 2>&1 || log "pm clear skipped, $ANDROID_APP_ID not installed yet" + return + fi + + if [[ "$REMOVE_DESKTOP_DATA" != "Y" ]]; then + log "Keeping the desktop wallet data, set REMOVE_DESKTOP_DATA=Y to wipe it between suites" + return + fi + + log "Cleaning app data..." + + for dir in "${DATA_DIRS[@]}"; do + if [[ -d "$dir" ]]; then + rm -rf "$dir" || error "Failed to remove $dir" + fi + done +} + +build_drive_command() { + drive_command=(flutter drive --driver=test_driver/integration_test.dart --dart-define=CI_BUILD=true) + + if [[ -n "${FLUTTER_DEVICE:-}" ]]; then + drive_command+=("-d" "$FLUTTER_DEVICE") + fi + + if [[ -n "$PREBUILT_APK" ]]; then + drive_command+=("--use-application-binary=$PREBUILT_APK") + fi + + if [[ -n "$EXTRA_DART_DEFINES" ]]; then + IFS=';' read -ra defines <<< "$EXTRA_DART_DEFINES" + for define in "${defines[@]}"; do + drive_command+=("--dart-define=$define") + done + fi + + if command -v timeout > /dev/null 2>&1; then + drive_command=(timeout "$TEST_TIMEOUT" "${drive_command[@]}") + elif command -v perl > /dev/null 2>&1; then + drive_command=(perl -e 'alarm shift; exec @ARGV' "$TEST_TIMEOUT" "${drive_command[@]}") + fi +} + +end_process_tree() { + local root="$1" signal="$2" + local child + + for child in $(pgrep -P "$root" 2>/dev/null); do + end_process_tree "$child" "$signal" + done + + kill "-$signal" "$root" 2>/dev/null || true +} + +watch_for_dead_driver() { + local log_file="$1" drive_pid="$2" + local marker_seen=0 + + while kill -0 "$drive_pid" 2>/dev/null; do + if (( marker_seen == 0 )) && grep -qE "$VOID_MARKERS" "$log_file" 2>/dev/null; then + marker_seen=$(date +%s) + log "Driver is struggling to attach, giving it ${VOID_GRACE}s to recover" + fi + + if (( marker_seen > 0 )) && (( $(date +%s) - marker_seen >= VOID_GRACE )); then + log "Driver never attached within ${VOID_GRACE}s, ending this attempt early" + + end_process_tree "$drive_pid" TERM + sleep 5 + end_process_tree "$drive_pid" KILL + + return 0 + fi + + sleep 5 + done +} + +collect_funds_results() { + local log_file="$1" + local line + + while IFS= read -r line; do + funds_results+=("$line") + done < <(sed -n 's/.*FUNDS_RESULT|//p' "$log_file" | sort -u) +} + run_test() { local test_file="$1" local test_name=$(basename "$test_file" .dart) local retry_count=0 + local void_attempts=0 while (( retry_count <= RETRY_COUNT )); do log "Running test: $test_name (attempt $((retry_count + 1)))" - # Clean data directories before each attempt clean_data_directories - # Record start time local start_time=$(date +%s) - test_start_times+=("$start_time") + local attempt_log + attempt_log=$(mktemp) - # Run the test in foreground - if flutter drive \ - --driver=test_driver/integration_test.dart \ - --target="$test_file" \ - --dart-define=CI_BUILD=true; then - # Calculate duration - local end_time=$(date +%s) - local duration=$((end_time - start_time)) - test_durations+=("$duration") + "${drive_command[@]}" --target="$test_file" > >(tee "$attempt_log") 2>&1 & + local drive_pid=$! - log "✅ Test passed: $test_name ($(format_duration $duration))" - passed_tests+=("$test_name") + watch_for_dead_driver "$attempt_log" "$drive_pid" & + local watchdog_pid=$! + disown "$watchdog_pid" 2>/dev/null || true + + local attempt_status=0 + wait "$drive_pid" 2>/dev/null || attempt_status=$? + + kill "$watchdog_pid" 2>/dev/null || true + + if (( attempt_status == 0 )) && ! grep -q "All tests passed." "$attempt_log"; then + attempt_status=1 + fi + + if (( attempt_status == 0 )); then + local duration=$(( $(date +%s) - start_time )) + + log "PASS: $test_name ($(format_duration $duration))" + passed_tests+=("$test_name|$duration") + collect_funds_results "$attempt_log" + rm -f "$attempt_log" return 0 else - # Calculate duration even for failed tests - local end_time=$(date +%s) - local duration=$((end_time - start_time)) - test_durations+=("$duration") + local duration=$(( $(date +%s) - start_time )) - log "❌ Test failed: $test_name ($(format_duration $duration))" + local attempt_was_void=N + if grep -qE "$VOID_MARKERS" "$attempt_log"; then + attempt_was_void=Y + fi - if (( retry_count < RETRY_COUNT )); then + collect_funds_results "$attempt_log" + + rm -f "$attempt_log" + + log "FAIL: $test_name ($(format_duration $duration))" + + capture_failure_logcat "$test_name" + + if [[ "$attempt_was_void" == "Y" ]]; then + total_void_attempts=$((total_void_attempts + 1)) + + if (( total_void_attempts > MAX_TOTAL_VOID_ATTEMPTS )); then + error "The flutter driver failed to attach $total_void_attempts times, the emulator or adb is broken" + failed_tests+=("$test_name|$duration|void") + return 1 + fi + fi + + if [[ "$attempt_was_void" == "Y" ]] && (( void_attempts < MAX_VOID_ATTEMPTS )); then + log "Driver never attached to the app, retrying without spending a retry" + void_attempts=$((void_attempts + 1)) + + if ! restart_adb; then + error "Could not bring adb back, aborting this suite" + failed_tests+=("$test_name|$duration|void") + return 1 + fi + + sleep 5 + elif (( retry_count < RETRY_COUNT )); then log "Retrying test: $test_name" retry_count=$((retry_count + 1)) - sleep 5 # Brief pause before retry + sleep 5 else - failed_tests+=("$test_name") + local fail_reason="test" + [[ "$attempt_was_void" == "Y" ]] && fail_reason="void" + + failed_tests+=("$test_name|$duration|$fail_reason") return 1 fi fi done } -# Main execution flow +write_github_summary() { + if [[ -z "${GITHUB_STEP_SUMMARY:-}" ]]; then + return + fi + + { + echo "### Integration tests ($SUITE_DIR, tier: $TEST_TIER)" + echo "" + echo "| Suite | Result | Duration |" + echo "|---|---|---|" + for entry in "${passed_tests[@]+"${passed_tests[@]}"}"; do + echo "| ${entry%%|*} | pass | $(format_duration "${entry##*|}") |" + done + for entry in "${failed_tests[@]+"${failed_tests[@]}"}"; do + local rest="${entry#*|}" + echo "| ${entry%%|*} | ${rest#*|} | $(format_duration "${rest%%|*}") |" + done + } >> "$GITHUB_STEP_SUMMARY" +} + +write_summary_file() { + if [[ -z "$SUMMARY_FILE" ]]; then + return + fi + + { + echo "tier=$TEST_TIER" + echo "total=${#targets[@]}" + echo "passed=${#passed_tests[@]}" + echo "failed=${#failed_tests[@]}" + echo "duration=$1" + for entry in "${passed_tests[@]+"${passed_tests[@]}"}"; do + echo "pass=$entry" + done + for entry in "${failed_tests[@]+"${failed_tests[@]}"}"; do + echo "fail=$entry" + done + for entry in "${funds_results[@]+"${funds_results[@]}"}"; do + echo "chain=$entry" + done + } > "$SUMMARY_FILE" +} + main() { log "Starting integration test runner" - log "Configuration: RETRY_COUNT=${RETRY_COUNT}" - # Collect all Dart test files from test_suites directory - while IFS= read -r -d $'\0' file; do - targets+=("$file") - done < <(find integration_test/test_suites -name "*.dart" -type f -print0) + local search_dir="$SUITE_DIR" + if [[ "$TEST_TIER" != "all" ]]; then + search_dir="$SUITE_DIR/$TEST_TIER" + fi - if [[ $? -ne 0 ]]; then - error "Failed to find test files" + if [[ -f "$SUITE_DIR" ]]; then + search_dir="$SUITE_DIR" + elif [[ ! -d "$search_dir" ]]; then + error "Suite directory not found: $search_dir" exit 1 fi + resolve_platform + resolve_android_app_id + build_drive_command + + log "Configuration: SUITE_DIR=$search_dir PLATFORM=$PLATFORM RETRY_COUNT=$RETRY_COUNT" + + if [[ -f "$search_dir" ]]; then + targets+=("$search_dir") + else + while IFS= read -r -d $'\0' file; do + targets+=("$file") + done < <(find "$search_dir" -name "*_test.dart" -type f -print0 | sort -z) + fi + if (( ${#targets[@]} == 0 )); then - error "No test files found in integration_test/test_suites directory" + error "No test files found in $search_dir" exit 1 fi log "Found ${#targets[@]} test files" - # Record overall start time local overall_start_time=$(date +%s) - # Run tests sequentially for target in "${targets[@]}"; do - run_test "$target" + run_test "$target" || true done - # Calculate total duration - local overall_end_time=$(date +%s) - local total_duration=$((overall_end_time - overall_start_time)) + local total_duration=$(( $(date +%s) - overall_start_time )) - # Generate summary echo -e "\n===== Test Summary =====" echo "Total tests: ${#targets[@]}" echo "Passed: ${#passed_tests[@]}" @@ -163,26 +444,26 @@ main() { echo "Total duration: $(format_duration $total_duration)" if (( ${#passed_tests[@]} > 0 )); then - echo -e "\n✅ Passed Tests:" - for i in $(seq 0 $((${#passed_tests[@]} - 1))); do - local test_name="${passed_tests[$i]}" - local duration="${test_durations[$i]}" - echo " - $test_name ($(format_duration $duration))" + echo -e "\nPassed Tests:" + for entry in "${passed_tests[@]}"; do + echo " - ${entry%%|*} ($(format_duration "${entry##*|}"))" done fi + write_github_summary + write_summary_file "$total_duration" + if (( ${#failed_tests[@]} > 0 )); then - echo -e "\n❌ Failed Tests:" - for i in $(seq 0 $((${#failed_tests[@]} - 1))); do - local test_name="${failed_tests[$i]}" - local duration="${test_durations[$i]}" - echo " - $test_name ($(format_duration $duration))" + echo -e "\nFailed Tests:" + local rest + for entry in "${failed_tests[@]}"; do + rest="${entry#*|}" + echo " - ${entry%%|*} ($(format_duration "${rest%%|*}"), ${rest#*|})" done exit 1 - else - echo -e "\n🎉 All tests passed successfully!" fi + + echo -e "\nAll tests passed successfully!" } -# Run main function main "$@" diff --git a/ios/Podfile.lock b/ios/Podfile.lock index 66410e642..df4391855 100644 --- a/ios/Podfile.lock +++ b/ios/Podfile.lock @@ -1,7 +1,7 @@ PODS: - bitbox_flutter (0.0.1): - Flutter - - breez_sdk_spark_flutter (0.11.0): + - breez_sdk_spark_flutter (0.23.0): - Flutter - connectivity_plus (0.0.1): - Flutter @@ -85,11 +85,8 @@ PODS: - OrderedSet (6.0.3) - package_info_plus (0.4.5): - Flutter - - path_provider_foundation (0.0.1): - - Flutter - - FlutterMacOS - payjoin_flutter (0.23.0) - - permission_handler_apple (9.3.0): + - permission_handler_apple (9.4.8): - Flutter - quick_actions_ios (0.0.1): - Flutter @@ -152,8 +149,6 @@ PODS: - Flutter - wakelock_plus (0.0.1): - Flutter - - warp_api (0.0.1): - - Flutter - YttriumWrapper (0.8.35) DEPENDENCIES: @@ -182,7 +177,6 @@ DEPENDENCIES: - integration_test (from `.symlinks/plugins/integration_test/ios`) - local_auth_darwin (from `.symlinks/plugins/local_auth_darwin/darwin`) - package_info_plus (from `.symlinks/plugins/package_info_plus/ios`) - - path_provider_foundation (from `.symlinks/plugins/path_provider_foundation/darwin`) - payjoin_flutter (from `.symlinks/plugins/payjoin_flutter/ios`) - permission_handler_apple (from `.symlinks/plugins/permission_handler_apple/ios`) - quick_actions_ios (from `.symlinks/plugins/quick_actions_ios/ios`) @@ -200,7 +194,6 @@ DEPENDENCIES: - universal_ble (from `.symlinks/plugins/universal_ble/darwin`) - url_launcher_ios (from `.symlinks/plugins/url_launcher_ios/ios`) - wakelock_plus (from `.symlinks/plugins/wakelock_plus/ios`) - - warp_api (from `.symlinks/plugins/warp_api/ios`) SPEC REPOS: https://github.com/CocoaPods/Specs.git: @@ -262,8 +255,6 @@ EXTERNAL SOURCES: :path: ".symlinks/plugins/local_auth_darwin/darwin" package_info_plus: :path: ".symlinks/plugins/package_info_plus/ios" - path_provider_foundation: - :path: ".symlinks/plugins/path_provider_foundation/darwin" payjoin_flutter: :path: ".symlinks/plugins/payjoin_flutter/ios" permission_handler_apple: @@ -298,12 +289,10 @@ EXTERNAL SOURCES: :path: ".symlinks/plugins/url_launcher_ios/ios" wakelock_plus: :path: ".symlinks/plugins/wakelock_plus/ios" - warp_api: - :path: ".symlinks/plugins/warp_api/ios" SPEC CHECKSUMS: bitbox_flutter: 9505732798041c413152669751beeaecc5fe400f - breez_sdk_spark_flutter: 5bf9ff7455198c770b908c5d2ee52283474225f1 + breez_sdk_spark_flutter: f02552ffcff6012e5a4849517d78376ad1046250 connectivity_plus: cb623214f4e1f6ef8fe7403d580fdad517d2f7dd CryptoSwift: e64e11850ede528a02a0f3e768cec8e9d92ecb90 cw_decred: a02cf30175a46971c1e2fa22c48407534541edc6 @@ -316,7 +305,7 @@ SPEC CHECKSUMS: dnssec_proof: d461cac7bd3301eb7447f87936745a0c1ae0a67e fast_scanner: 2cb1ad3e69e645e9980fb4961396ce5804caa3e3 file_picker: 9b3292d7c8bc68c8a7bf8eb78f730e49c8efc517 - Flutter: e0871f40cf51350855a761d2e70bf5af5b9b5de7 + Flutter: cabc95a1d2626b1b06e7179b784ebcf0c0cde467 flutter_inappwebview_ios: b89ba3482b96fb25e00c967aae065701b66e9b99 flutter_local_authentication: 989278c681612f1ee0e36019e149137f114b9d7f flutter_local_notifications: a5a732f069baa862e728d839dd2ebb904737effb @@ -324,23 +313,22 @@ SPEC CHECKSUMS: flutter_native_splash: c32d145d68aeda5502d5f543ee38c192065986cf flutter_secure_storage: 2c2ff13db9e0a5647389bff88b0ecac56e3f3418 fluttertoast: 2c67e14dce98bbdb200df9e1acf610d7a6264ea1 - image_picker_ios: 7fe1ff8e34c1790d6fff70a32484959f563a928a + image_picker_ios: e0ece4aa2a75771a7de3fa735d26d90817041326 in_app_review: 7dd1ea365263f834b8464673f9df72c80c17c937 integration_test: 4a889634ef21a45d28d50d622cf412dc6d9f586e - local_auth_darwin: d2e8c53ef0c4f43c646462e3415432c4dab3ae19 + local_auth_darwin: c3ee6cce0a8d56be34c8ccb66ba31f7f180aaebb OrderedSet: e539b66b644ff081c73a262d24ad552a69be3a94 package_info_plus: af8e2ca6888548050f16fa2f1938db7b5a5df499 - path_provider_foundation: 080d55be775b7414fd5a5ef3ac137b97b097e564 payjoin_flutter: d9d4c8aa16bd5dfedb9b21d0edc8199e0187d96e - permission_handler_apple: 4ed2196e43d0651e8ff7ca3483a069d469701f2d - quick_actions_ios: 4b07fb49d8d8f3518d7565fbb7a91014067a7d82 + permission_handler_apple: 92d754bbaa7361d436db2d6c3c1c2a0fdcec462e + quick_actions_ios: 500fcc11711d9f646739093395c4ae8eec25f779 reown_yttrium: cee334ade64725b1d83f7b34c706a6aae2696d58 rive_common: dd421daaf9ae69f0125aa761dd96abd278399952 rlz: 3f4022361f540a5c26d9d2474fea1d2455b69d8d SDWebImage: 9f177d83116802728e122410fb25ad88f5c7608a sensitive_clipboard: 161e9abc3d56b3131309d8a321eb4690a803c16b share_plus: 50da8cb520a8f0f65671c6c6a99b3617ed10a58a - shared_preferences_foundation: 9e1978ff2562383bd5676f64ec4e9aa8fa06a6f7 + shared_preferences_foundation: 7036424c3d8ec98dfe75ff1667cb0cd531ec82bb sp_scanner: b1bc9321690980bdb44bba7ec85d5543e716d1b5 sqflite_darwin: 20b2a3a3b70e43edae938624ce550a3cbf66a3d0 sqlite3: 73513155ec6979715d3904ef53a8d68892d4032b @@ -348,12 +336,11 @@ SPEC CHECKSUMS: SwiftyGif: 706c60cf65fa2bc5ee0313beece843c8eb8194d4 torch_dart: f4620705d10f05492fab047f2fa1c3a600e7d17d uni_links: ed8c961e47ed9ce42b6d91e1de8049e38a4b3152 - universal_ble: ff19787898040d721109c6324472e5dd4bc86adc - url_launcher_ios: 694010445543906933d732453a59da0a173ae33d + universal_ble: a322ebabee64f0ec27a313e89a5dd6967f37a60f + url_launcher_ios: 7a95fa5b60cc718a708b8f2966718e93db0cef1b wakelock_plus: e29112ab3ef0b318e58cfa5c32326458be66b556 - warp_api: 997d8fb3e777669bc6260d0c12e56cd40b9db1d1 YttriumWrapper: 31e937fe9fbe0f1314d2ca6be9ce9b379a059966 -PODFILE CHECKSUM: 5296465b1c6d14d506230356756826012f65d97a +PODFILE CHECKSUM: 18601c2df1b9c6704d6a786e4e6777ae88178c28 COCOAPODS: 1.16.2 diff --git a/ios/Runner/AppDelegate.swift b/ios/Runner/AppDelegate.swift index 853372825..691c85409 100644 --- a/ios/Runner/AppDelegate.swift +++ b/ios/Runner/AppDelegate.swift @@ -2,7 +2,7 @@ import UIKit import Flutter @main -@objc class AppDelegate: FlutterAppDelegate { +@objc class AppDelegate: FlutterAppDelegate, FlutterImplicitEngineDelegate { override func application( _ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]? @@ -11,101 +11,120 @@ import Flutter UNUserNotificationCenter.current().delegate = self as? UNUserNotificationCenterDelegate } - makeSecure() - - let controller : FlutterViewController = window?.rootViewController as! FlutterViewController - let legacyMigrationChannel = FlutterMethodChannel( - name: "com.cakewallet.cakewallet/legacy_wallet_migration", - binaryMessenger: controller.binaryMessenger) - legacyMigrationChannel.setMethodCallHandler({ - (call: FlutterMethodCall, result: @escaping FlutterResult) -> Void in - - switch call.method { - case "decrypt": - guard let args = call.arguments as? Dictionary, - let data = args["bytes"] as? FlutterStandardTypedData, - let key = args["key"] as? String, - let salt = args["salt"] as? String else { - result(nil) - return - } - - let content = decrypt(data: data.data, key: key, salt: salt) - result(content) - case "read_user_defaults": - guard let args = call.arguments as? Dictionary, - let key = args["key"] as? String, - let type = args["type"] as? String else { - result(nil) - return - } - - var value: Any? - - switch (type) { - case "string": - value = UserDefaults.standard.string(forKey: key) - case "int": - value = UserDefaults.standard.integer(forKey: key) - case "bool": - value = UserDefaults.standard.bool(forKey: key) - default: - break - } - - result(value) - default: - result(FlutterMethodNotImplemented) - } - }) - - let utilsChannel = FlutterMethodChannel( - name: "com.cake_wallet/native_utils", - binaryMessenger: controller.binaryMessenger) - utilsChannel.setMethodCallHandler({ [weak self] (call: FlutterMethodCall, result: @escaping FlutterResult) -> Void in - switch call.method { - case "sec_random": - guard let args = call.arguments as? Dictionary, - let count = args["count"] as? Int else { - result(nil) - return - } + DispatchQueue.main.async { [weak self] in + self?.makeSecure() + } - result(secRandom(count: count)) - - case "setIsAppSecure": - guard let args = call.arguments as? Dictionary, - let isAppSecure = args["isAppSecure"] else { - result(nil) - return - } - if isAppSecure { - self?.textField.isSecureTextEntry = true - } else { - self?.textField.isSecureTextEntry = false - } - - result(nil) - default: - result(FlutterMethodNotImplemented) - } - }) - - GeneratedPluginRegistrant.register(with: self) return super.application(application, didFinishLaunchingWithOptions: launchOptions) } + + + func didInitializeImplicitFlutterEngine(_ engineBridge: FlutterImplicitEngineBridge) { + GeneratedPluginRegistrant.register(with: engineBridge.pluginRegistry) + + let messenger = engineBridge.applicationRegistrar.messenger() + + let legacyMigrationChannel = FlutterMethodChannel( + name: "com.cakewallet.cakewallet/legacy_wallet_migration", + binaryMessenger: messenger) + legacyMigrationChannel.setMethodCallHandler({ + (call: FlutterMethodCall, result: @escaping FlutterResult) -> Void in + + switch call.method { + case "decrypt": + guard let args = call.arguments as? Dictionary, + let data = args["bytes"] as? FlutterStandardTypedData, + let key = args["key"] as? String, + let salt = args["salt"] as? String else { + result(nil) + return + } + + let content = decrypt(data: data.data, key: key, salt: salt) + result(content) + case "read_user_defaults": + guard let args = call.arguments as? Dictionary, + let key = args["key"] as? String, + let type = args["type"] as? String else { + result(nil) + return + } + + var value: Any? + + switch (type) { + case "string": + value = UserDefaults.standard.string(forKey: key) + case "int": + value = UserDefaults.standard.integer(forKey: key) + case "bool": + value = UserDefaults.standard.bool(forKey: key) + default: + break + } + + result(value) + default: + result(FlutterMethodNotImplemented) + } + }) + + let utilsChannel = FlutterMethodChannel( + name: "com.cake_wallet/native_utils", + binaryMessenger: messenger) + utilsChannel.setMethodCallHandler({ [weak self] (call: FlutterMethodCall, result: @escaping FlutterResult) -> Void in + switch call.method { + case "sec_random": + guard let args = call.arguments as? Dictionary, + let count = args["count"] as? Int else { + result(nil) + return + } + + result(secRandom(count: count)) + + case "setIsAppSecure": + guard let args = call.arguments as? Dictionary, + let isAppSecure = args["isAppSecure"] else { + result(nil) + return + } + + if isAppSecure { + self?.textField.isSecureTextEntry = true + } else { + self?.textField.isSecureTextEntry = false + } + + result(nil) + default: + result(FlutterMethodNotImplemented) + } + }) + } private var textField = UITextField() - private func makeSecure() { - if (!self.window.subviews.contains(textField)) { - let view = UIView(frame: CGRect(x: 0, y: 0, width: textField.frame.self.width, height: textField.frame.self.height)) - self.window.addSubview(textField) - self.window.layer.superlayer?.addSublayer(textField.layer) - textField.layer.sublayers?.last!.addSublayer(self.window.layer) - textField.leftView = view - textField.leftViewMode = .always - } +private func keyWindow() -> UIWindow? { + UIApplication.shared.connectedScenes + .compactMap { $0 as? UIWindowScene } + .flatMap { $0.windows } + .first { $0.isKeyWindow } +} + +private func makeSecure() { + guard let window = self.window ?? keyWindow() else { return } + + if (!window.subviews.contains(textField)) { + let view = UIView(frame: CGRect(x: 0, y: 0, width: textField.frame.self.width, height: textField.frame.self.height)) + window.addSubview(textField) + window.layer.superlayer?.addSublayer(textField.layer) + + textField.layer.sublayers?.last?.addSublayer(window.layer) + textField.leftView = view + textField.leftViewMode = .always } +} + } \ No newline at end of file diff --git a/ios/Runner/InfoBase.plist b/ios/Runner/InfoBase.plist index 58112f882..c852e5e86 100644 --- a/ios/Runner/InfoBase.plist +++ b/ios/Runner/InfoBase.plist @@ -195,6 +195,7 @@ solana-wallet CFBundleURLSchemes + solana solana-wallet @@ -447,5 +448,28 @@ FlutterDeepLinkingEnabled + ITSAppUsesNonExemptEncryption + + UIApplicationSceneManifest + + UIApplicationSupportsMultipleScenes + + UISceneConfigurations + + UIWindowSceneSessionRoleApplication + + + UISceneClassName + UIWindowScene + UISceneDelegateClassName + FlutterSceneDelegate + UISceneConfigurationName + flutter + UISceneStoryboardFile + Main + + + + diff --git a/lib/anonpay/anonpay_invoice_info.dart b/lib/anonpay/anonpay_invoice_info.dart index e4e4b68f9..f187fbd7e 100644 --- a/lib/anonpay/anonpay_invoice_info.dart +++ b/lib/anonpay/anonpay_invoice_info.dart @@ -3,37 +3,37 @@ import 'package:cw_core/hive_type_ids.dart'; import 'package:cw_core/keyable.dart'; import 'package:hive/hive.dart'; -part 'anonpay_invoice_info.g.dart'; +part 'anonpay_invoice_info.part.dart'; -@HiveType(typeId: AnonpayInvoiceInfo.typeId) +// @HiveType(typeId: AnonpayInvoiceInfo.typeId) class AnonpayInvoiceInfo extends HiveObject with Keyable implements AnonpayInfoBase { - @HiveField(0) + // @HiveField(0) final String invoiceId; - @HiveField(1) + // @HiveField(1) String status; - @HiveField(2) + // @HiveField(2) final double? fiatAmount; - @HiveField(3) + // @HiveField(3) final String? fiatEquiv; - @HiveField(4) + // @HiveField(4) final double? amountTo; - @HiveField(5) + // @HiveField(5) final String coinTo; - @HiveField(6) + // @HiveField(6) final String address; - @HiveField(7) + // @HiveField(7) final String clearnetUrl; - @HiveField(8) + // @HiveField(8) final String onionUrl; - @HiveField(9) + // @HiveField(9) final String clearnetStatusUrl; - @HiveField(10) + // @HiveField(10) final String onionStatusUrl; - @HiveField(11) + // @HiveField(11) final DateTime createdAt; - @HiveField(12) + // @HiveField(12) final String walletId; - @HiveField(13) + // @HiveField(13) final String provider; static const typeId = ANONPAY_INVOICE_INFO_TYPE_ID; diff --git a/lib/anonpay/anonpay_invoice_info.part.dart b/lib/anonpay/anonpay_invoice_info.part.dart new file mode 100644 index 000000000..7eba138c3 --- /dev/null +++ b/lib/anonpay/anonpay_invoice_info.part.dart @@ -0,0 +1,80 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'anonpay_invoice_info.dart'; + +// ************************************************************************** +// TypeAdapterGenerator +// ************************************************************************** + +class AnonpayInvoiceInfoAdapter extends TypeAdapter { + @override + final int typeId = 10; + + @override + AnonpayInvoiceInfo read(BinaryReader reader) { + final numOfFields = reader.readByte(); + final fields = { + for (int i = 0; i < numOfFields; i++) reader.readByte(): reader.read(), + }; + return AnonpayInvoiceInfo( + invoiceId: fields[0] as String, + clearnetUrl: fields[7] as String, + onionUrl: fields[8] as String, + clearnetStatusUrl: fields[9] as String, + onionStatusUrl: fields[10] as String, + status: fields[1] as String, + fiatAmount: fields[2] as double?, + fiatEquiv: fields[3] as String?, + amountTo: fields[4] as double?, + coinTo: fields[5] as String, + address: fields[6] as String, + createdAt: fields[11] as DateTime, + walletId: fields[12] as String, + provider: fields[13] as String, + ); + } + + @override + void write(BinaryWriter writer, AnonpayInvoiceInfo obj) { + writer + ..writeByte(14) + ..writeByte(0) + ..write(obj.invoiceId) + ..writeByte(1) + ..write(obj.status) + ..writeByte(2) + ..write(obj.fiatAmount) + ..writeByte(3) + ..write(obj.fiatEquiv) + ..writeByte(4) + ..write(obj.amountTo) + ..writeByte(5) + ..write(obj.coinTo) + ..writeByte(6) + ..write(obj.address) + ..writeByte(7) + ..write(obj.clearnetUrl) + ..writeByte(8) + ..write(obj.onionUrl) + ..writeByte(9) + ..write(obj.clearnetStatusUrl) + ..writeByte(10) + ..write(obj.onionStatusUrl) + ..writeByte(11) + ..write(obj.createdAt) + ..writeByte(12) + ..write(obj.walletId) + ..writeByte(13) + ..write(obj.provider); + } + + @override + int get hashCode => typeId.hashCode; + + @override + bool operator ==(Object other) => + identical(this, other) || + other is AnonpayInvoiceInfoAdapter && + runtimeType == other.runtimeType && + typeId == other.typeId; +} diff --git a/lib/bitcoin/cw_bitcoin.dart b/lib/bitcoin/cw_bitcoin.dart index 3213d99a5..0936a065a 100644 --- a/lib/bitcoin/cw_bitcoin.dart +++ b/lib/bitcoin/cw_bitcoin.dart @@ -156,13 +156,12 @@ class CWBitcoin extends Bitcoin { outputs .map((out) => OutputInfo( fiatAmount: out.fiatAmount, - cryptoAmount: out.cryptoAmount, + cryptoAmount: out.cryptoAmountMoney, address: out.address, note: out.note, sendAll: out.sendAll, extractedAddress: out.extractedAddress, isParsedAddress: out.isParsedAddress, - formattedCryptoAmount: out.formattedCryptoAmount, memo: out.memo.isNotEmpty ? out.memo : null, extra: out.extra, )) @@ -178,7 +177,7 @@ class CWBitcoin extends Bitcoin { List getSubAddresses(Object wallet) { final electrumWallet = wallet as ElectrumWallet; return electrumWallet.walletAddresses.addressesByReceiveType - .map((BaseBitcoinAddressRecord addr) => ElectrumSubAddress( + .map((addr) => ElectrumSubAddress( id: addr.index, name: addr.name, address: addr.address, @@ -191,7 +190,7 @@ class CWBitcoin extends Bitcoin { } @override - Future estimateFakeSendAllTxAmount(Object wallet, TransactionPriority priority, + Future estimateFakeSendAllTxAmount(WalletBase wallet, TransactionPriority priority, {UnspentCoinType coinTypeToSpendFrom = UnspentCoinType.any}) async { try { final sk = ECPrivate.random(); @@ -231,7 +230,7 @@ class CWBitcoin extends Bitcoin { return estimatedTx.amount; } catch (_) { - return 0; + return Money.zero(wallet.currency); } } @@ -245,10 +244,6 @@ class CWBitcoin extends Bitcoin { String formatterBitcoinAmountToString({required int amount}) => bitcoinAmountToString(amount: amount); - @override - double formatterBitcoinAmountToDouble({required int amount}) => - bitcoinAmountToDouble(amount: amount); - @override int formatterStringDoubleToBitcoinAmount(String amount) => stringDoubleToBitcoinAmount(amount); @@ -261,7 +256,10 @@ class CWBitcoin extends Bitcoin { List getUnspents(Object wallet, {UnspentCoinType coinTypeToSpendFrom = UnspentCoinType.any}) { final bitcoinWallet = wallet as ElectrumWallet; - return bitcoinWallet.unspentCoins.where((element) { + + final coins = bitcoinWallet.unspentCoinsForCurrentAccount; + + return coins.where((element) { switch (coinTypeToSpendFrom) { case UnspentCoinType.mweb: return element.bitcoinAddressRecord.type == SegwitAddresType.mweb; @@ -322,6 +320,12 @@ class CWBitcoin extends Bitcoin { return bitcoinWallet.walletAddresses.addressPageType == SilentPaymentsAddresType.p2sp; } + @override + bool hasSelectedLightning(Object wallet) { + final bitcoinWallet = wallet as ElectrumWallet; + return bitcoinWallet.walletAddresses.addressPageType is LightningAddressType; + } + @override BitcoinAddressType getBitcoinAddressType(ReceivePageOption option) { switch (option) { @@ -557,9 +561,9 @@ class CWBitcoin extends Bitcoin { @override HardwareWalletService getTrezorHardwareWalletService( - trezor.TrezorConnect connect, bool isBitcoin) { - if (isBitcoin) return BitcoinTrezorService(connect); - return LitecoinTrezorService(connect); + trezor.TrezorConnect? connect, trezor.TrezorClient? client, bool isBitcoin) { + if (isBitcoin) return BitcoinTrezorService(client!); + return LitecoinTrezorService(connect!); } @override @@ -692,28 +696,20 @@ class CWBitcoin extends Bitcoin { } List updateOutputs(PendingTransaction pendingTransaction, List outputs) { - if (pendingTransaction is PendingLightningTransaction) return outputs; - - final pendingTx = pendingTransaction as PendingBitcoinTransaction; - - if (!pendingTx.hasSilentPayment) { + if (pendingTransaction is! PendingBitcoinTransaction || !pendingTransaction.hasSilentPayment) { return outputs; } - final updatedOutputs = outputs.map((output) { - try { - final pendingOut = pendingTx.outputs[outputs.indexOf(output)]; - final updatedOutput = output; + final stealthAddresses = pendingTransaction.stealthAddresses; + if (stealthAddresses.length != outputs.length) { + printV("well, that shouldn't happen"); + return outputs; + } - updatedOutput.stealthAddress = P2trAddress.fromScriptPubkey(script: pendingOut.scriptPubKey) - .toAddress(BitcoinNetwork.mainnet); - return updatedOutput; - } catch (_) {} - - return output; - }).toList(); - - return updatedOutputs; + for (var i = 0; i < outputs.length; i++) { + outputs[i].stealthAddress = stealthAddresses[i]; + } + return outputs; } @override @@ -926,4 +922,37 @@ class CWBitcoin extends Bitcoin { } return null; } + + @override + Money balanceForAccount(Object wallet, int accountIndex) { + final bitcoinWallet = wallet as ElectrumWallet; + final balance = bitcoinWallet.balanceForAccount(accountIndex); + return balance.confirmed + balance.unconfirmed; + } + + @override + Map accountBalancesSnapshot(Object wallet) { + final bitcoinWallet = wallet as ElectrumWallet; + return Map.of(bitcoinWallet.accountBalances); + } + + @override + List getCurrentAccountBitcoinTransactions(Object wallet) => + (wallet as ElectrumWallet).currentAccountBitcoinTransactions; + + @override + Future setCurrentAccount(Object wallet, int accountIndex) async { + final bitcoinWallet = wallet as ElectrumWallet; + await bitcoinWallet.setCurrentAccount(accountIndex); + } + + @override + Future getCurrentAccount(Object wallet) async { + final electrumWallet = wallet as ElectrumWallet; + final acc = await electrumWallet.loadCurrentAccount(); + return Account( + id: acc.accountIndex, + label: acc.label, + balance: electrumWallet.balanceForAccount(acc.accountIndex).confirmed.toString()); + } } diff --git a/lib/buy/buy_quote.dart b/lib/buy/buy_quote.dart index 0a0645e7d..d5da32de2 100644 --- a/lib/buy/buy_quote.dart +++ b/lib/buy/buy_quote.dart @@ -5,6 +5,7 @@ import 'package:hash_wallet/entities/calculate_fiat_amount.dart'; import 'package:hash_wallet/entities/fiat_currency.dart'; import 'package:hash_wallet/entities/provider_types.dart'; import 'package:hash_wallet/exchange/limits.dart'; +import 'package:hash_wallet/generated/i18n.dart'; import 'package:cw_core/crypto_currency.dart'; enum ProviderRecommendation { bestRate, lowKyc, successRate } @@ -13,11 +14,11 @@ extension RecommendationTitle on ProviderRecommendation { String get title { switch (this) { case ProviderRecommendation.bestRate: - return 'BEST RATE'; + return S.current.best_rate; case ProviderRecommendation.lowKyc: - return 'LOW KYC'; + return S.current.low_kyc; case ProviderRecommendation.successRate: - return 'HIGHEST SUCCESS RATE'; + return S.current.highest_success_rate; } } } @@ -123,7 +124,8 @@ class Quote extends SelectableOption { set setLimits(Limits limits) => this.limits = limits; factory Quote.fromMoonPayJson( - Map json, bool isBuyAction, PaymentType paymentType) { + Map json, bool isBuyAction, PaymentType paymentType, + {String? customPaymentMethodType}) { final rate = isBuyAction ? json['quoteCurrencyPrice'] as double? ?? 0.0 : json['baseCurrencyPrice'] as double? ?? 0.0; @@ -149,6 +151,7 @@ class Quote extends SelectableOption { transactionFee: transactionFee, payout: _toDouble(json['quoteCurrencyAmount']) ?? 0.0, paymentType: paymentType, + customPaymentMethodType: customPaymentMethodType, recommendations: [], quoteId: json['signature'] as String? ?? '', provider: ProvidersHelper.getProviderByType(ProviderType.moonpay), diff --git a/lib/buy/moonpay/moonpay_payment_methods.dart b/lib/buy/moonpay/moonpay_payment_methods.dart new file mode 100644 index 000000000..d9e17c3b0 --- /dev/null +++ b/lib/buy/moonpay/moonpay_payment_methods.dart @@ -0,0 +1,32 @@ +class MoonPayPaymentMethod { + MoonPayPaymentMethod({ + required this.type, + required this.displayName, + required this.iconUrl, + required this.active, + this.limitAmount, + this.limitCurrencyCode, + }); + + factory MoonPayPaymentMethod.fromJson(Map json) { + final availability = json["availability"] as Map? ?? {}; + final limits = json["limits"] as Map?; + final perTransaction = limits?["perTransaction"] as Map?; + + return MoonPayPaymentMethod( + type: json["type"] as String? ?? "", + displayName: json["displayName"] as String? ?? "", + iconUrl: json["iconUrl"] as String? ?? "", + active: availability["active"] as bool? ?? false, + limitAmount: (perTransaction?["limit"] as num?)?.toDouble(), + limitCurrencyCode: perTransaction?["currencyCode"] as String?, + ); + } + + final String type; + final String displayName; + final String iconUrl; + final bool active; + final double? limitAmount; + final String? limitCurrencyCode; +} \ No newline at end of file diff --git a/lib/buy/moonpay/moonpay_provider.dart b/lib/buy/moonpay/moonpay_provider.dart index 35a7e295c..b546d4edd 100644 --- a/lib/buy/moonpay/moonpay_provider.dart +++ b/lib/buy/moonpay/moonpay_provider.dart @@ -1,41 +1,38 @@ -import 'dart:convert'; -import 'dart:developer'; +import "dart:convert"; +import "dart:developer"; -import 'package:hash_wallet/.secrets.g.dart' as secrets; -import 'package:hash_wallet/buy/buy_exception.dart'; -import 'package:hash_wallet/buy/buy_provider.dart'; -import 'package:hash_wallet/buy/buy_provider_description.dart'; -import 'package:hash_wallet/buy/buy_quote.dart'; -import 'package:hash_wallet/order/order.dart'; -import 'package:hash_wallet/order/order_source_description.dart'; -import 'package:hash_wallet/buy/pairs_utils.dart'; -import 'package:hash_wallet/buy/payment_method.dart'; -import 'package:hash_wallet/entities/fiat_currency.dart'; -import 'package:hash_wallet/exchange/trade_state.dart'; -import 'package:hash_wallet/generated/i18n.dart'; -import 'package:hash_wallet/palette.dart'; -import 'package:hash_wallet/src/widgets/alert_with_one_action.dart'; -import 'package:hash_wallet/store/app_store.dart'; -import 'package:hash_wallet/themes/core/material_base_theme.dart'; -import 'package:cw_core/currency_for_wallet_type.dart'; -import 'package:cw_core/utils/proxy_wrapper.dart'; -import 'package:cw_core/crypto_currency.dart'; -import 'package:cw_core/wallet_base.dart'; -import 'package:cw_core/utils/print_verbose.dart'; -import 'package:flutter/material.dart'; -import 'package:url_launcher/url_launcher.dart'; +import "package:hash_wallet/.secrets.g.dart" as secrets; +import "package:hash_wallet/buy/buy_exception.dart"; +import "package:hash_wallet/buy/buy_provider.dart"; +import "package:hash_wallet/buy/buy_provider_description.dart"; +import "package:hash_wallet/buy/buy_quote.dart"; +import "package:hash_wallet/buy/moonpay/moonpay_payment_methods.dart"; +import "package:hash_wallet/buy/pairs_utils.dart"; +import "package:hash_wallet/buy/payment_method.dart"; +import "package:hash_wallet/entities/fiat_currency.dart"; +import "package:hash_wallet/exchange/trade_state.dart"; +import "package:hash_wallet/generated/i18n.dart"; +import "package:hash_wallet/order/order.dart"; +import "package:hash_wallet/order/order_source_description.dart"; +import "package:hash_wallet/palette.dart"; +import "package:hash_wallet/src/widgets/alert_with_one_action.dart"; +import "package:hash_wallet/store/app_store.dart"; +import "package:cw_core/crypto_currency.dart"; +import "package:cw_core/currency_for_wallet_type.dart"; +import "package:cw_core/utils/print_verbose.dart"; +import "package:cw_core/utils/proxy_wrapper.dart"; +import "package:flutter/material.dart"; +import "package:url_launcher/url_launcher.dart"; class MoonPayProvider extends BuyProvider { MoonPayProvider({ required AppStore appStore, - required WalletBase wallet, - bool isTestEnvironment = false, + required super.wallet, + super.isTestEnvironment = false, }) : baseSellUrl = isTestEnvironment ? _baseSellTestUrl : _baseSellProductUrl, baseBuyUrl = isTestEnvironment ? _baseBuyTestUrl : _baseBuyProductUrl, - this._appStore = appStore, + _appStore = appStore, super( - wallet: wallet, - isTestEnvironment: isTestEnvironment, hardwareWalletVM: null, supportedCryptoList: supportedCryptoToFiatPairs( notSupportedCrypto: _notSupportedCrypto, notSupportedFiat: _notSupportedFiat), @@ -44,159 +41,207 @@ class MoonPayProvider extends BuyProvider { final AppStore _appStore; - static const _baseSellTestUrl = 'sell-sandbox.moonpay.com'; - static const _baseSellProductUrl = 'sell.moonpay.com'; - static const _baseBuyTestUrl = 'buy-staging.moonpay.com'; - static const _baseBuyProductUrl = 'buy.moonpay.com'; - static const _cIdBaseUrl = 'exchange-helper.such.software'; - static const _apiUrl = 'https://api.moonpay.com'; - static const _baseUrl = 'api.moonpay.com'; - static const _currenciesPath = '/v3/currencies'; - static const _buyQuote = '/buy_quote'; - static const _sellQuote = '/sell_quote'; + static const _baseSellTestUrl = "sell-sandbox.moonpay.com"; + static const _baseSellProductUrl = "sell.moonpay.com"; + static const _baseBuyTestUrl = "buy-staging.moonpay.com"; + static const _baseBuyProductUrl = "buy.moonpay.com"; + static const _cIdBaseUrl = "exchange-helper.such.software"; + static const _apiUrl = "https://api.moonpay.com"; + static const _baseUrl = "api.moonpay.com"; + static const _currenciesPath = "/v3/currencies"; + static const _buyQuote = "/buy_quote"; + static const _sellQuote = "/sell_quote"; + static const _paymentMethodsPath = "/api/moonpay/payment-methods"; - static const _transactionsSuffix = '/v1/transactions'; + static const _transactionsSuffix = "/v1/transactions"; static const List _notSupportedCrypto = []; static const List _notSupportedFiat = []; + static String get _exchangeHelperApiKey => secrets.exchangeHelperApiKey; + + static const Map _moonPayPaymentMethodStrings = { + PaymentType.creditCard: 'credit_debit_card', + PaymentType.debitCard: 'credit_debit_card', + PaymentType.ach: 'ach_bank_transfer', + PaymentType.applePay: 'apple_pay', + PaymentType.googlePay: 'google_pay', + PaymentType.sepa: 'sepa_bank_transfer', + PaymentType.paypal: 'paypal', + PaymentType.sepaOpenBankingPayment: 'sepa_open_banking_payment', + PaymentType.gbpOpenBankingPayment: 'gbp_open_banking_payment', + PaymentType.lowCostAch: 'low_cost_ach', + PaymentType.mobileWallet: 'mobile_wallet', + PaymentType.pixInstantPayment: 'pix_instant_payment', + PaymentType.yellowCardBankTransfer: 'yellow_card_bank_transfer', + PaymentType.fiatBalance: 'fiat_balance', + PaymentType.revolutPay: 'revolut_pay', + PaymentType.moonpayCashApp: 'cash_app', + PaymentType.moonpayBalance: 'moonpay_balance', + }; + + static final Map _paymentTypeByMoonPayString = { + for (final entry in _moonPayPaymentMethodStrings.entries) entry.value: entry.key, + }; + + String? normalizePaymentMethod(PaymentType paymentMethod) => + _moonPayPaymentMethodStrings[paymentMethod]; + + PaymentType _getPaymentTypeByString(String? paymentMethod) => + _paymentTypeByMoonPayString[paymentMethod] ?? PaymentType.unknown; + final String baseBuyUrl; final String baseSellUrl; @override String get providerDescription => - 'MoonPay offers a fast and simple way to buy and sell cryptocurrencies'; + "MoonPay offers a fast and simple way to buy and sell cryptocurrencies"; @override - String get title => 'MoonPay'; + String get title => "MoonPay"; @override - String get lightIcon => 'assets/images/moonpay_light.png'; + String get lightIcon => "assets/images/moonpay_light.png"; @override - String get darkIcon => 'assets/images/moonpay_dark.png'; + String get darkIcon => "assets/images/moonpay_dark.png"; @override bool get isAggregator => false; - static String get _apiKey => secrets.moonPayApiKey; + String get _apiKey => isTestEnvironment ? secrets.moonPaySandboxApiKey : secrets.moonPayApiKey; String get currencyCode => walletTypeToCryptoCurrency(wallet.type, chainId: wallet.chainId).title.toLowerCase(); - String get trackUrl => baseBuyUrl + '/transaction_receipt?transactionId='; + String get trackUrl => "$baseBuyUrl/transaction_receipt?transactionId="; - static String get _exchangeHelperApiKey => secrets.exchangeHelperApiKey; - - static String themeToMoonPayTheme(MaterialThemeBase theme) { - switch (theme.type) { - case ThemeType.light: - return 'light'; - case ThemeType.dark: - return 'dark'; - } - } - - Future getMoonpaySignature(String query) async { - final uri = Uri.https(_cIdBaseUrl, "/api/moonpay"); + Future getMoonpaySignedQuery(String query) async { + final uri = + Uri.https(_cIdBaseUrl, "/api/moonpay", isTestEnvironment ? {"useSandbox": "true"} : null); final response = await ProxyWrapper().post( clearnetUri: uri, - headers: {'Content-Type': 'application/json', 'x-api-key': _exchangeHelperApiKey}, - body: json.encode({'query': query}), + headers: {"Content-Type": "application/json", "x-api-key": _exchangeHelperApiKey}, + body: json.encode({"query": query}), ); if (response.statusCode == 200) { - return (jsonDecode(response.body) as Map)['signature'] as String; + printV(jsonDecode(response.body) as Map); + return (jsonDecode(response.body) as Map)["query"] as String; } else { throw Exception( - 'Provider currently unavailable. Status: ${response.statusCode} ${response.body}'); - } - } - - Future> fetchFiatCredentials( - String fiatCurrency, String cryptocurrency, String? paymentMethod) async { - final params = {'baseCurrencyCode': fiatCurrency.toLowerCase(), 'apiKey': _apiKey}; - - if (paymentMethod != null) params['paymentMethod'] = paymentMethod; - - final path = '$_currenciesPath/${cryptocurrency.toLowerCase()}/limits'; - final url = Uri.https(_baseUrl, path, params); - - try { - final response = await ProxyWrapper().get( - clearnetUri: url, - headers: {'accept': 'application/json'}, + "Provider currently unavailable. Status: ${response.statusCode} ${response.body}", ); - - if (response.statusCode == 200) { - return jsonDecode(response.body) as Map; - } else { - printV('MoonPay does not support fiat: $fiatCurrency'); - return {}; - } - } catch (e) { - printV('MoonPay Error fetching fiat currencies: $e'); - return {}; } } + Future> fetchMoonPayPaymentMethods({ + required String currencyCode, + String transactionType = "buy", + }) async { + final String? countryCode = FiatCurrency.tryDeserialize(raw: currencyCode)?.apiCountryCode; + + if (countryCode == null) { + throw Exception("failed to determine country code for currency: $currencyCode"); + } + + final params = { + "currencyCode": currencyCode, + "countryCode": countryCode, + "transactionType": transactionType, + if (countryCode == "USA") "stateOfResidence": "NY", + }; + + final uri = Uri.https(_cIdBaseUrl, _paymentMethodsPath, params); + + final response = await ProxyWrapper().get( + clearnetUri: uri, + headers: { + "Accept": "application/json", + "x-api-key": _exchangeHelperApiKey, + }, + ); + + if (response.statusCode != 200) { + if (response.statusCode == 500) { + throw Exception("failed to fetch payment methods. Status: 500 Internal Server Error"); + } + throw Exception( + "failed to fetch payment methods. Status: ${response.statusCode} ${response.body}"); + } + + final decoded = jsonDecode(response.body) as Map; + final list = decoded["payment-methods"] as List? ?? []; + + return list.map((e) => MoonPayPaymentMethod.fromJson(e as Map)).toList(); + } + + @override Future> getAvailablePaymentTypes( String fiatCurrency, CryptoCurrency cryptoCurrency, bool isBuyAction) async { final List paymentMethods = []; - if (isBuyAction) { - final fiatBuyCredentials = - await fetchFiatCredentials(fiatCurrency, cryptoCurrency.title, null); - if (fiatBuyCredentials.isNotEmpty) { - final paymentMethod = fiatBuyCredentials['paymentMethod'] as String?; + try { + final moonPayMethods = await fetchMoonPayPaymentMethods( + currencyCode: fiatCurrency, + transactionType: isBuyAction ? "buy" : "sell", + ); + + for (final method in moonPayMethods) { + if (!method.active) { + continue; + } + final paymentType = _getPaymentTypeByString(method.type); + paymentMethods.add(PaymentMethod.fromMoonPayJson( - fiatBuyCredentials, _getPaymentTypeByString(paymentMethod))); - return paymentMethods; + method, + paymentType, + ),); } + } catch (e) { + printV("Error fetching payment methods: $e"); } return paymentMethods; } @override - Future?> fetchQuote( - {required CryptoCurrency cryptoCurrency, - required FiatCurrency fiatCurrency, - required double amount, - required bool isBuyAction, - required String walletAddress, - PaymentType? paymentType, - String? customPaymentMethodType, - String? countryCode}) async { + Future?> fetchQuote({ + required CryptoCurrency cryptoCurrency, + required FiatCurrency fiatCurrency, + required double amount, + required bool isBuyAction, + required String walletAddress, + PaymentType? paymentType, + String? customPaymentMethodType, + String? countryCode, + }) async { String? paymentMethod; if (paymentType != null && paymentType != PaymentType.all) { - paymentMethod = normalizePaymentMethod(paymentType); - if (paymentMethod == null) paymentMethod = paymentType.name; - } else { - paymentMethod = 'credit_debit_card'; + paymentMethod = normalizePaymentMethod(paymentType) ?? customPaymentMethodType; } - final action = isBuyAction ? 'buy' : 'sell'; + final action = isBuyAction ? "buy" : "sell"; final formattedCryptoCurrency = _normalizeCurrency(cryptoCurrency); final baseCurrencyCode = isBuyAction ? fiatCurrency.name.toLowerCase() : cryptoCurrency.title.toLowerCase(); final params = { - 'baseCurrencyCode': baseCurrencyCode, - 'baseCurrencyAmount': amount.toStringAsFixed(2), - 'paymentMethod': paymentMethod, - 'areFeesIncluded': 'false', + "baseCurrencyCode": baseCurrencyCode, + "baseCurrencyAmount": amount.toStringAsFixed(2), + if (paymentMethod != null) "paymentMethod": paymentMethod, + "areFeesIncluded": "false", 'apiKey': _apiKey }; - log('MoonPay: Fetching $action quote: ${isBuyAction ? formattedCryptoCurrency : fiatCurrency.name.toLowerCase()} -> ${isBuyAction ? baseCurrencyCode : formattedCryptoCurrency}, amount: $amount, paymentMethod: $paymentMethod'); + log("MoonPay: Fetching $action quote: ${isBuyAction ? formattedCryptoCurrency : fiatCurrency.name.toLowerCase()} -> ${isBuyAction ? baseCurrencyCode : formattedCryptoCurrency}, amount: $amount, paymentMethod: $paymentMethod"); final quotePath = isBuyAction ? _buyQuote : _sellQuote; - final path = '$_currenciesPath/$formattedCryptoCurrency$quotePath'; + final path = "$_currenciesPath/$formattedCryptoCurrency$quotePath"; final url = Uri.https(_baseUrl, path, params); try { final response = await ProxyWrapper().get(clearnetUri: url); @@ -216,19 +261,30 @@ class MoonPayProvider extends BuyProvider { } final paymentMethods = data['paymentMethod'] as String?; - final quote = - Quote.fromMoonPayJson(data, isBuyAction, _getPaymentTypeByString(paymentMethods)); + + final quote = Quote.fromMoonPayJson( + data, + isBuyAction, + _getPaymentTypeByString(paymentMethods ?? paymentMethod), + customPaymentMethodType: paymentMethod, + ); quote.setFiatCurrency = fiatCurrency; quote.setCryptoCurrency = cryptoCurrency; return [quote]; - } else { - printV('Moon Pay: Error fetching buy quote: '); + } + try { + final errorData = jsonDecode(response.body) as Map; + final errorMessage = errorData["message"] as String?; + printV("Error fetching buy quote: $errorMessage"); + return null; + } catch (e) { + printV("Error parsing error response: $e"); return null; } } catch (e) { - printV('Moon Pay: Error fetching buy quote: $e'); + printV("Exception while fetching buy quote: $e"); return null; } } @@ -242,20 +298,21 @@ class MoonPayProvider extends BuyProvider { required String cryptoCurrencyAddress, String? countryCode}) async { final Map params = { - 'theme': themeToMoonPayTheme(_appStore.themeStore.currentTheme), - 'language': _appStore.settingsStore.languageCode, - 'colorCode': _appStore.themeStore.currentTheme.isDark + "theme": _appStore.themeStore.currentTheme.type.name, + "language": _appStore.settingsStore.languageCode, + "colorCode": _appStore.themeStore.currentTheme.isDark ? '#${Palette.blueCraiola.value.toRadixString(16).substring(2, 8)}' : '#${Palette.moderateSlateBlue.value.toRadixString(16).substring(2, 8)}', - 'baseCurrencyCode': isBuyAction ? quote.fiatCurrency.name : quote.cryptoCurrency.name, - 'baseCurrencyAmount': amount.toStringAsFixed(2), - 'walletAddress': cryptoCurrencyAddress, - 'lockAmount': 'false', - 'showAllCurrencies': 'false', - 'showWalletAddressForm': 'false', + "baseCurrencyCode": isBuyAction ? quote.fiatCurrency.name : quote.cryptoCurrency.name, + "baseCurrencyAmount": amount.toStringAsFixed(2), + "walletAddress": cryptoCurrencyAddress, + "lockAmount": "false", + "showAllCurrencies": "false", + "showWalletAddressForm": "false", if (isBuyAction) - 'enabledPaymentMethods': normalizePaymentMethod(quote.paymentType) ?? - 'credit_debit_card,apple_pay,google_pay,samsung_pay,sepa_bank_transfer,gbp_bank_transfer,gbp_open_banking_payment', + "enabledPaymentMethods": normalizePaymentMethod(quote.paymentType) ?? + quote.customPaymentMethodType ?? + "credit_debit_card,apple_pay,google_pay,samsung_pay,sepa_bank_transfer,gbp_bank_transfer,gbp_open_banking_payment", if (!isBuyAction) 'refundWalletAddress': cryptoCurrencyAddress }; @@ -272,20 +329,18 @@ class MoonPayProvider extends BuyProvider { if (await canLaunchUrl(uri)) { await launchUrl(uri, mode: LaunchMode.externalApplication); } else { - throw Exception('Could not launch URL'); + throw Exception("Could not launch URL"); } } catch (e) { if (context.mounted) { await showDialog( context: context, - builder: (BuildContext context) { - return AlertWithOneAction( - alertTitle: 'MoonPay', - alertContent: 'The MoonPay service is currently unavailable: $e', - buttonText: S.of(context).ok, - buttonAction: () => Navigator.of(context).pop(), - ); - }, + builder: (BuildContext context) => AlertWithOneAction( + alertTitle: 'MoonPay', + alertContent: 'The MoonPay service is currently unavailable: $e', + buttonText: S.of(context).ok, + buttonAction: () => Navigator.of(context).pop(), + ), ); } } @@ -297,18 +352,13 @@ class MoonPayProvider extends BuyProvider { required Map params, String? amount, }) async { - if (_apiKey.isNotEmpty) params['apiKey'] = _apiKey; + if (_apiKey.isNotEmpty) params["apiKey"] = _apiKey; final baseUrl = isBuyAction ? baseBuyUrl : baseSellUrl; - final originalUri = Uri.https(baseUrl, '', params); + final originalUri = Uri.https(baseUrl, "", params); - if (isTestEnvironment) return originalUri; - - final signature = await getMoonpaySignature('?${originalUri.query}'); - final query = Map.from(originalUri.queryParameters); - query['signature'] = signature; - final signedUri = originalUri.replace(queryParameters: query); - return signedUri; + final query = await getMoonpaySignedQuery("?${originalUri.query}"); + return Uri.parse(query); } Future findOrderById(String id) async { @@ -350,76 +400,4 @@ class MoonPayProvider extends BuyProvider { return currency.toString().toLowerCase(); } - - String? normalizePaymentMethod(PaymentType paymentMethod) { - switch (paymentMethod) { - case PaymentType.creditCard: - return 'credit_debit_card'; - case PaymentType.debitCard: - return 'credit_debit_card'; - case PaymentType.ach: - return 'ach_bank_transfer'; - case PaymentType.applePay: - return 'apple_pay'; - case PaymentType.googlePay: - return 'google_pay'; - case PaymentType.sepa: - return 'sepa_bank_transfer'; - case PaymentType.paypal: - return 'paypal'; - case PaymentType.sepaOpenBankingPayment: - return 'sepa_open_banking_payment'; - case PaymentType.gbpOpenBankingPayment: - return 'gbp_open_banking_payment'; - case PaymentType.lowCostAch: - return 'low_cost_ach'; - case PaymentType.mobileWallet: - return 'mobile_wallet'; - case PaymentType.pixInstantPayment: - return 'pix_instant_payment'; - case PaymentType.yellowCardBankTransfer: - return 'yellow_card_bank_transfer'; - case PaymentType.fiatBalance: - return 'fiat_balance'; - case PaymentType.revolutPay: - return 'revolut_pay'; - default: - return null; - } - } - - PaymentType _getPaymentTypeByString(String? paymentMethod) { - switch (paymentMethod) { - case 'ach_bank_transfer': - return PaymentType.ach; - case 'apple_pay': - return PaymentType.applePay; - case 'credit_debit_card': - return PaymentType.creditCard; - case 'fiat_balance': - return PaymentType.fiatBalance; - case 'gbp_open_banking_payment': - return PaymentType.gbpOpenBankingPayment; - case 'google_pay': - return PaymentType.googlePay; - case 'low_cost_ach': - return PaymentType.lowCostAch; - case 'mobile_wallet': - return PaymentType.mobileWallet; - case 'paypal': - return PaymentType.paypal; - case 'pix_instant_payment': - return PaymentType.pixInstantPayment; - case 'sepa_bank_transfer': - return PaymentType.sepa; - case 'sepa_open_banking_payment': - return PaymentType.sepaOpenBankingPayment; - case 'yellow_card_bank_transfer': - return PaymentType.yellowCardBankTransfer; - case 'revolut_pay': - return PaymentType.revolutPay; - default: - return PaymentType.unknown; - } - } } diff --git a/lib/buy/payment_method.dart b/lib/buy/payment_method.dart index 3c1327bbb..47b4c3819 100644 --- a/lib/buy/payment_method.dart +++ b/lib/buy/payment_method.dart @@ -1,4 +1,6 @@ +import "package:hash_wallet/buy/moonpay/moonpay_payment_methods.dart"; import 'package:hash_wallet/core/selectable_option.dart'; +import "package:hash_wallet/entities/calculate_fiat_amount.dart"; enum PaymentType { all, @@ -33,6 +35,8 @@ enum PaymentType { fiatBalance, bancontact, pixPay, + moonpayCashApp, + moonpayBalance, unknown, } @@ -103,6 +107,10 @@ extension PaymentTypeTitle on PaymentType { return 'Bancontact'; case PaymentType.pixPay: return 'PIX Pay'; + case PaymentType.moonpayCashApp: + return 'Cash App'; + case PaymentType.moonpayBalance: + return 'MoonPay Balance'; default: return null; } @@ -115,7 +123,7 @@ extension PaymentTypeTitle on PaymentType { case PaymentType.creditCard: case PaymentType.debitCard: case PaymentType.yellowCardBankTransfer: - return 'assets/images/card.svg'; + return 'assets/new-ui/buy_payment_methods/debit_card.svg'; case PaymentType.bankTransfer: return 'assets/images/bank_light.svg'; case PaymentType.skrill: @@ -132,17 +140,21 @@ extension PaymentTypeTitle on PaymentType { String? get darkIconPath { switch (this) { case PaymentType.all: - return 'assets/images/usd_round_dark.svg'; + return 'assets/new-ui/buy_payment_methods/all_methods.svg'; case PaymentType.creditCard: case PaymentType.debitCard: case PaymentType.yellowCardBankTransfer: - return 'assets/images/card_dark.svg'; + return 'assets/new-ui/buy_payment_methods/debit_card.svg'; case PaymentType.bankTransfer: - return 'assets/images/bank_dark.svg'; + return 'assets/new-ui/buy_payment_methods/bank_transfer.svg'; case PaymentType.skrill: return 'assets/images/skrill.svg'; case PaymentType.applePay: - return 'assets/images/apple_pay_round_dark.svg'; + return 'assets/new-ui/buy_payment_methods/apple_pay.svg'; + case PaymentType.googlePay: + return "assets/new-ui/buy_payment_methods/google_pay.svg"; + case PaymentType.paypal: + return "assets/new-ui/buy_payment_methods/paypal.svg"; case PaymentType.revolutPay: return 'assets/images/revolut_dark.svg'; default: @@ -150,6 +162,12 @@ extension PaymentTypeTitle on PaymentType { } } + bool get isMonochromeIcon => [ + "assets/new-ui/buy_payment_methods/all_methods.svg", + "assets/new-ui/buy_payment_methods/debit_card.svg", + "assets/new-ui/buy_payment_methods/bank_transfer.svg" + ].contains(darkIconPath); + String? get description { switch (this) { default: @@ -165,6 +183,8 @@ class PaymentMethod extends SelectableOption { required this.customIconPath, this.customDescription, this.customPaymentMethodType, + this.customBadges = const [], + this.customBottomLeftSubTitle, }) : super(title: paymentMethodType.title ?? customTitle); final PaymentType paymentMethodType; @@ -172,8 +192,16 @@ class PaymentMethod extends SelectableOption { final String customIconPath; final String? customDescription; final String? customPaymentMethodType; + final List customBadges; + final String? customBottomLeftSubTitle; bool isSelected = false; + @override + List get badges => customBadges; + + @override + String? get bottomLeftSubTitle => customBottomLeftSubTitle; + @override String? get description => paymentMethodType.description ?? customDescription; @@ -190,14 +218,64 @@ class PaymentMethod extends SelectableOption { return PaymentMethod( paymentMethodType: PaymentType.all, customTitle: 'All Payment Methods', - customIconPath: 'assets/images/dollar_coin.svg'); + customIconPath: 'assets/new-ui/buy_payment_methods/all_methods.svg'); } - factory PaymentMethod.fromMoonPayJson(Map json, PaymentType paymentType) { + factory PaymentMethod.fromOnramperJson(Map json) { + final type = PaymentMethod.getPaymentTypeId(json['paymentTypeId'] as String?); + return PaymentMethod( + paymentMethodType: type ?? PaymentType.unknown, + customPaymentMethodType: json['paymentTypeId'] as String?, + customTitle: json['name'] as String? ?? 'Unknown', + customIconPath: json['icon'] as String? ?? 'assets/new-ui/buy_payment_methods/debit_card.svg', + customDescription: json['description'] as String?); + } + + factory PaymentMethod.fromDFX(String paymentMethod, PaymentType paymentType) { return PaymentMethod( paymentMethodType: paymentType, - customTitle: json['paymentMethod'] as String, - customIconPath: 'assets/images/card.png'); + customTitle: paymentMethod, + customIconPath: 'assets/new-ui/buy_payment_methods/debit_card.svg'); + } + + factory PaymentMethod.fromMoonPayJson(MoonPayPaymentMethod method, PaymentType paymentType) { + final List badges = []; + String? limitSubtitle; + if (paymentType == PaymentType.moonpayCashApp) { + badges.add('New'); + } + if (method.limitAmount != null && method.limitCurrencyCode != null) { + limitSubtitle = + 'max: ${formatWithCommas(method.limitAmount.toString())} ${method.limitCurrencyCode!.toUpperCase()}'; + } + + return PaymentMethod( + paymentMethodType: paymentType, + customTitle: method.displayName, + customIconPath: method.iconUrl.isNotEmpty ? method.iconUrl : 'assets/images/card.png', + customPaymentMethodType: method.type, + customBadges: badges, + customBottomLeftSubTitle: limitSubtitle, + ); + } + + factory PaymentMethod.fromMeldJson(Map json) { + final type = PaymentMethod.getPaymentTypeId(json['paymentMethod'] as String?); + final logos = json['logos'] as Map; + return PaymentMethod( + paymentMethodType: type ?? PaymentType.unknown, + customTitle: json['name'] as String? ?? 'Unknown', + customIconPath: logos['dark'] as String? ?? 'assets/new-ui/buy_payment_methods/debit_card.svg', + customDescription: json['description'] as String?); + } + + factory PaymentMethod.fromKryptonimJson(Map json) { + final type = PaymentMethod.getPaymentTypeId(json['payment_method'] as String?); + return PaymentMethod( + paymentMethodType: type ?? PaymentType.unknown, + customTitle: json['payment_method'] as String? ?? 'Unknown', + customIconPath: 'assets/new-ui/buy_payment_methods/debit_card.svg', + ); } static PaymentType? getPaymentTypeId(String? type) { diff --git a/lib/ci_build_overlay.dart b/lib/ci_build_overlay.dart new file mode 100644 index 000000000..ff962d200 --- /dev/null +++ b/lib/ci_build_overlay.dart @@ -0,0 +1,60 @@ +import "package:hash_wallet/store/settings_store.dart"; +import "package:flutter/material.dart"; +import "package:flutter_mobx/flutter_mobx.dart"; + +class CiBuildOverlay extends StatelessWidget { + const CiBuildOverlay({required this.child, this.settingsStore, super.key}); + + static const _commit = String.fromEnvironment("CI_COMMIT"); + static const _branch = String.fromEnvironment("CI_BRANCH"); + static const isCiBuild = _commit != "" || _branch != ""; + + final Widget child; + final SettingsStore? settingsStore; + + @override + Widget build(BuildContext context) { + if (!isCiBuild) { + return child; + } + + final shortCommit = _commit.length > 7 ? _commit.substring(0, 7) : _commit; + final label = [shortCommit, _branch].where((part) => part.isNotEmpty).join(" · "); + + return Stack( + fit: StackFit.passthrough, + textDirection: TextDirection.ltr, + children: [ + child, + Positioned( + top: 0, + left: 0, + right: 0, + child: Observer( + builder: (_) => settingsStore?.showCiBuildOverlay ?? true + ? IgnorePointer( + child: ExcludeSemantics( + child: SafeArea( + bottom: false, + child: ColoredBox( + color: Colors.black54, + child: Text( + label, + textAlign: TextAlign.center, + textDirection: TextDirection.ltr, + textScaler: TextScaler.noScaling, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: const TextStyle(color: Colors.white, fontSize: 10), + ), + ), + ), + ), + ) + : const SizedBox.shrink(), + ), + ), + ], + ); + } +} diff --git a/lib/core/address_resolver/address_lookup_provider.dart b/lib/core/address_resolver/address_lookup_provider.dart new file mode 100644 index 000000000..2b93fc8ee --- /dev/null +++ b/lib/core/address_resolver/address_lookup_provider.dart @@ -0,0 +1,21 @@ +import 'package:hash_wallet/core/address_resolver/address_sources.dart'; +import 'package:hash_wallet/core/address_resolver/parsed_address.dart'; +import 'package:hash_wallet/store/settings_store.dart'; +import 'package:cw_core/crypto_currency.dart'; +import 'package:cw_core/wallet_base.dart'; + +abstract class AddressLookupProvider { + AddressSource get source; + + List get supportedCurrencies; + + bool isEnabled(SettingsStore settingsStore); + + bool canHandle(String query); + + Future> resolve({ + required String query, + required List currencies, + required WalletBase wallet, + }); +} diff --git a/lib/core/address_resolver/address_resolver_service.dart b/lib/core/address_resolver/address_resolver_service.dart new file mode 100644 index 000000000..29c957919 --- /dev/null +++ b/lib/core/address_resolver/address_resolver_service.dart @@ -0,0 +1,134 @@ +import 'package:hash_wallet/core/address_resolver/address_lookup_provider.dart'; +import 'package:hash_wallet/core/address_resolver/address_sources.dart'; +import 'package:hash_wallet/core/address_resolver/bip_353/bip_353_address_provider.dart'; +import 'package:hash_wallet/core/address_resolver/ens/ens_address_provider.dart'; +import 'package:hash_wallet/core/address_resolver/fio/fio_address_provider.dart'; +import 'package:hash_wallet/core/address_resolver/lnurl_pay/lnurl_pay_address_provider.dart'; +import 'package:hash_wallet/core/address_resolver/mastodon/mastodon_address_provider.dart'; +import 'package:hash_wallet/core/address_resolver/nostr/nostr_address_provider.dart'; +import 'package:hash_wallet/core/address_resolver/openalias/openalias_address_provider.dart'; +import 'package:hash_wallet/core/address_resolver/parsed_address.dart'; +import 'package:hash_wallet/core/address_resolver/twitter/twitter_address_provider.dart'; +import 'package:hash_wallet/core/address_resolver/unstoppable/unstoppable_address_provider.dart'; +import 'package:hash_wallet/core/address_resolver/wellknown/wellknown_address_provider.dart'; +import 'package:hash_wallet/core/address_resolver/zano/zano_alias_address_provider.dart'; +import 'package:hash_wallet/core/address_resolver/zcash/zcash_address_provider.dart'; +import 'package:hash_wallet/core/address_resolver/zcash/zcash_me_address_provider.dart'; +import 'package:hash_wallet/store/settings_store.dart'; +import 'package:cw_core/crypto_currency.dart'; +import 'package:cw_core/utils/print_verbose.dart'; +import 'package:cw_core/wallet_base.dart'; + +class AddressResolverService { + AddressResolverService({required this.settingsStore}); + + final SettingsStore settingsStore; + +// Sources available for address resolution and lookup settings. + // Hash Bags privacy rule: only domain-anchored resolvers, where the lookup goes to the domain + // the user typed (DNS for OpenAlias/BIP-353, the domain itself for .well-known/LNURL) or to a + // public chain RPC (ENS). Never add a third-party lookup (X, Mastodon, Nostr, Yat, Unstoppable, + // FIO, ThorChain, Zcash/Zano names) without an off-by-default setting and a PRIVACY.md update. + static const supportedSources = [ + AddressSource.lnurlPay, + AddressSource.openAlias, + AddressSource.ens, + AddressSource.wellKnown, + AddressSource.bip353, + ]; + + List _buildProviders() { + final allProviders = [ + LNUrlPayAddressProvider(), + TwitterAddressProvider(), + MastodonAddressProvider(), + UnstoppableAddressProvider(), + OpenaliasAddressProvider(), + EnsAddressProvider(), + ZcashNameAddressProvider(), + ZcashMeAddressProvider(), + WellKnownAddressProvider(), + ZanoAliasAddressProvider(), + Bip353AddressProvider(), + FioAddressProvider(), + NostrAddressProvider(), + ]; + + return allProviders.where((provider) => supportedSources.contains(provider.source)).toList(); + } + + Future> resolve({ + required String query, + required WalletBase wallet, + required CryptoCurrency currency, + }) async { + try { + final providers = _buildProviders(); + // return first entry if currency is specified and provider supports it + if (currency != null) { + for (final provider in providers) { + final cur = currency.title; + final src = provider.source.label; + + printV('[address resolver service] checking $src - $cur - $query'); + if (!provider.isEnabled(settingsStore)) { + printV('[address resolver service] ...skipping $src (disabled)'); + continue; + } + if (!provider.canHandle(query)) { + printV('[address resolver service] ...skipping $src (cannot handle)'); + continue; + } + if (!provider.supportedCurrencies.contains(currency)) { + printV('[address resolver service] ...skipping $src (unsupported currency)'); + continue; + } + + try { + printV('[address resolver service] ...resolving $src - $cur - $query'); + final results = await provider.resolve( + query: query, + currencies: [currency], + wallet: wallet, + ); + + if (results.isNotEmpty) { + final parsedAddress = results.first.parsedAddressByCurrencyMap[currency]; + final parsedAddressStr = + parsedAddress == null || parsedAddress.isEmpty ? 'N/A' : parsedAddress; + printV('[address resolver service] resolved $src - $cur - $parsedAddressStr'); + return [results.first]; + } + } catch (e) { + printV('[address resolver service] Error resolving $src: $e'); + } + } + + return []; + } + + return []; // TODO: Implement multi-currency resolution if no specific currency is provided. + + final tasks = >>[]; + + for (final provider in providers) { + if (!provider.isEnabled(settingsStore)) continue; + if (!provider.canHandle(query)) continue; + + tasks.add( + provider.resolve( + query: query, + currencies: provider.supportedCurrencies, + wallet: wallet, + ), + ); + } + + final results = await Future.wait(tasks); + return results.expand((items) => items).toList(); + } catch (e) { + printV('Error resolving address: $e'); + return []; + } + } +} diff --git a/lib/core/address_resolver/address_resolver_utils.dart b/lib/core/address_resolver/address_resolver_utils.dart new file mode 100644 index 000000000..8ecd185e0 --- /dev/null +++ b/lib/core/address_resolver/address_resolver_utils.dart @@ -0,0 +1,105 @@ +import 'package:hash_wallet/core/address_resolver/unstoppable/unstoppable_address_provider.dart'; +import 'package:hash_wallet/core/address_validator.dart'; +import 'package:cw_core/crypto_currency.dart'; +import 'package:cw_core/utils/print_verbose.dart'; + +class AddressResolverUtils { + static Future> resolveUnstoppableDomainFromText({ + required String raw, + required List currencies, + }) async { + final domain = extractUnstoppableDomain(raw); + if (domain.isEmpty) return {}; + + final result = {}; + + for (final currency in currencies) { + final address = + await UnstoppableAddressProvider.fetchUnstoppableDomainAddress(domain, currency.title); + if (address.isNotEmpty) { + result[currency] = address; + } + } + + return result; + } + + static String extractUnstoppableDomain(String raw) { +// sort by length to avoid matching shorter tld instead of longer + final domains = List.from(UnstoppableAddressProvider.unstoppableDomains) + ..sort((a, b) => b.length.compareTo(a.length)); + for (final tld in domains) { + final pattern = RegExp(r'([A-Za-z0-9-]+)\.' + RegExp.escape(tld), caseSensitive: false); + final match = pattern.firstMatch(raw); + if (match != null) return match.group(0)!; + } + return ''; + } + + static Map extractAddressesFromText({ + required String raw, + required List currencies, + bool requireSurroundingWhitespaces = true, + }) { + final result = {}; + var text = raw; + + for (final currency in currencies) { + final address = extractAddressByType( + raw: text, + type: currency, + requireSurroundingWhitespaces: requireSurroundingWhitespaces, + ); + + if (address != null && address.isNotEmpty) { + result[currency] = address; + text = text.replaceFirst(address, ''); + } + } + + return result; + } + + static String? extractAddressByType({ + required String raw, + required CryptoCurrency type, + bool requireSurroundingWhitespaces = true, + }) { + var addressPattern = AddressValidator.getAddressFromStringPattern(type); + if (addressPattern == null) { + printV('Unknown pattern for $type'); + return null; + } + if (requireSurroundingWhitespaces) addressPattern = "$BEFORE_REGEX$addressPattern$AFTER_REGEX"; + final text = stripHtmlTags(raw); + final match = RegExp(addressPattern, multiLine: true, caseSensitive: false).firstMatch(text); + if (match == null) return null; + return match.group(0)?.replaceAllMapped(RegExp('[^0-9a-zA-Z]|bitcoincash:|nano_|ban_'), + (Match match) { + String group = match.group(0)!; + if (group.startsWith('bitcoincash:') || + group.startsWith('nano_') || + group.startsWith('ban_')) { + return group; + } + return ''; + }); + } + + static bool isEmailFormat(String address) { + final RegExp emailRegex = RegExp( + r'^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$', + caseSensitive: false, + ); + return emailRegex.hasMatch(address); + } + + static String stripHtmlTags(String value) { + return value + .replaceAll(RegExp(r'[\u2028\u2029]'), '\n') + .replaceAll(RegExp(r'', caseSensitive: false), '\n') + .replaceAll(RegExp(r'', caseSensitive: false), '\n') + .replaceAll(RegExp(r'<[^>]*>'), ' ') + .trim(); + } +} diff --git a/lib/core/address_resolver/address_sources.dart b/lib/core/address_resolver/address_sources.dart new file mode 100644 index 000000000..b97d63531 --- /dev/null +++ b/lib/core/address_resolver/address_sources.dart @@ -0,0 +1,87 @@ +import 'package:hash_wallet/core/address_validator.dart'; +import 'package:cw_core/crypto_currency.dart'; + +enum AddressSource { + twitter(label: 'X', iconPath: 'assets/new-ui/address_sources/x.svg', alias: '@username'), + unstoppableDomains( + label: 'Unstoppable Domains', + iconPath: 'assets/new-ui/address_sources/unstoppable.svg', + alias: 'domain.tld'), + openAlias( + label: 'OpenAlias', + iconPath: 'assets/new-ui/address_sources/openalias.svg', + alias: 'name.domain.tld'), + yatRecord(label: 'Yat', iconPath: 'assets/new-ui/address_sources/yat.svg', alias: '🎂🎂🎂'), + fio(label: 'FIO', iconPath: 'assets/new-ui/address_sources/fio.svg', alias: 'user@domain'), + ens( + label: 'Ethereum Name Service', + iconPath: 'assets/new-ui/address_sources/ens.svg', + alias: 'domain.eth'), + mastodon( + label: 'Mastodon', + iconPath: 'assets/new-ui/address_sources/mastodon.svg', + alias: '@username@domain.tld'), + nostr( + label: 'Nostr', + iconPath: 'assets/new-ui/address_sources/nostr.svg', + alias: 'user@domain.tld'), + thorChain( + label: 'ThorChain', + iconPath: 'assets/new-ui/address_sources/icon-thorchain.svg', + alias: 'name'), + wellKnown( + label: '.wellknown', + iconPath: 'assets/new-ui/address_sources/wellknown.svg', + alias: 'domain.tld'), + zanoAlias( + label: 'Zano Alias', iconPath: 'assets/new-ui/address_sources/zano.svg', alias: '@alias'), + bip353( + label: 'BIP353', + iconPath: 'assets/new-ui/address_sources/bip353.svg', + alias: 'user@domain.com'), + zcashAddress( + label: 'Zcash.me', + iconPath: 'assets/new-ui/address_sources/icon-zcash-me.svg', + alias: 'zcash.me/username'), + zcashName( + label: 'Zcash Names', + iconPath: 'assets/new-ui/address_sources/icon-zcashnames.svg', + alias: 'name.zec'), + lnurlPay( + label: 'LNURL Pay', + iconPath: 'assets/new-ui/address_sources/icon-lnurl-pay.svg', + alias: 'user@domain.com'), + contact(label: 'Contact', iconPath: ''), + notParsed(label: 'Unknown', iconPath: ''), + ; + + const AddressSource({ + required this.label, + required this.iconPath, + this.alias = '', + }); + + final String label; + final String iconPath; + final String alias; +} + +extension AddressSourceIndex on AddressSource { + int get raw => index; + + static AddressSource fromRaw(int raw) => + AddressSource.values[raw.clamp(0, AddressSource.values.length - 1)]; +} + +extension AddressSourceNameParser on AddressSource { + static AddressSource fromLabel(String? text) { + if (text == null || text.trim().isEmpty) { + return AddressSource.notParsed; + } + final needle = text.trim().toLowerCase(); + return AddressSource.values.firstWhere( + (src) => src.label.toLowerCase() == needle, + orElse: () => AddressSource.notParsed, + ); + } +} diff --git a/lib/core/address_resolver/bip_353/bip_353_address_provider.dart b/lib/core/address_resolver/bip_353/bip_353_address_provider.dart new file mode 100644 index 000000000..538025cdc --- /dev/null +++ b/lib/core/address_resolver/bip_353/bip_353_address_provider.dart @@ -0,0 +1,70 @@ +import 'package:hash_wallet/core/address_resolver/address_lookup_provider.dart'; +import 'package:hash_wallet/core/address_resolver/address_sources.dart'; +import 'package:hash_wallet/core/address_resolver/bip_353/bip_353_record.dart'; +import 'package:hash_wallet/core/address_resolver/parsed_address.dart'; +import 'package:hash_wallet/store/settings_store.dart'; +import 'package:cw_core/crypto_currency.dart'; +import 'package:cw_core/utils/print_verbose.dart'; +import 'package:cw_core/wallet_base.dart'; + +class Bip353AddressProvider extends AddressLookupProvider { + @override + AddressSource get source => AddressSource.bip353; + + @override + List get supportedCurrencies => [CryptoCurrency.btc]; + + @override + bool isEnabled(SettingsStore settingsStore) => settingsStore.lookupsBip353; + + @override + bool canHandle(String query) { + final value = query.trim(); + return value.contains('@') && value.contains('.') && !value.startsWith('@'); + } + + @override + Future> resolve({ + required String query, + required List currencies, + required WalletBase wallet, + }) async { + try { + final result = {}; + String? dnsProof; + + for (final cur in currencies) { + final bip353AddressMap = await Bip353Record.fetchUriByCryptoCurrency(query, cur.title); + if (bip353AddressMap == null || bip353AddressMap.isEmpty) continue; + + final spAddress = bip353AddressMap['sp']; + final address = bip353AddressMap['address']; + final chosenAddress = spAddress?.isNotEmpty == true ? spAddress : address; + + if (chosenAddress != null && chosenAddress.isNotEmpty) { + result[cur] = chosenAddress; + } + } + + if (result.isEmpty) return []; + + try { + dnsProof = await Bip353Record.fetchDnsProof(query); + } catch (e) { + printV('Bip353Record.fetchDnsProof error: $e'); + } + + return [ + ParsedAddress( + parsedAddressByCurrencyMap: result, + addressSource: AddressSource.bip353, + handle: query, + bip353DnsProof: dnsProof ?? '', + ) + ]; + } catch (e) { + printV('[address resolver] Error resolving BIP353 address: $e'); + return []; + } + } +} diff --git a/lib/entities/bip_353_record.dart b/lib/core/address_resolver/bip_353/bip_353_record.dart similarity index 59% rename from lib/entities/bip_353_record.dart rename to lib/core/address_resolver/bip_353/bip_353_record.dart index aa4a51251..53c1a54f4 100644 --- a/lib/entities/bip_353_record.dart +++ b/lib/core/address_resolver/bip_353/bip_353_record.dart @@ -2,13 +2,9 @@ import 'dart:convert'; import 'dart:isolate'; import 'package:basic_utils/basic_utils.dart'; -import 'package:hash_wallet/generated/i18n.dart'; -import 'package:hash_wallet/src/widgets/alert_with_picker_option.dart'; -import 'package:hash_wallet/utils/show_pop_up.dart'; import 'package:cw_core/crypto_currency.dart'; import 'package:cw_core/utils/print_verbose.dart'; import 'package:dnssec_proof/dnssec_proof.dart'; -import 'package:flutter/material.dart'; class Bip353Record { Bip353Record({ @@ -103,69 +99,4 @@ class Bip353Record { return result; } } - - static Future pickBip353AddressChoice( - BuildContext context, - String bip353Name, - Map addressMap, - ) async { - if (addressMap.length == 1) { - return addressMap.values.first; - } - - final chosenAddress = await _showAddressChoiceDialog(context, bip353Name, addressMap); - - return chosenAddress; - } - - static Future _showAddressChoiceDialog( - BuildContext context, - String bip353Name, - Map addressMap, - ) async { - final entriesList = addressMap.entries.toList(); - final List> displayItems = entriesList.map((entry) { - final originalKey = entry.key; - final originalValue = entry.value; - - final extendedKeyName = keyDisplayMap[originalKey] ?? originalKey; - final truncatedValue = _truncate(originalValue, front: 6, back: 6); - - return { - 'displayKey': extendedKeyName, - 'displayValue': truncatedValue, - 'originalKey': originalKey, - 'originalValue': originalValue, - }; - }).toList(); - - String? selectedOriginalValue; - - if (context.mounted) { - await showPopUp( - context: context, - builder: (dialogContext) { - return AlertWithPickerOption( - alertTitle: S.of(context).multiple_addresses_detected + '\n$bip353Name', - alertTitleTextSize: 14, - alertSubtitle: S.of(context).please_choose_one + ':', - options: displayItems, - onOptionSelected: (Map chosenItem) { - selectedOriginalValue = chosenItem['originalValue']; - }, - alertBarrierDismissible: true, - ); - }, - ); - } - return selectedOriginalValue; - } - - static String _truncate(String value, {int front = 6, int back = 6}) { - if (value.length <= front + back) return value; - - final start = value.substring(0, front); - final end = value.substring(value.length - back); - return '$start...$end'; - } } diff --git a/lib/core/address_resolver/ens/ens_address_provider.dart b/lib/core/address_resolver/ens/ens_address_provider.dart new file mode 100644 index 000000000..2bebf866b --- /dev/null +++ b/lib/core/address_resolver/ens/ens_address_provider.dart @@ -0,0 +1,48 @@ +import 'package:hash_wallet/core/address_resolver/address_lookup_provider.dart'; +import 'package:hash_wallet/core/address_resolver/address_sources.dart'; +import 'package:hash_wallet/core/address_resolver/ens/ens_record.dart'; +import 'package:hash_wallet/core/address_resolver/parsed_address.dart'; +import 'package:hash_wallet/store/settings_store.dart'; +import 'package:cw_core/crypto_currency.dart'; +import 'package:cw_core/wallet_base.dart'; + +class EnsAddressProvider extends AddressLookupProvider { + @override + AddressSource get source => AddressSource.ens; + + @override + List get supportedCurrencies => [CryptoCurrency.eth]; + + @override + bool isEnabled(SettingsStore settingsStore) => settingsStore.lookupsENS; + + @override + bool canHandle(String query) => query.endsWith('.eth'); // ENS handle example: name.eth + + @override + Future> resolve({ + required String query, + required List currencies, + required WalletBase wallet, + }) async { + final Map result = {}; + + for (final cur in currencies) { + final address = await EnsRecord.fetchEnsAddress(query, cur, wallet: wallet); + if (address.isNotEmpty && address != "0x0000000000000000000000000000000000000000") { + result[cur] = address; + } + } + + if (result.isNotEmpty) { + return [ + ParsedAddress( + parsedAddressByCurrencyMap: result, + addressSource: AddressSource.ens, + handle: query, + ) + ]; + } + return []; + } +} diff --git a/lib/core/address_resolver/ens/ens_record.dart b/lib/core/address_resolver/ens/ens_record.dart new file mode 100644 index 000000000..c63d9b34c --- /dev/null +++ b/lib/core/address_resolver/ens/ens_record.dart @@ -0,0 +1,68 @@ +import "package:hash_wallet/evm/evm.dart"; +import "package:cw_core/crypto_currency.dart"; +import "package:cw_core/utils/print_verbose.dart"; +import "package:cw_core/utils/proxy_wrapper.dart"; +import "package:cw_core/wallet_base.dart"; +import "package:cw_core/wallet_type.dart"; +import "package:ens_dart/ens_dart.dart"; +import "package:web3dart/web3dart.dart"; + +class EnsRecord { + static Future fetchEnsAddress( + String name, + CryptoCurrency cur, { + WalletBase? wallet, + }) async { + Web3Client? _client; + + if (wallet?.type == WalletType.ethereum || wallet?.type == WalletType.polygon) { + _client = evm!.getWeb3Client(wallet!); + } + + _client ??= Web3Client( + "https://ethereum-rpc.publicnode.com", + ProxyWrapper().getHttpIOClient(), + ); + + final registryEns = Ens(client: _client).withName(name); + final coinType = getEnsCoinType(cur); + + if (coinType == null) { + printV("Unsupported currency for ENS: $cur"); + return ""; + } + + try { + // Check if the ENS name has its resolver set. If not, return an empty string. + final resolverAddress = await registryEns.getResolverAddress(registryEns.nodeHash); + + if (isZeroAddress(resolverAddress)) { + printV("No resolver set for ENS name: $name"); + return ""; + } + + final resolverEns = Ens(client: _client,address: resolverAddress).withName(name); + + if (coinType == CoinType.ETH) { + return (await resolverEns.getAddress()).hex; + } else { + return await resolverEns.getCoinAddress(coinType); + } + } catch (e) { + printV(e); + return ""; + } + } + + static bool isZeroAddress(EthereumAddress address) => + address.addressBytes.every((byte) => byte == 0); + + static CoinType? getEnsCoinType(CryptoCurrency cur) => switch (cur) { + CryptoCurrency.xmr => CoinType.XMR, + CryptoCurrency.btc => CoinType.BTC, + CryptoCurrency.ltc => CoinType.LTC, + CryptoCurrency.eth => CoinType.ETH, + CryptoCurrency.maticpoly => CoinType.MATIC, + _ => null, + }; +} diff --git a/lib/core/address_resolver/fio/fio_address_provider.dart b/lib/core/address_resolver/fio/fio_address_provider.dart new file mode 100644 index 000000000..bb3269054 --- /dev/null +++ b/lib/core/address_resolver/fio/fio_address_provider.dart @@ -0,0 +1,125 @@ +import 'dart:convert'; + +import 'package:hash_wallet/core/address_resolver/address_lookup_provider.dart'; +import 'package:hash_wallet/core/address_resolver/address_sources.dart'; +import 'package:hash_wallet/core/address_resolver/parsed_address.dart'; +import 'package:hash_wallet/core/address_validator.dart'; +import 'package:hash_wallet/store/settings_store.dart'; +import 'package:cw_core/crypto_currency.dart'; +import 'package:cw_core/utils/print_verbose.dart'; +import 'package:cw_core/utils/proxy_wrapper.dart'; +import 'package:cw_core/wallet_base.dart'; + +class FioAddressProvider extends AddressLookupProvider { + @override + AddressSource get source => AddressSource.fio; + + @override + List get supportedCurrencies => AddressValidator.reliableValidateCurrencies; + + @override + bool canHandle(String q) => + !q.startsWith('@') && + q.contains('@') && + !q.contains('.'); // FIO handle example: username@domain + + @override + bool isEnabled(SettingsStore settingsStore) => settingsStore.lookupsFio; + + @override + Future> resolve({ + required String query, + required List currencies, + required WalletBase wallet, + }) async { + final Map result = {}; + try { + final bool isFioRegistered = await FioAddressProvider.checkAvail(query); + if (!isFioRegistered) return []; + + for (final cur in currencies) { + final address = await FioAddressProvider.getPubAddress(query, cur.title); + if (address != null && address.isNotEmpty) { + result[cur] = address; + } + } + + if (result.isNotEmpty) { + return [ + ParsedAddress( + parsedAddressByCurrencyMap: result, addressSource: AddressSource.fio, handle: query) + ]; + } + return []; + } catch (e) { + printV('FioAddressProvider.resolve error: $e'); + return []; + } + } + + static const _apiAuthority = 'fio.blockpane.com'; + static const _availCheck = '/v1/chain/avail_check'; + static const _getAddress = '/v1/chain/get_pub_address'; + + static Future checkAvail(String fioAddress) async { + bool isFioRegistered = false; + final headers = {'Content-Type': 'application/json'}; + final body = {"fio_name": fioAddress}; + + final uri = Uri.https(_apiAuthority, _availCheck); + final response = await ProxyWrapper().post( + clearnetUri: uri, + headers: headers, + body: json.encode(body), + ); + + if (response.statusCode != 200) { + return isFioRegistered; + } + + final responseJSON = json.decode(response.body) as Map; + isFioRegistered = responseJSON['is_registered'] as int == 1; + + return isFioRegistered; + } + + static Future getPubAddress(String fioAddress, String token) async { + final headers = {'Content-Type': 'application/json'}; + final body = { + "fio_address": fioAddress, + "chain_code": token.toUpperCase(), + "token_code": token.toUpperCase(), + }; + + final uri = Uri.https(_apiAuthority, _getAddress); + final response = await ProxyWrapper().post( + clearnetUri: uri, + headers: headers, + body: json.encode(body), + ); + + final responseJSON = json.decode(response.body) as Map; + + if (response.statusCode == 400) { + final error = responseJSON['error'] as String; + final message = responseJSON['message'] as String; + printV('${error}\n$message'); + return null; + } + + if (response.statusCode != 200) { + final String message = responseJSON['message'] as String? ?? 'Unknown error'; + + printV('Error fetching public address for token $token: $message'); + return null; + } + + final String pubAddress = responseJSON['public_address'] as String? ?? ''; + + if (pubAddress.isNotEmpty) { + return pubAddress; + } + + return null; + } +} diff --git a/lib/core/address_resolver/lnurl_pay/lnurl_pay_address_provider.dart b/lib/core/address_resolver/lnurl_pay/lnurl_pay_address_provider.dart new file mode 100644 index 000000000..ddb6a9a7a --- /dev/null +++ b/lib/core/address_resolver/lnurl_pay/lnurl_pay_address_provider.dart @@ -0,0 +1,67 @@ +import 'package:hash_wallet/core/address_resolver/address_lookup_provider.dart'; +import 'package:hash_wallet/core/address_resolver/address_sources.dart'; +import 'package:hash_wallet/core/address_resolver/lnurl_pay/lnurlpay_record.dart'; +import 'package:hash_wallet/core/address_resolver/parsed_address.dart'; +import 'package:hash_wallet/store/settings_store.dart'; +import 'package:cw_core/crypto_currency.dart'; +import 'package:cw_core/utils/print_verbose.dart'; +import 'package:cw_core/wallet_base.dart'; +import 'package:cw_core/wallet_type.dart'; + +class LNUrlPayAddressProvider extends AddressLookupProvider { + @override + AddressSource get source => AddressSource.lnurlPay; + + @override + List get supportedCurrencies => [CryptoCurrency.btcln]; + + @override + bool canHandle(String q) => + q.contains('.') && q.contains('@'); // LNURL-pay handle example: user@domain.com + + @override + bool isEnabled(SettingsStore settingsStore) => settingsStore.lookupsLNUrl; + + @override + Future> resolve({ + required String query, + required List currencies, + required WalletBase wallet, + }) async { + try { + if (wallet.type != WalletType.bitcoin) return []; + + final formattedName = query.trim(); + if (formattedName.isEmpty) return []; + + final result = {}; + + for (final currency in currencies) { + if (!supportedCurrencies.contains(currency)) continue; + + final record = await LNUrlPayRecord.fetchAddressAndName( + formattedName: formattedName, + currency: currency, + ); + + if (record != null && record.address.isNotEmpty) { + result[currency] = record.address; + } + } + + if (result.isEmpty) return []; + + return [ + ParsedAddress( + parsedAddressByCurrencyMap: result, + addressSource: AddressSource.lnurlPay, + handle: formattedName, + profileName: formattedName, + ), + ]; + } catch (e) { + printV('[address resolver] Error resolving LNURL-pay address: $e'); + return []; + } + } +} diff --git a/lib/entities/lnurlpay_record.dart b/lib/core/address_resolver/lnurl_pay/lnurlpay_record.dart similarity index 100% rename from lib/entities/lnurlpay_record.dart rename to lib/core/address_resolver/lnurl_pay/lnurlpay_record.dart diff --git a/lib/core/address_resolver/mastodon/mastodon_address_provider.dart b/lib/core/address_resolver/mastodon/mastodon_address_provider.dart new file mode 100644 index 000000000..6682c91a2 --- /dev/null +++ b/lib/core/address_resolver/mastodon/mastodon_address_provider.dart @@ -0,0 +1,93 @@ +import 'package:hash_wallet/core/address_resolver/address_lookup_provider.dart'; +import 'package:hash_wallet/core/address_resolver/address_resolver_utils.dart'; +import 'package:hash_wallet/core/address_resolver/address_sources.dart'; +import 'package:hash_wallet/core/address_resolver/mastodon/mastodon_api.dart'; +import 'package:hash_wallet/core/address_resolver/parsed_address.dart'; +import 'package:hash_wallet/core/address_validator.dart'; +import 'package:hash_wallet/store/settings_store.dart'; +import 'package:cw_core/crypto_currency.dart'; +import 'package:cw_core/utils/print_verbose.dart'; +import 'package:cw_core/wallet_base.dart'; + +class MastodonAddressProvider extends AddressLookupProvider { + @override + AddressSource get source => AddressSource.mastodon; + + @override + List get supportedCurrencies => AddressValidator.reliableValidateCurrencies; + + @override + bool isEnabled(SettingsStore settingsStore) => settingsStore.lookupsMastodon; + + @override + bool canHandle(String query) => + query.startsWith('@') && + query.contains('@', 1) && + query.contains('.', 1); // Mastodon handle example: @username@hostname.xxx + + @override + Future> resolve({ + required String query, + required List currencies, + required WalletBase wallet, + }) async { + try { + final subText = query.substring(1); // Remove '@' from the beginning of the handle + final hostNameIndex = subText.indexOf('@'); + if (hostNameIndex <= 0 || hostNameIndex == subText.length - 1) return []; + + final userName = subText.substring(0, hostNameIndex); + final hostName = subText.substring(hostNameIndex + 1); + + final result = {}; + + final mastodonUser = await MastodonAPI.lookupUserByUserName( + userName: userName, + apiHost: hostName, + ); + if (mastodonUser == null) return []; + + final bio = AddressResolverUtils.stripHtmlTags(mastodonUser.note); + result.addAll( + AddressResolverUtils.extractAddressesFromText( + raw: bio, + currencies: currencies, + ), + ); + + final pinnedPosts = await MastodonAPI.getPinnedPosts( + userId: mastodonUser.id, + apiHost: hostName, + ); + + if (pinnedPosts.isNotEmpty) { + final pinnedPostsText = pinnedPosts + .map((item) => AddressResolverUtils.stripHtmlTags(item.content)) + .where((text) => text.isNotEmpty) + .join('\n'); + + result.addAll( + AddressResolverUtils.extractAddressesFromText( + raw: pinnedPostsText, + currencies: currencies, + ), + ); + } + + if (result.isEmpty) return []; + + return [ + ParsedAddress( + parsedAddressByCurrencyMap: result, + addressSource: AddressSource.mastodon, + handle: query, + profileImageUrl: mastodonUser.profileImageUrl, + profileName: mastodonUser.username, + ), + ]; + } catch (e) { + printV('[address resolver] Error resolving Mastodon address: $e'); + return []; + } + } +} diff --git a/lib/core/address_resolver/mastodon/mastodon_api.dart b/lib/core/address_resolver/mastodon/mastodon_api.dart new file mode 100644 index 000000000..a17048e6c --- /dev/null +++ b/lib/core/address_resolver/mastodon/mastodon_api.dart @@ -0,0 +1,64 @@ +import 'dart:convert'; +import 'package:hash_wallet/core/address_resolver/mastodon/mastodon_user.dart'; +import 'package:cw_core/utils/proxy_wrapper.dart'; +import 'package:cw_core/utils/print_verbose.dart'; + +class MastodonAPI { + static const httpsScheme = 'https'; + static const userPath = '/api/v1/accounts/lookup'; + static const statusesPath = '/api/v1/accounts/:id/statuses'; + + static Future lookupUserByUserName( + {required String userName, required String apiHost}) async { + try { + final queryParams = {'acct': userName}; + + final uri = Uri( + scheme: httpsScheme, + host: apiHost, + path: userPath, + queryParameters: queryParams, + ); + + final response = await ProxyWrapper().get(clearnetUri: uri); + + if (response.statusCode != 200) return null; + + final Map responseJSON = json.decode(response.body) as Map; + + return MastodonUser.fromJson(responseJSON); + } catch (e) { + printV('Error in lookupUserByUserName: $e'); + return null; + } + } + + static Future> getPinnedPosts({ + required String userId, + required String apiHost, + }) async { + try { + final queryParams = {'pinned': 'true'}; + + final uri = Uri( + scheme: httpsScheme, + host: apiHost, + path: statusesPath.replaceAll(':id', userId), + queryParameters: queryParams, + ); + + final response = await ProxyWrapper().get(clearnetUri: uri); + + if (response.statusCode != 200) { + throw Exception('Unexpected HTTP status: ${response.statusCode}'); + } + + final List responseJSON = json.decode(response.body) as List; + + return responseJSON.map((json) => PinnedPost.fromJson(json as Map)).toList(); + } catch (e) { + printV('Error in getPinnedPosts: $e'); + throw e; + } + } +} diff --git a/lib/core/address_resolver/mastodon/mastodon_user.dart b/lib/core/address_resolver/mastodon/mastodon_user.dart new file mode 100644 index 000000000..9b2cb1d70 --- /dev/null +++ b/lib/core/address_resolver/mastodon/mastodon_user.dart @@ -0,0 +1,38 @@ +class MastodonUser { + String id; + String username; + String profileImageUrl; + String acct; + String note; + + MastodonUser({ + required this.id, + required this.username, + required this.profileImageUrl, + required this.acct, + required this.note, + }); + + factory MastodonUser.fromJson(Map json) { + return MastodonUser( + id: json['id'] as String, + username: json['username'] as String? ?? '', + acct: json['acct'] as String, + note: json['note'] as String, + profileImageUrl: json['avatar'] as String? ?? ''); + } +} + +class PinnedPost { + final String id; + final String content; + + PinnedPost({required this.id, required this.content}); + + factory PinnedPost.fromJson(Map json) { + return PinnedPost( + id: json['id'] as String, + content: json['content'] as String, + ); + } +} diff --git a/lib/core/address_resolver/nostr/nostr_address_provider.dart b/lib/core/address_resolver/nostr/nostr_address_provider.dart new file mode 100644 index 000000000..028161f9a --- /dev/null +++ b/lib/core/address_resolver/nostr/nostr_address_provider.dart @@ -0,0 +1,66 @@ +import 'package:hash_wallet/core/address_resolver/address_lookup_provider.dart'; +import 'package:hash_wallet/core/address_resolver/address_resolver_utils.dart'; +import 'package:hash_wallet/core/address_resolver/address_sources.dart'; +import 'package:hash_wallet/core/address_resolver/nostr/nostr_api.dart'; +import 'package:hash_wallet/core/address_resolver/parsed_address.dart'; +import 'package:hash_wallet/core/address_validator.dart'; +import 'package:hash_wallet/store/settings_store.dart'; +import 'package:cw_core/crypto_currency.dart'; +import 'package:cw_core/utils/print_verbose.dart'; +import 'package:cw_core/wallet_base.dart'; + +class NostrAddressProvider extends AddressLookupProvider { + @override + AddressSource get source => AddressSource.nostr; + + @override + List get supportedCurrencies => AddressValidator.reliableValidateCurrencies; + + @override + bool canHandle(String q) => + AddressResolverUtils.isEmailFormat(q); // Nostr handle example: username@domain + + @override + bool isEnabled(SettingsStore settingsStore) => settingsStore.lookupsNostr; + + @override + Future> resolve({ + required String query, + required List currencies, + required WalletBase wallet, + }) async { + try { + final profile = await NostrProfileHandler.queryProfile(query); + if (profile == null) return []; + + final data = await NostrProfileHandler.processRelays(profile, query); + if (data == null) return []; + + final result = {}; + + String queryTxt = data.about; + + for (final cur in currencies) { + final addr = AddressResolverUtils.extractAddressByType(raw: queryTxt, type: cur); + if (addr != null && addr.isNotEmpty) { + result[cur] = addr; + queryTxt = queryTxt.replaceFirst(addr, ''); + } + } + if (result.isEmpty) return []; + + return [ + ParsedAddress( + parsedAddressByCurrencyMap: result, + addressSource: AddressSource.nostr, + handle: query, + profileImageUrl: data.picture, + profileName: data.name, + ) + ]; + } catch (e) { + printV('Error looking up Nostr profile: $e'); + return []; + } + } +} diff --git a/lib/core/address_resolver/nostr/nostr_api.dart b/lib/core/address_resolver/nostr/nostr_api.dart new file mode 100644 index 000000000..cb64e30a1 --- /dev/null +++ b/lib/core/address_resolver/nostr/nostr_api.dart @@ -0,0 +1,127 @@ +import 'dart:async'; +import 'dart:convert'; + +import 'package:hash_wallet/core/address_resolver/nostr/nostr_user.dart'; +import 'package:nostr_tools/nostr_tools.dart'; +import 'dart:async' show Completer, TimeoutException, runZonedGuarded; + +class NostrProfileHandler { + static final relayToDomainMap = { + 'relay.snort.social': 'snort.social', + }; + + static final Nip05 _nip05 = Nip05(); + + static Future queryProfile(String nip05Address) async { + final profile = await _nip05.queryProfile(nip05Address); + if (profile?.pubkey != null && profile?.relays?.isNotEmpty == true) { + return profile; + } + return null; + } + + static Future processRelays( + ProfilePointer profile, + String nip05Address, + ) async { + final userDomain = _extractDomain(nip05Address); + const int metaKind = 0; + + // Domain-matched relays first + for (final String relayUrl in profile.relays ?? []) { + final relayDomain = + relayToDomainMap[_getDomainFromRelayUrl(relayUrl)] ?? _getDomainFromRelayUrl(relayUrl); + + if (relayDomain == userDomain) { + final data = await _fetchInfoFromRelay(relayUrl, profile.pubkey, [metaKind]); + if (data != null) return data; + } + } + + // Then try every remaining relay + for (final String relayUrl in profile.relays ?? []) { + final data = await _fetchInfoFromRelay(relayUrl, profile.pubkey, [metaKind]); + if (data != null) return data; + } + + // Nothing found + return null; + } + + static const Duration _relayTimeout = Duration(seconds: 3); + + static Future _fetchInfoFromRelay( + String relayUrl, String userPubKey, List kinds) async { + // sanitize so obvious junk (like '#') doesn't reach connect() + final clean = _sanitizeRelay(relayUrl); + if (clean.isEmpty) return null; + + final result = Completer(); + + runZonedGuarded(() async { + try { + final relay = RelayApi(relayUrl: clean); + + final stream = await relay.connect().timeout( + _relayTimeout, + onTimeout: () { + relay.close(); + throw TimeoutException('Relay connect timeout'); + }, + ); + + relay.sub([ + Filter(kinds: kinds, authors: [userPubKey]) + ]); + + final sub = stream.listen((msg) { + if (msg.type == 'EVENT' && !result.isCompleted) { + try { + final event = msg.message as Event; + final jsonMap = json.decode(event.content) as Map; + result.complete(UserMetadata.fromJson(jsonMap)); + } catch (_) { + if (!result.isCompleted) result.complete(null); + } + } + }, onError: (_) { + if (!result.isCompleted) result.complete(null); + }, onDone: () { + if (!result.isCompleted) result.complete(null); + }); + + final value = await result.future.timeout(_relayTimeout, onTimeout: () => null); + await sub.cancel(); + relay.close(); + if (!result.isCompleted) result.complete(value); + } catch (_) { + if (!result.isCompleted) result.complete(null); + } + }, (error, stack) { + // swallow ALL async errors from the websocket layer (including "was not upgraded to websocket") + if (!result.isCompleted) result.complete(null); + }); + + return result.future; + } + + static String _sanitizeRelay(String url) { + url = url.replaceFirst(RegExp(r'^https?://'), 'wss://'); + final uri = Uri.parse(url); + return Uri( + scheme: uri.scheme.isEmpty ? 'wss' : uri.scheme, + host: uri.host, + port: uri.hasPort ? uri.port : 443, + ).toString(); + } + + static String _extractDomain(String nip05) => nip05.split('@').last; + + static String _getDomainFromRelayUrl(String url) { + try { + return Uri.parse(url).host; + } catch (_) { + return ''; + } + } +} diff --git a/lib/core/address_resolver/nostr/nostr_user.dart b/lib/core/address_resolver/nostr/nostr_user.dart new file mode 100644 index 000000000..b90e82792 --- /dev/null +++ b/lib/core/address_resolver/nostr/nostr_user.dart @@ -0,0 +1,34 @@ +class UserMetadata { + final String name; + final String lnurl; + final String email; + final String picture; + final String about; + final String nip05; + final String banner; + final String website; + + UserMetadata({ + required this.name, + required this.lnurl, + required this.email, + required this.picture, + required this.about, + required this.nip05, + required this.banner, + required this.website, + }); + + factory UserMetadata.fromJson(Map json) { + return UserMetadata( + name: json['name'] as String? ?? '', + lnurl: json['lud06'] as String? ?? '', + email: json['lud16'] as String? ?? '', + picture: json['picture'] as String? ?? '', + about: json['about'] as String? ?? '', + nip05: json['nip05'] as String? ?? '', + banner: json['banner'] as String? ?? '', + website: json['website'] as String? ?? '', + ); + } +} diff --git a/lib/core/address_resolver/openalias/openalias_address_provider.dart b/lib/core/address_resolver/openalias/openalias_address_provider.dart new file mode 100644 index 000000000..83f4f79da --- /dev/null +++ b/lib/core/address_resolver/openalias/openalias_address_provider.dart @@ -0,0 +1,64 @@ +import 'package:hash_wallet/core/address_resolver/address_lookup_provider.dart'; +import 'package:hash_wallet/core/address_resolver/address_sources.dart'; +import 'package:hash_wallet/core/address_resolver/openalias/openalias_record.dart'; +import 'package:hash_wallet/core/address_resolver/parsed_address.dart'; +import 'package:hash_wallet/store/settings_store.dart'; +import 'package:cw_core/crypto_currency.dart'; +import 'package:cw_core/utils/print_verbose.dart'; +import 'package:cw_core/wallet_base.dart'; + +class OpenaliasAddressProvider extends AddressLookupProvider { + @override + AddressSource get source => AddressSource.openAlias; + + @override + List get supportedCurrencies => [CryptoCurrency.xmr, CryptoCurrency.btc]; + + @override + bool canHandle(String q) { + final formattedName = OpenaliasRecord.formatDomainName(q); + return formattedName.contains("."); + } + + @override + bool isEnabled(SettingsStore settingsStore) => settingsStore.lookupsOpenAlias; + + @override + Future> resolve({ + required String query, + required List currencies, + required WalletBase wallet, + }) async { + try { + final formatted = OpenaliasRecord.formatDomainName(query); + + final txtRecords = await OpenaliasRecord.lookupOpenAliasRecord(formatted); + if (txtRecords == null) return []; + + final result = {}; + + for (final cur in currencies) { + final rec = OpenaliasRecord.fetchAddressAndName( + formattedName: formatted, + ticker: cur.title.toLowerCase(), + txtRecord: txtRecords, + ); + + if (rec.address.isNotEmpty) result[cur] = rec.address; + } + + if (result.isEmpty) return []; + + return [ + ParsedAddress( + parsedAddressByCurrencyMap: result, + addressSource: AddressSource.openAlias, + handle: query, + ) + ]; + } catch (e) { + printV('[address resolver] Error resolving OpenAlias address: $e'); + return []; + } + } +} diff --git a/lib/core/address_resolver/openalias/openalias_record.dart b/lib/core/address_resolver/openalias/openalias_record.dart new file mode 100644 index 000000000..4fe4b6a89 --- /dev/null +++ b/lib/core/address_resolver/openalias/openalias_record.dart @@ -0,0 +1,69 @@ +import 'package:basic_utils/basic_utils.dart'; +import 'package:cw_core/utils/print_verbose.dart'; + +class OpenaliasRecord { + OpenaliasRecord({ + required this.address, + required this.name, + required this.description, + }); + + final String name; + final String address; + final String description; + + static String formatDomainName(String name) { + String formattedName = name; + + if (name.contains("@")) { + formattedName = name.replaceAll("@", "."); + } + + return formattedName; + } + + static Future?> lookupOpenAliasRecord(String name) async { + try { + final txtRecord = await DnsUtils.lookupRecord(name, RRecordType.TXT, dnssec: true); + + return txtRecord; + } catch (e) { + printV("${e.toString()}"); + return null; + } + } + + static OpenaliasRecord fetchAddressAndName({ + required String formattedName, + required String ticker, + required List txtRecord, + }) { + var address = ''; + var name = formattedName; + var note = ''; + + final addrRe = RegExp(r'recipient_address=([^;\s]+)', caseSensitive: false); + final nameRe = RegExp(r'recipient_name=([^;]+)', caseSensitive: false); + final noteRe = RegExp(r'tx_description=([^;]+)', caseSensitive: false); + final tag = 'oa1:$ticker'; + + for (final rr in txtRecord) { + final txt = rr.data.replaceAll('"', ''); + + if (!txt.toLowerCase().contains(tag)) continue; + + final addrM = addrRe.firstMatch(txt); + if (addrM != null) address = addrM.group(1)!; + + final nameM = nameRe.firstMatch(txt); + if (nameM != null) name = nameM.group(1)!.trim(); + + final noteM = noteRe.firstMatch(txt); + if (noteM != null) note = noteM.group(1)!.trim(); + + break; + } + + return OpenaliasRecord(address: address, name: name, description: note); + } +} diff --git a/lib/core/address_resolver/parsed_address.dart b/lib/core/address_resolver/parsed_address.dart new file mode 100644 index 000000000..d34e9c69b --- /dev/null +++ b/lib/core/address_resolver/parsed_address.dart @@ -0,0 +1,22 @@ +import 'package:hash_wallet/core/address_resolver/address_sources.dart'; +import 'package:cw_core/crypto_currency.dart'; + +class ParsedAddress { + const ParsedAddress({ + required this.parsedAddressByCurrencyMap, + this.addressSource = AddressSource.notParsed, + this.handle = '', + this.profileImageUrl = '', + this.profileName = '', + this.description = '', + this.bip353DnsProof, + }); + + final Map parsedAddressByCurrencyMap; + final AddressSource addressSource; + final String handle; + final String profileImageUrl; + final String profileName; + final String description; + final String? bip353DnsProof; +} diff --git a/lib/core/address_resolver/twitter/twitter_address_provider.dart b/lib/core/address_resolver/twitter/twitter_address_provider.dart new file mode 100644 index 000000000..d47cda06d --- /dev/null +++ b/lib/core/address_resolver/twitter/twitter_address_provider.dart @@ -0,0 +1,96 @@ +import 'package:hash_wallet/core/address_resolver/address_lookup_provider.dart'; +import 'package:hash_wallet/core/address_resolver/address_resolver_utils.dart'; +import 'package:hash_wallet/core/address_resolver/address_sources.dart'; +import 'package:hash_wallet/core/address_resolver/parsed_address.dart'; +import 'package:hash_wallet/core/address_resolver/twitter/twitter_api.dart'; +import 'package:hash_wallet/core/address_resolver/twitter/twitter_user.dart'; +import 'package:hash_wallet/core/address_validator.dart'; +import 'package:hash_wallet/store/settings_store.dart'; +import 'package:cw_core/crypto_currency.dart'; +import 'package:cw_core/utils/print_verbose.dart'; +import 'package:cw_core/wallet_base.dart'; + +class TwitterAddressProvider extends AddressLookupProvider { + @override + AddressSource get source => AddressSource.twitter; + + @override + List get supportedCurrencies => AddressValidator.reliableValidateCurrencies; + + @override + bool isEnabled(SettingsStore settingsStore) => settingsStore.lookupsTwitter; + + @override + bool canHandle(String query) => + query.startsWith('@') && !query.substring(1).contains('@'); // x handle example: @username + + /// Resolves a Twitter handle to a list of ParsedAddress objects by extracting potential cryptocurrency addresses + /// from the user's, profile. + /// fallback to resolving Unstoppable Domains if no addresses are found in each of the Twitter user's bio, location, and pinned tweet. + + @override + Future> resolve({ + required String query, + required List currencies, + required WalletBase wallet, + }) async { + final formattedName = query.replaceFirst("@", ""); + try { + final result = {}; + + final twitterUser = await TwitterApi.lookupUserByName(userName: formattedName); + + final bio = (text: twitterUser.description, whitespaces: true); + final location = (text: twitterUser.location, whitespaces: false); + final pinnedTweet = (text: twitterUser.pinnedTweet?.text ?? '', whitespaces: true); + + final twitterTexts = [ + bio, + location, + pinnedTweet, + ].where((item) => item.text.isNotEmpty); + + for (final item in twitterTexts) { + result.addAll( + AddressResolverUtils.extractAddressesFromText( + raw: item.text, + currencies: currencies, + requireSurroundingWhitespaces: item.whitespaces, + ), + ); + + if (result.isEmpty) { + final domainResult = await AddressResolverUtils.resolveUnstoppableDomainFromText( + raw: item.text, + currencies: currencies, + ); + + if (domainResult.isNotEmpty) { + return [_buildTwitterParsedAddress(query, twitterUser, domainResult)]; + } + } + } + + if (result.isEmpty) return []; + + return [_buildTwitterParsedAddress(query, twitterUser, result)]; + } catch (e) { + printV('[address resolver service] Twitter error: $e'); + return []; + } + } + + ParsedAddress _buildTwitterParsedAddress( + String query, + TwitterUser twitterUser, + Map result, + ) { + return ParsedAddress( + parsedAddressByCurrencyMap: result, + addressSource: AddressSource.twitter, + handle: query, + profileImageUrl: twitterUser.profileImageUrl, + profileName: twitterUser.name, + ); + } +} diff --git a/lib/core/address_resolver/twitter/twitter_api.dart b/lib/core/address_resolver/twitter/twitter_api.dart new file mode 100644 index 000000000..b0a247141 --- /dev/null +++ b/lib/core/address_resolver/twitter/twitter_api.dart @@ -0,0 +1,67 @@ +import 'dart:convert'; + +import 'package:hash_wallet/.secrets.g.dart' as secrets; +import 'package:hash_wallet/core/address_resolver/twitter/twitter_user.dart'; +import 'package:cw_core/utils/proxy_wrapper.dart'; + +class TwitterApi { + static const twitterBearerToken = secrets.twitterBearerToken; + static const httpsScheme = 'https'; + static const apiHost = 'api.twitter.com'; + static const userPath = '/2/users/by/username/'; + + static Future lookupUserByName({required String userName}) async { + final queryParams = { + 'user.fields': 'description,profile_image_url,location', + 'expansions': 'pinned_tweet_id', + 'tweet.fields': 'note_tweet' + }; + final headers = {'authorization': 'Bearer $twitterBearerToken'}; + final uri = Uri( + scheme: httpsScheme, + host: apiHost, + path: userPath + userName, + queryParameters: queryParams); + + final response = + await ProxyWrapper().get(clearnetUri: uri, headers: headers).catchError((error) { + throw Exception('HTTP request failed: $error'); + }); + + if (response.statusCode != 200) { + throw Exception('Unexpected http status: ${response.statusCode}'); + } + + final Map responseJSON = jsonDecode(response.body) as Map; + if (responseJSON['errors'] != null && + !responseJSON['errors'][0]['detail'] + .toString() + .contains("Could not find tweet with pinned_tweet_id")) { + throw Exception(responseJSON['errors'][0]['detail']); + } + + return TwitterUser.fromJson(responseJSON, _getPinnedTweet(responseJSON)); + } + + static Tweet? _getPinnedTweet(Map responseJSON) { + try { + final tweetId = responseJSON['data']['pinned_tweet_id'] as String?; + if (tweetId == null || responseJSON['includes'] == null) return null; + + final tweetIncludes = List.from(responseJSON['includes']['tweets'] as List); + final pinnedTweetData = tweetIncludes.firstWhere( + (tweet) => tweet['id'] == tweetId, + orElse: () => null, + ) as Map?; + + if (pinnedTweetData == null) return null; + + final pinnedTweetText = + (pinnedTweetData['note_tweet']?['text'] ?? pinnedTweetData['text']) as String; + + return Tweet(id: tweetId, text: pinnedTweetText); + } catch (e) { + return null; + } + } +} diff --git a/lib/core/address_resolver/twitter/twitter_user.dart b/lib/core/address_resolver/twitter/twitter_user.dart new file mode 100644 index 000000000..f124de405 --- /dev/null +++ b/lib/core/address_resolver/twitter/twitter_user.dart @@ -0,0 +1,49 @@ +class TwitterUser { + TwitterUser( + {required this.id, + required this.username, + required this.name, + required this.description, + required this.location, + required this.profileImageUrl, + this.pinnedTweet}); + + final String id; + final String username; + final String name; + final String description; + final String location; + final String profileImageUrl; + final Tweet? pinnedTweet; + + factory TwitterUser.fromJson(Map json, [Tweet? pinnedTweet]) { + final profileImageUrl = json['data']['profile_image_url'] as String? ?? ''; + final scaledProfileImageUrl = profileImageUrl.replaceFirst('normal', '200x200'); + return TwitterUser( + id: json['data']['id'] as String, + username: json['data']['username'] as String? ?? '', + name: json['data']['name'] as String, + description: json['data']['description'] as String? ?? '', + location: json['data']['location'] as String? ?? '', + profileImageUrl: scaledProfileImageUrl, + pinnedTweet: pinnedTweet, + ); + } +} + +class Tweet { + Tweet({ + required this.id, + required this.text, + }); + + final String id; + final String text; + + factory Tweet.fromJson(Map json) { + return Tweet( + id: json['id'] as String, + text: json['text'] as String, + ); + } +} diff --git a/lib/core/address_resolver/unstoppable/unstoppable_address_provider.dart b/lib/core/address_resolver/unstoppable/unstoppable_address_provider.dart new file mode 100644 index 000000000..fbb239e78 --- /dev/null +++ b/lib/core/address_resolver/unstoppable/unstoppable_address_provider.dart @@ -0,0 +1,237 @@ +import 'dart:convert'; + +import 'package:hash_wallet/core/address_resolver/address_lookup_provider.dart'; +import 'package:hash_wallet/core/address_resolver/address_sources.dart'; +import 'package:hash_wallet/core/address_resolver/openalias/openalias_record.dart'; +import 'package:hash_wallet/core/address_resolver/parsed_address.dart'; +import 'package:hash_wallet/store/settings_store.dart'; +import 'package:cw_core/crypto_currency.dart'; +import 'package:cw_core/utils/print_verbose.dart'; +import 'package:cw_core/utils/proxy_wrapper.dart'; +import 'package:cw_core/wallet_base.dart'; + +class UnstoppableAddressProvider extends AddressLookupProvider { + static const unstoppableDomains = [ + "888", + "academy", + "agency", + "altimist", + "anime", + "austin", + "bald", + "bay", + "benji", + "bet", + "binanceus", + "bitcoin", + "bitget", + "bitscrunch", + "blockchain", + "boomer", + "boston", + "ca", + "caw", + "cc", + "chat", + "chomp", + "clay", + "club", + "co", + "com", + "company", + "crypto", + "dao", + "design", + "dfz", + "digital", + "doga", + "donut", + "dream", + "email", + "emir", + "eth", + "ethermail", + "family", + "farms", + "finance", + "fun", + "fyi", + "games", + "global", + "go", + "group", + "guru", + "hi", + "hockey", + "host", + "info", + "io", + "klever", + "kresus", + "kryptic", + "lfg", + "life", + "live", + "llc", + "ltc", + "ltd", + "manga", + "me", + "media", + "metropolis", + "miami", + "miku", + "money", + "moon", + "mumu", + "net", + "network", + "news", + "nft", + "npc", + "onchain", + "online", + "org", + "podcast", + "pog", + "polygon", + "base", + "arbitrum", + "press", + "privacy", + "pro", + "propykeys", + "pudgy", + "pw", + "quantum", + "rad", + "raiin", + "retardio", + "rip", + "rocks", + "secret", + "services", + "site", + "smobler", + "social", + "solutions", + "space", + "stepn", + "store", + "studio", + "systems", + "tball", + "tea", + "team", + "tech", + "technology", + "today", + "tribe", + "u", + "ubu", + "uno", + "unstoppable", + "vip", + "wallet", + "website", + "wif", + "wifi", + "witg", + "work", + "world", + "wrkx", + "wtf", + "x", + "xmr", + "xyz", + "zil", + "zone", + "pizza" + ]; + + @override + AddressSource get source => AddressSource.unstoppableDomains; + + @override + List get supportedCurrencies => [CryptoCurrency.xmr, CryptoCurrency.btc]; + + @override + bool isEnabled(SettingsStore settingsStore) => settingsStore.lookupsUnstoppableDomains; + + @override + bool canHandle(String query) { + // Unstoppable Domains handle example: name.crypto + final formattedName = OpenaliasRecord.formatDomainName(query); + final domainParts = formattedName.split('.'); + final name = domainParts.last; + return domainParts.length > 1 && + domainParts.first.isNotEmpty && + name.isNotEmpty && + unstoppableDomains.any((domain) => name.trim() == domain); + } + + @override + Future> resolve({ + required String query, + required List currencies, + required WalletBase wallet, + }) async { + try { + final formattedName = OpenaliasRecord.formatDomainName(query); + final result = {}; + + for (final currency in currencies) { + final address = await fetchUnstoppableDomainAddress( + formattedName, + currency.title, + ); + + if (address.isNotEmpty) { + result[currency] = address; + } + } + + if (result.isEmpty) return []; + + return [ + ParsedAddress( + parsedAddressByCurrencyMap: result, + profileName: formattedName, + addressSource: AddressSource.unstoppableDomains, + handle: formattedName, + ), + ]; + } catch (e) { + printV('[address resolver] Error resolving Unstoppable Domain: $e'); + return []; + } + } + + static Future fetchUnstoppableDomainAddress(String domain, String ticker) async { + var address = ''; + + try { + final uri = Uri.parse( + "https://api.unstoppabledomains.com/profile/public/${Uri.encodeQueryComponent(domain)}?fields=records"); + final response = await ProxyWrapper().get(clearnetUri: uri); + + final jsonParsed = json.decode(response.body) as Map; + if (jsonParsed["records"] == null) { + throw Exception(".records response from $uri is empty"); + } + ; + final records = jsonParsed["records"] as Map; + final key = "crypto.${ticker.toUpperCase()}.address"; + if (records[key] == null) { + throw Exception(".records.${key} response from $uri is empty"); + } + + return records[key] as String? ?? ''; + } catch (e) { + printV('Unstoppable domain error: ${e.toString()}'); + address = ''; + } + + return address; + } +} diff --git a/lib/core/address_resolver/wellknown/wellknown_address_provider.dart b/lib/core/address_resolver/wellknown/wellknown_address_provider.dart new file mode 100644 index 000000000..c0eecaee8 --- /dev/null +++ b/lib/core/address_resolver/wellknown/wellknown_address_provider.dart @@ -0,0 +1,49 @@ +import 'package:hash_wallet/core/address_resolver/address_lookup_provider.dart'; +import 'package:hash_wallet/core/address_resolver/address_sources.dart'; +import 'package:hash_wallet/core/address_resolver/parsed_address.dart'; +import 'package:hash_wallet/core/address_resolver/wellknown/wellknown_record.dart'; +import 'package:hash_wallet/store/settings_store.dart'; +import 'package:cw_core/crypto_currency.dart'; +import 'package:cw_core/utils/print_verbose.dart'; +import 'package:cw_core/wallet_base.dart'; + +class WellKnownAddressProvider extends AddressLookupProvider { + @override + AddressSource get source => AddressSource.wellKnown; + + @override + List get supportedCurrencies => [CryptoCurrency.nano]; + + @override + bool canHandle(String q) => q.contains('.') && q.contains('@'); + + // .well-known handle example: + + @override + bool isEnabled(SettingsStore settingsStore) => settingsStore.lookupsWellKnown; + + @override + Future> resolve({ + required String query, + required List currencies, + required WalletBase wallet, + }) async { + try { + final rec = await WellKnownRecord.fetch(query, CryptoCurrency.nano); + if (rec == null || rec.address.isEmpty) return []; + + return [ + ParsedAddress( + parsedAddressByCurrencyMap: {CryptoCurrency.nano: rec.address}, + addressSource: AddressSource.wellKnown, + handle: query, + profileName: rec.title ?? '', + profileImageUrl: rec.imageUrl ?? '', + ) + ]; + } catch (e) { + printV('[address resolver] Error resolving Well-Known address: $e'); + return []; + } + } +} diff --git a/lib/core/address_resolver/wellknown/wellknown_record.dart b/lib/core/address_resolver/wellknown/wellknown_record.dart new file mode 100644 index 000000000..2333d2192 --- /dev/null +++ b/lib/core/address_resolver/wellknown/wellknown_record.dart @@ -0,0 +1,78 @@ +import 'dart:convert'; + +import 'package:cw_core/crypto_currency.dart'; +import 'package:cw_core/utils/print_verbose.dart'; +import 'package:cw_core/utils/proxy_wrapper.dart'; + +class WellKnownRecord { + WellKnownRecord({ + required this.address, + required this.alias, + required this.imageUrl, + required this.title, + }); + + final String address; + final String alias; + final String? imageUrl; + final String? title; + + static const _jsonLocation = {CryptoCurrency.nano: 'nano-currency'}; + + static Future fetch( + String username, + CryptoCurrency currency, + ) async { + final location = _jsonLocation[currency]; + if (location == null) { + printV('well-known: unsupported coin $currency'); + return null; + } + + final parts = username.split('@'); + if (parts.length < 2) { + printV('well-known: missing @ in "$username"'); + return null; + } + final alias = (parts.length == 3 ? parts[1] : parts[0]).trim(); + final domain = parts.last.trim(); + final encoded = Uri.encodeComponent(alias.isEmpty ? '_' : alias); + + final uri = Uri.https( + domain, + '/.well-known/$location.json', + {'names': encoded}, + ); + + try { + final res = await ProxyWrapper().get( + clearnetUri: uri, + headers: {'Accept': 'application/json'}, + ); + + final cType = res.headers['content-type']?.toLowerCase() ?? ''; + if (res.statusCode != 200 || !cType.contains('application/json')) { + printV('well-known: $uri → ${res.statusCode} $cType'); + return null; + } + + final body = json.decode(res.body) as Map; + final items = body['names'] as List? ?? const []; + + for (final raw in items.whereType()) { + if (raw['name'].toString().toLowerCase() == alias.toLowerCase()) { + return WellKnownRecord( + address: raw['address']?.toString() ?? '', + alias: alias, + imageUrl: raw['image']?.toString(), + title: raw['name']?.toString(), + ); + } + } + printV('well-known: alias "$alias" not found in list'); + } catch (e) { + printV('well-known: network / JSON error → $e'); + } + return null; + } +} diff --git a/lib/core/address_resolver/zano/zano_alias_address_provider.dart b/lib/core/address_resolver/zano/zano_alias_address_provider.dart new file mode 100644 index 000000000..4303c5059 --- /dev/null +++ b/lib/core/address_resolver/zano/zano_alias_address_provider.dart @@ -0,0 +1,74 @@ +import 'dart:convert'; + +import 'package:hash_wallet/core/address_resolver/address_lookup_provider.dart'; +import 'package:hash_wallet/core/address_resolver/address_sources.dart'; +import 'package:hash_wallet/core/address_resolver/parsed_address.dart'; +import 'package:hash_wallet/store/settings_store.dart'; +import 'package:cw_core/crypto_currency.dart'; +import 'package:cw_core/utils/print_verbose.dart'; +import 'package:cw_core/utils/proxy_wrapper.dart'; +import 'package:cw_core/wallet_base.dart'; + +class ZanoAliasAddressProvider extends AddressLookupProvider { + @override + AddressSource get source => AddressSource.zanoAlias; + + @override + List get supportedCurrencies => [CryptoCurrency.zano]; + + @override + bool isEnabled(SettingsStore settingsStore) => settingsStore.lookupsZanoAlias; + + @override + bool canHandle(String query) => query.startsWith('@'); // zano handle example: @username + + static const _zanoUrl = 'http://37.27.100.59:10500/json_rpc'; + @override + Future> resolve({ + required String query, + required List currencies, + required WalletBase wallet, + }) async { + try { + if (!currencies.contains(CryptoCurrency.zano)) return []; + + final formattedName = query.replaceFirst("@", ""); + if (formattedName.isEmpty) return []; + + final zanoAddress = await fetchZanoAliasAddress(formattedName); + if (zanoAddress == null || zanoAddress.isEmpty) return []; + + return [ + ParsedAddress( + parsedAddressByCurrencyMap: {CryptoCurrency.zano: zanoAddress}, + addressSource: AddressSource.zanoAlias, + handle: query, + ), + ]; + } catch (e) { + printV('[address resolver] Error resolving Zano Alias: $e'); + return []; + } + } + + static Future fetchZanoAliasAddress(String alias) async { + try { + final uri = Uri.parse(_zanoUrl); + final response = await ProxyWrapper().post( + clearnetUri: uri, + body: json.encode({ + 'id': 0, + 'jsonrpc': '2.0', + 'method': 'get_alias_details', + 'params': {'alias': alias}, + }), + ); + + final jsonParsed = json.decode(response.body) as Map; + return jsonParsed['result']?['alias_details']?['address'] as String?; + } catch (e) { + printV('[address resolver] Zano Alias error: $e'); + return null; + } + } +} diff --git a/lib/core/address_resolver/zcash/zcash_address_provider.dart b/lib/core/address_resolver/zcash/zcash_address_provider.dart new file mode 100644 index 000000000..5c982793f --- /dev/null +++ b/lib/core/address_resolver/zcash/zcash_address_provider.dart @@ -0,0 +1,49 @@ +import 'package:hash_wallet/core/address_resolver/address_lookup_provider.dart'; +import 'package:hash_wallet/core/address_resolver/address_sources.dart'; +import 'package:hash_wallet/core/address_resolver/parsed_address.dart'; +import 'package:hash_wallet/core/address_resolver/zcash/zcash_names_record.dart'; +import 'package:hash_wallet/store/settings_store.dart'; +import 'package:cw_core/crypto_currency.dart'; +import 'package:cw_core/utils/print_verbose.dart'; +import 'package:cw_core/wallet_base.dart'; + +class ZcashNameAddressProvider extends AddressLookupProvider { + @override + AddressSource get source => AddressSource.zcashName; + + @override + List get supportedCurrencies => [CryptoCurrency.zec]; + + @override + bool isEnabled(SettingsStore settingsStore) => settingsStore.lookupsZcashNames; + + @override + bool canHandle(String query) { + final lowerText = query.toLowerCase(); + return lowerText.endsWith(".zec") || lowerText.endsWith(".zcash"); + } // Zcash handle example: name.zec + + @override + Future> resolve({ + required String query, + required List currencies, + required WalletBase wallet, + }) async { + try { + final address = await ZcashNamesRecord.fetchZcashNamesAddress(query); + if (address == null || address.isEmpty) return []; + + return [ + ParsedAddress( + parsedAddressByCurrencyMap: {CryptoCurrency.zec: address}, + profileName: query, + addressSource: AddressSource.zcashName, + handle: query, + ), + ]; + } catch (e) { + printV('[address resolver] Error resolving Zcash Name: $e'); + return []; + } + } +} diff --git a/lib/core/address_resolver/zcash/zcash_me_address_provider.dart b/lib/core/address_resolver/zcash/zcash_me_address_provider.dart new file mode 100644 index 000000000..140b77570 --- /dev/null +++ b/lib/core/address_resolver/zcash/zcash_me_address_provider.dart @@ -0,0 +1,74 @@ +import 'package:hash_wallet/core/address_resolver/address_lookup_provider.dart'; +import 'package:hash_wallet/core/address_resolver/address_sources.dart'; +import 'package:hash_wallet/core/address_resolver/parsed_address.dart'; +import 'package:hash_wallet/store/settings_store.dart'; +import 'package:cw_core/crypto_currency.dart'; +import 'package:cw_core/utils/print_verbose.dart'; +import 'package:cw_core/utils/proxy_wrapper.dart'; +import 'package:cw_core/wallet_base.dart'; + +class ZcashMeAddressProvider extends AddressLookupProvider { + @override + AddressSource get source => AddressSource.zcashAddress; + + @override + List get supportedCurrencies => [CryptoCurrency.zec]; + + @override + bool isEnabled(SettingsStore settingsStore) => settingsStore.lookupsZcashAddress; + + @override + bool canHandle(String query) { + final value = query.trim().toLowerCase(); + return value.startsWith('zcash.me/') && value.split('/').last.isNotEmpty; + } // zcash.me profile example: zcash.me/username + + @override + Future> resolve({ + required String query, + required List currencies, + required WalletBase wallet, + }) async { + try { + final handle = query.trim().split('/').last; + if (handle.isEmpty) return []; + + final address = await _fetchZcashAddress(handle); + if (address == null || address.isEmpty) return []; + + return [ + ParsedAddress( + parsedAddressByCurrencyMap: {CryptoCurrency.zec: address}, + profileName: handle, + addressSource: AddressSource.zcashAddress, + handle: query, + ), + ]; + } catch (e) { + printV('[address resolver] Error resolving zcash.me address: $e'); + return []; + } + } + + Future _fetchZcashAddress(String handle) async { + final url = Uri.parse('https://zcash.me/$handle'); + + try { + final response = await ProxyWrapper().get(clearnetUri: url); + + if (response.statusCode == 200) { + final addressRegex = RegExp( + r'(t1[0-9A-Za-z]{33}|t3[0-9A-Za-z]{33}|zs[a-z0-9]{76}|u1[a-z0-9]{1,300})', + caseSensitive: true, + ); + + final match = addressRegex.firstMatch(response.body); + return match?.group(0); + } + } catch (e) { + printV('[address resolver] Error fetching zcash.me profile: $e'); + } + + return null; + } +} diff --git a/lib/core/address_resolver/zcash/zcash_names_record.dart b/lib/core/address_resolver/zcash/zcash_names_record.dart new file mode 100644 index 000000000..cc812122f --- /dev/null +++ b/lib/core/address_resolver/zcash/zcash_names_record.dart @@ -0,0 +1,74 @@ +import 'dart:async'; +import 'dart:convert'; + +import 'package:hash_wallet/core/address_validator.dart'; +import 'package:cw_core/crypto_currency.dart'; +import 'package:cw_core/utils/print_verbose.dart'; +import 'package:cw_core/utils/proxy_wrapper.dart'; + +class ZcashNamesRecord { + static final Uri _mainnetEndpoint = Uri.parse('https://main.zcashnames.com'); + + static final RegExp _nameRegExp = RegExp(r'^[A-Za-z0-9_-]{1,63}$'); + + static Future fetchZcashNamesAddress(String input) async { + final name = _extractName(input); + if (name == null) return null; + + try { + final response = await ProxyWrapper().post( + clearnetUri: _mainnetEndpoint, + headers: const {'Content-Type': 'application/json'}, + body: jsonEncode({ + 'jsonrpc': '2.0', + 'id': 1, + 'method': 'resolve', + 'params': {'query': name}, + }), + ); + + if (response.statusCode != 200) { + printV('ZcashNames: non-200 status ${response.statusCode} for $name'); + return null; + } + + final decoded = jsonDecode(response.body); + if (decoded is! Map) return null; + if (decoded['error'] != null) return null; + + final result = decoded['result']; + if (result is! Map) return null; + + final address = result['address']; + if (address is! String || address.isEmpty) return null; + + if (!_isValidZcashAddress(address)) { + printV('ZcashNames: returned address failed validation for $name'); + return null; + } + return address; + } catch (e) { + printV('ZcashNames: lookup failed for $name: $e'); + return null; + } + } + + static String? _extractName(String input) { + final lower = input.toLowerCase().trim(); + String? raw; + if (lower.endsWith('.zcash')) { + raw = lower.substring(0, lower.length - '.zcash'.length); + } else if (lower.endsWith('.zec')) { + raw = lower.substring(0, lower.length - '.zec'.length); + } + if (raw == null || raw.isEmpty) return null; + if (!_nameRegExp.hasMatch(raw)) return null; + return raw; + } + + static bool _isValidZcashAddress(String address) { + final pattern = AddressValidator.getAddressFromStringPattern(CryptoCurrency.zec); + if (pattern == null) return false; + return RegExp('^(?:$pattern)\$').hasMatch(address); + } +} diff --git a/lib/core/address_validator.dart b/lib/core/address_validator.dart index 22987dbf6..7168650d8 100644 --- a/lib/core/address_validator.dart +++ b/lib/core/address_validator.dart @@ -8,8 +8,11 @@ const BEFORE_REGEX = '(^|\\s)'; const AFTER_REGEX = '(\$|\\s)'; class AddressValidator extends TextValidator { - AddressValidator({required CryptoCurrency type, bool isTestnet = false}) - : super( + AddressValidator({ + required CryptoCurrency type, + bool isTestnet = false, + String? network, + }) : super( errorMessage: S.current.error_text_address, useAdditionalValidation: [CryptoCurrency.btc, CryptoCurrency.ltc].contains(type) ? (String txt) { @@ -28,11 +31,36 @@ class AddressValidator extends TextValidator { ); } : null, - pattern: getPattern(type, isTestnet: isTestnet), + pattern: getPattern(type, isTestnet: isTestnet, network: network), length: getLength(type), ); - static String getPattern(CryptoCurrency type, {bool isTestnet = false}) { + static const List reliableValidateCurrencies = [ + CryptoCurrency.xmr, + CryptoCurrency.btc, + CryptoCurrency.ltc, + CryptoCurrency.bch, + CryptoCurrency.trx, + CryptoCurrency.nano, + CryptoCurrency.sol, + CryptoCurrency.zano, + CryptoCurrency.ada, + CryptoCurrency.xrp, + CryptoCurrency.xhv, + CryptoCurrency.zaddr, + CryptoCurrency.zec, + CryptoCurrency.dcr, + CryptoCurrency.rvn, + CryptoCurrency.near, + CryptoCurrency.rune, + CryptoCurrency.scrt, + CryptoCurrency.stx, + CryptoCurrency.kmd, + CryptoCurrency.doge, + CryptoCurrency.btcln, + ]; + + static String? getPattern(CryptoCurrency type, {bool isTestnet = false, String? network}) { var pattern = ""; if (type is Erc20Token) { pattern = '0x[0-9a-zA-Z]+'; @@ -80,6 +108,7 @@ class AddressValidator extends TextValidator { case CryptoCurrency.eth: case CryptoCurrency.baseEth: case CryptoCurrency.arbEth: + case CryptoCurrency.robEth: case CryptoCurrency.mana: case CryptoCurrency.matic: case CryptoCurrency.maticpoly: @@ -141,13 +170,7 @@ class AddressValidator extends TextValidator { case CryptoCurrency.hbar: pattern = '[0-9a-zA-Z.]+'; case CryptoCurrency.zec: - pattern = '(?:' - 't1[0-9A-Za-z]{33}' - '|t3[0-9A-Za-z]{33}' - '|zs[a-z0-9]{76}' - '|zxviews[a-z0-9]{278}' - '|u1[a-z0-9]{1,300}' - ')'; + pattern = "(?:$zcashAddressPattern|zxviews[a-z0-9]{278})"; case CryptoCurrency.dcr: pattern = '(D|T|S)[ksecS]([0-9a-zA-Z])+'; case CryptoCurrency.rvn: @@ -236,6 +259,7 @@ class AddressValidator extends TextValidator { case CryptoCurrency.avaxc: case CryptoCurrency.baseEth: case CryptoCurrency.arbEth: + case CryptoCurrency.robEth: return [42]; case CryptoCurrency.bch: return [42, 54, 26, 27, 28, 29, 30, 31, 32, 33, 34, 35]; @@ -314,6 +338,8 @@ class AddressValidator extends TextValidator { static String get silentPaymentAddressPatternMainnet => 'sp1[0-9a-zA-Z]{113}'; static String get silentPaymentAddressPatternTestnet => '(tsp|sprt)1[0-9a-zA-Z]{113}'; static String get mWebAddressPattern => MwebAddress.regex.pattern; + static String get zcashAddressPattern => + "t1[0-9A-Za-z]{33}|t3[0-9A-Za-z]{33}|zs[a-z0-9]{76}|u1[a-z0-9]{1,300}"; static const String bolt11InvoiceMatcher = r'^(lightning:)?(lnbc|lntb|lnbs|lnbcrt)[a-z0-9]+$'; static const String bolt12OfferMatcher = r'^(lightning:)?(lno1)[a-z0-9]+$'; @@ -345,6 +371,7 @@ class AddressValidator extends TextValidator { case CryptoCurrency.maticpoly: case CryptoCurrency.baseEth: case CryptoCurrency.arbEth: + case CryptoCurrency.robEth: case CryptoCurrency.arb: pattern = '0x[0-9a-zA-Z]+'; case CryptoCurrency.nano: @@ -360,13 +387,7 @@ class AddressValidator extends TextValidator { case CryptoCurrency.zano: pattern = '([1-9A-HJ-NP-Za-km-z]{90,200})|(@[\w\d.-]+)'; case CryptoCurrency.zec: - pattern = '(?:' - 't1[0-9A-Za-z]{33}' - '|t3[0-9A-Za-z]{33}' - '|zs[a-z0-9]{76}' - '|zxviews[a-z0-9]{278}' - '|u1[a-z0-9]{1,300}' - ')'; + pattern = "(?:$zcashAddressPattern|zxviews[a-z0-9]{278})"; default: if (type.tag == CryptoCurrency.eth.title) { pattern = '0x[0-9a-zA-Z]+'; diff --git a/lib/core/amount_parsing_proxy.dart b/lib/core/amount_parsing_proxy.dart index 6227ae173..bd4d8e805 100644 --- a/lib/core/amount_parsing_proxy.dart +++ b/lib/core/amount_parsing_proxy.dart @@ -1,7 +1,9 @@ import 'package:hash_wallet/entities/bitcoin_amount_display_mode.dart'; import 'package:hash_wallet/src/screens/wallet_connect/utils/string_parsing.dart'; +import 'package:cw_core/amount/money.dart'; import 'package:cw_core/crypto_amount_format.dart'; import 'package:cw_core/crypto_currency.dart'; +import 'package:cw_core/currency.dart'; import 'package:cw_core/utils/print_verbose.dart'; class AmountParsingProxy { @@ -24,7 +26,7 @@ class AmountParsingProxy { if (useSatoshi(cryptoCurrency) && amount.isNotEmpty) { return cryptoCurrency .parseAmount(amount.withMaxDecimals(cryptoCurrency.decimals)) - .toString(); + .toStringWithPrecision(useBaseUnit: true); } return amount.withMaxDecimals(cryptoCurrency.decimals); @@ -35,55 +37,39 @@ class AmountParsingProxy { } } - /// [getCryptoStringRepresentation] turns the input [amount] into the preferred representation of [cryptoCurrency] - String getDisplayCryptoString(int amount, CryptoCurrency cryptoCurrency) { - if (useSatoshi(cryptoCurrency)) { - return "$amount"; - } + String getDisplayCryptoString(int amount, CryptoCurrency cryptoCurrency) => + asDisplayString(Money.fromInt(amount, cryptoCurrency)); - return cryptoCurrency.formatAmount(BigInt.from(amount)); - } + /// [getDisplayCryptoString] turns the input [amount] into the preferred representation of [cryptoCurrency] + /// + /// if [displayMode] is [BitcoinAmountDisplayMode.satoshi] it returns 1 BTC as 100000000 + /// if [displayMode] is [BitcoinAmountDisplayMode.bitcoin] it returns 1 BTC as 1 + /// hardcoding fractionalDigits to 8 as it's a display string, so no need to show more than that + String asDisplayString(Money amount) => + amount.toStringWithPrecision(useBaseUnit: useSatoshi(amount.currency), fractionalDigits: 8); - String getDisplayCryptoStringFromBigInt(BigInt amount, CryptoCurrency cryptoCurrency) { - if (useSatoshi(cryptoCurrency)) { - return "$amount"; - } + /// [asDisplayStringWithSymbol] turns the input [amount] into the preferred representation of + /// [cryptoCurrency] following the symbol of the unit + /// + /// if [displayMode] is [BitcoinAmountDisplayMode.satoshi] it returns 1 BTC as 100000000 sats + /// if [displayMode] is [BitcoinAmountDisplayMode.bitcoin] it returns 1 BTC as 1 BTC + /// hardcoding fractionalDigits to 8 as it's a display string, so no need to show more than that + String asDisplayStringWithSymbol(Money amount) => + amount.toStringWithSymbol(useBaseUnit: useSatoshi(amount.currency), fractionalDigits: 8); - return cryptoCurrency.formatAmount(amount); - } + /// [parseCryptoString] turns the the display representation [string] into `Money` with [cryptoCurrency] + Money parseCryptoString(String amount, CryptoCurrency cryptoCurrency) => + Money.parse(amount, cryptoCurrency, isBaseUnit: useSatoshi(cryptoCurrency)); - /// [getCryptoStringFromDouble] turns the input [amount] into the preferred representation of [cryptoCurrency] and - String getDisplayCryptoStringFromDouble(double amount, CryptoCurrency cryptoCurrency) { - if (useSatoshi(cryptoCurrency)) { - return "$amount"; - } - - return cryptoCurrency.formatAmount(BigInt.from(amount)); - } - - /// [parseCryptoString] turns the input [string] into a `BigInt` presentation of the [cryptoCurrency] - BigInt parseCryptoString(String amount, CryptoCurrency cryptoCurrency) { - if (useSatoshi(cryptoCurrency)) { - return BigInt.parse(amount); - } - - return cryptoCurrency.parseAmount(amount); - } - - /// [tryParseCryptoString] tries to turn the display representation [string] into a `BigInt` presentation of the [cryptoCurrency] - BigInt? tryParseCryptoString(String amount, CryptoCurrency cryptoCurrency) { - if (useSatoshi(cryptoCurrency)) { - return BigInt.tryParse(amount); - } - - return cryptoCurrency.tryParseAmount(amount); - } + /// [tryParseCryptoString] tries to turn the display representation [string] into `Money` with [cryptoCurrency] + Money? tryParseCryptoString(String amount, CryptoCurrency cryptoCurrency) => + Money.tryParse(amount, cryptoCurrency, isBaseUnit: useSatoshi(cryptoCurrency)); /// [getCryptoSymbol] returns the correct Symbol related to the presentation String getCryptoSymbol(CryptoCurrency cryptoCurrency) => useSatoshi(cryptoCurrency) ? "sats" : cryptoCurrency.title.safeSubString(0, 8); - bool useSatoshi(CryptoCurrency cryptoCurrency) => + bool useSatoshi(Currency cryptoCurrency) => ([CryptoCurrency.btc, CryptoCurrency.btcln].contains(cryptoCurrency) && displayMode == BitcoinAmountDisplayMode.satoshi) || (CryptoCurrency.btcln == cryptoCurrency && diff --git a/lib/core/anypay/anypay_models.dart b/lib/core/anypay/anypay_models.dart new file mode 100644 index 000000000..c738bbc0a --- /dev/null +++ b/lib/core/anypay/anypay_models.dart @@ -0,0 +1,174 @@ +import "package:hash_wallet/core/universal_address_detector.dart"; +import "package:hash_wallet/utils/payment_request.dart"; +import "package:cw_core/amount/money.dart"; +import "package:cw_core/crypto_currency.dart"; +import "package:cw_core/wallet_info.dart"; +import "package:cw_core/wallet_type.dart"; + +sealed class ChainBinding { + const ChainBinding(); +} + +class ExplicitEvmChain extends ChainBinding { + const ExplicitEvmChain(this.chainId); + + final int chainId; +} + +class ChainlessEvm extends ChainBinding { + const ChainlessEvm(); +} + +class NoEvmBinding extends ChainBinding { + const NoEvmBinding(); +} + +class AnyPayRequest { + AnyPayRequest({ + required this.rawInput, + required this.paymentRequest, + required this.detection, + required this.chainBinding, + }); + + final String rawInput; + final PaymentRequest paymentRequest; + final AddressDetectionResult detection; + final ChainBinding chainBinding; + + String get address => paymentRequest.address; + + String get amount => paymentRequest.amount; + + String? get contractAddress => paymentRequest.contractAddress; + + bool get hasContract => contractAddress != null && contractAddress!.isNotEmpty; + + bool get isLightning => detection.detectedCurrency == CryptoCurrency.btcln; +} + +class WalletSnapshot { + WalletSnapshot({ + required this.type, + required this.currentWalletIsEvm, + required this.currentChainId, + required this.wallets, + required this.supportedEvmChains, + required this.hasEvmProxy, + required this.hasSolanaProxy, + required this.hasTronProxy, + }); + + final WalletType type; + final bool currentWalletIsEvm; + final int? currentChainId; + final List wallets; + final Map supportedEvmChains; + final bool hasEvmProxy; + final bool hasSolanaProxy; + final bool hasTronProxy; + + List walletsOfType(WalletType walletType) => + wallets.where((wallet) => wallet.type == walletType).toList(); +} + +sealed class AnyPayResolution { + const AnyPayResolution(); +} + +class NoTokenRequested extends AnyPayResolution { + const NoTokenRequested(); +} + +class TokenResolved extends AnyPayResolution { + const TokenResolved({required this.token, this.chainId, this.amountOverride}); + + final CryptoCurrency token; + final int? chainId; + final String? amountOverride; +} + +class TokenUnknown extends AnyPayResolution { + const TokenUnknown(this.contract); + + final String contract; +} + +sealed class AnyPayDecision { + const AnyPayDecision(); +} + +class AnyPayApplyToCurrentWallet extends AnyPayDecision { + const AnyPayApplyToCurrentWallet({ + this.token, + this.amountOverride, + this.fallbackCurrency, + }); + + final CryptoCurrency? token; + final String? amountOverride; + final CryptoCurrency? fallbackCurrency; +} + +class AnyPayEvmNetworkChoice extends AnyPayDecision { + const AnyPayEvmNetworkChoice(); +} + +class AnyPayCrossChainPayment extends AnyPayDecision { + const AnyPayCrossChainPayment({ + required this.targetWalletType, + required this.wallets, + this.targetChainId, + this.token, + this.amountOverride, + this.canSwap = true, + }); + + final WalletType targetWalletType; + final int? targetChainId; + final List wallets; + final CryptoCurrency? token; + final String? amountOverride; + final bool canSwap; + + bool get hasCompatibleWallet => wallets.isNotEmpty; +} + +class AnyPayUnsupportedNetwork extends AnyPayDecision { + const AnyPayUnsupportedNetwork(this.chainId); + + final int chainId; +} + +class AnyPayUnsupportedToken extends AnyPayDecision { + const AnyPayUnsupportedToken(this.contract); + + final String contract; +} + +class AnyPayEmptyInput extends AnyPayDecision { + const AnyPayEmptyInput(); +} + +class AnyPayEvaluation { + AnyPayEvaluation({required this.request, required this.decision}); + + final AnyPayRequest request; + final AnyPayDecision decision; +} + +class AnyPaySwapIntent { + AnyPaySwapIntent({ + required this.request, + required this.receiveCurrency, + required this.targetWalletType, + this.receiveAmount, + }); + + final AnyPayRequest request; + final CryptoCurrency receiveCurrency; + final WalletType targetWalletType; + final Money? receiveAmount; + + String get recipientAddress => request.address; +} diff --git a/lib/core/anypay/anypay_parser.dart b/lib/core/anypay/anypay_parser.dart new file mode 100644 index 000000000..120351887 --- /dev/null +++ b/lib/core/anypay/anypay_parser.dart @@ -0,0 +1,71 @@ +import "package:hash_wallet/core/anypay/anypay_models.dart"; +import "package:hash_wallet/core/universal_address_detector.dart"; +import "package:hash_wallet/utils/payment_request.dart"; +import "package:cw_core/crypto_currency.dart"; +import "package:cw_core/currency_for_wallet_type.dart"; +import "package:cw_core/payment_uris.dart"; + +class AnyPayParser { + static AnyPayRequest fromRaw(String input) { + final paymentRequest = PaymentRequest.fromString(input); + + return AnyPayRequest( + rawInput: input, + paymentRequest: paymentRequest, + detection: UniversalAddressDetector.detectAddress(input), + chainBinding: _bindingFor(paymentRequest), + ); + } + + static AnyPayRequest fromPaymentRequest(PaymentRequest request) { + final raw = _reconstructRawInput(request); + + return AnyPayRequest( + rawInput: raw, + paymentRequest: request, + detection: UniversalAddressDetector.detectAddress(raw), + chainBinding: _bindingFor(request), + ); + } + + static String _reconstructRawInput(PaymentRequest request) { + final scheme = request.scheme.toLowerCase(); + + if (scheme.isEmpty || scheme == "lightning") { + return request.address; + } + + if (scheme == "ethereum") { + return ERC681URI( + address: request.address, + amount: request.amount, + contractAddress: request.contractAddress, + chainId: request.chainId ?? 1, + rawTokenAmount: request.rawTokenAmount, + ).toString(); + } + + final amount = request.amount.isNotEmpty ? "?amount=${request.amount}" : ""; + return "${request.scheme}:${request.address}$amount"; + } + + static ChainBinding _bindingFor(PaymentRequest request) { + final scheme = request.scheme.toLowerCase(); + + if (scheme == "ethereum") { + final chainId = request.chainId; + return chainId != null ? ExplicitEvmChain(chainId) : const ChainlessEvm(); + } + + if (scheme.isNotEmpty) { + try { + final chainId = getChainIdByCryptoCurrency(CryptoCurrency.fromString(scheme)); + if (chainId != null) { + return ExplicitEvmChain(chainId); + } + } catch (_) {} + } + + return const NoEvmBinding(); + } +} diff --git a/lib/core/anypay/anypay_resolver.dart b/lib/core/anypay/anypay_resolver.dart new file mode 100644 index 000000000..d7141b882 --- /dev/null +++ b/lib/core/anypay/anypay_resolver.dart @@ -0,0 +1,109 @@ +import "package:hash_wallet/core/anypay/anypay_models.dart"; +import "package:hash_wallet/core/anypay/anypay_router.dart"; +import "package:hash_wallet/reactions/wallet_connect.dart"; +import "package:hash_wallet/utils/token_utilities.dart"; +import "package:cw_core/crypto_currency.dart"; +import "package:cw_core/wallet_type.dart"; + +class AnyPayResolver { + AnyPayResolver({AnyPayTokenLookup? tokenLookup}) + : _tokenLookup = tokenLookup ?? const TokenUtilitiesLookup(); + + final AnyPayTokenLookup _tokenLookup; + + Future resolve(AnyPayRequest request, WalletSnapshot snapshot) async { + if (!request.hasContract) { + return const NoTokenRequested(); + } + + final contract = request.contractAddress!; + final detectedType = request.detection.detectedWalletType; + + if (detectedType != null && isEVMCompatibleChain(detectedType)) { + return _resolveEvm(request, snapshot, contract); + } + + final token = await _tokenLookup.findTokenByAddress( + walletType: detectedType ?? snapshot.type, + address: contract, + ); + + if (token == null) { + return TokenUnknown(contract); + } + + return TokenResolved( + token: token, + amountOverride: request.paymentRequest.resolveTokenAmount(token), + ); + } + + Future _resolveEvm( + AnyPayRequest request, + WalletSnapshot snapshot, + String contract, + ) async { + final requestedChainId = AnyPayRouter.requestedEvmChainId(request, snapshot) ?? 1; + + final lookupType = snapshot.supportedEvmChains[requestedChainId]; + if (lookupType != null) { + final token = + await _tokenLookup.findTokenByAddress(walletType: lookupType, address: contract); + if (token != null) { + return TokenResolved( + token: token, + chainId: requestedChainId, + amountOverride: request.paymentRequest.resolveTokenAmount(token), + ); + } + } + + // The QRs from our old app versions omit the chainId on mainnet, so a contract the target + // network does not know may still belong to another EVM network. + if (request.chainBinding is ChainlessEvm) { + final reboundChainId = await _tokenLookup.findEvmChainIdForContract( + contract, + excludingChainId: requestedChainId, + ); + final reboundType = + reboundChainId != null ? snapshot.supportedEvmChains[reboundChainId] : null; + if (reboundType != null) { + final token = + await _tokenLookup.findTokenByAddress(walletType: reboundType, address: contract); + if (token != null) { + return TokenResolved( + token: token, + chainId: reboundChainId, + amountOverride: request.paymentRequest.resolveTokenAmount(token), + ); + } + } + } + + return TokenUnknown(contract); + } +} + +abstract class AnyPayTokenLookup { + Future findTokenByAddress({ + required WalletType walletType, + required String address, + }); + + Future findEvmChainIdForContract(String contractAddress, {int? excludingChainId}); +} + +class TokenUtilitiesLookup implements AnyPayTokenLookup { + const TokenUtilitiesLookup(); + + @override + Future findTokenByAddress({ + required WalletType walletType, + required String address, + }) => + TokenUtilities.findTokenByAddress(walletType: walletType, address: address); + + @override + Future findEvmChainIdForContract(String contractAddress, {int? excludingChainId}) => + TokenUtilities.findEvmChainIdForContract(contractAddress, excludingChainId: excludingChainId); +} diff --git a/lib/core/anypay/anypay_router.dart b/lib/core/anypay/anypay_router.dart new file mode 100644 index 000000000..779c95b4e --- /dev/null +++ b/lib/core/anypay/anypay_router.dart @@ -0,0 +1,135 @@ +import "package:hash_wallet/core/address_validator.dart"; +import "package:hash_wallet/core/anypay/anypay_models.dart"; +import "package:hash_wallet/reactions/wallet_connect.dart"; +import "package:cw_core/crypto_currency.dart"; +import "package:cw_core/wallet_type.dart"; + +class AnyPayRouter { + static final List _swapIncompatibleAddressPatterns = [ + RegExp("^(?:${AddressValidator.silentPaymentAddressPatternMainnet})\$"), + RegExp("^(?:${AddressValidator.silentPaymentAddressPatternTestnet})\$"), + RegExp("^(?:${AddressValidator.mWebAddressPattern})\$"), + ]; + + static bool _isSwapIncompatibleAddress(String address) => + _swapIncompatibleAddressPatterns.any((pattern) => pattern.hasMatch(address)); + + static AnyPayDecision route( + AnyPayRequest request, + WalletSnapshot snapshot, + AnyPayResolution resolution, + ) { + if (request.rawInput.trim().isEmpty) { + return const AnyPayEmptyInput(); + } + + final detection = request.detection; + final detectedType = detection.detectedWalletType; + + if (!detection.isValid || detectedType == null) { + return const AnyPayApplyToCurrentWallet(); + } + + + + if (isEVMCompatibleChain(detectedType)) { + if (!snapshot.hasEvmProxy) { + return const AnyPayApplyToCurrentWallet(); + } + + return _routeEvm(request, snapshot, resolution); + } + + if (resolution is TokenUnknown) { + return AnyPayUnsupportedToken(resolution.contract); + } + + if (detectedType == snapshot.type) { + return AnyPayApplyToCurrentWallet( + token: resolution is TokenResolved ? resolution.token : null, + amountOverride: resolution is TokenResolved ? resolution.amountOverride : null, + fallbackCurrency: _nativeRequestCurrency(request), + ); + } + + return AnyPayCrossChainPayment( + targetWalletType: detectedType, + wallets: snapshot.walletsOfType(detectedType), + token: resolution is TokenResolved ? resolution.token : null, + amountOverride: resolution is TokenResolved ? resolution.amountOverride : null, + canSwap: !_isSwapIncompatibleAddress(request.address), + ); + } + + static AnyPayDecision _routeEvm( + AnyPayRequest request, + WalletSnapshot snapshot, + AnyPayResolution resolution, + ) { + final requestedChainId = requestedEvmChainId(request, snapshot); + + if (requestedChainId == null) { + if (snapshot.currentWalletIsEvm) { + return const AnyPayApplyToCurrentWallet(); + } + + return const AnyPayEvmNetworkChoice(); + } + + if (!snapshot.supportedEvmChains.containsKey(requestedChainId)) { + return AnyPayUnsupportedNetwork(requestedChainId); + } + + if (resolution is TokenUnknown) { + return AnyPayUnsupportedToken(resolution.contract); + } + + int targetChainId = requestedChainId; + if (resolution is TokenResolved && resolution.chainId != null) { + targetChainId = resolution.chainId!; + } + + final token = resolution is TokenResolved ? resolution.token : null; + final amountOverride = resolution is TokenResolved ? resolution.amountOverride : null; + + if (snapshot.currentWalletIsEvm && snapshot.currentChainId == targetChainId) { + return AnyPayApplyToCurrentWallet( + token: token, + amountOverride: amountOverride, + fallbackCurrency: _nativeRequestCurrency(request), + ); + } + + final targetWalletType = snapshot.supportedEvmChains[targetChainId]!; + + return AnyPayCrossChainPayment( + targetWalletType: targetWalletType, + targetChainId: targetChainId, + wallets: snapshot.walletsOfType(targetWalletType), + token: token, + amountOverride: amountOverride, + ); + } + + static CryptoCurrency? _nativeRequestCurrency(AnyPayRequest request) { + if (request.paymentRequest.scheme.isEmpty || request.hasContract || request.amount.isEmpty) { + return null; + } + return request.detection.detectedCurrency; + } + + static int? requestedEvmChainId(AnyPayRequest request, WalletSnapshot snapshot) { + final binding = request.chainBinding; + + if (binding is ExplicitEvmChain) { + return binding.chainId; + } + + if (binding is ChainlessEvm) { + // the eip 681 spec says we should use the current chain id if there is no chainId in the incoming request + return snapshot.currentWalletIsEvm ? snapshot.currentChainId ?? 1 : 1; + } + + return null; + } +} diff --git a/lib/core/anypay/anypay_service.dart b/lib/core/anypay/anypay_service.dart new file mode 100644 index 000000000..c28a38129 --- /dev/null +++ b/lib/core/anypay/anypay_service.dart @@ -0,0 +1,138 @@ +import "package:hash_wallet/core/anypay/anypay_models.dart"; +import "package:hash_wallet/core/anypay/anypay_parser.dart"; +import "package:hash_wallet/core/anypay/anypay_resolver.dart"; +import "package:hash_wallet/core/anypay/anypay_router.dart"; +import "package:hash_wallet/evm/evm.dart"; +import "package:hash_wallet/new-ui/services/wallet_switch_service.dart"; +import "package:hash_wallet/reactions/wallet_connect.dart"; +import "package:hash_wallet/store/app_store.dart"; +import "package:hash_wallet/utils/payment_request.dart"; +import "package:cw_core/amount/money.dart"; +import "package:cw_core/crypto_currency.dart"; +import "package:cw_core/currency_for_wallet_type.dart"; +import "package:cw_core/utils/print_verbose.dart"; +import "package:cw_core/wallet_info.dart"; +import "package:cw_core/wallet_type.dart"; + +class AnyPayService { + AnyPayService({ + required this.appStore, + required this.walletSwitchService, + AnyPayResolver? resolver, + }) : resolver = resolver ?? AnyPayResolver(); + + final AppStore appStore; + final WalletSwitchService walletSwitchService; + final AnyPayResolver resolver; + + Future evaluateRawInput(String input) => _evaluate(AnyPayParser.fromRaw(input)); + + Future evaluatePaymentRequest(PaymentRequest request) => + _evaluate(AnyPayParser.fromPaymentRequest(request)); + + Future evaluateForEvmChain(AnyPayRequest request, int chainId) => _evaluate( + AnyPayRequest( + rawInput: request.rawInput, + paymentRequest: request.paymentRequest, + detection: request.detection, + chainBinding: ExplicitEvmChain(chainId), + ), + ); + + Future _evaluate(AnyPayRequest request) async { + try { + final snapshot = await _buildWalletSnapshot(); + final resolution = await resolver.resolve(request, snapshot); + + return AnyPayEvaluation( + request: request, + decision: AnyPayRouter.route(request, snapshot, resolution), + ); + } catch (e) { + printV("anypay evaluation failed: $e"); + return AnyPayEvaluation( + request: request, + decision: const AnyPayApplyToCurrentWallet(), + ); + } + } + + Future _buildWalletSnapshot() async { + final wallet = appStore.wallet!; + final currentWalletIsEvm = isEVMCompatibleChain(wallet.type); + + final supportedEvmChains = {}; + if (evm != null) { + for (final chain in evm!.getAllChains()) { + final walletType = evm!.getWalletTypeByChainId(chain.chainId); + if (walletType != null) { + supportedEvmChains[chain.chainId] = walletType; + } + } + } + + return WalletSnapshot( + type: wallet.type, + currentWalletIsEvm: currentWalletIsEvm, + currentChainId: currentWalletIsEvm && evm != null + ? evm!.getSelectedChainId(wallet) ?? evm!.getChainIdByWalletType(wallet.type) + : null, + wallets: await WalletInfo.getAll(), + supportedEvmChains: supportedEvmChains, + hasEvmProxy: evm != null, + hasSolanaProxy: false, + hasTronProxy: false, + ); + } + + AnyPaySwapIntent buildSwapIntent( + AnyPayRequest request, { + required WalletType targetWalletType, + int? targetChainId, + CryptoCurrency? token, + }) { + final receiveCurrency = token ?? + (targetChainId != null + ? walletTypeToCryptoCurrency(targetWalletType, chainId: targetChainId) + : request.detection.detectedCurrency ?? walletTypeToCryptoCurrency(targetWalletType)); + + final amountValue = token != null + ? request.paymentRequest.resolveTokenAmount(token) + : (request.amount.isNotEmpty ? request.amount : null); + + return AnyPaySwapIntent( + request: request, + receiveCurrency: receiveCurrency, + targetWalletType: targetWalletType, + receiveAmount: amountValue != null ? Money.tryParse(amountValue, receiveCurrency) : null, + ); + } + + Future switchWalletForPayment(WalletInfo walletInfo, {int? chainId}) async { + try { + await walletSwitchService.switchToWallet(walletInfo); + } catch (e) { + printV("wallet switch failed: $e"); + return false; + } + + final wallet = appStore.wallet!; + if (chainId != null && evm != null && isEVMCompatibleChain(wallet.type)) { + try { + final node = appStore.settingsStore.getCurrentNode(wallet.type, chainId: chainId); + await evm!.selectChain(wallet, chainId, node: node); + } catch (e, s) { + printV("switchWalletForPayment chain select failed: $e\n$s"); + return false; + } + } + + try { + await wallet.updateBalance(); + } catch (e) { + printV("balance refresh after wallet switch failed: $e"); + } + + return true; + } +} diff --git a/lib/core/auth_service.dart b/lib/core/auth_service.dart index 77f37e347..51106246a 100644 --- a/lib/core/auth_service.dart +++ b/lib/core/auth_service.dart @@ -36,6 +36,7 @@ class AuthService with Store { static const List _alwaysAuthenticateRoutes = [ Routes.showKeys, + Routes.seed, Routes.backup, Routes.setupPin, Routes.setup_2faPage, diff --git a/lib/core/background_sync.dart b/lib/core/background_sync.dart index 912d2aa29..804845112 100644 --- a/lib/core/background_sync.dart +++ b/lib/core/background_sync.dart @@ -208,10 +208,14 @@ class BackgroundSync { } await sharedPreferences.setString(PreferencesKey.backgroundSyncLastTrigger(wallet.name), tx.date.add(Duration(minutes: 1)).toIso8601String()); - final action = tx.direction == TransactionDirection.incoming ? "Received" : "Sent"; + final action = tx.additionalInfo['isAutoShield'] == true + ? "Autoshield" + : tx.direction == TransactionDirection.incoming + ? "Received" + : "Sent"; if (sharedPreferences.getBool(PreferencesKey.backgroundSyncNotificationsEnabled) ?? false) { await showNotification( - "$action ${wallet.currency.fullName} in ${wallet.name}", "${tx.amountFormatted()}"); + "$action ${wallet.currency.fullName} in ${wallet.name}", "${tx.amount.toString()}"); } printV( "${wallet.currency.fullName} in ${wallet.name}: TX: ${tx.date} ${tx.amount} ${tx.direction}"); diff --git a/lib/core/backup_service.dart b/lib/core/backup_service.dart index d2db1cf20..311a9e6aa 100644 --- a/lib/core/backup_service.dart +++ b/lib/core/backup_service.dart @@ -18,6 +18,9 @@ import 'package:hash_wallet/core/key_service.dart'; import 'package:hash_wallet/entities/encrypt.dart'; import 'package:hash_wallet/entities/preferences_key.dart'; import 'package:hash_wallet/entities/secret_store_key.dart'; +import 'package:cw_core/erc20_token_legacy.dart' show performErc20TokenHiveMigration; +import 'package:cw_core/spl_token_legacy.dart' show performSplTokenHiveMigration; +import 'package:cw_core/tron_token_legacy.dart' show performTronTokenHiveMigration; import 'package:cw_core/wallet_info.dart'; import 'package:hash_wallet/exchange/trade_legacy.dart'; import 'package:hash_wallet/.secrets.g.dart' as secrets; @@ -113,6 +116,10 @@ class $BackupService { Future verifyWallets() async { await performHiveMigration(); // for backups made before sqlite migration await performTradeHiveMigration(_secureStorage); + await performErc20TokenHiveMigration(); + await performSplTokenHiveMigration(); + await performTronTokenHiveMigration(); + correctWallets = (await WalletInfo.getAll()) .where((info) => availableWalletTypes.contains(info.type)) .toList(); diff --git a/lib/core/backup_service_v3.dart b/lib/core/backup_service_v3.dart index 5a39fc2b0..62a77387b 100644 --- a/lib/core/backup_service_v3.dart +++ b/lib/core/backup_service_v3.dart @@ -147,6 +147,21 @@ class BackupMetadata { } } +class IncompatibleBackupAppException implements Exception { + IncompatibleBackupAppException({ + required this.sourceAppName, + required this.currentAppName, + }); + + final String sourceAppName; + final String currentAppName; + + @override + String toString() { + return 'This backup was created in $sourceAppName and cannot be restored in $currentAppName.'; + } +} + class BackupServiceV3 extends $BackupService { BackupServiceV3(super.secureStorage, super.transactionDescriptionBox, super.keyService, super.sharedPreferences); @@ -201,7 +216,8 @@ class BackupServiceV3 extends $BackupService { } } - Future importBackupFile(File file, String password, {String nonce = secrets.backupSalt}) { + Future importBackupFile(File file, String password, + {String nonce = secrets.backupSalt, bool checkBackupApp = true}) { final version = getVersionFile(file); switch (version) { case BackupVersion.unknown: @@ -214,12 +230,12 @@ class BackupServiceV3 extends $BackupService { case BackupVersion.v2: return super.importBackupV2(file.readAsBytesSync(), password); case BackupVersion.v3: - return importBackupFileV3(file, password, nonce: nonce); + return importBackupFileV3(file, password, nonce: nonce, checkBackupApp: checkBackupApp); } } Future importBackupFileV3(File file, String password, - {String nonce = secrets.backupSalt}) async { + {String nonce = secrets.backupSalt, bool checkBackupApp = true}) async { // Overall design of v3 backup is the following: // 1. backup.zip - plaintext zip file that user can open with any archive manager // 2. backup.zip/README.txt - text file to let user know what is inside of this file @@ -228,6 +244,11 @@ class BackupServiceV3 extends $BackupService { final inputStream = InputFileStream(file.path); final archive = ZipDecoder().decodeStream(inputStream); + + if (checkBackupApp) { + await _throwIfBackupWasCreatedInAnotherApp(archive); + } + final metadataFile = archive.findFile('metadata.json'); if (metadataFile == null) { throw Exception('Invalid v3 backup: missing metadata.json'); @@ -497,6 +518,31 @@ This backup was created on ${DateTime.now().toIso8601String()} return file; } + Future _throwIfBackupWasCreatedInAnotherApp(Archive archive) async { + final readmeFile = archive.findFile('README.txt'); + if (readmeFile == null) return; + + final readmeBytes = readmeFile.rawContent?.readBytes(); + if (readmeBytes == null) return; + + final readmeString = utf8.decode(readmeBytes, allowMalformed: true); + final sourceAppName = _extractBackupAppName(readmeString); + if (sourceAppName == null) return; + + final currentAppName = (await PackageInfo.fromPlatform()).appName; + if (sourceAppName == currentAppName) return; + + throw IncompatibleBackupAppException( + sourceAppName: sourceAppName, + currentAppName: currentAppName, + ); + } + + String? _extractBackupAppName(String readme) { + final match = RegExp(r'^This is a (.+) backup\.', multiLine: true).firstMatch(readme); + return match?.group(1)?.trim(); + } + static const chunkSize = 24 * 1024 * 1024; // 24MiB File setVersionFile(File file, BackupVersion version) { diff --git a/lib/core/csv_export_service.dart b/lib/core/csv_export_service.dart new file mode 100644 index 000000000..bbf8d87c2 --- /dev/null +++ b/lib/core/csv_export_service.dart @@ -0,0 +1,339 @@ +import 'dart:io'; + +import 'package:hash_wallet/entities/transaction_description.dart'; +import 'package:hash_wallet/generated/i18n.dart'; +import 'package:hash_wallet/src/screens/backup/backup_page.dart'; +import 'package:hash_wallet/src/widgets/alert_with_two_actions.dart'; +import 'package:hash_wallet/utils/share_util.dart'; +import 'package:hash_wallet/utils/show_bar.dart'; +import 'package:hash_wallet/utils/show_pop_up.dart'; +import 'package:hash_wallet/view_model/dashboard/action_list_item.dart'; +import 'package:hash_wallet/view_model/dashboard/anonpay_transaction_list_item.dart'; +import 'package:hash_wallet/view_model/dashboard/date_section_item.dart'; +import 'package:hash_wallet/view_model/dashboard/order_list_item.dart'; +import 'package:hash_wallet/view_model/dashboard/payjoin_transaction_list_item.dart'; +import 'package:hash_wallet/view_model/dashboard/trade_list_item.dart'; +import 'package:hash_wallet/view_model/dashboard/transaction_list_item.dart'; +import 'package:collection/collection.dart'; +import 'package:cw_core/transaction_direction.dart'; +import 'package:file_picker/file_picker.dart'; +import 'package:flutter/material.dart'; +import 'package:hive/hive.dart'; +import 'package:intl/intl.dart'; +import 'package:path_provider/path_provider.dart'; + +class CsvExportService { + CsvExportService({required this.transactionDescriptionBox}); + + final Box transactionDescriptionBox; + + static const _columns = [ + 'record_type', + 'date_time', + 'type', + 'amount', + 'currency', + 'fee', + 'tx_id', + 'address', + 'status', + 'note', + 'from_amount', + 'from_currency', + 'to_amount', + 'to_currency', + 'trade_id', + 'provider', + 'confirmations', + ]; + + static const _utf8Bom = ''; + + static const _formulaTriggers = {'=', '+', '-', '@', '\t', '\r'}; + + static final _plainNumber = RegExp(r'^-?\d+(\.\d+)?$'); + + String buildCsvContent(List items) { + final buf = StringBuffer(); + buf.write(_utf8Bom); + buf.writeln(_columns.join(',')); + + final primaryAddress = _primaryAddressOf(items); + + for (final item in items) { + if (item is DateSectionItem) continue; + + final row = _buildRow(item, primaryAddress); + if (row != null) buf.writeln(row); + } + + return buf.toString(); + } + + String _primaryAddressOf(List items) { + for (final item in items) { + if (item is TransactionListItem) { + return item.balanceViewModel.wallet.walletAddresses.primaryAddress; + } + } + return ''; + } + + String _noteFor(TransactionListItem item, String primaryAddress) { + final txHash = item.transaction.txHash; + final descriptionKey = '${txHash}_$primaryAddress'; + final description = transactionDescriptionBox.values + .firstWhereOrNull((val) => val.id == descriptionKey || val.id == txHash); + return description?.note ?? ''; + } + + String? _buildRow(ActionListItem item, String primaryAddress) { + if (item is TransactionListItem) return _transactionRow(item, primaryAddress); + if (item is TradeListItem) return _tradeRow(item); + if (item is OrderListItem) return _orderRow(item); + if (item is AnonpayTransactionListItem) return _anonpayRow(item); + if (item is PayjoinTransactionListItem) return _payjoinRow(item); + return null; + } + + String _transactionRow(TransactionListItem item, String primaryAddress) { + final tx = item.transaction; + final type = tx.direction == TransactionDirection.incoming ? 'incoming' : 'outgoing'; + final status = tx.isPending ? 'pending' : 'confirmed'; + + // Prefer tx.to/tx.from; fall back to address lists for chains that don't populate them. + final address = tx.direction == TransactionDirection.incoming + ? (tx.from?.isNotEmpty == true ? tx.from! : (tx.inputAddresses?.firstOrNull ?? '')) + : (tx.to?.isNotEmpty == true ? tx.to! : (tx.outputAddresses?.firstOrNull ?? '')); + + final fee = tx.fee != null && !tx.fee!.isZero ? tx.fee.toString() : ''; + + return _row([ + 'transaction', + _isoDate(tx.date), + type, + tx.amount.toString(), + tx.amount.currency.symbol, + fee, + tx.id, + address, + status, + _noteFor(item, primaryAddress), + '', + '', + '', + '', + '', + '', + tx.confirmations.toString(), + ]); + } + + String _tradeRow(TradeListItem item) { + final trade = item.trade; + return _row([ + 'trade', + _isoDate(trade.createdAt ?? DateTime.fromMillisecondsSinceEpoch(0, isUtc: true)), + 'swap', + '', + '', + trade.fee?.toString() ?? '', + trade.outputTransaction ?? '', + trade.payoutAddress ?? '', + trade.state.title, + trade.memo ?? '', + trade.amount, + trade.from?.name ?? '', + trade.receiveAmount ?? '', + trade.to?.name ?? '', + trade.id, + trade.provider.title, + '', + ]); + } + + String _orderRow(OrderListItem item) { + final order = item.order; + return _row([ + 'order', + _isoDate(order.createdAt), + order.source.title, + order.amountFormatted(), + order.from ?? '', + '', + order.transferId, + '', + order.state.title, + '', + order.amountFormatted(), + order.from ?? '', + order.receiveAmount ?? '', + order.to ?? '', + order.id, + order.providerTitle, + '', + ]); + } + + String _anonpayRow(AnonpayTransactionListItem item) { + final tx = item.transaction; + final amount = tx.fiatAmount?.toString() ?? tx.amountTo?.toString() ?? ''; + final currency = tx.fiatEquiv ?? tx.coinTo; + + return _row([ + 'anonpay', + _isoDate(tx.createdAt), + 'anonymous_payment', + amount, + currency, + '', + tx.invoiceId, + tx.address, + tx.status, + '', + '', + '', + '', + '', + '', + tx.provider, + '', + ]); + } + + String _payjoinRow(PayjoinTransactionListItem item) { + final session = item.session; + final type = session.isSenderSession ? 'send' : 'receive'; + final amount = session.rawAmount ?? '0'; + + return _row([ + 'payjoin', + _isoDate(session.inProgressSince ?? DateTime.fromMillisecondsSinceEpoch(0, isUtc: true)), + type, + amount, + 'BTC', + '', + session.txId ?? '', + '', + item.status, + '', + '', + '', + '', + '', + '', + '', + '', + ]); + } + + String _row(List fields) => fields.map(escapeField).join(','); + + String escapeField(String field) { + final value = _neutralizeFormula(field); + if (value.contains(',') || + value.contains('"') || + value.contains('\n') || + value.contains('\r')) { + return '"${value.replaceAll('"', '""')}"'; + } + return value; + } + + String _neutralizeFormula(String field) { + if (field.isEmpty || !_formulaTriggers.contains(field[0])) return field; + if (_plainNumber.hasMatch(field)) return field; + return "'$field"; + } + + String _isoDate(DateTime dt) => dt.toUtc().toIso8601String(); + + Future exportToCsv(List items, BuildContext context) async { + final dataItems = + items.whereType().where((e) => e is! DateSectionItem).toList(); + + if (dataItems.isEmpty) { + await showBar(context, S.current.csv_nothing_to_export); + return; + } + + final now = DateTime.now(); + final fileName = 'cake_wallet_export_${DateFormat('yyyyMMdd_HHmmss').format(now)}.csv'; + + late File csvFile; + + final exportFuture = Future(() async { + final content = buildCsvContent(items); + csvFile = await _writeTempFile(fileName, content); + }); + + showPersistentActionOverlay(context, exportFuture, text: S.current.generating_csv); + await exportFuture; + + if (!context.mounted) return; + + if (Platform.isAndroid) { + _showAndroidExportDialog(context, csvFile, fileName); + } else if (Platform.isIOS) { + await _shareFile(csvFile, fileName, context); + } else { + await _saveFileDesktop(csvFile, fileName); + } + } + + void _showAndroidExportDialog(BuildContext context, File csvFile, String fileName) { + showPopUp( + context: context, + builder: (dialogContext) { + return AlertWithTwoActions( + alertTitle: S.current.export_csv, + alertContent: S.current.select_destination, + rightButtonText: S.current.save_to_downloads, + leftButtonText: S.current.share, + actionRightButton: () async { + await _saveToDownloads(fileName, csvFile); + Navigator.of(dialogContext).pop(); + await showBar(context, S.current.file_saved); + await csvFile.delete(); + }, + actionLeftButton: () async { + Navigator.of(dialogContext).pop(); + await _shareFile(csvFile, fileName, context); + }, + ); + }, + ); + } + + Future _shareFile(File file, String fileName, BuildContext context) async { + await ShareUtil.shareFile(filePath: file.path, fileName: fileName, context: context); + if (await file.exists()) await file.delete(); + } + + Future _saveFileDesktop(File csvFile, String fileName) async { + final outputPath = await FilePicker.platform.saveFile( + dialogTitle: 'Save CSV export', + fileName: fileName, + lockParentWindow: true, + ); + if (outputPath == null) return; + await csvFile.copy(outputPath); + await csvFile.delete(); + } + + Future _writeTempFile(String fileName, String content) async { + final dir = await getApplicationDocumentsDirectory(); + final file = File('${dir.path}/$fileName'); + if (file.existsSync()) file.deleteSync(); + await file.writeAsString(content, flush: true); + return file; + } + + Future _saveToDownloads(String fileName, File file) async { + if (!Platform.isAndroid) return; + const downloadsPath = '/storage/emulated/0/Download'; + final dest = File('$downloadsPath/$fileName'); + if (dest.existsSync()) dest.deleteSync(); + await file.copy(dest.path); + } +} diff --git a/lib/core/lightning_invoice_service.dart b/lib/core/lightning_invoice_service.dart index 995c62d6b..8b29f6c5d 100644 --- a/lib/core/lightning_invoice_service.dart +++ b/lib/core/lightning_invoice_service.dart @@ -1,6 +1,6 @@ -import 'dart:convert'; +import "dart:convert"; -import 'package:cw_core/utils/proxy_wrapper.dart'; +import "package:cw_core/utils/proxy_wrapper.dart"; Future getBolt11FromLightingAddress(String lightningAddress, {int amount = 0}) async { try { @@ -31,28 +31,34 @@ Uri getURlOfLightningAddress(String lightningAddress) { } class _LNURLPResponseDTO { + const _LNURLPResponseDTO( + this.callback, + this.maxSendable, + this.minSendable, + this.tag, + this.metadata, + ); + + factory _LNURLPResponseDTO.fromJson(Map map) => _LNURLPResponseDTO( + map["callback"] as String, + map["maxSendable"] as int, + map["minSendable"] as int, + map["tag"] as String, + map["metadata"] as String, + ); + final String callback; final int maxSendable; final int minSendable; final String tag; final String metadata; - - const _LNURLPResponseDTO( - this.callback, this.maxSendable, this.minSendable, this.tag, this.metadata); - - static _LNURLPResponseDTO fromJson(Map map) => _LNURLPResponseDTO( - map["callback"] as String, - map["maxSendable"] as int, - map["minSendable"] as int, - map["tag"] as String, - map["metadata"] as String); } class _LNURLPCallbackResponseDTO { - final String pr; - const _LNURLPCallbackResponseDTO(this.pr); - static _LNURLPCallbackResponseDTO fromJson(Map map) => + factory _LNURLPCallbackResponseDTO.fromJson(Map map) => _LNURLPCallbackResponseDTO(map["pr"] as String); + + final String pr; } diff --git a/lib/core/node_switching_service.dart b/lib/core/node_switching_service.dart index ef3d384e0..a3bbacb47 100644 --- a/lib/core/node_switching_service.dart +++ b/lib/core/node_switching_service.dart @@ -14,7 +14,6 @@ class NodeSwitchingService { NodeSwitchingService({ required this.appStore, required this.settingsStore, - required this.nodeSource, }); static const int _healthCheckIntervalSeconds = 30; @@ -38,7 +37,6 @@ class NodeSwitchingService { final AppStore appStore; final SettingsStore settingsStore; - final Box nodeSource; final Map> _usedNodeKeys = {}; @@ -118,13 +116,13 @@ class NodeSwitchingService { WalletType walletType, ) async { for (final node in nodes) { - if (!_usedNodeKeys[walletType]!.contains(node.key)) { + if (!_usedNodeKeys[walletType]!.contains(node.id)) { final isActive = await node.requestNode(); if (isActive) { return node; } else { printV('Node ${node.uriRaw} is not active. Marking as used.'); - _usedNodeKeys[walletType]!.add(node.key); + _usedNodeKeys[walletType]!.add(node.id); } } } @@ -157,7 +155,7 @@ class NodeSwitchingService { final currentNode = settingsStore.getCurrentNode(nodeWalletType, chainId: chainId); // Get all trusted nodes for this wallet type - final trustedNodes = nodeSource.values + final trustedNodes = (await Node.getAll()) .where((node) => node.type == nodeWalletType && node.isEnabledForAutoSwitching) .toList(); @@ -171,8 +169,8 @@ class NodeSwitchingService { _usedNodeKeys.putIfAbsent(nodeWalletType, () => []); // Add current node to used list if not already there - if (!_usedNodeKeys[nodeWalletType]!.contains(currentNode.key)) { - _usedNodeKeys[nodeWalletType]!.add(currentNode.key); + if (!_usedNodeKeys[nodeWalletType]!.contains(currentNode.id)) { + _usedNodeKeys[nodeWalletType]!.add(currentNode.id); } // Try to find an active unused node @@ -199,8 +197,8 @@ class NodeSwitchingService { } // Ensure the selected node is marked as used - if (!_usedNodeKeys[nodeWalletType]!.contains(nextNode.key)) { - _usedNodeKeys[nodeWalletType]!.add(nextNode.key); + if (!_usedNodeKeys[nodeWalletType]!.contains(nextNode.id)) { + _usedNodeKeys[nodeWalletType]!.add(nextNode.id); } printV( diff --git a/lib/core/sync_status_title.dart b/lib/core/sync_status_title.dart index a7646e0e3..ff4fdcd9a 100644 --- a/lib/core/sync_status_title.dart +++ b/lib/core/sync_status_title.dart @@ -42,7 +42,7 @@ String syncStatusTitle(SyncStatus syncStatus, SyncStatusDisplayMode syncStatusDi } if (syncStatus is SyncedSyncStatus) { - return ""; + return S.current.sync_synced; } if (syncStatus is FailedSyncStatus) { diff --git a/lib/core/universal_address_detector.dart b/lib/core/universal_address_detector.dart index a83ff4b24..ff1ae9d4c 100644 --- a/lib/core/universal_address_detector.dart +++ b/lib/core/universal_address_detector.dart @@ -238,6 +238,12 @@ class UniversalAddressDetector { currency: CryptoCurrency.doge, ), + // Zcash (transparent, Sapling shielded, unified) + _DetectionPattern( + pattern: RegExp("^(?:${AddressValidator.zcashAddressPattern})\$"), + currency: CryptoCurrency.zec, + ), + // Solana (Base58 format) _DetectionPattern( pattern: RegExp(r'^[1-9A-HJ-NP-Za-km-z]{43,44}$'), diff --git a/lib/di.dart b/lib/di.dart index 723f931d7..6c3ecbc39 100644 --- a/lib/di.dart +++ b/lib/di.dart @@ -6,11 +6,14 @@ import 'package:hash_wallet/anonpay/anonpay_invoice_info.dart'; import 'package:hash_wallet/anypay/anypay_api.dart'; import 'package:hash_wallet/bitcoin/bitcoin.dart'; import 'package:hash_wallet/bitcoin_cash/bitcoin_cash.dart'; +import 'package:hash_wallet/core/address_resolver/address_resolver_service.dart'; +import 'package:hash_wallet/core/anypay/anypay_service.dart'; import 'package:hash_wallet/entities/bitcoin_amount_display_mode.dart'; import 'package:hash_wallet/evm/evm.dart'; import 'package:hash_wallet/buy/moonpay/moonpay_provider.dart'; import 'package:hash_wallet/core/auth_service.dart'; import 'package:hash_wallet/core/backup_service_v3.dart'; +import 'package:hash_wallet/core/csv_export_service.dart'; import 'package:hash_wallet/core/key_service.dart'; import 'package:hash_wallet/core/new_wallet_arguments.dart'; import 'package:hash_wallet/core/new_wallet_type_arguments.dart'; @@ -28,7 +31,6 @@ import 'package:hash_wallet/entities/contact.dart'; import 'package:hash_wallet/entities/contact_record.dart'; import 'package:hash_wallet/entities/exchange_api_mode.dart'; import 'package:hash_wallet/entities/hardware_wallet/require_hardware_wallet_connection.dart'; -import 'package:hash_wallet/entities/parse_address_from_domain.dart'; import 'package:hash_wallet/entities/preferences_key.dart'; import 'package:hash_wallet/entities/qr_view_data.dart'; import 'package:hash_wallet/entities/template.dart'; @@ -40,20 +42,29 @@ import 'package:hash_wallet/exchange/provider/trocador_exchange_provider.dart'; import 'package:hash_wallet/exchange/trade.dart'; import 'package:hash_wallet/monero/monero.dart'; import 'package:hash_wallet/nano/nano.dart'; +import 'package:hash_wallet/new-ui/model/charts/price_store.dart'; import 'package:hash_wallet/new-ui/new_dashboard.dart'; import 'package:hash_wallet/new-ui/pages/about_page.dart'; -import 'package:hash_wallet/new-ui/pages/account_customizer.dart'; import 'package:hash_wallet/new-ui/pages/bridge/bridge_amount_page.dart'; import 'package:hash_wallet/new-ui/pages/bridge/bridge_network_page.dart'; import 'package:hash_wallet/new-ui/pages/bridge/bridge_receiving_wallet_page.dart'; +import 'package:hash_wallet/new-ui/pages/buy_sell/buy_sell_amount_page.dart'; +import 'package:hash_wallet/new-ui/pages/bridge/bridge_network_page.dart'; +import 'package:hash_wallet/new-ui/pages/bridge/bridge_receiving_wallet_page.dart'; +import 'package:hash_wallet/new-ui/pages/charts_page.dart'; import 'package:hash_wallet/new-ui/pages/coin_control_page.dart'; import 'package:hash_wallet/new-ui/pages/addresses_page.dart'; import 'package:hash_wallet/new-ui/pages/home_page.dart'; import 'package:hash_wallet/new-ui/pages/send_page.dart'; +import "package:hash_wallet/new-ui/services/wallet_switch_service.dart"; import 'package:hash_wallet/new-ui/pages/lightning_username_page.dart'; import 'package:hash_wallet/new-ui/pages/receive_page.dart'; +import "package:hash_wallet/new-ui/pages/seed/pre_seed_page.dart"; +import "package:hash_wallet/new-ui/pages/seed/show_keys_disclaimer_page.dart"; +import 'package:hash_wallet/new-ui/viewmodels/charts/charts_bloc.dart'; import 'package:hash_wallet/new-ui/viewmodels/lightning_username/lightning_username_bloc.dart'; import 'package:hash_wallet/new-ui/widgets/addresses_page/address_label_input.dart'; +import 'package:hash_wallet/new-ui/widgets/buy_sell/buy_sell_selector_modal.dart'; import 'package:hash_wallet/new-ui/widgets/coins_page/assets_history/transaction_details_modal.dart'; import 'package:hash_wallet/new-ui/widgets/receive_page/receive_label_modal.dart'; import 'package:hash_wallet/new-ui/pages/swap_page.dart'; @@ -64,22 +75,11 @@ import 'package:hash_wallet/src/screens/anonpay_details/anonpay_details_page.dar import 'package:hash_wallet/src/screens/auth/auth_page.dart'; import 'package:hash_wallet/src/screens/backup/backup_page.dart'; import 'package:hash_wallet/src/screens/backup/edit_backup_password_page.dart'; -import 'package:hash_wallet/src/screens/buy/buy_sell_options_page.dart'; -import 'package:hash_wallet/src/screens/buy/buy_webview_page.dart'; -import 'package:hash_wallet/src/screens/buy/payment_method_options_page.dart'; -import 'package:hash_wallet/src/screens/buy/webview_page.dart'; import 'package:hash_wallet/src/screens/contact/contact_list_page.dart'; import 'package:hash_wallet/src/screens/contact/contact_page.dart'; -import 'package:hash_wallet/src/screens/dashboard/dashboard_page.dart'; -import 'package:hash_wallet/src/screens/dashboard/desktop_dashboard_page.dart'; -import 'package:hash_wallet/src/screens/dashboard/desktop_widgets/desktop_sidebar_wrapper.dart'; -import 'package:hash_wallet/src/screens/dashboard/desktop_widgets/desktop_wallet_selection_dropdown.dart'; import 'package:hash_wallet/src/screens/dashboard/edit_token_page.dart'; import 'package:hash_wallet/src/screens/dashboard/home_settings_page.dart'; -import 'package:hash_wallet/src/screens/dashboard/pages/address_page.dart'; -import 'package:hash_wallet/src/screens/dashboard/pages/balance/balance_page.dart'; import 'package:hash_wallet/src/screens/dashboard/pages/cake_features_page.dart'; -import 'package:hash_wallet/src/screens/dashboard/pages/transactions_page.dart'; import 'package:hash_wallet/src/screens/dev/background_sync_logs_page.dart'; import 'package:hash_wallet/src/screens/dev/exchange_provider_logs_page.dart'; import 'package:hash_wallet/src/screens/dev/monero_background_sync.dart'; @@ -90,43 +90,24 @@ import 'package:hash_wallet/src/screens/dev/qr_tools_page.dart'; import 'package:hash_wallet/src/screens/dev/secure_preferences_page.dart'; import 'package:hash_wallet/src/screens/dev/shared_preferences_page.dart'; import 'package:hash_wallet/src/screens/dev/socket_health_logs_page.dart'; -import 'package:hash_wallet/src/screens/exchange/exchange_page.dart'; -import 'package:hash_wallet/src/screens/exchange/exchange_template_page.dart'; -import 'package:hash_wallet/src/screens/exchange_trade/exchange_confirm_page.dart'; -import 'package:hash_wallet/src/screens/exchange_trade/exchange_trade_external_send_page.dart'; -import 'package:hash_wallet/src/screens/exchange_trade/exchange_trade_page.dart'; -import 'package:hash_wallet/src/screens/faq/faq_page.dart'; import 'package:hash_wallet/src/screens/integrations/deuro/savings_page.dart'; -import 'package:hash_wallet/src/screens/monero_accounts/monero_account_edit_or_create_page.dart'; -import 'package:hash_wallet/src/screens/monero_accounts/monero_account_list_page.dart'; import 'package:hash_wallet/src/screens/nano/nano_change_rep_page.dart'; -import 'package:hash_wallet/src/screens/nano_accounts/nano_account_edit_or_create_page.dart'; -import 'package:hash_wallet/src/screens/nano_accounts/nano_account_list_page.dart'; import 'package:hash_wallet/src/screens/new_wallet/new_wallet_type_page.dart'; import 'package:hash_wallet/src/screens/nodes/node_create_or_edit_page.dart'; import 'package:hash_wallet/src/screens/nodes/pow_node_create_or_edit_page.dart'; import 'package:hash_wallet/src/screens/order_details/order_details_page.dart'; import 'package:hash_wallet/src/screens/payjoin_details/payjoin_details_page.dart'; import 'package:hash_wallet/src/screens/pin_code/pin_code_widget.dart'; -import 'package:hash_wallet/src/screens/receive/address_list_page.dart'; -import 'package:hash_wallet/src/screens/receive/anonpay_invoice_page.dart'; -import 'package:hash_wallet/src/screens/receive/anonpay_receive_page.dart'; import 'package:hash_wallet/src/screens/receive/fullscreen_qr_page.dart'; -import 'package:hash_wallet/src/screens/receive/receive_page.dart'; import 'package:hash_wallet/src/screens/rescan/rescan_page.dart'; import 'package:hash_wallet/src/screens/restore/restore_from_backup_page.dart'; import 'package:hash_wallet/src/screens/restore/restore_options_page.dart'; import 'package:hash_wallet/src/screens/restore/wallet_restore_choose_derivation.dart'; import 'package:hash_wallet/src/screens/restore/wallet_restore_page.dart'; -import 'package:hash_wallet/src/screens/seed/pre_seed_page.dart'; import 'package:hash_wallet/src/screens/seed/seed_verification/seed_verification_page.dart'; import 'package:hash_wallet/src/screens/seed/wallet_seed_page.dart'; -import 'package:hash_wallet/src/screens/send/send_page.dart'; -import 'package:hash_wallet/src/screens/send/send_template_page.dart'; -import 'package:hash_wallet/src/screens/send/transaction_success_info_page.dart'; import 'package:hash_wallet/src/screens/settings/background_sync_page.dart'; import 'package:hash_wallet/src/screens/settings/connection_sync_page.dart'; -import 'package:hash_wallet/src/screens/settings/desktop_settings/desktop_settings_page.dart'; import 'package:hash_wallet/src/screens/settings/display_settings_page.dart'; import 'package:hash_wallet/src/screens/settings/domain_lookups_page.dart'; import 'package:hash_wallet/src/screens/settings/manage_nodes_page.dart'; @@ -138,7 +119,7 @@ import 'package:hash_wallet/src/screens/settings/privacy_page.dart'; import 'package:hash_wallet/src/screens/settings/security_backup_page.dart'; import 'package:hash_wallet/src/screens/settings/silent_payments_logs_page.dart'; import 'package:hash_wallet/src/screens/settings/silent_payments_settings.dart'; -import 'package:hash_wallet/src/screens/settings/trocador_providers_page.dart'; +import 'package:hash_wallet/new-ui/pages/wallet_accounts_page.dart'; import 'package:hash_wallet/src/screens/setup_2fa/modify_2fa_page.dart'; import 'package:hash_wallet/src/screens/setup_2fa/setup_2fa.dart'; import 'package:hash_wallet/src/screens/setup_2fa/setup_2fa_enter_code_page.dart'; @@ -146,7 +127,6 @@ import 'package:hash_wallet/src/screens/setup_2fa/setup_2fa_info_page.dart'; import 'package:hash_wallet/src/screens/setup_2fa/setup_2fa_qr_page.dart'; import 'package:hash_wallet/src/screens/setup_pin_code/setup_pin_code.dart'; import 'package:hash_wallet/src/screens/start_tor/start_tor_page.dart'; -import 'package:hash_wallet/src/screens/subaddress/address_edit_or_create_page.dart'; import 'package:hash_wallet/src/screens/support/support_page.dart'; import 'package:hash_wallet/src/screens/support_chat/support_chat_page.dart'; import 'package:hash_wallet/src/screens/support_other_links/support_other_links_page.dart'; @@ -154,10 +134,8 @@ import 'package:hash_wallet/src/screens/trade_details/trade_details_page.dart'; import 'package:hash_wallet/src/screens/transaction_details/rbf_details_page.dart'; import 'package:hash_wallet/src/screens/transaction_details/transaction_details_page.dart'; import 'package:hash_wallet/src/screens/unspent_coins/unspent_coins_details_page.dart'; -import 'package:hash_wallet/src/screens/unspent_coins/unspent_coins_list_page.dart'; import 'package:hash_wallet/src/screens/ur/animated_ur_page.dart'; import 'package:hash_wallet/new-ui/pages/bridge/bridge_detail_page.dart'; -import 'package:hash_wallet/new-ui/pages/bridge/bridge_history_page.dart'; import 'package:hash_wallet/src/screens/wallet/wallet_edit_page.dart'; import 'package:hash_wallet/src/screens/wallet_connect/services/bottom_sheet_service.dart'; import 'package:hash_wallet/src/screens/wallet_connect/services/key_service/wallet_connect_key_service.dart'; @@ -181,19 +159,14 @@ import 'package:hash_wallet/store/dashboard/trade_filter_store.dart'; import 'package:hash_wallet/store/bridge_transfers_store.dart'; import 'package:hash_wallet/store/dashboard/trades_store.dart'; import 'package:hash_wallet/store/dashboard/transaction_filter_store.dart'; -import 'package:hash_wallet/store/node_list_store.dart'; import 'package:hash_wallet/store/seed_settings_store.dart'; import 'package:hash_wallet/store/settings_store.dart'; -import 'package:hash_wallet/store/templates/exchange_template_store.dart'; -import 'package:hash_wallet/store/templates/send_template_store.dart'; import 'package:hash_wallet/store/wallet_list_store.dart'; -import 'package:hash_wallet/store/yat/yat_store.dart'; import 'package:hash_wallet/themes/core/theme_store.dart'; import 'package:hash_wallet/utils/device_info.dart'; import 'package:hash_wallet/utils/payment_request.dart'; import 'package:hash_wallet/view_model/advanced_privacy_settings_view_model.dart'; import 'package:hash_wallet/view_model/animated_ur_model.dart'; -import 'package:hash_wallet/view_model/anon_invoice_page_view_model.dart'; import 'package:hash_wallet/view_model/anonpay_details_view_model.dart'; import 'package:hash_wallet/view_model/auth_view_model.dart'; import 'package:hash_wallet/view_model/backup_view_model.dart'; @@ -201,13 +174,12 @@ import 'package:hash_wallet/view_model/bridge/bridge_details_view_model.dart'; import 'package:hash_wallet/view_model/bridge/bridge_history_view_model.dart'; import 'package:hash_wallet/view_model/buy/buy_amount_view_model.dart'; import 'package:hash_wallet/view_model/buy/buy_sell_view_model.dart'; -import 'package:hash_wallet/view_model/buy/buy_view_model.dart'; import 'package:hash_wallet/view_model/contact_list/contact_list_view_model.dart'; import 'package:hash_wallet/view_model/contact_list/contact_view_model.dart'; import 'package:hash_wallet/view_model/dashboard/balance_view_model.dart'; import 'package:hash_wallet/view_model/dashboard/dashboard_view_model.dart'; -import 'package:hash_wallet/view_model/dashboard/desktop_sidebar_view_model.dart'; import 'package:hash_wallet/view_model/dashboard/home_settings_view_model.dart'; +import "package:hash_wallet/view_model/dashboard/nft_send_view_model.dart"; import 'package:hash_wallet/view_model/dashboard/nft_view_model.dart'; import 'package:hash_wallet/view_model/dashboard/receive_option_view_model.dart'; import 'package:hash_wallet/view_model/dashboard/sign_view_model.dart'; @@ -223,17 +195,19 @@ import 'package:hash_wallet/view_model/exchange/exchange_view_model.dart'; import 'package:hash_wallet/view_model/hardware_wallet/bitbox_view_model.dart'; import 'package:hash_wallet/view_model/hardware_wallet/hardware_wallet_view_model.dart'; import 'package:hash_wallet/view_model/hardware_wallet/ledger_view_model.dart'; -import 'package:hash_wallet/view_model/hardware_wallet/trezor_view_model.dart'; +import 'package:hash_wallet/view_model/hardware_wallet/trezor_connect_view_model.dart'; import 'package:hash_wallet/view_model/integrations/deuro_view_model.dart'; import 'package:hash_wallet/view_model/link_view_model.dart'; -import 'package:hash_wallet/view_model/monero_account_list/account_list_item.dart'; -import 'package:hash_wallet/view_model/monero_account_list/monero_account_edit_or_create_view_model.dart'; -import 'package:hash_wallet/view_model/monero_account_list/monero_account_list_view_model.dart'; -import 'package:hash_wallet/view_model/nano_account_list/nano_account_edit_or_create_view_model.dart'; -import 'package:hash_wallet/view_model/nano_account_list/nano_account_list_view_model.dart'; +import "package:hash_wallet/view_model/wallet_account_list/account_edit_or_create_view_model.dart"; +import 'package:hash_wallet/view_model/wallet_account_list/account_list_item.dart'; +import 'package:hash_wallet/view_model/wallet_account_list/bitcoin_account_list/bitcoin_account_edit_or_create_view_model.dart'; +import 'package:hash_wallet/view_model/wallet_account_list/bitcoin_account_list/bitcoin_account_list_view_model.dart'; +import 'package:hash_wallet/view_model/wallet_account_list/monero_account_list/monero_account_edit_or_create_view_model.dart'; +import 'package:hash_wallet/view_model/wallet_account_list/monero_account_list/monero_account_list_view_model.dart'; +import 'package:hash_wallet/view_model/wallet_account_list/nano_account_list/nano_account_edit_or_create_view_model.dart'; +import 'package:hash_wallet/view_model/wallet_account_list/nano_account_list/nano_account_list_view_model.dart'; import 'package:hash_wallet/view_model/node_list/node_create_or_edit_view_model.dart'; import 'package:hash_wallet/view_model/node_list/node_list_view_model.dart'; -import 'package:hash_wallet/view_model/node_list/pow_node_list_view_model.dart'; import 'package:hash_wallet/view_model/order_details_view_model.dart'; import 'package:hash_wallet/view_model/payjoin_details_view_model.dart'; import 'package:hash_wallet/view_model/payment/payment_view_model.dart'; @@ -242,7 +216,6 @@ import 'package:hash_wallet/view_model/restore/restore_wallet.dart'; import 'package:hash_wallet/view_model/restore_from_backup_view_model.dart'; import 'package:hash_wallet/view_model/seed_settings_view_model.dart'; import 'package:hash_wallet/view_model/send/fees_view_model.dart'; -import 'package:hash_wallet/view_model/send/send_template_view_model.dart'; import 'package:hash_wallet/view_model/send/send_view_model.dart'; import 'package:hash_wallet/view_model/set_up_2fa_viewmodel.dart'; import 'package:hash_wallet/view_model/settings/connection_sync_view_model.dart'; @@ -262,6 +235,7 @@ import 'package:hash_wallet/view_model/unspent_coins/unspent_coins_details_view_ import 'package:hash_wallet/view_model/unspent_coins/unspent_coins_item.dart'; import 'package:hash_wallet/view_model/unspent_coins/unspent_coins_list_view_model.dart'; import 'package:hash_wallet/view_model/bridge/bridge_view_model.dart'; +import 'package:hash_wallet/view_model/wallet_account_list/wallet_account_list_view_model.dart'; import 'package:hash_wallet/view_model/wallet_address_list/wallet_address_edit_or_create_view_model.dart'; import 'package:hash_wallet/view_model/wallet_address_list/wallet_address_list_item.dart'; import 'package:hash_wallet/view_model/wallet_address_list/wallet_address_list_view_model.dart'; @@ -302,11 +276,12 @@ import 'new-ui/viewmodels/card_customizer/card_customizer_bloc.dart'; import 'new-ui/widgets/addresses_page/address_info.dart'; import 'src/screens/buy/buy_sell_page.dart'; + final getIt = GetIt.instance; var _isSetupFinished = false; -late Box _nodeSource; -late Box _powNodeSource; +// late Box _nodeSource; +// late Box _powNodeSource; late Box _contactSource; late Box