diff --git a/.circleci/config.yml b/.circleci/config.yml index 227fe17c63..ce6bbf961c 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -3,7 +3,7 @@ jobs: lint: docker: - - image: cimg/node:16.20.2 + - image: cimg/node:20.17.0 working_directory: ~/repo @@ -26,7 +26,7 @@ jobs: unit: docker: - - image: cimg/node:16.20.2 + - image: cimg/node:20.17.0 working_directory: ~/repo @@ -50,7 +50,10 @@ jobs: integration: docker: - - image: cimg/node:16.20.2 + - image: cimg/node:20.17.0 + + environment: + RETRY: "1" working_directory: ~/repo @@ -71,7 +74,7 @@ jobs: # run tests! - run: - command: npm run jest || npm run jest || npm run jest + command: npm run jest || npm run jest || npm run jest || npm run jest # Orchestrate our job run sequence workflows: diff --git a/.detoxrc.json b/.detoxrc.json index 5adda885c9..b59fd439c1 100644 --- a/.detoxrc.json +++ b/.detoxrc.json @@ -21,7 +21,7 @@ "type": "android.apk", "testBinaryPath": "android/app/build/outputs/apk/androidTest/release/app-release-androidTest.apk", "binaryPath": "android/app/build/outputs/apk/release/app-release.apk", - "build": "find android | grep '\\.apk' --color=never | xargs -l rm\n\n# creating fresh keystore\nrm detox.keystore\nkeytool -genkeypair -v -keystore detox.keystore -alias detox -keyalg RSA -keysize 2048 -validity 10000 -storepass 123456 -keypass 123456 -dname 'cn=Unknown, ou=Unknown, o=Unknown, c=Unknown'\n\n# building release APK\ncd android && ./gradlew assembleRelease assembleAndroidTest -DtestBuildType=release && cd ..\n\n# wip\nfind $ANDROID_HOME | grep apksigner\n\n# signing\nmv ./android/app/build/outputs/apk/release/app-release-unsigned.apk ./android/app/build/outputs/apk/release/app-release.apk\n$ANDROID_HOME/build-tools/30.0.2/apksigner sign --ks detox.keystore --ks-pass=pass:123456 ./android/app/build/outputs/apk/release/app-release.apk\n$ANDROID_HOME/build-tools/30.0.2/apksigner sign --ks detox.keystore --ks-pass=pass:123456 ./android/app/build/outputs/apk/androidTest/release/app-release-androidTest.apk" + "build": "./tests/e2e/detox-build-release-apk.sh" } }, "devices": { diff --git a/.eslintrc b/.eslintrc index 7bc38aa980..cd5a4bb7b2 100644 --- a/.eslintrc +++ b/.eslintrc @@ -2,7 +2,7 @@ "parser": "@typescript-eslint/parser", "plugins": [ "@typescript-eslint", - "react-native" // for no-inline-styles rule + "react-native", // for no-inline-styles rule ], "extends": [ "standard", @@ -11,7 +11,7 @@ "plugin:react-hooks/recommended", "plugin:react/recommended", "plugin:@typescript-eslint/recommended", - "@react-native-community", + "@react-native", "plugin:prettier/recommended" // removes all eslint rules that can mess up with prettier ], "rules": { @@ -19,6 +19,7 @@ "react/display-name": "off", "react-native/no-inline-styles": "error", "react-native/no-unused-styles": "error", + "react/no-is-mounted": "off", "react-native/no-single-element-style-arrays": "error", "prettier/prettier": [ "warn", @@ -49,7 +50,7 @@ "@typescript-eslint/explicit-module-boundary-types": "off", "@typescript-eslint/no-var-requires": "off", "@typescript-eslint/no-this-alias": "off", - "@typescript-eslint/no-use-before-define": "off" + "@typescript-eslint/no-use-before-define": "off", }, "overrides": [ { diff --git a/.github/workflows/build-ios-release-pullrequest.yml b/.github/workflows/build-ios-release-pullrequest.yml new file mode 100644 index 0000000000..0e6b1f6a0e --- /dev/null +++ b/.github/workflows/build-ios-release-pullrequest.yml @@ -0,0 +1,249 @@ +name: Build Release and Upload to TestFlight (iOS) + +on: + push: + branches: + - master + pull_request: + types: [opened, reopened, synchronize, labeled] + branches: + - master + workflow_dispatch: + +jobs: + build: + runs-on: macos-15 + timeout-minutes: 180 + outputs: + new_build_number: ${{ steps.generate_build_number.outputs.build_number }} + project_version: ${{ steps.determine_marketing_version.outputs.project_version }} + ipa_output_path: ${{ steps.build_app.outputs.ipa_output_path }} + latest_commit_message: ${{ steps.get_latest_commit_details.outputs.commit_message }} + branch_name: ${{ steps.get_latest_commit_details.outputs.branch_name }} + env: + APPLE_ID: ${{ secrets.APPLE_ID }} + + steps: + - name: Checkout Project + uses: actions/checkout@v4 + with: + fetch-depth: 0 # Ensures the full Git history is available + + - name: Ensure Correct Branch + if: github.ref != 'refs/heads/master' + run: | + if [ -n "${GITHUB_HEAD_REF}" ]; then + git fetch origin ${GITHUB_HEAD_REF}:${GITHUB_HEAD_REF} + git checkout ${GITHUB_HEAD_REF} + else + git fetch origin ${GITHUB_REF##*/}:${GITHUB_REF##*/} + git checkout ${GITHUB_REF##*/} + fi + echo "Checked out branch: $(git rev-parse --abbrev-ref HEAD)" + + - name: Get Latest Commit Details + id: get_latest_commit_details + run: | + # Check if we are in a detached HEAD state + if [ "$(git rev-parse --abbrev-ref HEAD)" == "HEAD" ]; then + CURRENT_BRANCH=$(git show-ref --head -s HEAD | xargs -I {} git branch --contains {} | grep -v "detached" | head -n 1 | sed 's/^[* ]*//') + else + CURRENT_BRANCH=$(git rev-parse --abbrev-ref HEAD) + fi + + LATEST_COMMIT_MESSAGE=$(git log -1 --pretty=format:"%s") + + echo "CURRENT_BRANCH=${CURRENT_BRANCH}" >> $GITHUB_ENV + echo "LATEST_COMMIT_MESSAGE=${LATEST_COMMIT_MESSAGE}" >> $GITHUB_ENV + echo "branch_name=${CURRENT_BRANCH}" >> $GITHUB_OUTPUT + echo "commit_message=${LATEST_COMMIT_MESSAGE}" >> $GITHUB_OUTPUT + + - name: Print Commit Details + run: | + echo "Commit Message: ${{ env.LATEST_COMMIT_MESSAGE }}" + echo "Branch Name: ${{ env.CURRENT_BRANCH }}" + + - name: Specify Node.js Version + uses: actions/setup-node@v4 + with: + node-version: 20 + + - uses: maxim-lobanov/setup-xcode@v1 + with: + xcode-version: 16.0 + + - name: Set Up Ruby + uses: ruby/setup-ruby@v1 + with: + ruby-version: 3.1.6 + + - name: Install Dependencies with Bundler + run: | + bundle config path vendor/bundle + bundle install --jobs 4 --retry 3 --quiet + + - name: Install Node Modules + run: npm install --omit=dev --yes + + - name: Install CocoaPods Dependencies + run: | + bundle exec fastlane ios install_pods + + - name: Generate Build Number Based on Timestamp + id: generate_build_number + run: | + NEW_BUILD_NUMBER=$(date +%s) + echo "NEW_BUILD_NUMBER=$NEW_BUILD_NUMBER" >> $GITHUB_ENV + echo "build_number=$NEW_BUILD_NUMBER" >> $GITHUB_OUTPUT + + - name: Set Build Number + run: bundle exec fastlane ios increment_build_number_lane + + - name: Determine Marketing Version + id: determine_marketing_version + run: | + MARKETING_VERSION=$(grep MARKETING_VERSION BlueWallet.xcodeproj/project.pbxproj | awk -F '= ' '{print $2}' | tr -d ' ;' | head -1) + echo "PROJECT_VERSION=$MARKETING_VERSION" >> $GITHUB_ENV + echo "project_version=$MARKETING_VERSION" >> $GITHUB_OUTPUT + working-directory: ios + + - name: Set Up Git Authentication + env: + ACCESS_TOKEN: ${{ secrets.GIT_ACCESS_TOKEN }} + run: | + git config --global credential.helper 'cache --timeout=3600' + git config --global http.https://github.com/.extraheader "AUTHORIZATION: basic $(echo -n x-access-token:${ACCESS_TOKEN} | base64)" + + - name: Create Temporary Keychain + run: bundle exec fastlane ios create_temp_keychain + env: + KEYCHAIN_PASSWORD: ${{ secrets.KEYCHAIN_PASSWORD }} + + - name: Setup Provisioning Profiles + env: + MATCH_PASSWORD: ${{ secrets.MATCH_PASSWORD }} + GIT_ACCESS_TOKEN: ${{ secrets.GIT_ACCESS_TOKEN }} + GIT_URL: ${{ secrets.GIT_URL }} + ITC_TEAM_ID: ${{ secrets.ITC_TEAM_ID }} + ITC_TEAM_NAME: ${{ secrets.ITC_TEAM_NAME }} + KEYCHAIN_PASSWORD: ${{ secrets.KEYCHAIN_PASSWORD }} + run: | + bundle exec fastlane ios setup_provisioning_profiles + + - name: Build App + id: build_app + run: | + bundle exec fastlane ios build_app_lane --verbose + echo "ipa_output_path=$IPA_OUTPUT_PATH" >> $GITHUB_OUTPUT # Set the IPA output path for future jobs + + - name: Upload Bugsnag Sourcemaps + if: success() + run: bundle exec fastlane ios upload_bugsnag_sourcemaps + env: + BUGSNAG_API_KEY: ${{ secrets.BUGSNAG_API_KEY }} + BUGSNAG_RELEASE_STAGE: production + PROJECT_VERSION: ${{ needs.build.outputs.project_version }} + NEW_BUILD_NUMBER: ${{ needs.build.outputs.new_build_number }} + + - name: Upload Build Logs + if: always() + uses: actions/upload-artifact@v4 + with: + name: build_logs + path: ./ios/build_logs/ + + - name: Upload IPA as Artifact + if: success() + uses: actions/upload-artifact@v4 + with: + name: BlueWallet_${{env.PROJECT_VERSION}}_${{env.NEW_BUILD_NUMBER}}.ipa + path: ${{ env.IPA_OUTPUT_PATH }} # Directly from Fastfile `IPA_OUTPUT_PATH` + + testflight-upload: + needs: build + runs-on: macos-15 + if: github.event_name == 'push' || contains(github.event.pull_request.labels.*.name, 'testflight') + env: + APPLE_ID: ${{ secrets.APPLE_ID }} + NEW_BUILD_NUMBER: ${{ needs.build.outputs.new_build_number }} + PROJECT_VERSION: ${{ needs.build.outputs.project_version }} + LATEST_COMMIT_MESSAGE: ${{ needs.build.outputs.latest_commit_message }} + BRANCH_NAME: ${{ needs.build.outputs.branch_name }} + steps: + - name: Checkout Project + uses: actions/checkout@v4 + + - name: Set Up Ruby + uses: ruby/setup-ruby@v1 + with: + ruby-version: 3.1.6 + + - name: Install Dependencies with Bundler + run: | + bundle config path vendor/bundle + bundle install --jobs 4 --retry 3 --quiet + + - name: Download IPA from Artifact + uses: actions/download-artifact@v4 + with: + name: BlueWallet_${{ needs.build.outputs.project_version }}_${{ needs.build.outputs.new_build_number }}.ipa + path: ./ + + - name: Create App Store Connect API Key JSON + run: echo '${{ secrets.APP_STORE_CONNECT_API_KEY_CONTENT }}' > ./appstore_api_key.json + + - name: Verify IPA File Download + run: | + echo "Current directory:" + pwd + echo "Files in current directory:" + ls -la ./ + + - name: Set IPA Path Environment Variable + run: echo "IPA_OUTPUT_PATH=$(pwd)/BlueWallet_${{ needs.build.outputs.project_version }}_${{ needs.build.outputs.new_build_number }}.ipa" >> $GITHUB_ENV + + - name: Verify IPA Path Before Upload + run: | + if [ ! -f "$IPA_OUTPUT_PATH" ]; then + echo "IPA file not found at path: $IPA_OUTPUT_PATH" + exit 1 + fi + + - name: Print Environment Variables for Debugging + run: | + echo "LATEST_COMMIT_MESSAGE: $LATEST_COMMIT_MESSAGE" + echo "BRANCH_NAME: $BRANCH_NAME" + + - name: Upload to TestFlight + run: | + ls -la $IPA_OUTPUT_PATH + bundle exec fastlane ios upload_to_testflight_lane + env: + APP_STORE_CONNECT_API_KEY_PATH: $(pwd)/appstore_api_key.p8 + MATCH_PASSWORD: ${{ secrets.MATCH_PASSWORD }} + GIT_ACCESS_TOKEN: ${{ secrets.GIT_ACCESS_TOKEN }} + GIT_URL: ${{ secrets.GIT_URL }} + ITC_TEAM_ID: ${{ secrets.ITC_TEAM_ID }} + ITC_TEAM_NAME: ${{ secrets.ITC_TEAM_NAME }} + APP_STORE_CONNECT_API_KEY_KEY_ID: ${{ secrets.APP_STORE_CONNECT_API_KEY_KEY_ID }} + APP_STORE_CONNECT_API_KEY_ISSUER_ID: ${{ secrets.APP_STORE_CONNECT_API_KEY_ISSUER_ID }} + KEYCHAIN_PASSWORD: ${{ secrets.KEYCHAIN_PASSWORD }} + IPA_OUTPUT_PATH: ${{ env.IPA_OUTPUT_PATH }} + + - name: Post PR Comment + if: success() && github.event_name == 'pull_request' + uses: actions/github-script@v6 + env: + BUILD_NUMBER: ${{ needs.build.outputs.new_build_number }} + LATEST_COMMIT_MESSAGE: ${{ needs.build.outputs.latest_commit_message }} + with: + script: | + const buildNumber = process.env.BUILD_NUMBER; + const message = `The build ${buildNumber} has been uploaded to TestFlight.`; + const prNumber = context.payload.pull_request.number; + const repo = context.repo; + github.rest.issues.createComment({ + ...repo, + issue_number: prNumber, + body: message, + }); \ No newline at end of file diff --git a/.github/workflows/build-release-apk.yml b/.github/workflows/build-release-apk.yml index e752696292..09a54fbf91 100644 --- a/.github/workflows/build-release-apk.yml +++ b/.github/workflows/build-release-apk.yml @@ -1,23 +1,31 @@ name: BuildReleaseApk -on: [pull_request] + +on: + pull_request: + branches: + - master + types: [opened, synchronize, reopened] + push: + branches: + - master jobs: buildReleaseApk: - runs-on: macos-latest + runs-on: ubuntu-latest steps: - name: Checkout project - uses: actions/checkout@v3 + uses: actions/checkout@v4 with: fetch-depth: "0" - name: Specify node version - uses: actions/setup-node@v2-beta + uses: actions/setup-node@v4 with: - node-version: 16 + node-version: 20 - name: Use npm caches - uses: actions/cache@v2 + uses: actions/cache@v4 with: path: ~/.npm key: ${{ runner.os }}-npm-${{ hashFiles('**/package-lock.json') }} @@ -25,23 +33,103 @@ jobs: ${{ runner.os }}-npm- - name: Use specific Java version for sdkmanager to work - uses: actions/setup-java@v3 + uses: actions/setup-java@v4 with: distribution: 'temurin' - java-version: '11' + java-version: '17' cache: 'gradle' - name: Install node_modules - run: npm install --production + run: npm install --omit=dev --yes + + - name: Set up Ruby + uses: ruby/setup-ruby@v1 + with: + ruby-version: 3.1.6 + bundler-cache: true + + - name: Cache Ruby Gems + uses: actions/cache@v4 + with: + path: vendor/bundle + key: ${{ runner.os }}-gems-${{ hashFiles('**/Gemfile.lock') }} + restore-keys: | + ${{ runner.os }}-gems- + + - name: Generate Build Number based on timestamp + id: build_number + run: | + NEW_BUILD_NUMBER="$(date +%s)" + echo "NEW_BUILD_NUMBER=$NEW_BUILD_NUMBER" >> $GITHUB_ENV - - name: Build + - name: Prepare Keystore + run: bundle exec fastlane android prepare_keystore env: KEYSTORE_FILE_HEX: ${{ secrets.KEYSTORE_FILE_HEX }} + + - name: Update Version Code, Build, and Sign APK + id: build_and_sign_apk + run: | + bundle exec fastlane android update_version_build_and_sign_apk + env: + BUILD_NUMBER: ${{ env.NEW_BUILD_NUMBER }} KEYSTORE_PASSWORD: ${{ secrets.KEYSTORE_PASSWORD }} - run: ./scripts/build-release-apk.sh - - uses: actions/upload-artifact@v2 - if: success() + - name: Determine APK Filename and Path + id: determine_apk_path + run: | + VERSION_NAME=$(grep versionName android/app/build.gradle | awk '{print $2}' | tr -d '"') + BRANCH_NAME=${GITHUB_HEAD_REF:-${GITHUB_REF_NAME}} + BRANCH_NAME=$(echo "$BRANCH_NAME" | sed 's/[^a-zA-Z0-9_-]/_/g') + + if [ -n "$BRANCH_NAME" ] && [ "$BRANCH_NAME" != "master" ]; then + EXPECTED_FILENAME="BlueWallet-${VERSION_NAME}-${NEW_BUILD_NUMBER}-${BRANCH_NAME}.apk" + else + EXPECTED_FILENAME="BlueWallet-${VERSION_NAME}-${NEW_BUILD_NUMBER}.apk" + fi + + APK_PATH="android/app/build/outputs/apk/release/${EXPECTED_FILENAME}" + echo "EXPECTED_FILENAME=${EXPECTED_FILENAME}" >> $GITHUB_ENV + echo "APK_PATH=${APK_PATH}" >> $GITHUB_ENV + + - name: Upload APK as artifact + uses: actions/upload-artifact@v4 with: - name: apk - path: ./android/app/build/outputs/apk/release/app-release.apk + name: signed-apk + path: ${{ env.APK_PATH }} + + browserstack: + runs-on: ubuntu-latest + needs: buildReleaseApk + if: ${{ github.event_name == 'pull_request' }} + + steps: + - name: Checkout code + uses: actions/checkout@v3 + + - name: Set up Ruby + uses: ruby/setup-ruby@v1 + with: + ruby-version: 3.1.6 + bundler-cache: true + + - name: Install dependencies with Bundler + run: bundle install --jobs 4 --retry 3 + + - name: Download APK artifact + uses: actions/download-artifact@v4 + with: + name: signed-apk + + - name: Set APK Path + run: | + APK_PATH=$(find ${{ github.workspace }} -name '*.apk') + echo "APK_PATH=$APK_PATH" >> $GITHUB_ENV + + - name: Upload APK to BrowserStack and Post PR Comment + env: + BROWSERSTACK_USERNAME: ${{ secrets.BROWSERSTACK_USERNAME }} + BROWSERSTACK_ACCESS_KEY: ${{ secrets.BROWSERSTACK_ACCESS_KEY }} + GITHUB_PR_NUMBER: ${{ github.event.pull_request.number }} + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: bundle exec fastlane upload_to_browserstack_and_comment \ No newline at end of file diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 4ecd193041..372322d671 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -3,41 +3,39 @@ name: Tests # https://dev.to/edvinasbartkus/running-react-native-detox-tests-for-ios-and-android-on-github-actions-2ekn # https://medium.com/@reime005/the-best-ci-cd-for-react-native-with-e2e-support-4860b4aaab29 +env: + TRAVIS: 1 + HD_MNEMONIC: ${{ secrets.HD_MNEMONIC }} + HD_MNEMONIC_BIP84: ${{ secrets.HD_MNEMONIC_BIP84 }} + on: [pull_request] jobs: test: - runs-on: macos-latest + runs-on: ubuntu-latest steps: - name: Checkout project - uses: actions/checkout@v3 + uses: actions/checkout@v4 - name: Specify node version - uses: actions/setup-node@v2-beta + uses: actions/setup-node@v4 with: - node-version: 16 + node-version: 20 - name: Use npm caches - uses: actions/cache@v2 + uses: actions/cache@v4 with: path: ~/.npm key: ${{ runner.os }}-npm-${{ hashFiles('**/package-lock.json') }} restore-keys: | ${{ runner.os }}-npm- - - name: Use node_modules caches - id: cache-nm - uses: actions/cache@v2 - with: - path: node_modules - key: ${{ runner.os }}-nm-${{ hashFiles('package-lock.json') }} - - name: Install node_modules if: steps.cache-nm.outputs.cache-hit != 'true' - run: npm install + run: npm ci - name: Run tests - run: npm test || npm test || npm test + run: npm test || npm test || npm test || npm test env: BIP47_HD_MNEMONIC: ${{ secrets.BIP47_HD_MNEMONIC}} HD_MNEMONIC: ${{ secrets.HD_MNEMONIC }} @@ -48,28 +46,63 @@ jobs: FAULTY_ZPUB: ${{ secrets.FAULTY_ZPUB }} MNEMONICS_COBO: ${{ secrets.MNEMONICS_COBO }} MNEMONICS_COLDCARD: ${{ secrets.MNEMONICS_COLDCARD }} + RETRY: 1 + + - name: Prune devDependencies + run: npm prune --omit=dev e2e: - runs-on: macos-latest + runs-on: ubuntu-latest steps: - name: checkout - uses: actions/checkout@v3 + uses: actions/checkout@v4 + with: + fetch-depth: 1 + + - name: Free Disk Space + uses: jlumbroso/free-disk-space@main + with: + tool-cache: true + android: false + dotnet: true + haskell: true + large-packages: true + docker-images: true + swap-storage: true + + - name: npm and gradle caches in /mnt + run: | + rm -rf ~/.npm + rm -rf ~/.gradle + sudo mkdir -p /mnt/.npm + sudo mkdir -p /mnt/.gradle + sudo chown -R runner /mnt/.npm + sudo chown -R runner /mnt/.gradle + ln -s /mnt/.npm /home/runner/ + ln -s /mnt/.gradle /home/runner/ + + - name: Create artifacts directory on /mnt + run: | + sudo mkdir -p /mnt/artifacts + sudo chown -R runner /mnt/artifacts - name: Specify node version - uses: actions/setup-node@v2-beta + uses: actions/setup-node@v4 with: - node-version: 16 + node-version: 20 - name: Use gradle caches - uses: actions/cache@v2 + uses: actions/cache@v4 with: - path: ~/.gradle/caches - key: ${{ runner.os }}-gradle-${{ hashFiles('**/*.gradle*') }} + path: | + ~/.gradle/caches + ~/.gradle/wrapper + key: ${{ runner.os }}-gradle-${{ hashFiles('**/*.gradle*', '**/gradle-wrapper.properties') }} restore-keys: | ${{ runner.os }}-gradle- - name: Use npm caches - uses: actions/cache@v2 + uses: actions/cache@v4 with: path: ~/.npm key: ${{ runner.os }}-npm-${{ hashFiles('**/package-lock.json') }} @@ -77,32 +110,35 @@ jobs: ${{ runner.os }}-npm- - name: Install node_modules - run: npm install + run: npm install || npm install - name: Use specific Java version for sdkmanager to work - uses: actions/setup-java@v2 + uses: actions/setup-java@v4 with: distribution: 'temurin' - java-version: '11' + java-version: '17' + + - name: Enable KVM group perms + 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 - name: Build run: npm run e2e:release-build - - name: run tests + - name: Run tests uses: reactivecircus/android-emulator-runner@v2 with: api-level: 31 avd-name: Pixel_API_29_AOSP + force-avd-creation: false emulator-options: -no-window -gpu swiftshader_indirect -no-snapshot -noaudio -no-boot-anim -camera-back none -camera-front none -partition-size 2047 arch: x86_64 - script: npm run e2e:release-test || npm run e2e:release-test || npm run e2e:release-test || npm run e2e:release-test - env: - TRAVIS: 1 - HD_MNEMONIC: ${{ secrets.HD_MNEMONIC }} - HD_MNEMONIC_BIP84: ${{ secrets.HD_MNEMONIC_BIP84 }} + script: npm run e2e:release-test -- --record-videos all --record-logs all --take-screenshots all --headless -d 200000 -R 5 --artifacts-location /mnt/artifacts - - uses: actions/upload-artifact@v2 + - uses: actions/upload-artifact@v4 if: failure() with: name: e2e-test-videos - path: ./artifacts/ + path: /mnt/artifacts/ diff --git a/.github/workflows/lockfiles_update.yml b/.github/workflows/lockfiles_update.yml new file mode 100644 index 0000000000..0ccd04c8db --- /dev/null +++ b/.github/workflows/lockfiles_update.yml @@ -0,0 +1,97 @@ +name: Lock Files Update + +on: + workflow_dispatch: + push: + branches: + - master + +jobs: + pod-update: + runs-on: macos-15 + permissions: + contents: write + steps: + + - name: Checkout master branch + uses: actions/checkout@v4 + with: + ref: master # Ensures we're checking out the master branch + fetch-depth: 0 # Ensures full history to enable branch deletion and recreation + + - name: Delete existing branch + run: | + git push origin --delete pod-update-branch || echo "Branch does not exist, continuing..." + git branch -D pod-update-branch || echo "Local branch does not exist, continuing..." + + - name: Create new branch from master + run: git checkout -b pod-update-branch # Create a new branch from the master branch + + - name: Specify node version + uses: actions/setup-node@v4 + with: + node-version: 20 + + - name: Install node modules + run: npm install + + - name: Set up Ruby + uses: ruby/setup-ruby@v1 + with: + ruby-version: 3.1.6 + bundler-cache: true + + - name: Install and update Ruby Gems + run: | + bundle install + + - name: Install CocoaPods Dependencies + run: | + cd ios + pod install + pod update + + - name: Check for changes + id: check-changes + run: | + git diff --quiet package-lock.json ios/Podfile.lock || echo "Changes detected" + continue-on-error: true + + - name: Stop job if no changes + if: steps.check-changes.outcome == 'success' + run: | + echo "No changes detected in package-lock.json or Podfile.lock. Stopping the job." + exit 0 + + - name: Commit changes + if: steps.check-changes.outcome != 'success' + run: | + git add package-lock.json ios/Podfile.lock + git commit -m "Update lock files" + + # Step 10: Get the list of changed files for PR description + - name: Get changed files for PR description + id: get-changes + if: steps.check-changes.outcome != 'success' + run: | + git diff --name-only HEAD^ HEAD > changed_files.txt + echo "CHANGES=$(cat changed_files.txt)" >> $GITHUB_ENV + + # Step 11: Push the changes and create the PR using the LockFiles PAT + - name: Push and create PR + if: steps.check-changes.outcome != 'success' + run: | + git push origin pod-update-branch + gh pr create --title "Lock Files Updates" --body "The following lock files were updated:\n\n${{ env.CHANGES }}" --base master + env: + GITHUB_TOKEN: ${{ secrets.LOCKFILES_WORKFLOW }} # Use the LockFiles PAT for PR creation + + cleanup: + runs-on: macos-15 + if: github.event.pull_request.merged == true || github.event.pull_request.state == 'closed' + needs: pod-update + steps: + + - name: Delete branch after PR merge/close + run: | + git push origin --delete pod-update-branch \ No newline at end of file diff --git a/.gitignore b/.gitignore index d802dd90b5..fbe227533e 100644 --- a/.gitignore +++ b/.gitignore @@ -20,12 +20,11 @@ DerivedData *.hmap *.ipa *.xcuserstate -ios/.xcode.env.local +**/.xcode.env.local *.hprof .cxx/ *.keystore !debug.keystore - # Android/IntelliJ # build/ @@ -34,6 +33,9 @@ build/ local.properties *.iml +# testing +/coverage + # node.js # node_modules/ @@ -57,6 +59,7 @@ buck-out/ */fastlane/Preview.html */fastlane/screenshots **/fastlane/test_output +ios/fastlane # Bundle artifact *.jsbundle @@ -67,7 +70,7 @@ release-notes.txt current-branch.json # Ruby / CocoaPods -/ios/Pods/ +**/Pods/ /vendor/bundle/ ios/BlueWallet.xcodeproj/xcuserdata/ @@ -81,4 +84,11 @@ artifacts/ *.mx *.realm -*.realm.lock \ No newline at end of file +*.realm.lock +android/app/.project +android/app/.classpath +android/.settings/org.eclipse.buildship.core.prefs +android/.project +android/.settings/org.eclipse.buildship.core.prefs +android/app/.classpath +android/app/.project diff --git a/.ruby-version b/.ruby-version index 49cdd668e1..8a4b2758ef 100644 --- a/.ruby-version +++ b/.ruby-version @@ -1 +1 @@ -2.7.6 +3.1.6 \ No newline at end of file diff --git a/App.js b/App.js deleted file mode 100644 index 9051c159d6..0000000000 --- a/App.js +++ /dev/null @@ -1,396 +0,0 @@ -import 'react-native-gesture-handler'; // should be on top -import React, { useContext, useEffect, useRef } from 'react'; -import { - AppState, - DeviceEventEmitter, - NativeModules, - NativeEventEmitter, - Linking, - Platform, - StyleSheet, - UIManager, - useColorScheme, - View, - StatusBar, - LogBox, -} from 'react-native'; -import { NavigationContainer, CommonActions } from '@react-navigation/native'; -import { SafeAreaProvider } from 'react-native-safe-area-context'; -import ReactNativeHapticFeedback from 'react-native-haptic-feedback'; - -import { navigationRef } from './NavigationService'; -import * as NavigationService from './NavigationService'; -import { Chain } from './models/bitcoinUnits'; -import OnAppLaunch from './class/on-app-launch'; -import DeeplinkSchemaMatch from './class/deeplink-schema-match'; -import loc from './loc'; -import { BlueDefaultTheme, BlueDarkTheme } from './components/themes'; -import InitRoot from './Navigation'; -import BlueClipboard from './blue_modules/clipboard'; -import { isDesktop } from './blue_modules/environment'; -import { BlueStorageContext } from './blue_modules/storage-context'; -import WatchConnectivity from './WatchConnectivity'; -import DeviceQuickActions from './class/quick-actions'; -import Notifications from './blue_modules/notifications'; -import Biometric from './class/biometrics'; -import WidgetCommunication from './blue_modules/WidgetCommunication'; -import changeNavigationBarColor from 'react-native-navigation-bar-color'; -import ActionSheet from './screen/ActionSheet'; -import HandoffComponent from './components/handoff'; -import Privacy from './blue_modules/Privacy'; -const A = require('./blue_modules/analytics'); -const currency = require('./blue_modules/currency'); - -const eventEmitter = Platform.OS === 'ios' ? new NativeEventEmitter(NativeModules.EventEmitter) : undefined; -const { EventEmitter } = NativeModules; - -LogBox.ignoreLogs(['Require cycle:']); - -const ClipboardContentType = Object.freeze({ - BITCOIN: 'BITCOIN', - LIGHTNING: 'LIGHTNING', -}); - -if (Platform.OS === 'android') { - if (UIManager.setLayoutAnimationEnabledExperimental) { - UIManager.setLayoutAnimationEnabledExperimental(true); - } -} - -const App = () => { - const { walletsInitialized, wallets, addWallet, saveToDisk, fetchAndSaveWalletTransactions, refreshAllWalletTransactions } = - useContext(BlueStorageContext); - const appState = useRef(AppState.currentState); - const clipboardContent = useRef(); - const colorScheme = useColorScheme(); - - const onNotificationReceived = async notification => { - const payload = Object.assign({}, notification, notification.data); - if (notification.data && notification.data.data) Object.assign(payload, notification.data.data); - payload.foreground = true; - - await Notifications.addNotification(payload); - // if user is staring at the app when he receives the notification we process it instantly - // so app refetches related wallet - if (payload.foreground) await processPushNotifications(); - }; - - const openSettings = () => { - NavigationService.dispatch( - CommonActions.navigate({ - name: 'Settings', - }), - ); - }; - - const onUserActivityOpen = data => { - switch (data.activityType) { - case HandoffComponent.activityTypes.ReceiveOnchain: - NavigationService.navigate('ReceiveDetailsRoot', { - screen: 'ReceiveDetails', - params: { - address: data.userInfo.address, - }, - }); - break; - case HandoffComponent.activityTypes.Xpub: - NavigationService.navigate('WalletXpubRoot', { - screen: 'WalletXpub', - params: { - xpub: data.userInfo.xpub, - }, - }); - break; - default: - break; - } - }; - - useEffect(() => { - if (walletsInitialized) { - addListeners(); - } - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [walletsInitialized]); - - useEffect(() => { - return () => { - Linking.removeEventListener('url', handleOpenURL); - AppState.removeEventListener('change', handleAppStateChange); - eventEmitter?.removeAllListeners('onNotificationReceived'); - eventEmitter?.removeAllListeners('openSettings'); - eventEmitter?.removeAllListeners('onUserActivityOpen'); - }; - // eslint-disable-next-line react-hooks/exhaustive-deps - }, []); - - useEffect(() => { - if (colorScheme) { - if (colorScheme === 'light') { - changeNavigationBarColor(BlueDefaultTheme.colors.background, true, true); - } else { - changeNavigationBarColor(BlueDarkTheme.colors.buttonBackgroundColor, false, true); - } - } - }, [colorScheme]); - - const addListeners = () => { - Linking.addEventListener('url', handleOpenURL); - AppState.addEventListener('change', handleAppStateChange); - DeviceEventEmitter.addListener('quickActionShortcut', walletQuickActions); - DeviceQuickActions.popInitialAction().then(popInitialAction); - EventEmitter?.getMostRecentUserActivity() - .then(onUserActivityOpen) - .catch(() => console.log('No userActivity object sent')); - handleAppStateChange(undefined); - /* - When a notification on iOS is shown while the app is on foreground; - On willPresent on AppDelegate.m - */ - eventEmitter?.addListener('onNotificationReceived', onNotificationReceived); - eventEmitter?.addListener('openSettings', openSettings); - eventEmitter?.addListener('onUserActivityOpen', onUserActivityOpen); - }; - - const popInitialAction = async data => { - if (data) { - const wallet = wallets.find(w => w.getID() === data.userInfo.url.split('wallet/')[1]); - NavigationService.dispatch( - CommonActions.navigate({ - name: 'WalletTransactions', - key: `WalletTransactions-${wallet.getID()}`, - params: { - walletID: wallet.getID(), - walletType: wallet.type, - }, - }), - ); - } else { - const url = await Linking.getInitialURL(); - if (url) { - if (DeeplinkSchemaMatch.hasSchema(url)) { - handleOpenURL({ url }); - } - } else { - const isViewAllWalletsEnabled = await OnAppLaunch.isViewAllWalletsEnabled(); - if (!isViewAllWalletsEnabled) { - const selectedDefaultWallet = await OnAppLaunch.getSelectedDefaultWallet(); - const wallet = wallets.find(w => w.getID() === selectedDefaultWallet.getID()); - if (wallet) { - NavigationService.dispatch( - CommonActions.navigate({ - name: 'WalletTransactions', - key: `WalletTransactions-${wallet.getID()}`, - params: { - walletID: wallet.getID(), - walletType: wallet.type, - }, - }), - ); - } - } - } - } - }; - - const walletQuickActions = data => { - const wallet = wallets.find(w => w.getID() === data.userInfo.url.split('wallet/')[1]); - NavigationService.dispatch( - CommonActions.navigate({ - name: 'WalletTransactions', - key: `WalletTransactions-${wallet.getID()}`, - params: { - walletID: wallet.getID(), - walletType: wallet.type, - }, - }), - ); - }; - - /** - * Processes push notifications stored in AsyncStorage. Might navigate to some screen. - * - * @returns {Promise} returns TRUE if notification was processed _and acted_ upon, i.e. navigation happened - * @private - */ - const processPushNotifications = async () => { - if (!walletsInitialized) { - console.log('not processing push notifications because wallets are not initialized'); - return; - } - await new Promise(resolve => setTimeout(resolve, 200)); - // sleep needed as sometimes unsuspend is faster than notification module actually saves notifications to async storage - const notifications2process = await Notifications.getStoredNotifications(); - - await Notifications.clearStoredNotifications(); - Notifications.setApplicationIconBadgeNumber(0); - const deliveredNotifications = await Notifications.getDeliveredNotifications(); - setTimeout(() => Notifications.removeAllDeliveredNotifications(), 5000); // so notification bubble wont disappear too fast - - for (const payload of notifications2process) { - const wasTapped = payload.foreground === false || (payload.foreground === true && payload.userInteraction); - - console.log('processing push notification:', payload); - let wallet; - switch (+payload.type) { - case 2: - case 3: - wallet = wallets.find(w => w.weOwnAddress(payload.address)); - break; - case 1: - case 4: - wallet = wallets.find(w => w.weOwnTransaction(payload.txid || payload.hash)); - break; - } - - if (wallet) { - const walletID = wallet.getID(); - fetchAndSaveWalletTransactions(walletID); - if (wasTapped) { - if (payload.type !== 3 || wallet.chain === Chain.OFFCHAIN) { - NavigationService.dispatch( - CommonActions.navigate({ - name: 'WalletTransactions', - key: `WalletTransactions-${wallet.getID()}`, - params: { - walletID, - walletType: wallet.type, - }, - }), - ); - } else { - NavigationService.navigate('ReceiveDetailsRoot', { - screen: 'ReceiveDetails', - params: { - walletID, - address: payload.address, - }, - }); - } - - return true; - } - } else { - console.log('could not find wallet while processing push notification, NOP'); - } - } // end foreach notifications loop - - if (deliveredNotifications.length > 0) { - // notification object is missing userInfo. We know we received a notification but don't have sufficient - // data to refresh 1 wallet. let's refresh all. - refreshAllWalletTransactions(); - } - - // if we are here - we did not act upon any push - return false; - }; - - const handleAppStateChange = async nextAppState => { - if (wallets.length === 0) return; - if ((appState.current.match(/background/) && nextAppState === 'active') || nextAppState === undefined) { - setTimeout(() => A(A.ENUM.APP_UNSUSPENDED), 2000); - currency.updateExchangeRate(); - const processed = await processPushNotifications(); - if (processed) return; - const clipboard = await BlueClipboard().getClipboardContent(); - const isAddressFromStoredWallet = wallets.some(wallet => { - if (wallet.chain === Chain.ONCHAIN) { - // checking address validity is faster than unwrapping hierarchy only to compare it to garbage - return wallet.isAddressValid && wallet.isAddressValid(clipboard) && wallet.weOwnAddress(clipboard); - } else { - return wallet.isInvoiceGeneratedByWallet(clipboard) || wallet.weOwnAddress(clipboard); - } - }); - const isBitcoinAddress = DeeplinkSchemaMatch.isBitcoinAddress(clipboard); - const isLightningInvoice = DeeplinkSchemaMatch.isLightningInvoice(clipboard); - const isLNURL = DeeplinkSchemaMatch.isLnUrl(clipboard); - const isBothBitcoinAndLightning = DeeplinkSchemaMatch.isBothBitcoinAndLightning(clipboard); - if ( - !isAddressFromStoredWallet && - clipboardContent.current !== clipboard && - (isBitcoinAddress || isLightningInvoice || isLNURL || isBothBitcoinAndLightning) - ) { - let contentType; - if (isBitcoinAddress) { - contentType = ClipboardContentType.BITCOIN; - } else if (isLightningInvoice || isLNURL) { - contentType = ClipboardContentType.LIGHTNING; - } else if (isBothBitcoinAndLightning) { - contentType = ClipboardContentType.BITCOIN; - } - showClipboardAlert({ contentType }); - } - clipboardContent.current = clipboard; - } - if (nextAppState) { - appState.current = nextAppState; - } - }; - - const handleOpenURL = event => { - DeeplinkSchemaMatch.navigationRouteFor(event, value => NavigationService.navigate(...value), { wallets, addWallet, saveToDisk }); - }; - - const showClipboardAlert = ({ contentType }) => { - ReactNativeHapticFeedback.trigger('impactLight', { ignoreAndroidSystemSettings: false }); - BlueClipboard() - .getClipboardContent() - .then(clipboard => { - if (Platform.OS === 'ios' || Platform.OS === 'macos') { - ActionSheet.showActionSheetWithOptions( - { - options: [loc._.cancel, loc._.continue], - title: loc._.clipboard, - message: contentType === ClipboardContentType.BITCOIN ? loc.wallets.clipboard_bitcoin : loc.wallets.clipboard_lightning, - cancelButtonIndex: 0, - }, - buttonIndex => { - if (buttonIndex === 1) { - handleOpenURL({ url: clipboard }); - } - }, - ); - } else { - ActionSheet.showActionSheetWithOptions({ - buttons: [ - { text: loc._.cancel, style: 'cancel', onPress: () => {} }, - { - text: loc._.continue, - style: 'default', - onPress: () => { - handleOpenURL({ url: clipboard }); - }, - }, - ], - title: loc._.clipboard, - message: contentType === ClipboardContentType.BITCOIN ? loc.wallets.clipboard_bitcoin : loc.wallets.clipboard_lightning, - }); - } - }); - }; - - return ( - - - - - - - - {walletsInitialized && !isDesktop && } - - - - - - - ); -}; - -const styles = StyleSheet.create({ - root: { - flex: 1, - }, -}); - -export default App; diff --git a/App.tsx b/App.tsx new file mode 100644 index 0000000000..f549c7d02d --- /dev/null +++ b/App.tsx @@ -0,0 +1,32 @@ +import 'react-native-gesture-handler'; // should be on top + +import { NavigationContainer } from '@react-navigation/native'; +import React from 'react'; +import { useColorScheme } from 'react-native'; +import { SafeAreaProvider } from 'react-native-safe-area-context'; +import { LargeScreenProvider } from './components/Context/LargeScreenProvider'; +import { SettingsProvider } from './components/Context/SettingsProvider'; +import { BlueDarkTheme, BlueDefaultTheme } from './components/themes'; +import MasterView from './navigation/MasterView'; +import { navigationRef } from './NavigationService'; +import { StorageProvider } from './components/Context/StorageProvider'; + +const App = () => { + const colorScheme = useColorScheme(); + + return ( + + + + + + + + + + + + ); +}; + +export default App; diff --git a/BlueComponents.js b/BlueComponents.js index bb49e91b87..61b798455a 100644 --- a/BlueComponents.js +++ b/BlueComponents.js @@ -1,33 +1,8 @@ /* eslint react/prop-types: "off", react-native/no-inline-styles: "off" */ -import React, { Component, forwardRef } from 'react'; -import PropTypes from 'prop-types'; -import { Icon, Text, Header, ListItem, Avatar } from 'react-native-elements'; -import { - ActivityIndicator, - Alert, - Animated, - Dimensions, - Image, - InputAccessoryView, - Keyboard, - KeyboardAvoidingView, - Platform, - SafeAreaView, - StyleSheet, - Switch, - TextInput, - TouchableOpacity, - View, - I18nManager, - ImageBackground, -} from 'react-native'; -import Clipboard from '@react-native-clipboard/clipboard'; -import NetworkTransactionFees, { NetworkTransactionFee, NetworkTransactionFeeType } from './models/networkTransactionFees'; -import AsyncStorage from '@react-native-async-storage/async-storage'; -import { useTheme } from '@react-navigation/native'; -import { BlueCurrentTheme } from './components/themes'; -import PlusIcon from './components/icons/PlusIcon'; -import loc, { formatStringAddTwoWhiteSpaces } from './loc'; +import React, { forwardRef } from 'react'; +import { ActivityIndicator, Dimensions, I18nManager, Platform, StyleSheet, TextInput, TouchableOpacity, View } from 'react-native'; +import { Icon, Text } from '@rneui/themed'; +import { useTheme } from './components/themes'; const { height, width } = Dimensions.get('window'); const aspectRatio = height / width; @@ -38,212 +13,6 @@ if (aspectRatio > 1.6) { isIpad = true; } -export const BlueButton = props => { - const { colors } = useTheme(); - - let backgroundColor = props.backgroundColor ? props.backgroundColor : colors.mainColor || BlueCurrentTheme.colors.mainColor; - let fontColor = props.buttonTextColor || colors.buttonTextColor; - if (props.disabled === true) { - backgroundColor = colors.buttonDisabledBackgroundColor; - fontColor = colors.buttonDisabledTextColor; - } - - return ( - - - {props.icon && } - {props.title && {props.title}} - - - ); -}; - -export const SecondButton = forwardRef((props, ref) => { - const { colors } = useTheme(); - let backgroundColor = props.backgroundColor ? props.backgroundColor : colors.buttonBlueBackgroundColor; - let fontColor = colors.buttonTextColor; - if (props.disabled === true) { - backgroundColor = colors.buttonDisabledBackgroundColor; - fontColor = colors.buttonDisabledTextColor; - } - - return ( - - - {props.icon && } - {props.title && {props.title}} - - - ); -}); - -export const BitcoinButton = props => { - const { colors } = useTheme(); - return ( - - - - - - - - - {loc.wallets.add_bitcoin} - - - {loc.wallets.add_bitcoin_explain} - - - - - - ); -}; - -export const VaultButton = props => { - const { colors } = useTheme(); - return ( - - - - - - - - - {loc.multisig.multisig_vault} - - - {loc.multisig.multisig_vault_explain} - - - - - - ); -}; - -export const LightningButton = props => { - const { colors } = useTheme(); - return ( - - - - - - - - - {loc.wallets.add_lightning} - - - {loc.wallets.add_lightning_explain} - - - - - - ); -}; - /** * TODO: remove this comment once this file gets properly converted to typescript. * @@ -255,8 +24,8 @@ export const BlueButtonLink = forwardRef((props, ref) => { { ); }); -export const BlueAlertWalletExportReminder = ({ onSuccess = () => {}, onFailure }) => { - Alert.alert( - loc.wallets.details_title, - loc.pleasebackup.ask, - [ - { text: loc.pleasebackup.ask_yes, onPress: onSuccess, style: 'cancel' }, - { text: loc.pleasebackup.ask_no, onPress: onFailure }, - ], - { cancelable: false }, - ); -}; - -export const BluePrivateBalance = () => { - return ( - - - - - ); -}; - -export const BlueCopyToClipboardButton = ({ stringToCopy, displayText = false }) => { - return ( - Clipboard.setString(stringToCopy)}> - {displayText || loc.transactions.details_copy} - - ); -}; - -export class BlueCopyTextToClipboard extends Component { - static propTypes = { - text: PropTypes.string, - truncated: PropTypes.bool, - }; - - static defaultProps = { - text: '', - truncated: false, - }; - - constructor(props) { - super(props); - this.state = { hasTappedText: false, address: props.text }; - } - - static getDerivedStateFromProps(props, state) { - if (state.hasTappedText) { - return { hasTappedText: state.hasTappedText, address: state.address, truncated: props.truncated }; - } else { - return { hasTappedText: state.hasTappedText, address: props.text, truncated: props.truncated }; - } - } - - copyToClipboard = () => { - this.setState({ hasTappedText: true }, () => { - Clipboard.setString(this.props.text); - this.setState({ address: loc.wallets.xpub_copiedToClipboard }, () => { - setTimeout(() => { - this.setState({ hasTappedText: false, address: this.props.text }); - }, 1000); - }); - }); - }; - - render() { - return ( - - - - {this.state.address} - - - - ); - } -} - -const styleCopyTextToClipboard = StyleSheet.create({ - address: { - marginVertical: 32, - fontSize: 15, - color: '#9aa0aa', - textAlign: 'center', - }, -}); - -export const SafeBlueArea = props => { - const { style, ...nonStyleProps } = props; - const { colors } = useTheme(); - const baseStyle = { flex: 1, backgroundColor: colors.background }; - return ; -}; - export const BlueCard = props => { return ; }; @@ -387,73 +51,6 @@ export const BlueTextCentered = props => { return ; }; -export const BlueListItem = React.memo(props => { - const { colors } = useTheme(); - - return ( - - {props.leftAvatar && {props.leftAvatar}} - {props.leftIcon && } - - - {props.title} - - {props.subtitle && ( - - {props.subtitle} - - )} - - {props.rightTitle && ( - - - {props.rightTitle} - - - )} - {props.isLoading ? ( - - ) : ( - <> - {props.chevron && } - {props.rightIcon && } - {props.switch && } - {props.checkmark && } - - )} - - ); -}); - export const BlueFormLabel = props => { const { colors } = useTheme(); @@ -503,72 +100,6 @@ export const BlueFormMultiInput = props => { ); }; -export const BlueHeaderDefaultSub = props => { - const { colors } = useTheme(); - - return ( - -
- {props.leftText} - - } - {...props} - /> - - ); -}; - -export const BlueHeaderDefaultMain = props => { - const { colors } = useTheme(); - const { isDrawerList } = props; - return ( - - - {props.leftText} - - - - ); -}; - export const BlueSpacing = props => { return ; }; @@ -592,60 +123,6 @@ export const BlueSpacing10 = props => { return ; }; -export const BlueDismissKeyboardInputAccessory = () => { - const { colors } = useTheme(); - BlueDismissKeyboardInputAccessory.InputAccessoryViewID = 'BlueDismissKeyboardInputAccessory'; - - return Platform.OS !== 'ios' ? null : ( - - - - - - ); -}; - -export const BlueDoneAndDismissKeyboardInputAccessory = props => { - const { colors } = useTheme(); - BlueDoneAndDismissKeyboardInputAccessory.InputAccessoryViewID = 'BlueDoneAndDismissKeyboardInputAccessory'; - - const onPasteTapped = async () => { - const clipboard = await Clipboard.getString(); - props.onPasteTapped(clipboard); - }; - - const inputView = ( - - - - - - ); - - if (Platform.OS === 'ios') { - return {inputView}; - } else { - return {inputView}; - } -}; - export const BlueLoading = props => { return ( @@ -654,166 +131,7 @@ export const BlueLoading = props => { ); }; -export class BlueReplaceFeeSuggestions extends Component { - static propTypes = { - onFeeSelected: PropTypes.func.isRequired, - transactionMinimum: PropTypes.number.isRequired, - }; - - static defaultProps = { - transactionMinimum: 1, - }; - - state = { - customFeeValue: '1', - }; - - async componentDidMount() { - try { - const cachedNetworkTransactionFees = JSON.parse(await AsyncStorage.getItem(NetworkTransactionFee.StorageKey)); - - if (cachedNetworkTransactionFees && 'fastestFee' in cachedNetworkTransactionFees) { - this.setState({ networkFees: cachedNetworkTransactionFees }, () => this.onFeeSelected(NetworkTransactionFeeType.FAST)); - } - } catch (_) {} - const networkFees = await NetworkTransactionFees.recommendedFees(); - this.setState({ networkFees }, () => this.onFeeSelected(NetworkTransactionFeeType.FAST)); - } - - onFeeSelected = selectedFeeType => { - if (selectedFeeType !== NetworkTransactionFeeType.CUSTOM) { - Keyboard.dismiss(); - } - if (selectedFeeType === NetworkTransactionFeeType.FAST) { - this.props.onFeeSelected(this.state.networkFees.fastestFee); - this.setState({ selectedFeeType }, () => this.props.onFeeSelected(this.state.networkFees.fastestFee)); - } else if (selectedFeeType === NetworkTransactionFeeType.MEDIUM) { - this.setState({ selectedFeeType }, () => this.props.onFeeSelected(this.state.networkFees.mediumFee)); - } else if (selectedFeeType === NetworkTransactionFeeType.SLOW) { - this.setState({ selectedFeeType }, () => this.props.onFeeSelected(this.state.networkFees.slowFee)); - } else if (selectedFeeType === NetworkTransactionFeeType.CUSTOM) { - this.props.onFeeSelected(Number(this.state.customFeeValue)); - } - }; - - onCustomFeeTextChange = customFee => { - const customFeeValue = customFee.replace(/[^0-9]/g, ''); - this.setState({ customFeeValue, selectedFeeType: NetworkTransactionFeeType.CUSTOM }, () => { - this.onFeeSelected(NetworkTransactionFeeType.CUSTOM); - }); - }; - - render() { - const { networkFees, selectedFeeType } = this.state; - - return ( - - {networkFees && - [ - { - label: loc.send.fee_fast, - time: loc.send.fee_10m, - type: NetworkTransactionFeeType.FAST, - rate: networkFees.fastestFee, - active: selectedFeeType === NetworkTransactionFeeType.FAST, - }, - { - label: formatStringAddTwoWhiteSpaces(loc.send.fee_medium), - time: loc.send.fee_3h, - type: NetworkTransactionFeeType.MEDIUM, - rate: networkFees.mediumFee, - active: selectedFeeType === NetworkTransactionFeeType.MEDIUM, - }, - { - label: loc.send.fee_slow, - time: loc.send.fee_1d, - type: NetworkTransactionFeeType.SLOW, - rate: networkFees.slowFee, - active: selectedFeeType === NetworkTransactionFeeType.SLOW, - }, - ].map(({ label, type, time, rate, active }, index) => ( - this.onFeeSelected(type)} - style={[ - { paddingHorizontal: 16, paddingVertical: 8, marginBottom: 10 }, - active && { borderRadius: 8, backgroundColor: BlueCurrentTheme.colors.incomingBackgroundColor }, - ]} - > - - {label} - - ~{time} - - - - {rate} sat/byte - - - ))} - this.customTextInput.focus()} - style={[ - { paddingHorizontal: 16, paddingVertical: 8, marginBottom: 10 }, - selectedFeeType === NetworkTransactionFeeType.CUSTOM && { - borderRadius: 8, - backgroundColor: BlueCurrentTheme.colors.incomingBackgroundColor, - }, - ]} - > - - - {formatStringAddTwoWhiteSpaces(loc.send.fee_custom)} - - - - (this.customTextInput = ref)} - maxLength={9} - style={{ - backgroundColor: BlueCurrentTheme.colors.inputBackgroundColor, - borderBottomColor: BlueCurrentTheme.colors.formBorder, - borderBottomWidth: 0.5, - borderColor: BlueCurrentTheme.colors.formBorder, - borderRadius: 4, - borderWidth: 1.0, - color: '#81868e', - flex: 1, - marginRight: 10, - minHeight: 33, - paddingRight: 5, - paddingLeft: 5, - }} - onFocus={() => this.onCustomFeeTextChange(this.state.customFeeValue)} - defaultValue={this.props.transactionMinimum} - placeholder={loc.send.fee_satvbyte} - placeholderTextColor="#81868e" - inputAccessoryViewID={BlueDismissKeyboardInputAccessory.InputAccessoryViewID} - /> - sat/byte - - - - {loc.formatString(loc.send.fee_replace_minvb, { min: this.props.transactionMinimum })} - - - ); - } -} - -export function BlueBigCheckmark({ style }) { +export function BlueBigCheckmark({ style = {} }) { const defaultStyles = { backgroundColor: '#ccddf9', width: 120, @@ -831,40 +149,3 @@ export function BlueBigCheckmark({ style }) { ); } - -const tabsStyles = StyleSheet.create({ - root: { - flexDirection: 'row', - height: 50, - borderColor: '#e3e3e3', - borderBottomWidth: 1, - }, - tabRoot: { - flex: 1, - justifyContent: 'center', - alignItems: 'center', - borderColor: 'white', - borderBottomWidth: 2, - }, -}); - -export const BlueTabs = ({ active, onSwitch, tabs }) => ( - - {tabs.map((Tab, i) => ( - onSwitch(i)} - style={[ - tabsStyles.tabRoot, - active === i && { - borderColor: BlueCurrentTheme.colors.buttonAlternativeTextColor, - borderBottomWidth: 2, - }, - ]} - > - - - ))} - -); diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 734eb4d9c2..496abe302c 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -1,8 +1,13 @@ -All commits should have one of the following prefixes: REL, FIX, ADD, TST, OPS, DOC. For example `"ADD: new feature"`. -Adding new feature is ADD, fixing a bug is FIX, something related to infrastructure is OPS etc. +## Commits + +All commits should have one of the following prefixes: REL, FIX, ADD, REF, TST, OPS, DOC. For example `"ADD: new feature"`. +Adding new feature is ADD, fixing a bug is FIX, something related to infrastructure is OPS etc. REL is for releases, REF is for +refactoring, DOC is for changing documentation (like this file). Commits should be atomic: one commit - one feature, one commit - one bugfix etc. +## Releases + When you tag a new release, use the following example: `git tag -m "REL v1.4.0: 157c9c2" v1.4.0 -s` You may get the commit hash from git log. Don't forget to push tags `git push origin --tags` @@ -13,5 +18,10 @@ When tagging a new release, make sure to increment version in package.json and o In the commit where you up version you can have the commit message as `"REL vX.X.X: Summary message"`. +## Guidelines Do *not* add new dependencies. Bonus points if you manage to actually remove a dependency. + +All new files must be in typescript. Bonus points if you convert some of the existing files to typescript. + +New components must go in `components/`. Bonus points if you refactor some of old components in `BlueComponents.js` to separate files. diff --git a/Gemfile b/Gemfile index 67a7229866..f1642ccab5 100644 --- a/Gemfile +++ b/Gemfile @@ -1,6 +1,10 @@ -source 'https://rubygems.org' +source "https://rubygems.org" # You may use http://rbenv.org/ or https://rvm.io/ to install and use this version -ruby '>= 2.6.10' - -gem 'cocoapods', '~> 1.11', '>= 1.11.3' \ No newline at end of file +ruby "3.1.6" +gem 'rubyzip', '2.3.2' +gem 'cocoapods', '>= 1.13', '!= 1.15.0', '!= 1.15.1' +gem 'activesupport', '>= 6.1.7.5', '!= 7.1.0' +gem "fastlane", ">= 2.225.0" +plugins_path = File.join(File.dirname(__FILE__), 'fastlane', 'Pluginfile') +eval_gemfile(plugins_path) if File.exist?(plugins_path) diff --git a/Gemfile.lock b/Gemfile.lock index 285424cd4b..b6a771c081 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -1,30 +1,59 @@ GEM remote: https://rubygems.org/ specs: - CFPropertyList (3.0.5) + CFPropertyList (3.0.7) + base64 + nkf rexml - activesupport (6.1.4.4) - concurrent-ruby (~> 1.0, >= 1.0.2) + activesupport (7.2.2) + base64 + benchmark (>= 0.3) + bigdecimal + concurrent-ruby (~> 1.0, >= 1.3.1) + connection_pool (>= 2.2.5) + drb i18n (>= 1.6, < 2) + logger (>= 1.4.2) minitest (>= 5.1) - tzinfo (~> 2.0) - zeitwerk (~> 2.3) - addressable (2.8.0) - public_suffix (>= 2.0.2, < 5.0) + securerandom (>= 0.3) + tzinfo (~> 2.0, >= 2.0.5) + addressable (2.8.7) + public_suffix (>= 2.0.2, < 7.0) algoliasearch (1.27.5) httpclient (~> 2.8, >= 2.8.3) json (>= 1.5.1) + artifactory (3.0.17) atomos (0.1.3) + aws-eventstream (1.3.0) + aws-partitions (1.1002.0) + aws-sdk-core (3.212.0) + aws-eventstream (~> 1, >= 1.3.0) + aws-partitions (~> 1, >= 1.992.0) + aws-sigv4 (~> 1.9) + jmespath (~> 1, >= 1.6.1) + aws-sdk-kms (1.95.0) + aws-sdk-core (~> 3, >= 3.210.0) + aws-sigv4 (~> 1.5) + aws-sdk-s3 (1.170.0) + aws-sdk-core (~> 3, >= 3.210.0) + aws-sdk-kms (~> 1) + aws-sigv4 (~> 1.5) + aws-sigv4 (1.10.1) + aws-eventstream (~> 1, >= 1.0.2) + babosa (1.0.4) + base64 (0.2.0) + benchmark (0.4.0) + bigdecimal (3.1.8) claide (1.1.0) - cocoapods (1.11.2) + cocoapods (1.16.2) addressable (~> 2.8) claide (>= 1.0.2, < 2.0) - cocoapods-core (= 1.11.2) + cocoapods-core (= 1.16.2) cocoapods-deintegrate (>= 1.0.3, < 2.0) - cocoapods-downloader (>= 1.4.0, < 2.0) + cocoapods-downloader (>= 2.1, < 3.0) cocoapods-plugins (>= 1.0.0, < 2.0) cocoapods-search (>= 1.0.0, < 2.0) - cocoapods-trunk (>= 1.4.0, < 2.0) + cocoapods-trunk (>= 1.6.0, < 2.0) cocoapods-try (>= 1.1.0, < 2.0) colored2 (~> 3.1) escape (~> 0.0.4) @@ -32,10 +61,10 @@ GEM gh_inspector (~> 1.0) molinillo (~> 0.8.0) nap (~> 1.0) - ruby-macho (>= 1.0, < 3.0) - xcodeproj (>= 1.21.0, < 2.0) - cocoapods-core (1.11.2) - activesupport (>= 5.0, < 7) + ruby-macho (>= 2.3.0, < 3.0) + xcodeproj (>= 1.27.0, < 2.0) + cocoapods-core (1.16.2) + activesupport (>= 5.0, < 8) addressable (~> 2.8) algoliasearch (~> 1.0) concurrent-ruby (~> 1.1) @@ -45,7 +74,7 @@ GEM public_suffix (~> 4.0) typhoeus (~> 1.0) cocoapods-deintegrate (1.0.5) - cocoapods-downloader (1.5.1) + cocoapods-downloader (2.1) cocoapods-plugins (1.0.0) nap cocoapods-search (1.0.1) @@ -53,48 +82,237 @@ GEM nap (>= 0.8, < 2.0) netrc (~> 0.11) cocoapods-try (1.2.0) + colored (1.2) colored2 (3.1.2) - concurrent-ruby (1.1.9) + commander (4.6.0) + highline (~> 2.0.0) + concurrent-ruby (1.3.4) + connection_pool (2.4.1) + declarative (0.0.20) + digest-crc (0.6.5) + rake (>= 12.0.0, < 14.0.0) + domain_name (0.6.20240107) + dotenv (2.8.1) + drb (2.2.1) + emoji_regex (3.2.3) escape (0.0.4) - ethon (0.15.0) + ethon (0.16.0) ffi (>= 1.15.0) - ffi (1.15.5) + excon (0.112.0) + faraday (1.10.4) + faraday-em_http (~> 1.0) + faraday-em_synchrony (~> 1.0) + faraday-excon (~> 1.1) + faraday-httpclient (~> 1.0) + faraday-multipart (~> 1.0) + faraday-net_http (~> 1.0) + faraday-net_http_persistent (~> 1.0) + faraday-patron (~> 1.0) + faraday-rack (~> 1.0) + faraday-retry (~> 1.0) + ruby2_keywords (>= 0.0.4) + faraday-cookie_jar (0.0.7) + faraday (>= 0.8.0) + http-cookie (~> 1.0.0) + faraday-em_http (1.0.0) + faraday-em_synchrony (1.0.0) + faraday-excon (1.1.0) + faraday-httpclient (1.0.1) + faraday-multipart (1.0.4) + multipart-post (~> 2) + faraday-net_http (1.0.2) + faraday-net_http_persistent (1.2.0) + faraday-patron (1.0.0) + faraday-rack (1.0.0) + faraday-retry (1.0.3) + faraday_middleware (1.2.1) + faraday (~> 1.0) + fastimage (2.3.1) + fastlane (2.225.0) + CFPropertyList (>= 2.3, < 4.0.0) + addressable (>= 2.8, < 3.0.0) + artifactory (~> 3.0) + aws-sdk-s3 (~> 1.0) + babosa (>= 1.0.3, < 2.0.0) + bundler (>= 1.12.0, < 3.0.0) + colored (~> 1.2) + commander (~> 4.6) + dotenv (>= 2.1.1, < 3.0.0) + emoji_regex (>= 0.1, < 4.0) + excon (>= 0.71.0, < 1.0.0) + faraday (~> 1.0) + faraday-cookie_jar (~> 0.0.6) + faraday_middleware (~> 1.0) + fastimage (>= 2.1.0, < 3.0.0) + fastlane-sirp (>= 1.0.0) + gh_inspector (>= 1.1.2, < 2.0.0) + google-apis-androidpublisher_v3 (~> 0.3) + google-apis-playcustomapp_v1 (~> 0.1) + google-cloud-env (>= 1.6.0, < 2.0.0) + google-cloud-storage (~> 1.31) + highline (~> 2.0) + http-cookie (~> 1.0.5) + json (< 3.0.0) + jwt (>= 2.1.0, < 3) + mini_magick (>= 4.9.4, < 5.0.0) + multipart-post (>= 2.0.0, < 3.0.0) + naturally (~> 2.2) + optparse (>= 0.1.1, < 1.0.0) + plist (>= 3.1.0, < 4.0.0) + rubyzip (>= 2.0.0, < 3.0.0) + security (= 0.1.5) + simctl (~> 1.6.3) + terminal-notifier (>= 2.0.0, < 3.0.0) + terminal-table (~> 3) + tty-screen (>= 0.6.3, < 1.0.0) + tty-spinner (>= 0.8.0, < 1.0.0) + word_wrap (~> 1.0.0) + xcodeproj (>= 1.13.0, < 2.0.0) + xcpretty (~> 0.3.0) + xcpretty-travis-formatter (>= 0.0.3, < 2.0.0) + fastlane-plugin-browserstack (0.3.3) + rest-client (~> 2.0, >= 2.0.2) + fastlane-plugin-bugsnag_sourcemaps_upload (0.2.0) + fastlane-sirp (1.0.0) + sysrandom (~> 1.0) + ffi (1.17.0) fourflusher (2.3.1) fuzzy_match (2.0.4) gh_inspector (1.1.3) + google-apis-androidpublisher_v3 (0.54.0) + google-apis-core (>= 0.11.0, < 2.a) + google-apis-core (0.11.3) + addressable (~> 2.5, >= 2.5.1) + googleauth (>= 0.16.2, < 2.a) + httpclient (>= 2.8.1, < 3.a) + mini_mime (~> 1.0) + representable (~> 3.0) + retriable (>= 2.0, < 4.a) + rexml + google-apis-iamcredentials_v1 (0.17.0) + google-apis-core (>= 0.11.0, < 2.a) + google-apis-playcustomapp_v1 (0.13.0) + google-apis-core (>= 0.11.0, < 2.a) + google-apis-storage_v1 (0.31.0) + google-apis-core (>= 0.11.0, < 2.a) + google-cloud-core (1.7.1) + google-cloud-env (>= 1.0, < 3.a) + google-cloud-errors (~> 1.0) + google-cloud-env (1.6.0) + faraday (>= 0.17.3, < 3.0) + google-cloud-errors (1.4.0) + google-cloud-storage (1.47.0) + addressable (~> 2.8) + digest-crc (~> 0.4) + google-apis-iamcredentials_v1 (~> 0.1) + google-apis-storage_v1 (~> 0.31.0) + google-cloud-core (~> 1.6) + googleauth (>= 0.16.2, < 2.a) + mini_mime (~> 1.0) + googleauth (1.8.1) + faraday (>= 0.17.3, < 3.a) + jwt (>= 1.4, < 3.0) + multi_json (~> 1.11) + os (>= 0.9, < 2.0) + signet (>= 0.16, < 2.a) + highline (2.0.3) + http-accept (1.7.0) + http-cookie (1.0.7) + domain_name (~> 0.5) httpclient (2.8.3) - i18n (1.9.1) + i18n (1.14.6) concurrent-ruby (~> 1.0) - json (2.6.1) - minitest (5.15.0) + jmespath (1.6.2) + json (2.8.1) + jwt (2.9.3) + base64 + logger (1.6.1) + mime-types (3.6.0) + logger + mime-types-data (~> 3.2015) + mime-types-data (3.2024.1105) + mini_magick (4.13.2) + mini_mime (1.1.5) + minitest (5.25.1) molinillo (0.8.0) - nanaimo (0.3.0) + multi_json (1.15.0) + multipart-post (2.4.1) + nanaimo (0.4.0) nap (1.1.0) + naturally (2.2.1) netrc (0.11.0) - public_suffix (4.0.6) - rexml (3.2.5) + nkf (0.2.0) + optparse (0.5.0) + os (1.1.4) + plist (3.7.1) + public_suffix (4.0.7) + rake (13.2.1) + representable (3.2.0) + declarative (< 0.1.0) + trailblazer-option (>= 0.1.1, < 0.2.0) + uber (< 0.2.0) + rest-client (2.1.0) + http-accept (>= 1.7.0, < 2.0) + http-cookie (>= 1.0.2, < 2.0) + mime-types (>= 1.16, < 4.0) + netrc (~> 0.8) + retriable (3.1.2) + rexml (3.3.9) + rouge (2.0.7) ruby-macho (2.5.1) - typhoeus (1.4.0) + ruby2_keywords (0.0.5) + rubyzip (2.3.2) + securerandom (0.3.1) + security (0.1.5) + signet (0.19.0) + addressable (~> 2.8) + faraday (>= 0.17.5, < 3.a) + jwt (>= 1.5, < 3.0) + multi_json (~> 1.10) + simctl (1.6.10) + CFPropertyList + naturally + sysrandom (1.0.5) + terminal-notifier (2.0.0) + terminal-table (3.0.2) + unicode-display_width (>= 1.1.1, < 3) + trailblazer-option (0.1.2) + tty-cursor (0.7.1) + tty-screen (0.8.2) + tty-spinner (0.9.3) + tty-cursor (~> 0.7) + typhoeus (1.4.1) ethon (>= 0.9.0) - tzinfo (2.0.4) + tzinfo (2.0.6) concurrent-ruby (~> 1.0) - xcodeproj (1.21.0) + uber (0.1.0) + unicode-display_width (2.6.0) + word_wrap (1.0.0) + xcodeproj (1.27.0) CFPropertyList (>= 2.3.3, < 4.0) atomos (~> 0.1.3) claide (>= 1.0.2, < 2.0) colored2 (~> 3.1) - nanaimo (~> 0.3.0) - rexml (~> 3.2.4) - zeitwerk (2.5.4) + nanaimo (~> 0.4.0) + rexml (>= 3.3.6, < 4.0) + xcpretty (0.3.0) + rouge (~> 2.0.7) + xcpretty-travis-formatter (1.0.1) + xcpretty (~> 0.2, >= 0.0.7) PLATFORMS ruby DEPENDENCIES - cocoapods (~> 1.11, >= 1.11.2) + activesupport (>= 6.1.7.5, != 7.1.0) + cocoapods (>= 1.13, != 1.15.1, != 1.15.0) + fastlane (>= 2.225.0) + fastlane-plugin-browserstack + fastlane-plugin-bugsnag_sourcemaps_upload + rubyzip (= 2.3.2) RUBY VERSION - ruby 2.7.4p191 + ruby 3.3.5p100 BUNDLED WITH - 2.2.27 + 2.5.18 diff --git a/LICENSE b/LICENSE index 9d6b974ffc..7c6a630f99 100644 --- a/LICENSE +++ b/LICENSE @@ -1,6 +1,6 @@ MIT License -Copyright (c) 2023 BlueWallet developers +Copyright (c) 2024 BlueWallet developers Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal diff --git a/MasterView.tsx b/MasterView.tsx new file mode 100644 index 0000000000..e982d6207f --- /dev/null +++ b/MasterView.tsx @@ -0,0 +1,23 @@ +import 'react-native-gesture-handler'; // should be on top + +import React, { lazy, Suspense } from 'react'; +import MainRoot from './navigation'; +import { useStorage } from './hooks/context/useStorage'; +const CompanionDelegates = lazy(() => import('./components/CompanionDelegates')); + +const MasterView = () => { + const { walletsInitialized } = useStorage(); + + return ( + <> + + {walletsInitialized && ( + + + + )} + + ); +}; + +export default MasterView; diff --git a/Navigation.js b/Navigation.js deleted file mode 100644 index 9bede94f44..0000000000 --- a/Navigation.js +++ /dev/null @@ -1,528 +0,0 @@ -import React, { useCallback, useMemo } from 'react'; -import { createNativeStackNavigator } from 'react-native-screens/native-stack'; -import { createDrawerNavigator } from '@react-navigation/drawer'; -import { Platform, useWindowDimensions, Dimensions, I18nManager } from 'react-native'; -import { useTheme } from '@react-navigation/native'; - -import Settings from './screen/settings/settings'; -import About from './screen/settings/about'; -import ReleaseNotes from './screen/settings/releasenotes'; -import Licensing from './screen/settings/licensing'; -import Selftest from './screen/selftest'; -import Language from './screen/settings/language'; -import Currency from './screen/settings/currency'; -import EncryptStorage from './screen/settings/encryptStorage'; -import PlausibleDeniability from './screen/plausibledeniability'; -import LightningSettings from './screen/settings/lightningSettings'; -import ElectrumSettings from './screen/settings/electrumSettings'; -import TorSettings from './screen/settings/torSettings'; -import Tools from './screen/settings/tools'; -import GeneralSettings from './screen/settings/GeneralSettings'; -import NetworkSettings from './screen/settings/NetworkSettings'; -import NotificationSettings from './screen/settings/notificationSettings'; -import DefaultView from './screen/settings/defaultView'; - -import WalletsList from './screen/wallets/list'; -import WalletTransactions from './screen/wallets/transactions'; -import AddWallet from './screen/wallets/add'; -import WalletsAddMultisig from './screen/wallets/addMultisig'; -import WalletsAddMultisigStep2 from './screen/wallets/addMultisigStep2'; -import WalletsAddMultisigHelp from './screen/wallets/addMultisigHelp'; -import PleaseBackup from './screen/wallets/pleaseBackup'; -import PleaseBackupLNDHub from './screen/wallets/pleaseBackupLNDHub'; -import PleaseBackupLdk from './screen/wallets/pleaseBackupLdk'; -import ImportWallet from './screen/wallets/import'; -import ImportWalletDiscovery from './screen/wallets/importDiscovery'; -import ImportCustomDerivationPath from './screen/wallets/importCustomDerivationPath'; -import ImportSpeed from './screen/wallets/importSpeed'; -import WalletDetails from './screen/wallets/details'; -import WalletExport from './screen/wallets/export'; -import ExportMultisigCoordinationSetup from './screen/wallets/exportMultisigCoordinationSetup'; -import ViewEditMultisigCosigners from './screen/wallets/viewEditMultisigCosigners'; -import WalletXpub from './screen/wallets/xpub'; -import SignVerify from './screen/wallets/signVerify'; -import WalletAddresses from './screen/wallets/addresses'; -import ReorderWallets from './screen/wallets/reorderWallets'; -import SelectWallet from './screen/wallets/selectWallet'; -import ProvideEntropy from './screen/wallets/provideEntropy'; - -import TransactionDetails from './screen/transactions/details'; -import TransactionStatus from './screen/transactions/transactionStatus'; -import CPFP from './screen/transactions/CPFP'; -import RBFBumpFee from './screen/transactions/RBFBumpFee'; -import RBFCancel from './screen/transactions/RBFCancel'; - -import ReceiveDetails from './screen/receive/details'; -import AztecoRedeem from './screen/receive/aztecoRedeem'; - -import SendDetails from './screen/send/details'; -import ScanQRCode from './screen/send/ScanQRCode'; -import SendCreate from './screen/send/create'; -import Confirm from './screen/send/confirm'; -import PsbtWithHardwareWallet from './screen/send/psbtWithHardwareWallet'; -import PsbtMultisig from './screen/send/psbtMultisig'; -import PsbtMultisigQRCode from './screen/send/psbtMultisigQRCode'; -import Success from './screen/send/success'; -import Broadcast from './screen/send/broadcast'; -import IsItMyAddress from './screen/send/isItMyAddress'; -import CoinControl from './screen/send/coinControl'; - -import ScanLndInvoice from './screen/lnd/scanLndInvoice'; -import LappBrowser from './screen/lnd/browser'; -import LNDCreateInvoice from './screen/lnd/lndCreateInvoice'; -import LNDViewInvoice from './screen/lnd/lndViewInvoice'; -import LdkOpenChannel from './screen/lnd/ldkOpenChannel'; -import LdkInfo from './screen/lnd/ldkInfo'; -import LNDViewAdditionalInvoiceInformation from './screen/lnd/lndViewAdditionalInvoiceInformation'; -import LnurlPay from './screen/lnd/lnurlPay'; -import LnurlPaySuccess from './screen/lnd/lnurlPaySuccess'; -import LnurlAuth from './screen/lnd/lnurlAuth'; -import UnlockWith from './UnlockWith'; -import DrawerList from './screen/wallets/drawerList'; -import { isDesktop, isTablet, isHandset } from './blue_modules/environment'; -import SettingsPrivacy from './screen/settings/SettingsPrivacy'; -import LNDViewAdditionalInvoicePreImage from './screen/lnd/lndViewAdditionalInvoicePreImage'; -import LdkViewLogs from './screen/wallets/ldkViewLogs'; -import PaymentCode from './screen/wallets/paymentCode'; -import PaymentCodesList from './screen/wallets/paymentCodesList'; -import loc from './loc'; - -const WalletsStack = createNativeStackNavigator(); - -const WalletsRoot = () => { - const theme = useTheme(); - - return ( - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - ); -}; - -const AddWalletStack = createNativeStackNavigator(); -const AddWalletRoot = () => { - const theme = useTheme(); - - return ( - - - - - - - - - - - - - - - ); -}; - -// CreateTransactionStackNavigator === SendDetailsStack -const SendDetailsStack = createNativeStackNavigator(); -const SendDetailsRoot = () => { - const theme = useTheme(); - - return ( - - - - - - - - - - - - ); -}; - -const LNDCreateInvoiceStack = createNativeStackNavigator(); -const LNDCreateInvoiceRoot = () => { - const theme = useTheme(); - - return ( - - - - - - - - ); -}; - -// LightningScanInvoiceStackNavigator === ScanLndInvoiceStack -const ScanLndInvoiceStack = createNativeStackNavigator(); -const ScanLndInvoiceRoot = () => { - const theme = useTheme(); - - return ( - - - - - - - - ); -}; - -const LDKOpenChannelStack = createNativeStackNavigator(); -const LDKOpenChannelRoot = () => { - const theme = useTheme(); - - return ( - - - - - - ); -}; - -const AztecoRedeemStack = createNativeStackNavigator(); -const AztecoRedeemRoot = () => { - const theme = useTheme(); - - return ( - - - - - ); -}; - -const ScanQRCodeStack = createNativeStackNavigator(); -const ScanQRCodeRoot = () => ( - - - -); - -const UnlockWithScreenStack = createNativeStackNavigator(); -const UnlockWithScreenRoot = () => ( - - - -); - -const ReorderWalletsStack = createNativeStackNavigator(); -const ReorderWalletsStackRoot = () => { - const theme = useTheme(); - - return ( - - - - ); -}; - -const Drawer = createDrawerNavigator(); -const DrawerRoot = () => { - const dimensions = useWindowDimensions(); - const isLargeScreen = useMemo(() => { - return Platform.OS === 'android' ? isTablet() : (dimensions.width >= Dimensions.get('screen').width / 2 && isTablet()) || isDesktop; - }, [dimensions.width]); - const drawerStyle = useMemo(() => ({ width: isLargeScreen ? 320 : '0%' }), [isLargeScreen]); - const drawerContent = useCallback(props => (isLargeScreen ? : null), [isLargeScreen]); - - return ( - - - - ); -}; - -const ReceiveDetailsStack = createNativeStackNavigator(); -const ReceiveDetailsStackRoot = () => { - const theme = useTheme(); - - return ( - - - - ); -}; - -const WalletXpubStack = createNativeStackNavigator(); -const WalletXpubStackRoot = () => { - const theme = useTheme(); - - return ( - - - - ); -}; - -const SignVerifyStack = createNativeStackNavigator(); -const SignVerifyStackRoot = () => { - const theme = useTheme(); - - return ( - - - - ); -}; - -const WalletExportStack = createNativeStackNavigator(); -const WalletExportStackRoot = () => { - const theme = useTheme(); - - return ( - - - - ); -}; - -const LappBrowserStack = createNativeStackNavigator(); -const LappBrowserStackRoot = () => { - const theme = useTheme(); - - return ( - - - - ); -}; - -const InitStack = createNativeStackNavigator(); -const InitRoot = () => ( - - - - - -); - -const ViewEditMultisigCosignersStack = createNativeStackNavigator(); -const ViewEditMultisigCosignersRoot = () => { - const theme = useTheme(); - - return ( - - - - ); -}; - -const ExportMultisigCoordinationSetupStack = createNativeStackNavigator(); -const ExportMultisigCoordinationSetupRoot = () => { - const theme = useTheme(); - - return ( - - - - ); -}; - -const PaymentCodeStack = createNativeStackNavigator(); -const PaymentCodeStackRoot = () => { - return ( - - - - - ); -}; - -const RootStack = createNativeStackNavigator(); -const NavigationDefaultOptions = { headerShown: false, stackPresentation: isDesktop ? 'containedModal' : 'modal' }; -const Navigation = () => { - return ( - - {/* stacks */} - - - - - - - {/* screens */} - - - - - - - - - - - - - - - ); -}; - -export default InitRoot; diff --git a/NavigationService.js b/NavigationService.js deleted file mode 100644 index 0531645958..0000000000 --- a/NavigationService.js +++ /dev/null @@ -1,11 +0,0 @@ -import * as React from 'react'; - -export const navigationRef = React.createRef(); - -export function navigate(name, params) { - navigationRef.current?.navigate(name, params); -} - -export function dispatch(params) { - navigationRef.current?.dispatch(params); -} diff --git a/NavigationService.ts b/NavigationService.ts new file mode 100644 index 0000000000..21f9074db3 --- /dev/null +++ b/NavigationService.ts @@ -0,0 +1,40 @@ +import { createNavigationContainerRef, NavigationAction, ParamListBase, StackActions } from '@react-navigation/native'; + +export const navigationRef = createNavigationContainerRef(); + +export function navigate(name: string, params?: ParamListBase, options?: { merge: boolean }) { + if (navigationRef.isReady()) { + navigationRef.current?.navigate({ name, params, merge: options?.merge }); + } +} + +export function dispatch(action: NavigationAction) { + if (navigationRef.isReady()) { + navigationRef.current?.dispatch(action); + } +} + +export function navigateToWalletsList() { + navigate('WalletsList'); +} + +export function reset() { + if (navigationRef.isReady()) { + navigationRef.current?.reset({ + index: 0, + routes: [{ name: 'UnlockWithScreen' }], + }); + } +} + +export function popToTop() { + if (navigationRef.isReady()) { + navigationRef.current?.dispatch(StackActions.popToTop()); + } +} + +export function pop() { + if (navigationRef.isReady()) { + navigationRef.current?.dispatch(StackActions.pop()); + } +} diff --git a/README.md b/README.md index 7b33353993..f950b26f28 100644 --- a/README.md +++ b/README.md @@ -76,14 +76,17 @@ In another terminal window within the BlueWallet folder: ``` npx react-native run-ios ``` +**To debug BlueWallet on the iOS Simulator, you must choose a Rosetta-compatible iOS Simulator. This can be done by navigating to the Product menu in Xcode, selecting Destination Architectures, and then opting for "Show Both." This action will reveal the simulators that support Rosetta. +** * To run on macOS using Mac Catalyst: ``` -npm run maccatalystpatches +npx pod-install +npm start ``` -Once the patches are applied, open Xcode and select "My Mac" as destination. +Open ios/BlueWallet.xcworkspace. Once the project loads, select the scheme/target BlueWallet. Click Run. ## TESTS @@ -98,7 +101,7 @@ MIT ## WANT TO CONTRIBUTE? -Grab an issue from [the backlog](https://github.com/BlueWallet/BlueWallet/projects/1), try to start or submit a PR, any doubts we will try to guide you. Contributors have a private telegram group, request access by email bluewallet@bluewallet.io +Grab an issue from [the backlog](https://github.com/BlueWallet/BlueWallet/issues), try to start or submit a PR, any doubts we will try to guide you. Contributors have a private telegram group, request access by email bluewallet@bluewallet.io ## Translations diff --git a/RELEASE.md b/RELEASE.md index df06d6c8ee..c5299fff12 100644 --- a/RELEASE.md +++ b/RELEASE.md @@ -2,27 +2,8 @@ ## Apple -* test the build on a real device. It is imperative that you run selftest and it gives you OK -* if necessary, up version number in all relevant files (you can use `./edit-version-number.sh`) -* run `./scripts/release-notes.sh` - it prints changelog between latest tag and now, put this output under -new version in file `ios/fastlane/metadata/en-US/release_notes.txt` (on top); if file got too big -delete the oldest version from the bottom of the file -* now is a good time to commit a ver bump and release notes changes -* create this release version in App Store Connect (iTunes) and attach appropriate build. note -last 4 digits of the build and announce it - this is now a RC. no need to fill release notes yet -* `cd ios/` and then run `DELIVER_USERNAME="my_itunes_email@example.com" DELIVER_PASSWORD="my_itunes_password" fastlane deliver --force --skip_binary_upload --skip_screenshots --ignore_language_directory_validation -a io.bluewallet.bluewallet --app_version "6.6.6"` -but replace `6.6.6` with your version number - this will upload release notes to all locales in itunes -* go back to App Store Connect and press `Submit for Review`. choose Yes, we use identifiers - for installs tracking -* once its approved and released it is safe to cut a release tag: run `git tag -m "REL v6.6.6: 76ed479" v6.6.6 -s` -where `76ed479` is a latest commit in this version. replace the version as well. then run `git push origin --tags`; alternative way to tag: `git tag -a v6.0.0 2e1a00609d5a0dbc91bcda2421df0f61bdfc6b10 -m "v6.0.0" -s` -* you are awesome! +* TBD ## Android -* do android after ios usually -* test the build on a real device. We have accounts with browserstack where you can do so. -* its imperative that you run selftest and it gives you OK. note which build you are testing -* go to appcenter.ms, find this exact build under `master` builds, and press `Distribute` -> `Store` -> `Production`. -in `Release notes` write the release, this field is to smaller than iOS, so you need to keep it bellow 500 characters. -* now just wait till appcenter displays a message that it is succesfully distributed -* noice! +* TBD diff --git a/UnlockWith.js b/UnlockWith.js deleted file mode 100644 index 6bb0aa979e..0000000000 --- a/UnlockWith.js +++ /dev/null @@ -1,141 +0,0 @@ -import React, { useContext, useEffect, useState } from 'react'; -import { View, Image, TouchableOpacity, StyleSheet, StatusBar, ActivityIndicator, useColorScheme, LayoutAnimation } from 'react-native'; -import { Icon } from 'react-native-elements'; -import Biometric from './class/biometrics'; -import LottieView from 'lottie-react-native'; -import { SafeAreaView } from 'react-native-safe-area-context'; -import { StackActions, useNavigation, useRoute } from '@react-navigation/native'; -import { BlueStorageContext } from './blue_modules/storage-context'; -import ReactNativeHapticFeedback from 'react-native-haptic-feedback'; -import { isHandset } from './blue_modules/environment'; - -const styles = StyleSheet.create({ - root: { - flex: 1, - }, - container: { - flex: 1, - justifyContent: 'space-between', - alignItems: 'center', - }, - biometric: { - flex: 1, - justifyContent: 'flex-end', - marginBottom: 58, - }, - biometricRow: { - justifyContent: 'center', - flexDirection: 'row', - }, - icon: { - width: 64, - height: 64, - }, -}); - -const UnlockWith = () => { - const { setWalletsInitialized, isStorageEncrypted, startAndDecrypt } = useContext(BlueStorageContext); - const { dispatch } = useNavigation(); - const { unlockOnComponentMount } = useRoute().params; - const [biometricType, setBiometricType] = useState(false); - const [isStorageEncryptedEnabled, setIsStorageEncryptedEnabled] = useState(false); - const [isAuthenticating, setIsAuthenticating] = useState(false); - const [animationDidFinish, setAnimationDidFinish] = useState(false); - const colorScheme = useColorScheme(); - - const initialRender = async () => { - let bt = false; - if (await Biometric.isBiometricUseCapableAndEnabled()) { - bt = await Biometric.biometricType(); - } - - setBiometricType(bt); - }; - - useEffect(() => { - initialRender(); - }, []); - - const successfullyAuthenticated = () => { - setWalletsInitialized(true); - dispatch(StackActions.replace(isHandset ? 'Navigation' : 'DrawerRoot')); - }; - - const unlockWithBiometrics = async () => { - if (await isStorageEncrypted()) { - unlockWithKey(); - } - setIsAuthenticating(true); - - if (await Biometric.unlockWithBiometrics()) { - setIsAuthenticating(false); - await startAndDecrypt(); - return successfullyAuthenticated(); - } - setIsAuthenticating(false); - }; - - const unlockWithKey = async () => { - setIsAuthenticating(true); - if (await startAndDecrypt()) { - ReactNativeHapticFeedback.trigger('notificationSuccess', { ignoreAndroidSystemSettings: false }); - successfullyAuthenticated(); - } else { - setIsAuthenticating(false); - } - }; - - const renderUnlockOptions = () => { - if (isAuthenticating) { - return ; - } else { - const color = colorScheme === 'dark' ? '#FFFFFF' : '#000000'; - if ((biometricType === Biometric.TouchID || biometricType === Biometric.Biometrics) && !isStorageEncryptedEnabled) { - return ( - - - - ); - } else if (biometricType === Biometric.FaceID && !isStorageEncryptedEnabled) { - return ( - - - - ); - } else if (isStorageEncryptedEnabled) { - return ( - - - - ); - } - } - }; - - const onAnimationFinish = async () => { - LayoutAnimation.configureNext(LayoutAnimation.Presets.easeInEaseOut); - if (unlockOnComponentMount) { - const storageIsEncrypted = await isStorageEncrypted(); - setIsStorageEncryptedEnabled(storageIsEncrypted); - if (!biometricType || storageIsEncrypted) { - unlockWithKey(); - } else if (typeof biometricType === 'string') unlockWithBiometrics(); - } - setAnimationDidFinish(true); - }; - - return ( - - - - - {animationDidFinish && {renderUnlockOptions()}} - - - ); -}; - -export default UnlockWith; diff --git a/WatchConnectivity.ios.js b/WatchConnectivity.ios.js deleted file mode 100644 index 6c21a16a87..0000000000 --- a/WatchConnectivity.ios.js +++ /dev/null @@ -1,227 +0,0 @@ -import { useContext, useEffect, useRef } from 'react'; -import { - updateApplicationContext, - watchEvents, - useReachability, - useInstalled, - usePaired, - transferCurrentComplicationUserInfo, -} from 'react-native-watch-connectivity'; -import { Chain } from './models/bitcoinUnits'; -import loc, { formatBalance, transactionTimeToReadable } from './loc'; -import { BlueStorageContext } from './blue_modules/storage-context'; -import Notifications from './blue_modules/notifications'; -import { FiatUnit } from './models/fiatUnit'; -import { MultisigHDWallet } from './class'; - -function WatchConnectivity() { - const { walletsInitialized, wallets, fetchWalletTransactions, saveToDisk, txMetadata, preferredFiatCurrency } = - useContext(BlueStorageContext); - const isReachable = useReachability(); - const isPaired = usePaired(); - const isInstalled = useInstalled(); // true | false - const messagesListenerActive = useRef(false); - const lastPreferredCurrency = useRef(FiatUnit.USD.endPointKey); - - useEffect(() => { - let messagesListener = () => {}; - if (isPaired && isInstalled && isReachable && walletsInitialized && messagesListenerActive.current === false) { - messagesListener = watchEvents.addListener('message', handleMessages); - messagesListenerActive.current = true; - } else { - messagesListener(); - messagesListenerActive.current = false; - } - return () => { - messagesListener(); - messagesListenerActive.current = false; - }; - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [walletsInitialized, isPaired, isReachable, isInstalled]); - - useEffect(() => { - if (isPaired && isInstalled && isReachable && walletsInitialized) { - sendWalletsToWatch(); - } - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [walletsInitialized, wallets, isPaired, isReachable, isInstalled]); - - useEffect(() => { - updateApplicationContext({ isWalletsInitialized: walletsInitialized, randomID: Math.floor(Math.random() * 11) }); - }, [walletsInitialized]); - - useEffect(() => { - if (isInstalled && isReachable && walletsInitialized && preferredFiatCurrency) { - const preferredFiatCurrencyParsed = JSON.parse(preferredFiatCurrency); - try { - if (lastPreferredCurrency.current !== preferredFiatCurrencyParsed.endPointKey) { - transferCurrentComplicationUserInfo({ - preferredFiatCurrency: preferredFiatCurrencyParsed.endPointKey, - }); - lastPreferredCurrency.current = preferredFiatCurrency.endPointKey; - } else { - console.log('WatchConnectivity lastPreferredCurrency has not changed'); - } - } catch (e) { - console.log('WatchConnectivity useEffect preferredFiatCurrency error'); - console.log(e); - } - } - }, [preferredFiatCurrency, walletsInitialized, isReachable, isInstalled]); - - const handleMessages = (message, reply) => { - if (message.request === 'createInvoice') { - handleLightningInvoiceCreateRequest(message.walletIndex, message.amount, message.description) - .then(createInvoiceRequest => reply({ invoicePaymentRequest: createInvoiceRequest })) - .catch(e => { - console.log(e); - reply({}); - }); - } else if (message.message === 'sendApplicationContext') { - sendWalletsToWatch(); - reply({}); - } else if (message.message === 'fetchTransactions') { - fetchWalletTransactions() - .then(() => saveToDisk()) - .finally(() => reply({})); - } else if (message.message === 'hideBalance') { - const walletIndex = message.walletIndex; - const wallet = wallets[walletIndex]; - wallet.hideBalance = message.hideBalance; - saveToDisk().finally(() => reply({})); - } - }; - - const handleLightningInvoiceCreateRequest = async (walletIndex, amount, description = loc.lnd.placeholder) => { - const wallet = wallets[walletIndex]; - if (wallet.allowReceive() && amount > 0) { - try { - const invoiceRequest = await wallet.addInvoice(amount, description); - - // lets decode payreq and subscribe groundcontrol so we can receive push notification when our invoice is paid - try { - // Let's verify if notifications are already configured. Otherwise the watch app will freeze waiting for user approval in iOS app - if (await Notifications.isNotificationsEnabled()) { - const decoded = await wallet.decodeInvoice(invoiceRequest); - Notifications.majorTomToGroundControl([], [decoded.payment_hash], []); - } - } catch (e) { - console.log('WatchConnectivity - Running in Simulator'); - console.log(e); - } - return invoiceRequest; - } catch (error) { - return error; - } - } - }; - - const sendWalletsToWatch = async () => { - if (!Array.isArray(wallets)) { - console.log('No Wallets set to sync with Watch app. Exiting...'); - return; - } - if (!walletsInitialized) { - console.log('Wallets not initialized. Exiting...'); - return; - } - const walletsToProcess = []; - - for (const wallet of wallets) { - let receiveAddress; - if (wallet.chain === Chain.ONCHAIN) { - try { - receiveAddress = await wallet.getAddressAsync(); - } catch (_) {} - if (!receiveAddress) { - // either sleep expired or getAddressAsync threw an exception - receiveAddress = wallet._getExternalAddressByIndex(wallet.next_free_address_index); - } - } else if (wallet.chain === Chain.OFFCHAIN) { - try { - await wallet.getAddressAsync(); - receiveAddress = wallet.getAddress(); - } catch (_) {} - if (!receiveAddress) { - // either sleep expired or getAddressAsync threw an exception - receiveAddress = wallet.getAddress(); - } - } - const transactions = wallet.getTransactions(10); - const watchTransactions = []; - for (const transaction of transactions) { - let type = 'pendingConfirmation'; - let memo = ''; - let amount = 0; - - if ('confirmations' in transaction && !(transaction.confirmations > 0)) { - type = 'pendingConfirmation'; - } else if (transaction.type === 'user_invoice' || transaction.type === 'payment_request') { - const currentDate = new Date(); - const now = (currentDate.getTime() / 1000) | 0; // eslint-disable-line no-bitwise - const invoiceExpiration = transaction.timestamp + transaction.expire_time; - - if (invoiceExpiration > now) { - type = 'pendingConfirmation'; - } else if (invoiceExpiration < now) { - if (transaction.ispaid) { - type = 'received'; - } else { - type = 'sent'; - } - } - } else if (transaction.value / 100000000 < 0) { - type = 'sent'; - } else { - type = 'received'; - } - if (transaction.type === 'user_invoice' || transaction.type === 'payment_request') { - amount = isNaN(transaction.value) ? '0' : amount; - const currentDate = new Date(); - const now = (currentDate.getTime() / 1000) | 0; // eslint-disable-line no-bitwise - const invoiceExpiration = transaction.timestamp + transaction.expire_time; - - if (invoiceExpiration > now) { - amount = formatBalance(transaction.value, wallet.getPreferredBalanceUnit(), true).toString(); - } else if (invoiceExpiration < now) { - if (transaction.ispaid) { - amount = formatBalance(transaction.value, wallet.getPreferredBalanceUnit(), true).toString(); - } else { - amount = loc.lnd.expired; - } - } else { - amount = formatBalance(transaction.value, wallet.getPreferredBalanceUnit(), true).toString(); - } - } else { - amount = formatBalance(transaction.value, wallet.getPreferredBalanceUnit(), true).toString(); - } - if (txMetadata[transaction.hash] && txMetadata[transaction.hash].memo) { - memo = txMetadata[transaction.hash].memo; - } else if (transaction.memo) { - memo = transaction.memo; - } - const watchTX = { type, amount, memo, time: transactionTimeToReadable(transaction.received) }; - watchTransactions.push(watchTX); - } - - const walletInformation = { - label: wallet.getLabel(), - balance: formatBalance(Number(wallet.getBalance()), wallet.getPreferredBalanceUnit(), true), - type: wallet.type, - preferredBalanceUnit: wallet.getPreferredBalanceUnit(), - receiveAddress, - transactions: watchTransactions, - hideBalance: wallet.hideBalance, - }; - if (wallet.chain === Chain.ONCHAIN && wallet.type !== MultisigHDWallet.type) { - walletInformation.xpub = wallet.getXpub() ? wallet.getXpub() : wallet.getSecret(); - } - walletsToProcess.push(walletInformation); - } - updateApplicationContext({ wallets: walletsToProcess, randomID: Math.floor(Math.random() * 11) }); - }; - - return null; -} - -export default WatchConnectivity; diff --git a/WatchConnectivity.js b/WatchConnectivity.js deleted file mode 100644 index 93d040a1a5..0000000000 --- a/WatchConnectivity.js +++ /dev/null @@ -1,4 +0,0 @@ -const WatchConnectivity = () => { - return null; -}; -export default WatchConnectivity; diff --git a/__mocks__/@react-native-async-storage/async-storage.js b/__mocks__/@react-native-async-storage/async-storage.ts similarity index 100% rename from __mocks__/@react-native-async-storage/async-storage.js rename to __mocks__/@react-native-async-storage/async-storage.ts diff --git a/__mocks__/react-native-image-picker.js b/__mocks__/react-native-image-picker.ts similarity index 87% rename from __mocks__/react-native-image-picker.js rename to __mocks__/react-native-image-picker.ts index 3392a0e6b3..c401aabe35 100644 --- a/__mocks__/react-native-image-picker.js +++ b/__mocks__/react-native-image-picker.ts @@ -1,4 +1,4 @@ -import {NativeModules} from 'react-native'; +import { NativeModules } from 'react-native'; // Mock the ImagePickerManager native module to allow us to unit test the JavaScript code NativeModules.ImagePickerManager = { diff --git a/__mocks__/react-native-localize.js b/__mocks__/react-native-localize.ts similarity index 100% rename from __mocks__/react-native-localize.js rename to __mocks__/react-native-localize.ts diff --git a/__mocks__/react-native-tor.js b/__mocks__/react-native-tor.js deleted file mode 100644 index d4bf7deed4..0000000000 --- a/__mocks__/react-native-tor.js +++ /dev/null @@ -1,18 +0,0 @@ -/* global jest */ - -export const startIfNotStarted = jest.fn(async (key, value, callback) => { - return 666; -}); - - -export const get = jest.fn(); -export const post = jest.fn(); -export const deleteMock = jest.fn(); -export const stopIfRunning = jest.fn(); -export const getDaemonStatus = jest.fn(); - -const mock = jest.fn().mockImplementation(() => { - return { startIfNotStarted, get, post, delete: deleteMock, stopIfRunning, getDaemonStatus }; -}); - -export default mock; \ No newline at end of file diff --git a/android/.project b/android/.project index d22beed79e..0117c3a813 100644 --- a/android/.project +++ b/android/.project @@ -14,4 +14,15 @@ org.eclipse.buildship.core.gradleprojectnature + + + 1729710829465 + + 30 + + org.eclipse.core.resources.regexFilterMatcher + node_modules|\.git|__CREATED_BY_JAVA_LANGUAGE_SERVER__ + + + diff --git a/android/.settings/org.eclipse.buildship.core.prefs b/android/.settings/org.eclipse.buildship.core.prefs index e8895216fd..9d2efc8e78 100644 --- a/android/.settings/org.eclipse.buildship.core.prefs +++ b/android/.settings/org.eclipse.buildship.core.prefs @@ -1,2 +1,2 @@ connection.project.dir= -eclipse.preferences.version=1 +eclipse.preferences.version=1 \ No newline at end of file diff --git a/android/app/.project b/android/app/.project index ac485d7c3e..1a270d11d7 100644 --- a/android/app/.project +++ b/android/app/.project @@ -20,4 +20,15 @@ org.eclipse.jdt.core.javanature org.eclipse.buildship.core.gradleprojectnature + + + 1729710829486 + + 30 + + org.eclipse.core.resources.regexFilterMatcher + node_modules|\.git|__CREATED_BY_JAVA_LANGUAGE_SERVER__ + + + diff --git a/android/app/.settings/org.eclipse.buildship.core.prefs b/android/app/.settings/org.eclipse.buildship.core.prefs index b1886adb46..7e818ac109 100644 --- a/android/app/.settings/org.eclipse.buildship.core.prefs +++ b/android/app/.settings/org.eclipse.buildship.core.prefs @@ -1,2 +1,2 @@ connection.project.dir=.. -eclipse.preferences.version=1 +eclipse.preferences.version=1 \ No newline at end of file diff --git a/android/app/build.gradle b/android/app/build.gradle index a1b77db54d..66c695c74f 100644 --- a/android/app/build.gradle +++ b/android/app/build.gradle @@ -1,23 +1,21 @@ apply plugin: "com.android.application" -apply plugin: "kotlin-android" +apply plugin: "org.jetbrains.kotlin.android" apply plugin: "com.facebook.react" -import com.android.build.OutputFile - /** * This is the configuration block to customize your React Native Android app. * By default you don't need to apply any configuration, just uncomment the lines you need. */ react { /* Folders */ - // The root of your project, i.e. where "package.json" lives. Default is '..' - // root = file("../") - // The folder where the react-native NPM package is. Default is ../node_modules/react-native - // reactNativeDir = file("../node_modules/react-native") - // The folder where the react-native Codegen package is. Default is ../node_modules/react-native-codegen - // codegenDir = file("../node_modules/react-native-codegen") - // The cli.js file which is the React Native CLI entrypoint. Default is ../node_modules/react-native/cli.js - // cliFile = file("../node_modules/react-native/cli.js") + // The root of your project, i.e. where "package.json" lives. Default is '../..' + // root = file("../../") + // The folder where the react-native NPM package is. Default is ../../node_modules/react-native + // reactNativeDir = file("../../node_modules/react-native") + // The folder where the react-native Codegen package is. Default is ../../node_modules/@react-native/codegen + // codegenDir = file("../../node_modules/@react-native/codegen") + // The cli.js file which is the React Native CLI entrypoint. Default is ../../node_modules/react-native/cli.js + // cliFile = file("../../node_modules/react-native/cli.js") /* Variants */ // The list of variants to that are debuggable. For those we're going to @@ -51,15 +49,10 @@ react { // // The list of flags to pass to the Hermes compiler. By default is "-O", "-output-source-map" // hermesFlags = ["-O", "-output-source-map"] -} -/** - * Set this to true to create four separate APKs instead of one, - * one for each native architecture. This is useful if you don't - * use App Bundles (https://developer.android.com/guide/app-bundle/) - * and want to have separate APKs to upload to the Play Store. - */ -def enableSeparateBuildPerCPUArchitecture = false + /* Autolinking */ + autolinkLibrariesWithApp() +} /** * Set this to true to Run Proguard on Release builds to minify the Java bytecode. @@ -79,39 +72,33 @@ def enableProguardInReleaseBuilds = false */ def jscFlavor = 'org.webkit:android-jsc-intl:+' -/** - * Private function to get the list of Native Architectures you want to build. - * This reads the value from reactNativeArchitectures in your gradle.properties - * file and works together with the --active-arch-only flag of react-native run-android. - */ -def reactNativeArchitectures() { - def value = project.getProperties().get("reactNativeArchitectures") - return value ? value.split(",") : ["armeabi-v7a", "x86", "x86_64", "arm64-v8a"] -} - android { ndkVersion rootProject.ext.ndkVersion - + buildToolsVersion rootProject.ext.buildToolsVersion compileSdkVersion rootProject.ext.compileSdkVersion + namespace "io.bluewallet.bluewallet" defaultConfig { applicationId "io.bluewallet.bluewallet" minSdkVersion rootProject.ext.minSdkVersion targetSdkVersion rootProject.ext.targetSdkVersion versionCode 1 - versionName "6.4.9" + versionName "7.0.7" testBuildType System.getProperty('testBuildType', 'debug') testInstrumentationRunner 'androidx.test.runner.AndroidJUnitRunner' } - splits { - abi { - reset() - enable enableSeparateBuildPerCPUArchitecture - universalApk false // If true, also generate a universal APK - include (*reactNativeArchitectures()) + lintOptions { + abortOnError false + checkReleaseBuilds false + } + + sourceSets { + main { + assets.srcDirs = ['src/main/assets', 'src/main/res/assets'] } } + buildTypes { release { // Caution! In production, you need to generate your own keystore file. @@ -122,38 +109,31 @@ android { } } - // applicationVariants are e.g. debug, release - applicationVariants.all { variant -> - variant.outputs.each { output -> - // For each separate APK per architecture, set a unique version code as described here: - // https://developer.android.com/studio/build/configure-apk-splits.html - // Example: versionCode 1 will generate 1001 for armeabi-v7a, 1002 for x86, etc. - def versionCodes = ["armeabi-v7a": 1, "x86": 2, "arm64-v8a": 3, "x86_64": 4] - def abi = output.getFilter(OutputFile.ABI) - if (abi != null) { // null for the universal-debug, universal-release variants - output.versionCodeOverride = - defaultConfig.versionCode * 1000 + versionCodes.get(abi) - } +} - } - } +task copyFiatUnits(type: Copy) { + from '../../models/fiatUnits.json' + into 'src/main/assets' } +preBuild.dependsOn(copyFiatUnits) + dependencies { + androidTestImplementation('com.wix:detox:+') // The version of react-native is set by the React Native Gradle Plugin implementation("com.facebook.react:react-android") - implementation files("../../node_modules/rn-ldk/android/libs/LDK-release.aar") - implementation("androidx.swiperefreshlayout:swiperefreshlayout:1.0.0") - implementation files("../../node_modules/react-native-tor/android/libs/sifir_android.aar") + implementation 'androidx.core:core-ktx:1.13.1' + implementation 'androidx.work:work-runtime-ktx:2.9.1' if (hermesEnabled.toBoolean()) { implementation("com.facebook.react:hermes-android") } else { implementation jscFlavor } - androidTestImplementation('com.wix:detox:+') - implementation 'androidx.appcompat:appcompat:1.1.0' + androidTestImplementation('com.wix:detox:0.1.1') + implementation 'androidx.appcompat:appcompat:1.7.0' implementation fileTree(dir: "libs", include: ["*.jar"]) + implementation 'androidx.constraintlayout:constraintlayout:2.2.0' } apply plugin: 'com.google.gms.google-services' // Google Services plugin -apply from: file("../../node_modules/@react-native-community/cli-platform-android/native_modules.gradle"); applyNativeModulesAppBuildGradle(project) \ No newline at end of file +apply plugin: "com.bugsnag.android.gradle" \ No newline at end of file diff --git a/android/app/proguard-rules.pro b/android/app/proguard-rules.pro index b964573e4e..cb6ff29732 100644 --- a/android/app/proguard-rules.pro +++ b/android/app/proguard-rules.pro @@ -11,8 +11,5 @@ -keep class com.facebook.hermes.unicode.** { *; } -keep class com.facebook.jni.** { *; } --keep class com.sifir.** { *;} --keep interface com.sifir.** { *;} --keep enum com.sifir.** { *;} -keep class com.swmansion.reanimated.** { *; } -keep class com.facebook.react.turbomodule.** { *; } diff --git a/android/app/src/debug/AndroidManifest.xml b/android/app/src/debug/AndroidManifest.xml index 0c4927bccd..476d7dc132 100644 --- a/android/app/src/debug/AndroidManifest.xml +++ b/android/app/src/debug/AndroidManifest.xml @@ -1,10 +1,6 @@ - - - - diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml index feb5e9c0bb..e957b14155 100644 --- a/android/app/src/main/AndroidManifest.xml +++ b/android/app/src/main/AndroidManifest.xml @@ -1,115 +1,172 @@ + xmlns:tools="http://schemas.android.com/tools" + android:installLocation="auto"> + + - - - + + + + + - + + + + + + + + + - - + - - - - - - + + + + + + + - + + + + + + + + + + android:name="com.dieam.reactnativepushnotification.modules.RNPushNotificationListenerService" + android:exported="false"> - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + - + - + - + - + \ No newline at end of file diff --git a/android/app/src/main/assets/fiatUnits.json b/android/app/src/main/assets/fiatUnits.json new file mode 100644 index 0000000000..c00a8ddaa4 --- /dev/null +++ b/android/app/src/main/assets/fiatUnits.json @@ -0,0 +1,422 @@ +{ + "USD": { + "endPointKey": "USD", + "locale": "en-US", + "source": "Kraken", + "symbol": "$", + "country": "United States (US Dollar)" + }, + "AED": { + "endPointKey": "AED", + "locale": "ar-AE", + "source": "CoinGecko", + "symbol": "د.إ.", + "country": "United Arab Emirates (UAE Dirham)" + }, + "AMD": { + "endPointKey": "AMD", + "locale": "hy-AM", + "source": "CoinDesk", + "symbol": "֏", + "country": "Armenia (Armenian Dram)" + }, + "ANG": { + "endPointKey": "ANG", + "locale": "en-SX", + "source": "CoinDesk", + "symbol": "ƒ", + "country": "Sint Maarten (Netherlands Antillean Guilder)" + }, + "ARS": { + "endPointKey": "ARS", + "locale": "es-AR", + "source": "Yadio", + "symbol": "$", + "country": "Argentina (Argentine Peso)" + }, + "AUD": { + "endPointKey": "AUD", + "locale": "en-AU", + "source": "CoinGecko", + "symbol": "$", + "country": "Australia (Australian Dollar)" + }, + "AWG": { + "endPointKey": "AWG", + "locale": "nl-AW", + "source": "CoinDesk", + "symbol": "ƒ", + "country": "Aruba (Aruban Florin)" + }, + "BHD": { + "endPointKey": "BHD", + "locale": "ar-BH", + "source": "CoinGecko", + "symbol": "د.ب.", + "country": "Bahrain (Bahraini Dinar)" + }, + "BRL": { + "endPointKey": "BRL", + "locale": "pt-BR", + "source": "CoinGecko", + "symbol": "R$", + "country": "Brazil (Brazilian Real)" + }, + "CAD": { + "endPointKey": "CAD", + "locale": "en-CA", + "source": "CoinGecko", + "symbol": "$", + "country": "Canada (Canadian Dollar)" + }, + "CHF": { + "endPointKey": "CHF", + "locale": "de-CH", + "source": "CoinGecko", + "symbol": "CHF", + "country": "Switzerland (Swiss Franc)" + }, + "CLP": { + "endPointKey": "CLP", + "locale": "es-CL", + "source": "Yadio", + "symbol": "$", + "country": "Chile (Chilean Peso)" + }, + "CNY": { + "endPointKey": "CNY", + "locale": "zh-CN", + "source": "Coinbase", + "symbol": "¥", + "country": "China (Chinese Yuan)" + }, + "COP": { + "endPointKey": "COP", + "locale": "es-CO", + "source": "CoinDesk", + "symbol": "$", + "country": "Colombia (Colombian Peso)" + }, + "CZK": { + "endPointKey": "CZK", + "locale": "cs-CZ", + "source": "CoinGecko", + "symbol": "Kč", + "country": "Czech Republic (Czech Koruna)" + }, + "DKK": { + "endPointKey": "DKK", + "locale": "da-DK", + "source": "CoinGecko", + "symbol": "kr", + "country": "Denmark (Danish Krone)" + }, + "EUR": { + "endPointKey": "EUR", + "locale": "en-IE", + "source": "Kraken", + "symbol": "€", + "country": "European Union (Euro)" + }, + "GBP": { + "endPointKey": "GBP", + "locale": "en-GB", + "source": "Kraken", + "symbol": "£", + "country": "United Kingdom (British Pound)" + }, + "HRK": { + "endPointKey": "HRK", + "locale": "hr-HR", + "source": "CoinDesk", + "symbol": "HRK", + "country": "Croatia (Croatian Kuna)" + }, + "HUF": { + "endPointKey": "HUF", + "locale": "hu-HU", + "source": "CoinGecko", + "symbol": "Ft", + "country": "Hungary (Hungarian Forint)" + }, + "IDR": { + "endPointKey": "IDR", + "locale": "id-ID", + "source": "CoinGecko", + "symbol": "Rp", + "country": "Indonesia (Indonesian Rupiah)" + }, + "ILS": { + "endPointKey": "ILS", + "locale": "he-IL", + "source": "CoinGecko", + "symbol": "₪", + "country": "Israel (Israeli New Shekel)" + }, + "INR": { + "endPointKey": "INR", + "locale": "hi-IN", + "source": "coinpaprika", + "symbol": "₹", + "country": "India (Indian Rupee)" + }, + "IRR": { + "endPointKey": "IRR", + "locale": "fa-IR", + "source": "Exir", + "symbol": "﷼", + "country": "Iran (Iranian Rial)" + }, + "IRT": { + "endPointKey": "IRT", + "locale": "fa-IR", + "source": "Exir", + "symbol": "تومان", + "country": "Iran (Iranian Toman)" + }, + "ISK": { + "endPointKey": "ISK", + "locale": "is-IS", + "source": "CoinDesk", + "symbol": "kr", + "country": "Iceland (Icelandic Króna)" + }, + "JPY": { + "endPointKey": "JPY", + "locale": "ja-JP", + "source": "CoinGecko", + "symbol": "¥", + "country": "Japan (Japanese Yen)" + }, + "KES": { + "endPointKey": "KES", + "locale": "en-KE", + "source": "CoinDesk", + "symbol": "Ksh", + "country": "Kenya (Kenyan Shilling)" + }, + "KRW": { + "endPointKey": "KRW", + "locale": "ko-KR", + "source": "CoinGecko", + "symbol": "₩", + "country": "South Korea (South Korean Won)" + }, + "KWD": { + "endPointKey": "KWD", + "locale": "ar-KW", + "source": "CoinGecko", + "symbol": "د.ك.", + "country": "Kuwait (Kuwaiti Dinar)" + }, + "LBP": { + "endPointKey": "LBP", + "locale": "ar-LB", + "source": "YadioConvert", + "symbol": "ل.ل.", + "country": "Lebanon (Lebanese Pound)" + }, + "LKR": { + "endPointKey": "LKR", + "locale": "si-LK", + "source": "CoinGecko", + "symbol": "රු.", + "country": "Sri Lanka (Sri Lankan Rupee)" + }, + "MXN": { + "endPointKey": "MXN", + "locale": "es-MX", + "source": "CoinGecko", + "symbol": "$", + "country": "Mexico (Mexican Peso)" + }, + "MYR": { + "endPointKey": "MYR", + "locale": "ms-MY", + "source": "CoinGecko", + "symbol": "RM", + "country": "Malaysia (Malaysian Ringgit)" + }, + "MZN": { + "endPointKey": "MZN", + "locale": "seh-MZ", + "source": "CoinDesk", + "symbol": "MTn", + "country": "Mozambique (Mozambican Metical)" + }, + "NGN": { + "endPointKey": "NGN", + "locale": "en-NG", + "source": "CoinGecko", + "symbol": "₦", + "country": "Nigeria (Nigerian Naira)" + }, + "NOK": { + "endPointKey": "NOK", + "locale": "nb-NO", + "source": "CoinGecko", + "symbol": "kr", + "country": "Norway (Norwegian Krone)" + }, + "NZD": { + "endPointKey": "NZD", + "locale": "en-NZ", + "source": "CoinGecko", + "symbol": "$", + "country": "New Zealand (New Zealand Dollar)" + }, + "OMR": { + "endPointKey": "OMR", + "locale": "ar-OM", + "source": "CoinDesk", + "symbol": "ر.ع.", + "country": "Oman (Omani Rial)" + }, + "PHP": { + "endPointKey": "PHP", + "locale": "en-PH", + "source": "CoinGecko", + "symbol": "₱", + "country": "Philippines (Philippine Peso)" + }, + "PLN": { + "endPointKey": "PLN", + "locale": "pl-PL", + "source": "CoinGecko", + "symbol": "zł", + "country": "Poland (Polish Zloty)" + }, + "QAR": { + "endPointKey": "QAR", + "locale": "ar-QA", + "source": "CoinDesk", + "symbol": "ر.ق.", + "country": "Qatar (Qatari Riyal)" + }, + "RON": { + "endPointKey": "RON", + "locale": "ro-RO", + "source": "BNR", + "symbol": "lei", + "country": "Romania (Romanian Leu)" + }, + "RSD": { + "endPointKey": "RSD", + "locale": "sr-RS", + "source": "CoinDesk", + "symbol": "DIN", + "country": "Serbia (Serbian Dinar)" + }, + "RUB": { + "endPointKey": "RUB", + "locale": "ru-RU", + "source": "CoinGecko", + "symbol": "₽", + "country": "Russia (Russian Ruble)" + }, + "SAR": { + "endPointKey": "SAR", + "locale": "ar-SA", + "source": "CoinGecko", + "symbol": "ر.س.", + "country": "Saudi Arabia (Saudi Riyal)" + }, + "SEK": { + "endPointKey": "SEK", + "locale": "sv-SE", + "source": "CoinGecko", + "symbol": "kr", + "country": "Sweden (Swedish Krona)" + }, + "SGD": { + "endPointKey": "SGD", + "locale": "zh-SG", + "source": "CoinGecko", + "symbol": "S$", + "country": "Singapore (Singapore Dollar)" + }, + "THB": { + "endPointKey": "THB", + "locale": "th-TH", + "source": "CoinGecko", + "symbol": "฿", + "country": "Thailand (Thai Baht)" + }, + "TRY": { + "endPointKey": "TRY", + "locale": "tr-TR", + "source": "CoinGecko", + "symbol": "₺", + "country": "Turkey (Turkish Lira)" + }, + "TWD": { + "endPointKey": "TWD", + "locale": "zh-Hant-TW", + "source": "CoinGecko", + "symbol": "NT$", + "country": "Taiwan (New Taiwan Dollar)" + }, + "TZS": { + "endPointKey": "TZS", + "locale": "en-TZ", + "source": "CoinDesk", + "symbol": "TSh", + "country": "Tanzania (Tanzanian Shilling)" + }, + "UAH": { + "endPointKey": "UAH", + "locale": "uk-UA", + "source": "CoinGecko", + "symbol": "₴", + "country": "Ukraine (Ukrainian Hryvnia)" + }, + "UGX": { + "endPointKey": "UGX", + "locale": "en-UG", + "source": "CoinDesk", + "symbol": "USh", + "country": "Uganda (Ugandan Shilling)" + }, + "UYU": { + "endPointKey": "UYU", + "locale": "es-UY", + "source": "CoinDesk", + "symbol": "$", + "country": "Uruguay (Uruguayan Peso)" + }, + "VEF": { + "endPointKey": "VEF", + "locale": "es-VE", + "source": "CoinGecko", + "symbol": "Bs.", + "country": "Venezuela (Venezuelan Bolívar Fuerte)" + }, + "VES": { + "endPointKey": "VES", + "locale": "es-VE", + "source": "Yadio", + "symbol": "Bs.", + "country": "Venezuela (Venezuelan Bolívar Soberano)" + }, + "XAF": { + "endPointKey": "XAF", + "locale": "fr-CF", + "source": "CoinDesk", + "symbol": "Fr", + "country": "Central African Republic (Central African Franc)" + }, + "ZAR": { + "endPointKey": "ZAR", + "locale": "en-ZA", + "source": "CoinGecko", + "symbol": "R", + "country": "South Africa (South African Rand)" + }, + "GHS": { + "endPointKey": "GHS", + "locale": "en-GH", + "source": "CoinDesk", + "symbol": "₵", + "country": "Ghana (Ghanaian Cedi)" + } +} diff --git a/android/app/src/main/java/io/bluewallet/bluewallet/BitcoinPriceWidget.kt b/android/app/src/main/java/io/bluewallet/bluewallet/BitcoinPriceWidget.kt new file mode 100644 index 0000000000..0c5dcb1a2f --- /dev/null +++ b/android/app/src/main/java/io/bluewallet/bluewallet/BitcoinPriceWidget.kt @@ -0,0 +1,35 @@ +package io.bluewallet.bluewallet + +import android.appwidget.AppWidgetManager +import android.appwidget.AppWidgetProvider +import android.content.Context +import android.util.Log +import androidx.work.WorkManager + +class BitcoinPriceWidget : AppWidgetProvider() { + + override fun onUpdate(context: Context, appWidgetManager: AppWidgetManager, appWidgetIds: IntArray) { + super.onUpdate(context, appWidgetManager, appWidgetIds) + Log.d("BitcoinPriceWidget", "onUpdate called") + WidgetUpdateWorker.scheduleWork(context) + } + + override fun onEnabled(context: Context) { + super.onEnabled(context) + Log.d("BitcoinPriceWidget", "onEnabled called") + WidgetUpdateWorker.scheduleWork(context) + } + + override fun onDisabled(context: Context) { + super.onDisabled(context) + Log.d("BitcoinPriceWidget", "onDisabled called") + clearCache(context) + WorkManager.getInstance(context).cancelUniqueWork(WidgetUpdateWorker.WORK_NAME) + } + + private fun clearCache(context: Context) { + val sharedPref = context.getSharedPreferences("group.io.bluewallet.bluewallet", Context.MODE_PRIVATE) + sharedPref.edit().clear().apply() // Clear all preferences in the group + Log.d("BitcoinPriceWidget", "Cache cleared from group.io.bluewallet.bluewallet") + } +} \ No newline at end of file diff --git a/android/app/src/main/java/io/bluewallet/bluewallet/MainActivity.java b/android/app/src/main/java/io/bluewallet/bluewallet/MainActivity.java deleted file mode 100644 index 06101f3843..0000000000 --- a/android/app/src/main/java/io/bluewallet/bluewallet/MainActivity.java +++ /dev/null @@ -1,50 +0,0 @@ -package io.bluewallet.bluewallet; - -import android.content.pm.ActivityInfo; -import android.os.Bundle; -import android.os.PersistableBundle; - -import androidx.annotation.Nullable; - -import com.facebook.react.ReactActivity; - -import com.facebook.react.ReactActivityDelegate; -import com.facebook.react.defaults.DefaultNewArchitectureEntryPoint; -import com.facebook.react.defaults.DefaultReactActivityDelegate; - -public class MainActivity extends ReactActivity { - - /** - * Returns the name of the main component registered from JavaScript. - * This is used to schedule rendering of the component. - */ - @Override - protected String getMainComponentName() { - return "BlueWallet"; - } - - @Override - protected void onCreate(Bundle savedInstanceState) { - super.onCreate(null); - if (getResources().getBoolean(R.bool.portrait_only)) { - setRequestedOrientation(ActivityInfo.SCREEN_ORIENTATION_PORTRAIT); - } - } - - /** - * Returns the instance of the {@link ReactActivityDelegate}. Here we use a util class {@link - * DefaultReactActivityDelegate} which allows you to easily enable Fabric and Concurrent React - * (aka React 18) with two boolean flags. - */ - @Override - protected ReactActivityDelegate createReactActivityDelegate() { - return new DefaultReactActivityDelegate( - this, - getMainComponentName(), - // If you opted-in for the New Architecture, we enable the Fabric Renderer. - DefaultNewArchitectureEntryPoint.getFabricEnabled(), // fabricEnabled - // If you opted-in for the New Architecture, we enable Concurrent React (i.e. React 18). - DefaultNewArchitectureEntryPoint.getConcurrentReactEnabled() // concurrentRootEnabled - ); - } -} diff --git a/android/app/src/main/java/io/bluewallet/bluewallet/MainActivity.kt b/android/app/src/main/java/io/bluewallet/bluewallet/MainActivity.kt new file mode 100644 index 0000000000..e5892f2dcc --- /dev/null +++ b/android/app/src/main/java/io/bluewallet/bluewallet/MainActivity.kt @@ -0,0 +1,35 @@ +package io.bluewallet.bluewallet + +import android.content.pm.ActivityInfo +import android.os.Bundle +import com.facebook.react.ReactActivity +import com.facebook.react.ReactActivityDelegate +import com.facebook.react.defaults.DefaultNewArchitectureEntryPoint +import com.facebook.react.defaults.DefaultReactActivityDelegate +import com.facebook.react.defaults.DefaultNewArchitectureEntryPoint.fabricEnabled + +class MainActivity : ReactActivity() { + + /** + * Returns the name of the main component registered from JavaScript. + * This is used to schedule rendering of the component. + */ + override fun getMainComponentName(): String { + return "BlueWallet" + } + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(null) + if (resources.getBoolean(R.bool.portrait_only)) { + requestedOrientation = ActivityInfo.SCREEN_ORIENTATION_PORTRAIT + } + } + + /** + * Returns the instance of the [ReactActivityDelegate]. Here we use a util class [DefaultReactActivityDelegate] + * which allows you to easily enable Fabric and Concurrent React (aka React 18) with two boolean flags. + */ + + override fun createReactActivityDelegate(): ReactActivityDelegate = + DefaultReactActivityDelegate(this, mainComponentName, fabricEnabled) +} diff --git a/android/app/src/main/java/io/bluewallet/bluewallet/MainApplication.java b/android/app/src/main/java/io/bluewallet/bluewallet/MainApplication.java deleted file mode 100644 index 29dc4e9b6d..0000000000 --- a/android/app/src/main/java/io/bluewallet/bluewallet/MainApplication.java +++ /dev/null @@ -1,69 +0,0 @@ -package io.bluewallet.bluewallet; - -import android.app.Application; -import android.content.Context; -import com.facebook.react.PackageList; -import com.facebook.react.ReactApplication; -import com.facebook.react.ReactInstanceManager; -import com.facebook.react.ReactNativeHost; -import com.facebook.react.ReactPackage; -import com.facebook.react.defaults.DefaultNewArchitectureEntryPoint; -import com.facebook.react.defaults.DefaultReactNativeHost; -import com.facebook.soloader.SoLoader; -import java.lang.reflect.InvocationTargetException; -import com.facebook.react.modules.i18nmanager.I18nUtil; -import java.util.List; -import com.bugsnag.android.Bugsnag; - -public class MainApplication extends Application implements ReactApplication { - - private final ReactNativeHost mReactNativeHost = - new DefaultReactNativeHost(this) { - @Override - public boolean getUseDeveloperSupport() { - return BuildConfig.DEBUG; - } - - @Override - protected List getPackages() { - @SuppressWarnings("UnnecessaryLocalVariable") - List packages = new PackageList(this).getPackages(); - // Packages that cannot be autolinked yet can be added manually here, for example: - // packages.add(new MyReactNativePackage()); - return packages; - } - - @Override - protected String getJSMainModuleName() { - return "index"; - } - - @Override - protected boolean isNewArchEnabled() { - return BuildConfig.IS_NEW_ARCHITECTURE_ENABLED; - } - - @Override - protected Boolean isHermesEnabled() { - return BuildConfig.IS_HERMES_ENABLED; - } - }; - - @Override - public ReactNativeHost getReactNativeHost() { - return mReactNativeHost; - } - - @Override - public void onCreate() { - super.onCreate(); - Bugsnag.start(this); - I18nUtil sharedI18nUtilInstance = I18nUtil.getInstance(); - sharedI18nUtilInstance.allowRTL(getApplicationContext(), true); - SoLoader.init(this, /* native exopackage */ false); - if (BuildConfig.IS_NEW_ARCHITECTURE_ENABLED) { - // If you opted-in for the New Architecture, we load the native entry point for this app. - DefaultNewArchitectureEntryPoint.load(); - } - } -} diff --git a/android/app/src/main/java/io/bluewallet/bluewallet/MainApplication.kt b/android/app/src/main/java/io/bluewallet/bluewallet/MainApplication.kt new file mode 100644 index 0000000000..b05d49ad9f --- /dev/null +++ b/android/app/src/main/java/io/bluewallet/bluewallet/MainApplication.kt @@ -0,0 +1,62 @@ +package io.bluewallet.bluewallet + +import android.app.Application +import android.content.Context +import com.bugsnag.android.Bugsnag +import com.facebook.react.PackageList +import com.facebook.react.ReactApplication +import com.facebook.react.ReactHost +import com.facebook.react.ReactNativeHost +import com.facebook.react.ReactPackage +import com.facebook.react.defaults.DefaultNewArchitectureEntryPoint.load +import com.facebook.react.defaults.DefaultReactHost.getDefaultReactHost +import com.facebook.react.defaults.DefaultReactNativeHost +import com.facebook.soloader.SoLoader +import com.facebook.react.modules.i18nmanager.I18nUtil + +class MainApplication : Application(), ReactApplication { + + override val reactNativeHost: ReactNativeHost = + object : DefaultReactNativeHost(this) { + override fun getPackages(): List = + PackageList(this).packages.apply { + // Packages that cannot be autolinked yet can be added manually here, for example: + // add(MyReactNativePackage()) + } + + override fun getJSMainModuleName(): String = "index" + + override fun getUseDeveloperSupport(): Boolean = BuildConfig.DEBUG + + override val isNewArchEnabled: Boolean = BuildConfig.IS_NEW_ARCHITECTURE_ENABLED + override val isHermesEnabled: Boolean = BuildConfig.IS_HERMES_ENABLED + } + + override val reactHost: ReactHost + get() = getDefaultReactHost(applicationContext, reactNativeHost) + + + + override fun onCreate() { + super.onCreate() + val sharedI18nUtilInstance = I18nUtil.getInstance() + sharedI18nUtilInstance.allowRTL(applicationContext, true) + SoLoader.init(this, /* native exopackage */ false) + + if (BuildConfig.IS_NEW_ARCHITECTURE_ENABLED) { + // If you opted-in for the New Architecture, we load the native entry point for this app. + load() + } + + val sharedPref = applicationContext.getSharedPreferences("group.io.bluewallet.bluewallet", Context.MODE_PRIVATE) + + // Retrieve the "donottrack" value. Default to "0" if not found. + val isDoNotTrackEnabled = sharedPref.getString("donottrack", "0") + + // Check if do not track is not enabled and initialize Bugsnag if so + if (isDoNotTrackEnabled != "1") { + // Initialize Bugsnag or your error tracking here + Bugsnag.start(this) + } + } +} \ No newline at end of file diff --git a/android/app/src/main/java/io/bluewallet/bluewallet/MarketAPI.kt b/android/app/src/main/java/io/bluewallet/bluewallet/MarketAPI.kt new file mode 100644 index 0000000000..f4307c1894 --- /dev/null +++ b/android/app/src/main/java/io/bluewallet/bluewallet/MarketAPI.kt @@ -0,0 +1,92 @@ +package io.bluewallet.bluewallet + +import android.content.Context +import android.util.Log +import org.json.JSONObject +import java.io.InputStreamReader +import java.net.HttpURLConnection +import java.net.URL + +object MarketAPI { + + private const val TAG = "MarketAPI" + + var baseUrl: String? = null + + fun fetchPrice(context: Context, currency: String): String? { + return try { + // Load the JSON data from the assets + val fiatUnitsJson = context.assets.open("fiatUnits.json").bufferedReader().use { it.readText() } + val json = JSONObject(fiatUnitsJson) + val currencyInfo = json.getJSONObject(currency) + val source = currencyInfo.getString("source") + val endPointKey = currencyInfo.getString("endPointKey") + + val urlString = buildURLString(source, endPointKey) + Log.d(TAG, "Fetching price from URL: $urlString") + + val url = URL(urlString) + val urlConnection = url.openConnection() as HttpURLConnection + urlConnection.requestMethod = "GET" + urlConnection.connect() + + val responseCode = urlConnection.responseCode + if (responseCode != 200) { + Log.e(TAG, "Failed to fetch price. Response code: $responseCode") + return null + } + + val reader = InputStreamReader(urlConnection.inputStream) + val jsonResponse = StringBuilder() + val buffer = CharArray(1024) + var read: Int + while (reader.read(buffer).also { read = it } != -1) { + jsonResponse.append(buffer, 0, read) + } + + parseJSONBasedOnSource(jsonResponse.toString(), source, endPointKey) + } catch (e: Exception) { + Log.e(TAG, "Error fetching price", e) + null + } + } + + private fun buildURLString(source: String, endPointKey: String): String { + return if (baseUrl != null) { + baseUrl + endPointKey + } else { + when (source) { + "Yadio" -> "https://api.yadio.io/json/$endPointKey" + "YadioConvert" -> "https://api.yadio.io/convert/1/BTC/$endPointKey" + "Exir" -> "https://api.exir.io/v1/ticker?symbol=btc-irt" + "coinpaprika" -> "https://api.coinpaprika.com/v1/tickers/btc-bitcoin?quotes=INR" + "Bitstamp" -> "https://www.bitstamp.net/api/v2/ticker/btc${endPointKey.lowercase()}" + "Coinbase" -> "https://api.coinbase.com/v2/prices/BTC-${endPointKey.uppercase()}/buy" + "CoinGecko" -> "https://api.coingecko.com/api/v3/simple/price?ids=bitcoin&vs_currencies=${endPointKey.lowercase()}" + "BNR" -> "https://www.bnr.ro/nbrfxrates.xml" + "Kraken" -> "https://api.kraken.com/0/public/Ticker?pair=XXBTZ${endPointKey.uppercase()}" + else -> "https://api.coindesk.com/v1/bpi/currentprice/$endPointKey.json" + } + } + } + + private fun parseJSONBasedOnSource(jsonString: String, source: String, endPointKey: String): String? { + return try { + val json = JSONObject(jsonString) + when (source) { + "Yadio" -> json.getJSONObject(endPointKey).getString("price") + "YadioConvert" -> json.getString("rate") + "CoinGecko" -> json.getJSONObject("bitcoin").getString(endPointKey.lowercase()) + "Exir" -> json.getString("last") + "Bitstamp" -> json.getString("last") + "coinpaprika" -> json.getJSONObject("quotes").getJSONObject("INR").getString("price") + "Coinbase" -> json.getJSONObject("data").getString("amount") + "Kraken" -> json.getJSONObject("result").getJSONObject("XXBTZ${endPointKey.uppercase()}").getJSONArray("c").getString(0) + else -> null + } + } catch (e: Exception) { + Log.e(TAG, "Error parsing price", e) + null + } + } +} \ No newline at end of file diff --git a/android/app/src/main/java/io/bluewallet/bluewallet/WidgetUpdateWorker.kt b/android/app/src/main/java/io/bluewallet/bluewallet/WidgetUpdateWorker.kt new file mode 100644 index 0000000000..3f1462c1be --- /dev/null +++ b/android/app/src/main/java/io/bluewallet/bluewallet/WidgetUpdateWorker.kt @@ -0,0 +1,231 @@ +package io.bluewallet.bluewallet + +import android.appwidget.AppWidgetManager +import android.content.ComponentName +import android.content.Context +import android.content.SharedPreferences +import android.util.Log +import android.view.View +import android.widget.RemoteViews +import androidx.work.* +import java.text.DecimalFormatSymbols +import java.text.NumberFormat +import java.text.SimpleDateFormat +import java.util.* +import java.util.concurrent.TimeUnit + +class WidgetUpdateWorker(context: Context, workerParams: WorkerParameters) : Worker(context, workerParams) { + + companion object { + const val TAG = "WidgetUpdateWorker" + const val WORK_NAME = "widget_update_work" + const val REPEAT_INTERVAL_MINUTES = 15L + + fun scheduleWork(context: Context) { + val workRequest = PeriodicWorkRequestBuilder( + REPEAT_INTERVAL_MINUTES, TimeUnit.MINUTES + ).build() + WorkManager.getInstance(context).enqueueUniquePeriodicWork( + WORK_NAME, + ExistingPeriodicWorkPolicy.REPLACE, + workRequest + ) + Log.d(TAG, "Scheduling work for widget updates, will run every $REPEAT_INTERVAL_MINUTES minutes") + } + } + + private lateinit var sharedPref: SharedPreferences + private lateinit var preferenceChangeListener: SharedPreferences.OnSharedPreferenceChangeListener + + override fun doWork(): Result { + Log.d(TAG, "Widget update worker running") + + sharedPref = applicationContext.getSharedPreferences("group.io.bluewallet.bluewallet", Context.MODE_PRIVATE) + registerPreferenceChangeListener() + + val appWidgetManager = AppWidgetManager.getInstance(applicationContext) + val thisWidget = ComponentName(applicationContext, BitcoinPriceWidget::class.java) + val appWidgetIds = appWidgetManager.getAppWidgetIds(thisWidget) + val views = RemoteViews(applicationContext.packageName, R.layout.widget_layout) + + val preferredCurrency = sharedPref.getString("preferredCurrency", null) ?: "USD" + val preferredCurrencyLocale = sharedPref.getString("preferredCurrencyLocale", null) ?: "en-US" + val previousPrice = sharedPref.getString("previous_price", null) + + val currentTime = SimpleDateFormat("hh:mm a", Locale.getDefault()).format(Date()) + + fetchPrice(preferredCurrency) { fetchedPrice, error -> + handlePriceResult( + appWidgetManager, appWidgetIds, views, sharedPref, + fetchedPrice, previousPrice, currentTime, preferredCurrency, preferredCurrencyLocale, error + ) + } + + return Result.success() + } + + private fun registerPreferenceChangeListener() { + preferenceChangeListener = SharedPreferences.OnSharedPreferenceChangeListener { sharedPreferences, key -> + if (key == "preferredCurrency" || key == "preferredCurrencyLocale" || key == "previous_price") { + Log.d(TAG, "Preference changed: $key") + updateWidgetOnPreferenceChange() + } + } + sharedPref.registerOnSharedPreferenceChangeListener(preferenceChangeListener) + } + + override fun onStopped() { + super.onStopped() + sharedPref.unregisterOnSharedPreferenceChangeListener(preferenceChangeListener) + } + + private fun updateWidgetOnPreferenceChange() { + val appWidgetManager = AppWidgetManager.getInstance(applicationContext) + val thisWidget = ComponentName(applicationContext, BitcoinPriceWidget::class.java) + val appWidgetIds = appWidgetManager.getAppWidgetIds(thisWidget) + val views = RemoteViews(applicationContext.packageName, R.layout.widget_layout) + + val preferredCurrency = sharedPref.getString("preferredCurrency", null) ?: "USD" + val preferredCurrencyLocale = sharedPref.getString("preferredCurrencyLocale", null) ?: "en-US" + val previousPrice = sharedPref.getString("previous_price", null) + val currentTime = SimpleDateFormat("hh:mm a", Locale.getDefault()).format(Date()) + + fetchPrice(preferredCurrency) { fetchedPrice, error -> + handlePriceResult( + appWidgetManager, appWidgetIds, views, sharedPref, + fetchedPrice, previousPrice, currentTime, preferredCurrency, preferredCurrencyLocale, error + ) + } + } + + private fun handlePriceResult( + appWidgetManager: AppWidgetManager, + appWidgetIds: IntArray, + views: RemoteViews, + sharedPref: SharedPreferences, + fetchedPrice: String?, + previousPrice: String?, + currentTime: String, + preferredCurrency: String?, + preferredCurrencyLocale: String?, + error: String? + ) { + val isPriceFetched = fetchedPrice != null + val isPriceCached = previousPrice != null + + if (error != null || !isPriceFetched) { + Log.e(TAG, "Error fetching price: $error") + if (!isPriceCached) { + showLoadingError(views) + } else { + displayCachedPrice(views, previousPrice, currentTime, preferredCurrency, preferredCurrencyLocale) + } + } else { + displayFetchedPrice( + views, fetchedPrice!!, previousPrice, currentTime, preferredCurrency, preferredCurrencyLocale + ) + savePrice(sharedPref, fetchedPrice) + } + + appWidgetManager.updateAppWidget(appWidgetIds, views) + } + + private fun showLoadingError(views: RemoteViews) { + views.apply { + setViewVisibility(R.id.loading_indicator, View.VISIBLE) + setViewVisibility(R.id.price_value, View.GONE) + setViewVisibility(R.id.last_updated_label, View.GONE) + setViewVisibility(R.id.last_updated_time, View.GONE) + setViewVisibility(R.id.price_arrow_container, View.GONE) + } + } + + private fun displayCachedPrice( + views: RemoteViews, + previousPrice: String?, + currentTime: String, + preferredCurrency: String?, + preferredCurrencyLocale: String? + ) { + val currencyFormat = getCurrencyFormat(preferredCurrency, preferredCurrencyLocale) + + views.apply { + setViewVisibility(R.id.loading_indicator, View.GONE) + setTextViewText(R.id.price_value, currencyFormat.format(previousPrice?.toDouble()?.toInt())) + setTextViewText(R.id.last_updated_time, currentTime) + setViewVisibility(R.id.price_value, View.VISIBLE) + setViewVisibility(R.id.last_updated_label, View.VISIBLE) + setViewVisibility(R.id.last_updated_time, View.VISIBLE) + setViewVisibility(R.id.price_arrow_container, View.GONE) + } + } + + private fun displayFetchedPrice( + views: RemoteViews, + fetchedPrice: String, + previousPrice: String?, + currentTime: String, + preferredCurrency: String?, + preferredCurrencyLocale: String? + ) { + val currentPrice = fetchedPrice.toDouble().toInt() // Remove cents + val currencyFormat = getCurrencyFormat(preferredCurrency, preferredCurrencyLocale) + + views.apply { + setViewVisibility(R.id.loading_indicator, View.GONE) + setTextViewText(R.id.price_value, currencyFormat.format(currentPrice)) + setTextViewText(R.id.last_updated_time, currentTime) + setViewVisibility(R.id.price_value, View.VISIBLE) + setViewVisibility(R.id.last_updated_label, View.VISIBLE) + setViewVisibility(R.id.last_updated_time, View.VISIBLE) + + if (previousPrice != null) { + setViewVisibility(R.id.price_arrow_container, View.VISIBLE) + setTextViewText(R.id.previous_price, currencyFormat.format(previousPrice.toDouble().toInt())) + setImageViewResource( + R.id.price_arrow, + if (currentPrice > previousPrice.toDouble().toInt()) android.R.drawable.arrow_up_float else android.R.drawable.arrow_down_float + ) + } else { + setViewVisibility(R.id.price_arrow_container, View.GONE) + } + } + } + + private fun getCurrencyFormat(currencyCode: String?, localeString: String?): NumberFormat { + val localeParts = localeString?.split("-") ?: listOf("en", "US") + val locale = if (localeParts.size == 2) { + Locale(localeParts[0], localeParts[1]) + } else { + Locale.getDefault() + } + val currencyFormat = NumberFormat.getCurrencyInstance(locale) + val currency = try { + Currency.getInstance(currencyCode ?: "USD") + } catch (e: IllegalArgumentException) { + Currency.getInstance("USD") // Default to USD if an invalid code is provided + } + currencyFormat.currency = currency + currencyFormat.maximumFractionDigits = 0 // No cents + + // Remove the ISO country code and keep only the symbol + val decimalFormatSymbols = (currencyFormat as java.text.DecimalFormat).decimalFormatSymbols + decimalFormatSymbols.currencySymbol = currency.symbol + currencyFormat.decimalFormatSymbols = decimalFormatSymbols + + return currencyFormat + } + + private fun fetchPrice(currency: String?, callback: (String?, String?) -> Unit) { + val price = MarketAPI.fetchPrice(applicationContext, currency ?: "USD") + if (price == null) { + callback(null, "Failed to fetch price") + } else { + callback(price, null) + } + } + + private fun savePrice(sharedPref: SharedPreferences, price: String) { + sharedPref.edit().putString("previous_price", price).apply() + } +} \ No newline at end of file diff --git a/android/app/src/main/res/drawable-mdpi/splash_icon.png b/android/app/src/main/res/drawable-mdpi/splash_icon.png new file mode 100644 index 0000000000..ec541528a9 Binary files /dev/null and b/android/app/src/main/res/drawable-mdpi/splash_icon.png differ diff --git a/android/app/src/main/res/drawable-xhdpi/splash_icon.png b/android/app/src/main/res/drawable-xhdpi/splash_icon.png new file mode 100644 index 0000000000..a72c80addc Binary files /dev/null and b/android/app/src/main/res/drawable-xhdpi/splash_icon.png differ diff --git a/android/app/src/main/res/drawable-xxhdpi/splash_icon.png b/android/app/src/main/res/drawable-xxhdpi/splash_icon.png new file mode 100644 index 0000000000..5727bf3317 Binary files /dev/null and b/android/app/src/main/res/drawable-xxhdpi/splash_icon.png differ diff --git a/android/app/src/main/res/drawable/rn_edit_text_material.xml b/android/app/src/main/res/drawable/rn_edit_text_material.xml index f35d996202..5c0239d0ad 100644 --- a/android/app/src/main/res/drawable/rn_edit_text_material.xml +++ b/android/app/src/main/res/drawable/rn_edit_text_material.xml @@ -17,7 +17,8 @@ android:insetLeft="@dimen/abc_edit_text_inset_horizontal_material" android:insetRight="@dimen/abc_edit_text_inset_horizontal_material" android:insetTop="@dimen/abc_edit_text_inset_top_material" - android:insetBottom="@dimen/abc_edit_text_inset_bottom_material"> + android:insetBottom="@dimen/abc_edit_text_inset_bottom_material" + > + - + + \ No newline at end of file diff --git a/android/app/src/main/res/xml/bitcoin_price_widget_info.xml b/android/app/src/main/res/xml/bitcoin_price_widget_info.xml new file mode 100644 index 0000000000..553186c4c0 --- /dev/null +++ b/android/app/src/main/res/xml/bitcoin_price_widget_info.xml @@ -0,0 +1,10 @@ + + diff --git a/android/build.gradle b/android/build.gradle index ecd03e872d..1615d0e3cb 100644 --- a/android/build.gradle +++ b/android/build.gradle @@ -2,33 +2,28 @@ buildscript { ext { - minSdkVersion = 28 - supportLibVersion = "28.0.0" - buildToolsVersion = "33.0.0" - compileSdkVersion = 33 - targetSdkVersion = 33 + minSdkVersion = 24 + buildToolsVersion = "34.0.0" + compileSdkVersion = 34 + targetSdkVersion = 34 googlePlayServicesVersion = "16.+" googlePlayServicesIidVersion = "16.0.1" firebaseVersion = "17.3.4" firebaseMessagingVersion = "21.1.0" - - // We use NDK 23 which has both M1 support and is the side-by-side NDK version from AGP. - ndkVersion = "23.1.7779620" - kotlin_version = '1.9.0' - kotlinVersion = '1.8.0' - + ndkVersion = "26.1.10909125" + kotlin_version = '1.9.25' + kotlinVersion = '1.9.25' } repositories { google() mavenCentral() } dependencies { - classpath("com.android.tools.build:gradle:7.4.2") - classpath("com.bugsnag:bugsnag-android-gradle-plugin:5.+") - classpath 'com.google.gms:google-services:4.3.14' // Google Services plugin - classpath("com.facebook.react:react-native-gradle-plugin") + classpath("com.android.tools.build:gradle") + classpath("com.facebook.react:react-native-gradle-plugin") classpath("org.jetbrains.kotlin:kotlin-gradle-plugin:$kotlin_version") - + classpath 'com.google.gms:google-services:4.4.2' // Google Services plugin + classpath("com.bugsnag:bugsnag-android-gradle-plugin:8.1.0") } } @@ -37,23 +32,7 @@ allprojects { maven { url("$rootDir/../node_modules/detox/Detox-android") } - jcenter() { - content { - includeModule("com.facebook.yoga", "proguard-annotations") - includeModule("com.facebook.fbjni", "fbjni-java-only") - includeModule("com.facebook.fresco", "fresco") - includeModule("com.facebook.fresco", "stetho") - includeModule("com.facebook.fresco", "fbcore") - includeModule("com.facebook.fresco", "drawee") - includeModule("com.facebook.fresco", "imagepipeline") - includeModule("com.facebook.fresco", "imagepipeline-native") - includeModule("com.facebook.fresco", "memory-type-native") - includeModule("com.facebook.fresco", "memory-type-java") - includeModule("com.facebook.fresco", "nativeimagefilters") - includeModule("com.facebook.stetho", "stetho") - includeModule("com.wei.android.lib", "fingerprintidentify") - } - } + mavenCentral { // We don't want to fetch react-native from Maven Central as there are // older versions over there. @@ -79,18 +58,20 @@ subprojects { afterEvaluate {project -> if (project.hasProperty("android")) { android { - buildToolsVersion '30.0.3' - compileSdkVersion 31 + buildToolsVersion "34.0.0" + compileSdkVersion 34 defaultConfig { - minSdkVersion 28 + minSdkVersion 24 } } } - } -} - -subprojects { subproject -> - if(project['name'] == 'react-native-widget-center') { - project.configurations { compile { } } + tasks.withType(org.jetbrains.kotlin.gradle.tasks.KotlinCompile) { + if (project.plugins.hasPlugin("com.android.application") || project.plugins.hasPlugin("com.android.library")) { + kotlinOptions.jvmTarget = android.compileOptions.sourceCompatibility + } else { + kotlinOptions.jvmTarget = sourceCompatibility + } } -} + + } +} \ No newline at end of file diff --git a/android/gradle.properties b/android/gradle.properties index cc86a726d9..bf403032b1 100644 --- a/android/gradle.properties +++ b/android/gradle.properties @@ -10,7 +10,7 @@ # Specifies the JVM arguments used for the daemon process. # The setting is particularly useful for tweaking memory settings. # Default value: -Xmx512m -XX:MaxMetaspaceSize=256m -org.gradle.jvmargs=-Xmx2048m -XX:MaxMetaspaceSize=512m +org.gradle.jvmargs=-Xmx4096m -XX:MaxMetaspaceSize=1024m # When configured, Gradle will run in incubating parallel mode. # This option should only be used with decoupled projects. More details, visit @@ -21,9 +21,7 @@ org.gradle.jvmargs=-Xmx2048m -XX:MaxMetaspaceSize=512m # Android operating system, and which are packaged with your app's APK # https://developer.android.com/topic/libraries/support-library/androidx-rn android.useAndroidX=true -# Automatically convert third-party libraries to use AndroidX android.enableJetifier=true - # Use this property to specify which architecture you want to build. # You can also override it from the CLI using # ./gradlew -PreactNativeArchitectures=x86_64 diff --git a/android/gradle/wrapper/gradle-wrapper.jar b/android/gradle/wrapper/gradle-wrapper.jar index 5c2d1cf016..a4b76b9530 100644 Binary files a/android/gradle/wrapper/gradle-wrapper.jar and b/android/gradle/wrapper/gradle-wrapper.jar differ diff --git a/android/gradle/wrapper/gradle-wrapper.properties b/android/gradle/wrapper/gradle-wrapper.properties index 7cbb1e48ee..79eb9d003f 100644 --- a/android/gradle/wrapper/gradle-wrapper.properties +++ b/android/gradle/wrapper/gradle-wrapper.properties @@ -1,6 +1,7 @@ -#Tue Jul 21 23:04:55 CDT 2020 distributionBase=GRADLE_USER_HOME distributionPath=wrapper/dists +distributionUrl=https\://services.gradle.org/distributions/gradle-8.10.2-all.zip +networkTimeout=10000 +validateDistributionUrl=true zipStoreBase=GRADLE_USER_HOME zipStorePath=wrapper/dists -distributionUrl=https\://services.gradle.org/distributions/gradle-7.6.1-all.zip diff --git a/android/gradlew b/android/gradlew index a58591e97b..f5feea6d6b 100755 --- a/android/gradlew +++ b/android/gradlew @@ -15,6 +15,8 @@ # See the License for the specific language governing permissions and # limitations under the License. # +# SPDX-License-Identifier: Apache-2.0 +# ############################################################################## # @@ -55,7 +57,7 @@ # Darwin, MinGW, and NonStop. # # (3) This script is generated from the Groovy template -# https://github.com/gradle/gradle/blob/master/subprojects/plugins/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt +# https://github.com/gradle/gradle/blob/HEAD/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt # within the Gradle project. # # You can find Gradle at https://github.com/gradle/gradle/. @@ -80,13 +82,12 @@ do esac done -APP_HOME=$( cd "${APP_HOME:-./}" && pwd -P ) || exit - -APP_NAME="Gradle" +# This is normally unused +# shellcheck disable=SC2034 APP_BASE_NAME=${0##*/} - -# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. -DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"' +# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036) +APP_HOME=$( cd -P "${APP_HOME:-./}" > /dev/null && printf '%s +' "$PWD" ) || exit # Use the maximum available, or set MAX_FD != -1 to use that value. MAX_FD=maximum @@ -133,22 +134,29 @@ location of your Java installation." fi else JAVACMD=java - which java >/dev/null 2>&1 || die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. + if ! command -v java >/dev/null 2>&1 + then + die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. Please set the JAVA_HOME variable in your environment to match the location of your Java installation." + fi fi # Increase the maximum file descriptors if we can. if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then case $MAX_FD in #( max*) + # In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked. + # shellcheck disable=SC2039,SC3045 MAX_FD=$( ulimit -H -n ) || warn "Could not query maximum file descriptor limit" esac case $MAX_FD in #( '' | soft) :;; #( *) + # In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked. + # shellcheck disable=SC2039,SC3045 ulimit -n "$MAX_FD" || warn "Could not set maximum file descriptor limit to $MAX_FD" esac @@ -193,11 +201,15 @@ if "$cygwin" || "$msys" ; then done fi -# Collect all arguments for the java command; -# * $DEFAULT_JVM_OPTS, $JAVA_OPTS, and $GRADLE_OPTS can contain fragments of -# shell script including quotes and variable substitutions, so put them in -# double quotes to make sure that they get re-expanded; and -# * put everything else in single quotes, so that it's not re-expanded. + +# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"' + +# Collect all arguments for the java command: +# * DEFAULT_JVM_OPTS, JAVA_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments, +# and any embedded shellness will be escaped. +# * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be +# treated as '${Hostname}' itself on the command line. set -- \ "-Dorg.gradle.appname=$APP_BASE_NAME" \ @@ -205,6 +217,12 @@ set -- \ org.gradle.wrapper.GradleWrapperMain \ "$@" +# Stop when "xargs" is not available. +if ! command -v xargs >/dev/null 2>&1 +then + die "xargs is not available" +fi + # Use "xargs" to parse quoted args. # # With -n1 it outputs one arg per line, with the quotes and backslashes removed. @@ -231,4 +249,4 @@ eval "set -- $( tr '\n' ' ' )" '"$@"' -exec "$JAVACMD" "$@" \ No newline at end of file +exec "$JAVACMD" "$@" diff --git a/android/gradlew.bat b/android/gradlew.bat index e3ecc7d777..9b42019c79 100644 --- a/android/gradlew.bat +++ b/android/gradlew.bat @@ -1,99 +1,94 @@ -@rem -@rem Copyright 2015 the original author or authors. -@rem -@rem Licensed under the Apache License, Version 2.0 (the "License"); -@rem you may not use this file except in compliance with the License. -@rem You may obtain a copy of the License at -@rem -@rem http://www.apache.org/licenses/LICENSE-2.0 -@rem -@rem Unless required by applicable law or agreed to in writing, software -@rem distributed under the License is distributed on an "AS IS" BASIS, -@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -@rem See the License for the specific language governing permissions and -@rem limitations under the License. -@rem - -@if "%DEBUG%" == "" @echo off -@rem ########################################################################## -@rem -@rem Gradle startup script for Windows -@rem -@rem ########################################################################## - -@rem Set local scope for the variables with windows NT shell -if "%OS%"=="Windows_NT" setlocal - -set DIRNAME=%~dp0 -if "%DIRNAME%" == "" set DIRNAME=. -set APP_BASE_NAME=%~n0 -set APP_HOME=%DIRNAME% - -@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. -set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m" - -@rem Find java.exe -if defined JAVA_HOME goto findJavaFromJavaHome - -set JAVA_EXE=java.exe -%JAVA_EXE% -version >NUL 2>&1 -if "%ERRORLEVEL%" == "0" goto execute - -echo. -echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. -echo. -echo Please set the JAVA_HOME variable in your environment to match the -echo location of your Java installation. - -goto fail - -:findJavaFromJavaHome -set JAVA_HOME=%JAVA_HOME:"=% -set JAVA_EXE=%JAVA_HOME%/bin/java.exe - -if exist "%JAVA_EXE%" goto execute - -echo. -echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% -echo. -echo Please set the JAVA_HOME variable in your environment to match the -echo location of your Java installation. - -goto fail - -:init -@rem Get command-line arguments, handling Windows variants - -if not "%OS%" == "Windows_NT" goto win9xME_args - -:win9xME_args -@rem Slurp the command line arguments. -set CMD_LINE_ARGS= -set _SKIP=2 - -:win9xME_args_slurp -if "x%~1" == "x" goto execute - -set CMD_LINE_ARGS=%* - -:execute -@rem Setup the command line - -set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar - -@rem Execute Gradle -"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %* -:end -@rem End local scope for the variables with windows NT shell -if "%ERRORLEVEL%"=="0" goto mainEnd - -:fail -rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of -rem the _cmd.exe /c_ return code! -if not "" == "%GRADLE_EXIT_CONSOLE%" exit 1 -exit /b 1 - -:mainEnd -if "%OS%"=="Windows_NT" endlocal - -:omega +@rem +@rem Copyright 2015 the original author or authors. +@rem +@rem Licensed under the Apache License, Version 2.0 (the "License"); +@rem you may not use this file except in compliance with the License. +@rem You may obtain a copy of the License at +@rem +@rem https://www.apache.org/licenses/LICENSE-2.0 +@rem +@rem Unless required by applicable law or agreed to in writing, software +@rem distributed under the License is distributed on an "AS IS" BASIS, +@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +@rem See the License for the specific language governing permissions and +@rem limitations under the License. +@rem +@rem SPDX-License-Identifier: Apache-2.0 +@rem + +@if "%DEBUG%"=="" @echo off +@rem ########################################################################## +@rem +@rem Gradle startup script for Windows +@rem +@rem ########################################################################## + +@rem Set local scope for the variables with windows NT shell +if "%OS%"=="Windows_NT" setlocal + +set DIRNAME=%~dp0 +if "%DIRNAME%"=="" set DIRNAME=. +@rem This is normally unused +set APP_BASE_NAME=%~n0 +set APP_HOME=%DIRNAME% + +@rem Resolve any "." and ".." in APP_HOME to make it shorter. +for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi + +@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m" + +@rem Find java.exe +if defined JAVA_HOME goto findJavaFromJavaHome + +set JAVA_EXE=java.exe +%JAVA_EXE% -version >NUL 2>&1 +if %ERRORLEVEL% equ 0 goto execute + +echo. 1>&2 +echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2 +echo. 1>&2 +echo Please set the JAVA_HOME variable in your environment to match the 1>&2 +echo location of your Java installation. 1>&2 + +goto fail + +:findJavaFromJavaHome +set JAVA_HOME=%JAVA_HOME:"=% +set JAVA_EXE=%JAVA_HOME%/bin/java.exe + +if exist "%JAVA_EXE%" goto execute + +echo. 1>&2 +echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2 +echo. 1>&2 +echo Please set the JAVA_HOME variable in your environment to match the 1>&2 +echo location of your Java installation. 1>&2 + +goto fail + +:execute +@rem Setup the command line + +set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar + + +@rem Execute Gradle +"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %* + +:end +@rem End local scope for the variables with windows NT shell +if %ERRORLEVEL% equ 0 goto mainEnd + +:fail +rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of +rem the _cmd.exe /c_ return code! +set EXIT_CODE=%ERRORLEVEL% +if %EXIT_CODE% equ 0 set EXIT_CODE=1 +if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE% +exit /b %EXIT_CODE% + +:mainEnd +if "%OS%"=="Windows_NT" endlocal + +:omega diff --git a/android/settings.gradle b/android/settings.gradle index f003f339a7..2221f87814 100644 --- a/android/settings.gradle +++ b/android/settings.gradle @@ -1,6 +1,9 @@ +pluginManagement { includeBuild("../node_modules/@react-native/gradle-plugin") } +plugins { id("com.facebook.react.settings") } +extensions.configure(com.facebook.react.ReactSettingsExtension){ ex -> ex.autolinkLibrariesFromCommand() } rootProject.name = 'BlueWallet' -apply from: file("../node_modules/@react-native-community/cli-platform-android/native_modules.gradle"); applyNativeModulesSettingsGradle(settings) include ':app' -includeBuild('../node_modules/react-native-gradle-plugin') +includeBuild('../node_modules/@react-native/gradle-plugin') include ':detox' -project(':detox').projectDir = new File(rootProject.projectDir, '../node_modules/detox/android/detox') \ No newline at end of file +project(':detox').projectDir = new File(rootProject.projectDir, '../node_modules/detox/android/detox') + diff --git a/appcenter-post-build.sh b/appcenter-post-build.sh deleted file mode 100755 index eeff0bf8a3..0000000000 --- a/appcenter-post-build.sh +++ /dev/null @@ -1,24 +0,0 @@ -#!/bin/sh - -echo Uploading to Appetize and publishing link to Github... -echo "Branch " -BRANCH=`git ls-remote --heads origin | grep $(git rev-parse HEAD) | cut -d / -f 3` -echo $BRANCH -echo "Branch 2 " -git log -n 1 --pretty=%d HEAD | awk '{print $2}' | sed 's/origin\///' | sed 's/)//' - -FILENAME="$APPCENTER_OUTPUT_DIRECTORY/app-release.apk" - -if [ -f $FILENAME ]; then - APTZ=`curl "https://$APPETIZE@api.appetize.io/v1/apps" -F "file=@$FILENAME" -F "platform=android"` - echo Apptezize response: - echo $APTZ - APPURL=`node -e "let e = JSON.parse('$APTZ'); console.log(e.publicURL + '?device=pixel4');"` - echo App url: $APPURL - PR=`node scripts/appcenter-post-build-get-pr-number.js` - echo PR: $PR - - DLOAD_APK="https://lambda-download-android-build.herokuapp.com/download/$BUILD_BUILDID" - - curl -X POST --data "{\"body\":\"♫ This was a triumph. I'm making a note here: HUGE SUCCESS ♫\n\n [android in browser] $APPURL\n\n[download apk]($DLOAD_APK) \"}" -u "$GITHUB" "https://api.github.com/repos/BlueWallet/BlueWallet/issues/$PR/comments" -fi diff --git a/babel.config.js b/babel.config.js index fff45f944a..3687ce3b77 100644 --- a/babel.config.js +++ b/babel.config.js @@ -1,4 +1,4 @@ module.exports = { - presets: ['module:metro-react-native-babel-preset'], + presets: ['module:@react-native/babel-preset'], plugins: ['react-native-reanimated/plugin'], // required by react-native-reanimated v2 https://docs.swmansion.com/react-native-reanimated/docs/installation/ }; diff --git a/blue_modules/BlueElectrum.d.ts b/blue_modules/BlueElectrum.d.ts deleted file mode 100644 index a51dfaef16..0000000000 --- a/blue_modules/BlueElectrum.d.ts +++ /dev/null @@ -1,98 +0,0 @@ -type Utxo = { - height: number; - value: number; - address: string; - txId: string; - vout: number; - wif?: string; -}; - -export type ElectrumTransaction = { - txid: string; - hash: string; - version: number; - size: number; - vsize: number; - weight: number; - locktime: number; - vin: { - txid: string; - vout: number; - scriptSig: { asm: string; hex: string }; - txinwitness: string[]; - sequence: number; - addresses?: string[]; - value?: number; - }[]; - vout: { - value: number; - n: number; - scriptPubKey: { - asm: string; - hex: string; - reqSigs: number; - type: string; - addresses: string[]; - }; - }[]; - blockhash: string; - confirmations?: number; - time: number; - blocktime: number; -}; - -type MempoolTransaction = { - height: 0; - tx_hash: string; - fee: number; -}; - -export async function connectMain(): Promise; - -export async function waitTillConnected(): Promise; - -export function forceDisconnect(): void; - -export function getBalanceByAddress(address: string): Promise<{ confirmed: number; unconfirmed: number }>; - -export function multiGetUtxoByAddress(addresses: string[]): Promise>; - -// TODO: this function returns different results based on the value of `verbose`, consider splitting it into two -export function multiGetTransactionByTxid( - txIds: string[], - batchsize: number = 45, - verbose: true = true, -): Promise>; -export function multiGetTransactionByTxid(txIds: string[], batchsize: number, verbose: false): Promise>; - -export type MultiGetBalanceResponse = { - balance: number; - unconfirmed_balance: number; - addresses: Record; -}; - -export function multiGetBalanceByAddress(addresses: string[], batchsize?: number): Promise; - -export function getTransactionsByAddress(address: string): ElectrumTransaction[]; - -export function getMempoolTransactionsByAddress(address: string): Promise; - -export function estimateCurrentBlockheight(): number; - -export type ElectrumHistory = { - tx_hash: string; - height: number; - address: string; -}; - -export function multiGetHistoryByAddress(addresses: string[]): Promise>; - -export function estimateFees(): Promise<{ fast: number; medium: number; slow: number }>; - -export function broadcastV2(txhex: string): Promise; - -export function getTransactionsFullByAddress(address: string): Promise; - -export function txhexToElectrumTransaction(txhes: string): ElectrumTransaction; - -export function isDisabled(): Promise; diff --git a/blue_modules/BlueElectrum.js b/blue_modules/BlueElectrum.ts similarity index 72% rename from blue_modules/BlueElectrum.js rename to blue_modules/BlueElectrum.ts index 79ede14583..8e1cfc83d7 100644 --- a/blue_modules/BlueElectrum.js +++ b/blue_modules/BlueElectrum.ts @@ -1,32 +1,119 @@ import AsyncStorage from '@react-native-async-storage/async-storage'; -import { Alert } from 'react-native'; -import { LegacyWallet, SegwitBech32Wallet, SegwitP2SHWallet, TaprootWallet } from '../class'; +import BigNumber from 'bignumber.js'; +import * as bitcoin from 'bitcoinjs-lib'; import DefaultPreference from 'react-native-default-preference'; +import RNFS from 'react-native-fs'; +import Realm from 'realm'; + +import { LegacyWallet, SegwitBech32Wallet, SegwitP2SHWallet, TaprootWallet } from '../class'; +import presentAlert from '../components/Alert'; import loc from '../loc'; -import WidgetCommunication from './WidgetCommunication'; -import { isTorDaemonDisabled } from './environment'; -import alert from '../components/Alert'; -const bitcoin = require('bitcoinjs-lib'); +import { GROUP_IO_BLUEWALLET } from './currency'; + const ElectrumClient = require('electrum-client'); -const reverse = require('buffer-reverse'); -const BigNumber = require('bignumber.js'); -const torrific = require('./torrific'); -const Realm = require('realm'); - -const ELECTRUM_HOST = 'electrum_host'; -const ELECTRUM_TCP_PORT = 'electrum_tcp_port'; -const ELECTRUM_SSL_PORT = 'electrum_ssl_port'; -const ELECTRUM_SERVER_HISTORY = 'electrum_server_history'; +const net = require('net'); +const tls = require('tls'); + +type Utxo = { + height: number; + value: number; + address: string; + txid: string; + vout: number; + wif?: string; +}; + +type ElectrumTransaction = { + txid: string; + hash: string; + version: number; + size: number; + vsize: number; + weight: number; + locktime: number; + vin: { + txid: string; + vout: number; + scriptSig: { asm: string; hex: string }; + txinwitness: string[]; + sequence: number; + addresses?: string[]; + value?: number; + }[]; + vout: { + value: number; + n: number; + scriptPubKey: { + asm: string; + hex: string; + reqSigs: number; + type: string; + addresses: string[]; + }; + }[]; + blockhash: string; + confirmations: number; + time: number; + blocktime: number; +}; + +type ElectrumTransactionWithHex = ElectrumTransaction & { + hex: string; +}; + +type MempoolTransaction = { + height: 0; + tx_hash: string; + fee: number; +}; + +type Peer = + | { + host: string; + ssl: string; + tcp?: undefined; + } + | { + host: string; + tcp: string; + ssl?: undefined; + }; + +export const ELECTRUM_HOST = 'electrum_host'; +export const ELECTRUM_TCP_PORT = 'electrum_tcp_port'; +export const ELECTRUM_SSL_PORT = 'electrum_ssl_port'; +export const ELECTRUM_SERVER_HISTORY = 'electrum_server_history'; const ELECTRUM_CONNECTION_DISABLED = 'electrum_disabled'; +const storageKey = 'ELECTRUM_PEERS'; +const defaultPeer = { host: 'electrum1.bluewallet.io', ssl: '443' }; +export const hardcodedPeers: Peer[] = [ + { host: 'mainnet.foundationdevices.com', ssl: '50002' }, + // { host: 'bitcoin.lukechilds.co', ssl: '50002' }, + // { host: 'electrum.jochen-hoenicke.de', ssl: '50006' }, + { host: 'electrum1.bluewallet.io', ssl: '443' }, + { host: 'electrum.acinq.co', ssl: '50002' }, + { host: 'electrum.bitaroo.net', ssl: '50002' }, +]; + +let mainClient: typeof ElectrumClient | undefined; +let mainConnected: boolean = false; +let wasConnectedAtLeastOnce: boolean = false; +let serverName: string | false = false; +let disableBatching: boolean = false; +let connectionAttempt: number = 0; +let currentPeerIndex = Math.floor(Math.random() * hardcodedPeers.length); +let latestBlock: { height: number; time: number } | { height: undefined; time: undefined } = { height: undefined, time: undefined }; +const txhashHeightCache: Record = {}; +let _realm: Realm | undefined; -let _realm; async function _getRealm() { if (_realm) return _realm; + const cacheFolderPath = RNFS.CachesDirectoryPath; // Path to cache folder const password = bitcoin.crypto.sha256(Buffer.from('fyegjitkyf[eqjnc.lf')).toString('hex'); const buf = Buffer.from(password + password, 'hex'); const encryptionKey = Int8Array.from(buf); - const path = 'electrumcache.realm'; + const path = `${cacheFolderPath}/electrumcache.realm`; // Use cache folder path const schema = [ { @@ -38,40 +125,19 @@ async function _getRealm() { }, }, ]; + + // @ts-ignore schema doesn't match Realm's schema type _realm = await Realm.open({ schema, path, encryptionKey, + excludeFromIcloudBackup: true, }); + return _realm; } -const storageKey = 'ELECTRUM_PEERS'; -const defaultPeer = { host: 'electrum1.bluewallet.io', ssl: '443' }; -const hardcodedPeers = [ - { host: 'mainnet.foundationdevices.com', ssl: '50002' }, - { host: 'bitcoin.lukechilds.co', ssl: '50002' }, - { host: 'electrum.jochen-hoenicke.de', ssl: '50006' }, - { host: 'electrum1.bluewallet.io', ssl: '443' }, - { host: 'electrum.acinq.co', ssl: '50002' }, - { host: 'electrum.bitaroo.net', ssl: '50002' }, -]; - -/** @type {ElectrumClient} */ -let mainClient; -let mainConnected = false; -let wasConnectedAtLeastOnce = false; -let serverName = false; -let disableBatching = false; -let connectionAttempt = 0; -let currentPeerIndex = Math.floor(Math.random() * hardcodedPeers.length); - -let latestBlockheight = false; -let latestBlockheightTimestamp = false; - -const txhashHeightCache = {}; - -async function isDisabled() { +export async function isDisabled(): Promise { let result; try { const savedValue = await AsyncStorage.getItem(ELECTRUM_CONNECTION_DISABLED); @@ -86,35 +152,67 @@ async function isDisabled() { return !!result; } -async function setDisabled(disabled = true) { +export async function setDisabled(disabled = true) { return AsyncStorage.setItem(ELECTRUM_CONNECTION_DISABLED, disabled ? '1' : ''); } -async function connectMain() { +function getCurrentPeer() { + return hardcodedPeers[currentPeerIndex]; +} + +/** + * Returns NEXT hardcoded electrum server (increments index after use) + */ +function getNextPeer() { + const peer = getCurrentPeer(); + currentPeerIndex++; + if (currentPeerIndex + 1 >= hardcodedPeers.length) currentPeerIndex = 0; + return peer; +} + +async function getSavedPeer(): Promise { + const host = await AsyncStorage.getItem(ELECTRUM_HOST); + const tcpPort = await AsyncStorage.getItem(ELECTRUM_TCP_PORT); + const sslPort = await AsyncStorage.getItem(ELECTRUM_SSL_PORT); + + if (!host) { + return null; + } + + if (sslPort) { + return { host, ssl: sslPort }; + } + + if (tcpPort) { + return { host, tcp: tcpPort }; + } + + return null; +} + +export async function connectMain(): Promise { if (await isDisabled()) { console.log('Electrum connection disabled by user. Skipping connectMain call'); return; } - let usingPeer = await getNextPeer(); + let usingPeer = getNextPeer(); const savedPeer = await getSavedPeer(); if (savedPeer && savedPeer.host && (savedPeer.tcp || savedPeer.ssl)) { usingPeer = savedPeer; } - await DefaultPreference.setName('group.io.bluewallet.bluewallet'); + await DefaultPreference.setName(GROUP_IO_BLUEWALLET); try { if (usingPeer.host.endsWith('onion')) { - const randomPeer = await getCurrentPeer(); + const randomPeer = getCurrentPeer(); await DefaultPreference.set(ELECTRUM_HOST, randomPeer.host); - await DefaultPreference.set(ELECTRUM_TCP_PORT, randomPeer.tcp); - await DefaultPreference.set(ELECTRUM_SSL_PORT, randomPeer.ssl); + await DefaultPreference.set(ELECTRUM_TCP_PORT, randomPeer.tcp ?? ''); + await DefaultPreference.set(ELECTRUM_SSL_PORT, randomPeer.ssl ?? ''); } else { await DefaultPreference.set(ELECTRUM_HOST, usingPeer.host); - await DefaultPreference.set(ELECTRUM_TCP_PORT, usingPeer.tcp); - await DefaultPreference.set(ELECTRUM_SSL_PORT, usingPeer.ssl); + await DefaultPreference.set(ELECTRUM_TCP_PORT, usingPeer.tcp ?? ''); + await DefaultPreference.set(ELECTRUM_SSL_PORT, usingPeer.ssl ?? ''); } - - WidgetCommunication.reloadAllTimelines(); } catch (e) { // Must be running on Android console.log(e); @@ -122,15 +220,9 @@ async function connectMain() { try { console.log('begin connection:', JSON.stringify(usingPeer)); - mainClient = new ElectrumClient( - usingPeer.host.endsWith('.onion') && !(await isTorDaemonDisabled()) ? torrific : global.net, - global.tls, - usingPeer.ssl || usingPeer.tcp, - usingPeer.host, - usingPeer.ssl ? 'tls' : 'tcp', - ); + mainClient = new ElectrumClient(net, tls, usingPeer.ssl || usingPeer.tcp, usingPeer.host, usingPeer.ssl ? 'tls' : 'tcp'); - mainClient.onError = function (e) { + mainClient.onError = function (e: { message: string }) { console.log('electrum mainClient.onError():', e.message); if (mainConnected) { // most likely got a timeout from electrum ping. lets reconnect @@ -174,8 +266,10 @@ async function connectMain() { } const header = await mainClient.blockchainHeaders_subscribe(); if (header && header.height) { - latestBlockheight = header.height; - latestBlockheightTimestamp = Math.floor(+new Date() / 1000); + latestBlock = { + height: header.height, + time: Math.floor(+new Date() / 1000), + }; } // AsyncStorage.setItem(storageKey, JSON.stringify(peers)); TODO: refactor } @@ -198,20 +292,21 @@ async function connectMain() { } } -async function presentNetworkErrorAlert(usingPeer) { +const presentNetworkErrorAlert = async (usingPeer?: Peer) => { if (await isDisabled()) { console.log( 'Electrum connection disabled by user. Perhaps we are attempting to show this network error alert after the user disabled connections.', ); return; } - Alert.alert( - loc.errors.network, - loc.formatString( + presentAlert({ + allowRepeat: false, + title: loc.errors.network, + message: loc.formatString( usingPeer ? loc.settings.electrum_unable_to_connect : loc.settings.electrum_error_connect, usingPeer ? { server: `${usingPeer.host}:${usingPeer.ssl ?? usingPeer.tcp}` } : {}, ), - [ + buttons: [ { text: loc.wallets.list_tryagain, onPress: () => { @@ -224,10 +319,10 @@ async function presentNetworkErrorAlert(usingPeer) { { text: loc.settings.electrum_reset, onPress: () => { - Alert.alert( - loc.settings.electrum_reset, - loc.settings.electrum_reset_to_default, - [ + presentAlert({ + title: loc.settings.electrum_reset, + message: loc.settings.electrum_reset_to_default, + buttons: [ { text: loc._.cancel, style: 'cancel', @@ -245,18 +340,16 @@ async function presentNetworkErrorAlert(usingPeer) { await DefaultPreference.clear(ELECTRUM_HOST); await DefaultPreference.clear(ELECTRUM_SSL_PORT); await DefaultPreference.clear(ELECTRUM_TCP_PORT); - WidgetCommunication.reloadAllTimelines(); } catch (e) { - // Must be running on Android - console.log(e); + console.log(e); // Must be running on Android } - alert(loc.settings.electrum_saved); + presentAlert({ message: loc.settings.electrum_saved }); setTimeout(connectMain, 500); }, }, ], - { cancelable: true }, - ); + options: { cancelable: true }, + }); connectionAttempt = 0; mainClient.close() && mainClient.close(); }, @@ -271,49 +364,26 @@ async function presentNetworkErrorAlert(usingPeer) { style: 'cancel', }, ], - { cancelable: false }, - ); -} - -async function getCurrentPeer() { - return hardcodedPeers[currentPeerIndex]; -} - -/** - * Returns NEXT hardcoded electrum server (increments index after use) - * - * @returns {Promise<{tcp, host, ssl?}|*>} - */ -async function getNextPeer() { - const peer = getCurrentPeer(); - currentPeerIndex++; - if (currentPeerIndex + 1 >= hardcodedPeers.length) currentPeerIndex = 0; - return peer; -} - -async function getSavedPeer() { - const host = await AsyncStorage.getItem(ELECTRUM_HOST); - const port = await AsyncStorage.getItem(ELECTRUM_TCP_PORT); - const sslPort = await AsyncStorage.getItem(ELECTRUM_SSL_PORT); - return { host, tcp: port, ssl: sslPort }; -} + options: { cancelable: false }, + }); +}; /** * Returns random electrum server out of list of servers * previous electrum server told us. Nearly half of them is * usually offline. * Not used for now. - * - * @returns {Promise<{tcp: number, host: string}>} */ // eslint-disable-next-line @typescript-eslint/no-unused-vars -async function getRandomDynamicPeer() { +async function getRandomDynamicPeer(): Promise { try { - let peers = JSON.parse(await AsyncStorage.getItem(storageKey)); + let peers = JSON.parse((await AsyncStorage.getItem(storageKey)) as string); peers = peers.sort(() => Math.random() - 0.5); // shuffle for (const peer of peers) { - const ret = {}; - ret.host = peer[1]; + const ret = { + host: peer[1] as string, + tcp: '', + }; for (const item of peer[2]) { if (item.startsWith('t')) { ret.tcp = item.replace('t', ''); @@ -328,22 +398,17 @@ async function getRandomDynamicPeer() { } } -/** - * - * @param address {String} - * @returns {Promise} - */ -module.exports.getBalanceByAddress = async function (address) { +export const getBalanceByAddress = async function (address: string): Promise<{ confirmed: number; unconfirmed: number }> { if (!mainClient) throw new Error('Electrum client is not connected'); const script = bitcoin.address.toOutputScript(address); const hash = bitcoin.crypto.sha256(script); - const reversedHash = Buffer.from(reverse(hash)); + const reversedHash = Buffer.from(hash).reverse(); const balance = await mainClient.blockchainScripthash_getBalance(reversedHash.toString('hex')); balance.addr = address; return balance; }; -module.exports.getConfig = async function () { +export const getConfig = async function () { if (!mainClient) throw new Error('Electrum client is not connected'); return { host: mainClient.host, @@ -353,20 +418,15 @@ module.exports.getConfig = async function () { }; }; -module.exports.getSecondsSinceLastRequest = function () { +export const getSecondsSinceLastRequest = function () { return mainClient && mainClient.timeLastCall ? (+new Date() - mainClient.timeLastCall) / 1000 : -1; }; -/** - * - * @param address {String} - * @returns {Promise} - */ -module.exports.getTransactionsByAddress = async function (address) { +export const getTransactionsByAddress = async function (address: string): Promise { if (!mainClient) throw new Error('Electrum client is not connected'); const script = bitcoin.address.toOutputScript(address); const hash = bitcoin.crypto.sha256(script); - const reversedHash = Buffer.from(reverse(hash)); + const reversedHash = Buffer.from(hash).reverse(); const history = await mainClient.blockchainScripthash_getHistory(reversedHash.toString('hex')); for (const h of history || []) { if (h.tx_hash) txhashHeightCache[h.tx_hash] = h.height; // cache tx height @@ -375,20 +435,15 @@ module.exports.getTransactionsByAddress = async function (address) { return history; }; -/** - * - * @param address {String} - * @returns {Promise} - */ -module.exports.getMempoolTransactionsByAddress = async function (address) { +export const getMempoolTransactionsByAddress = async function (address: string): Promise { if (!mainClient) throw new Error('Electrum client is not connected'); const script = bitcoin.address.toOutputScript(address); const hash = bitcoin.crypto.sha256(script); - const reversedHash = Buffer.from(reverse(hash)); + const reversedHash = Buffer.from(hash).reverse(); return mainClient.blockchainScripthash_getMempool(reversedHash.toString('hex')); }; -module.exports.ping = async function () { +export const ping = async function () { try { await mainClient.server_ping(); } catch (_) { @@ -398,14 +453,100 @@ module.exports.ping = async function () { return true; }; -module.exports.getTransactionsFullByAddress = async function (address) { - const txs = await this.getTransactionsByAddress(address); - const ret = []; +// exported only to be used in unit tests +export function txhexToElectrumTransaction(txhex: string): ElectrumTransactionWithHex { + const tx = bitcoin.Transaction.fromHex(txhex); + + const ret: ElectrumTransactionWithHex = { + txid: tx.getId(), + hash: tx.getId(), + version: tx.version, + size: Math.ceil(txhex.length / 2), + vsize: tx.virtualSize(), + weight: tx.weight(), + locktime: tx.locktime, + vin: [], + vout: [], + hex: txhex, + blockhash: '', + confirmations: 0, + time: 0, + blocktime: 0, + }; + + if (txhashHeightCache[ret.txid]) { + // got blockheight where this tx was confirmed + ret.confirmations = estimateCurrentBlockheight() - txhashHeightCache[ret.txid]; + if (ret.confirmations < 0) { + // ugly fix for when estimator lags behind + ret.confirmations = 1; + } + ret.time = calculateBlockTime(txhashHeightCache[ret.txid]); + ret.blocktime = calculateBlockTime(txhashHeightCache[ret.txid]); + } + + for (const inn of tx.ins) { + const txinwitness = []; + if (inn.witness[0]) txinwitness.push(inn.witness[0].toString('hex')); + if (inn.witness[1]) txinwitness.push(inn.witness[1].toString('hex')); + + ret.vin.push({ + txid: Buffer.from(inn.hash).reverse().toString('hex'), + vout: inn.index, + scriptSig: { hex: inn.script.toString('hex'), asm: '' }, + txinwitness, + sequence: inn.sequence, + }); + } + + let n = 0; + for (const out of tx.outs) { + const value = new BigNumber(out.value).dividedBy(100000000).toNumber(); + let address: false | string = false; + let type: false | string = false; + + if (SegwitBech32Wallet.scriptPubKeyToAddress(out.script.toString('hex'))) { + address = SegwitBech32Wallet.scriptPubKeyToAddress(out.script.toString('hex')); + type = 'witness_v0_keyhash'; + } else if (SegwitP2SHWallet.scriptPubKeyToAddress(out.script.toString('hex'))) { + address = SegwitP2SHWallet.scriptPubKeyToAddress(out.script.toString('hex')); + type = '???'; // TODO + } else if (LegacyWallet.scriptPubKeyToAddress(out.script.toString('hex'))) { + address = LegacyWallet.scriptPubKeyToAddress(out.script.toString('hex')); + type = '???'; // TODO + } else { + address = TaprootWallet.scriptPubKeyToAddress(out.script.toString('hex')); + type = 'witness_v1_taproot'; + } + + if (!address) { + throw new Error('Internal error: unable to decode address from output script'); + } + + ret.vout.push({ + value, + n, + scriptPubKey: { + asm: '', + hex: out.script.toString('hex'), + reqSigs: 1, // todo + type, + addresses: [address], + }, + }); + n++; + } + return ret; +} + +export const getTransactionsFullByAddress = async (address: string): Promise => { + const txs = await getTransactionsByAddress(address); + const ret: ElectrumTransaction[] = []; for (const tx of txs) { let full; try { full = await mainClient.blockchainTransaction_get(tx.tx_hash, true); - } catch (error) { + } catch (error: any) { if (String(error?.message ?? error).startsWith('verbose transactions are currently unsupported')) { // apparently, stupid esplora instead of returning txhex when it cant return verbose tx started // throwing a proper exception. lets fetch txhex manually and decode on our end @@ -422,7 +563,7 @@ module.exports.getTransactionsFullByAddress = async function (address) { let prevTxForVin; try { prevTxForVin = await mainClient.blockchainTransaction_get(input.txid, true); - } catch (error) { + } catch (error: any) { if (String(error?.message ?? error).startsWith('verbose transactions are currently unsupported')) { // apparently, stupid esplora instead of returning txhex when it cant return verbose tx started // throwing a proper exception. lets fetch txhex manually and decode on our end @@ -447,7 +588,7 @@ module.exports.getTransactionsFullByAddress = async function (address) { } for (const output of full.vout) { - if (output.scriptPubKey && output.scriptPubKey.addresses) output.addresses = output.scriptPubKey.addresses; + if (output?.scriptPubKey && output.scriptPubKey.addresses) output.addresses = output.scriptPubKey.addresses; // in bitcoin core 22.0.0+ they removed `.addresses` and replaced it with plain `.address`: if (output?.scriptPubKey?.address) output.addresses = [output.scriptPubKey.address]; } @@ -463,26 +604,28 @@ module.exports.getTransactionsFullByAddress = async function (address) { return ret; }; -/** - * - * @param addresses {Array} - * @param batchsize {Number} - * @returns {Promise<{balance: number, unconfirmed_balance: number, addresses: object}>} - */ -module.exports.multiGetBalanceByAddress = async function (addresses, batchsize) { - batchsize = batchsize || 200; +type MultiGetBalanceResponse = { + balance: number; + unconfirmed_balance: number; + addresses: Record; +}; + +export const multiGetBalanceByAddress = async (addresses: string[], batchsize: number = 200): Promise => { if (!mainClient) throw new Error('Electrum client is not connected'); - const ret = { balance: 0, unconfirmed_balance: 0, addresses: {} }; + const ret = { + balance: 0, + unconfirmed_balance: 0, + addresses: {} as Record, + }; const chunks = splitIntoChunks(addresses, batchsize); for (const chunk of chunks) { const scripthashes = []; - const scripthash2addr = {}; + const scripthash2addr: Record = {}; for (const addr of chunk) { const script = bitcoin.address.toOutputScript(addr); const hash = bitcoin.crypto.sha256(script); - let reversedHash = Buffer.from(reverse(hash)); - reversedHash = reversedHash.toString('hex'); + const reversedHash = Buffer.from(hash).reverse().toString('hex'); scripthashes.push(reversedHash); scripthash2addr[reversedHash] = addr; } @@ -491,7 +634,7 @@ module.exports.multiGetBalanceByAddress = async function (addresses, batchsize) if (disableBatching) { const promises = []; - const index2scripthash = {}; + const index2scripthash: Record = {}; for (let promiseIndex = 0; promiseIndex < scripthashes.length; promiseIndex++) { promises.push(mainClient.blockchainScripthash_getBalance(scripthashes[promiseIndex])); index2scripthash[promiseIndex] = scripthashes[promiseIndex]; @@ -515,20 +658,18 @@ module.exports.multiGetBalanceByAddress = async function (addresses, batchsize) return ret; }; -module.exports.multiGetUtxoByAddress = async function (addresses, batchsize) { - batchsize = batchsize || 100; +export const multiGetUtxoByAddress = async function (addresses: string[], batchsize: number = 100): Promise> { if (!mainClient) throw new Error('Electrum client is not connected'); - const ret = {}; + const ret: Record = {}; const chunks = splitIntoChunks(addresses, batchsize); for (const chunk of chunks) { const scripthashes = []; - const scripthash2addr = {}; + const scripthash2addr: Record = {}; for (const addr of chunk) { const script = bitcoin.address.toOutputScript(addr); const hash = bitcoin.crypto.sha256(script); - let reversedHash = Buffer.from(reverse(hash)); - reversedHash = reversedHash.toString('hex'); + const reversedHash = Buffer.from(hash).reverse().toString('hex'); scripthashes.push(reversedHash); scripthash2addr[reversedHash] = addr; } @@ -547,7 +688,7 @@ module.exports.multiGetUtxoByAddress = async function (addresses, batchsize) { ret[scripthash2addr[utxos.param]] = utxos.result; for (const utxo of ret[scripthash2addr[utxos.param]]) { utxo.address = scripthash2addr[utxos.param]; - utxo.txId = utxo.tx_hash; + utxo.txid = utxo.tx_hash; utxo.vout = utxo.tx_pos; delete utxo.tx_pos; delete utxo.tx_hash; @@ -558,20 +699,27 @@ module.exports.multiGetUtxoByAddress = async function (addresses, batchsize) { return ret; }; -module.exports.multiGetHistoryByAddress = async function (addresses, batchsize) { - batchsize = batchsize || 100; +export type ElectrumHistory = { + tx_hash: string; + height: number; + address: string; +}; + +export const multiGetHistoryByAddress = async function ( + addresses: string[], + batchsize: number = 100, +): Promise> { if (!mainClient) throw new Error('Electrum client is not connected'); - const ret = {}; + const ret: Record = {}; const chunks = splitIntoChunks(addresses, batchsize); for (const chunk of chunks) { const scripthashes = []; - const scripthash2addr = {}; + const scripthash2addr: Record = {}; for (const addr of chunk) { const script = bitcoin.address.toOutputScript(addr); const hash = bitcoin.crypto.sha256(script); - let reversedHash = Buffer.from(reverse(hash)); - reversedHash = reversedHash.toString('hex'); + const reversedHash = Buffer.from(hash).reverse().toString('hex'); scripthashes.push(reversedHash); scripthash2addr[reversedHash] = addr; } @@ -580,7 +728,7 @@ module.exports.multiGetHistoryByAddress = async function (addresses, batchsize) if (disableBatching) { const promises = []; - const index2scripthash = {}; + const index2scripthash: Record = {}; for (let promiseIndex = 0; promiseIndex < scripthashes.length; promiseIndex++) { index2scripthash[promiseIndex] = scripthashes[promiseIndex]; promises.push(mainClient.blockchainScripthash_getHistory(scripthashes[promiseIndex])); @@ -609,12 +757,20 @@ module.exports.multiGetHistoryByAddress = async function (addresses, batchsize) return ret; }; -module.exports.multiGetTransactionByTxid = async function (txids, batchsize, verbose = true) { - batchsize = batchsize || 45; +// if verbose === true ? Record : Record +type MultiGetTransactionByTxidResult = T extends true ? Record : Record; + +// TODO: this function returns different results based on the value of `verboseParam`, consider splitting it into two +export async function multiGetTransactionByTxid( + txids: string[], + verbose: T, + batchsize: number = 45, +): Promise> { + txids = txids.filter(txid => !!txid); // failsafe: removing 'undefined' or other falsy stuff from txids array // this value is fine-tuned so althrough wallets in test suite will occasionally // throw 'response too large (over 1,000,000 bytes', test suite will pass if (!mainClient) throw new Error('Electrum client is not connected'); - const ret = {}; + const ret: MultiGetTransactionByTxidResult = {}; txids = [...new Set(txids)]; // deduplicate just for any case // lets try cache first: @@ -625,7 +781,7 @@ module.exports.multiGetTransactionByTxid = async function (txids, batchsize, ver const jsonString = realm.objectForPrimaryKey('Cache', txid + cacheKeySuffix); // search for a realm object with a primary key if (jsonString && jsonString.cache_value) { try { - ret[txid] = JSON.parse(jsonString.cache_value); + ret[txid] = JSON.parse(jsonString.cache_value as string); } catch (error) { console.log(error, 'cache failed to parse', jsonString.cache_value); } @@ -650,7 +806,7 @@ module.exports.multiGetTransactionByTxid = async function (txids, batchsize, ver // in case of ElectrumPersonalServer it might not track some transactions (like source transactions for our transactions) // so we wrap it in try-catch. note, when `Promise.all` fails we will get _zero_ results, but we have a fallback for that const promises = []; - const index2txid = {}; + const index2txid: Record = {}; for (let promiseIndex = 0; promiseIndex < chunk.length; promiseIndex++) { const txid = chunk[promiseIndex]; index2txid[promiseIndex] = txid; @@ -668,16 +824,16 @@ module.exports.multiGetTransactionByTxid = async function (txids, batchsize, ver const txid = index2txid[resultIndex]; results.push({ result: tx, param: txid }); } - } catch (_) { - if (String(_?.message ?? _).startsWith('verbose transactions are currently unsupported')) { + } catch (error: any) { + if (String(error?.message ?? error).startsWith('verbose transactions are currently unsupported')) { // electrs-esplora. cant use verbose, so fetching txs one by one and decoding locally for (const txid of chunk) { try { let tx = await mainClient.blockchainTransaction_get(txid, false); tx = txhexToElectrumTransaction(tx); results.push({ result: tx, param: txid }); - } catch (error) { - console.log(error); + } catch (err) { + console.log(err); } } } else { @@ -692,8 +848,8 @@ module.exports.multiGetTransactionByTxid = async function (txids, batchsize, ver tx = txhexToElectrumTransaction(tx); } results.push({ result: tx, param: txid }); - } catch (error) { - console.log(error); + } catch (err) { + console.log(err); } } } @@ -711,13 +867,17 @@ module.exports.multiGetTransactionByTxid = async function (txids, batchsize, ver txdata.result = txhexToElectrumTransaction(txdata.result); } ret[txdata.param] = txdata.result; + // @ts-ignore: hex property if (ret[txdata.param]) delete ret[txdata.param].hex; // compact } } // in bitcoin core 22.0.0+ they removed `.addresses` and replaced it with plain `.address`: - for (const txid of Object.keys(ret) ?? []) { - for (const vout of ret[txid]?.vout ?? []) { + for (const txid of Object.keys(ret)) { + const tx = ret[txid]; + if (typeof tx === 'string') continue; + for (const vout of tx?.vout ?? []) { + // @ts-ignore: address is not in type definition if (vout?.scriptPubKey?.address) vout.scriptPubKey.addresses = [vout.scriptPubKey.address]; } } @@ -725,9 +885,13 @@ module.exports.multiGetTransactionByTxid = async function (txids, batchsize, ver // saving cache: realm.write(() => { for (const txid of Object.keys(ret)) { - if (verbose && (!ret[txid].confirmations || ret[txid].confirmations < 7)) continue; + const tx = ret[txid]; // dont cache immature txs, but only for 'verbose', since its fully decoded tx jsons. non-verbose are just plain // strings txhex + if (verbose && typeof tx !== 'string' && (!tx?.confirmations || tx.confirmations < 7)) { + continue; + } + realm.create( 'Cache', { @@ -740,21 +904,18 @@ module.exports.multiGetTransactionByTxid = async function (txids, batchsize, ver }); return ret; -}; +} /** * Simple waiter till `mainConnected` becomes true (which means * it Electrum was connected in other function), or timeout 30 sec. - * - * - * @returns {Promise | Promise<*>>} */ -module.exports.waitTillConnected = async function () { - let waitTillConnectedInterval = false; +export const waitTillConnected = async function (): Promise { + let waitTillConnectedInterval: NodeJS.Timeout | undefined; let retriesCounter = 0; if (await isDisabled()) { console.warn('Electrum connections disabled by user. waitTillConnected skipping...'); - return; + return false; } return new Promise(function (resolve, reject) { waitTillConnectedInterval = setInterval(() => { @@ -782,7 +943,7 @@ module.exports.waitTillConnected = async function () { // Returns the value at a given percentile in a sorted numeric array. // "Linear interpolation between closest ranks" method -function percentile(arr, p) { +function percentile(arr: number[], p: number) { if (arr.length === 0) return 0; if (typeof p !== 'number') throw new TypeError('p must be a number'); if (p <= 0) return arr[0]; @@ -800,12 +961,8 @@ function percentile(arr, p) { /** * The histogram is an array of [fee, vsize] pairs, where vsizen is the cumulative virtual size of mempool transactions * with a fee rate in the interval [feen-1, feen], and feen-1 > feen. - * - * @param numberOfBlocks {Number} - * @param feeHistorgram {Array} - * @returns {number} */ -module.exports.calcEstimateFeeFromFeeHistorgam = function (numberOfBlocks, feeHistorgram) { +export const calcEstimateFeeFromFeeHistorgam = function (numberOfBlocks: number, feeHistorgram: number[][]) { // first, transforming histogram: let totalVsize = 0; const histogramToUse = []; @@ -825,7 +982,7 @@ module.exports.calcEstimateFeeFromFeeHistorgam = function (numberOfBlocks, feeHi // now we have histogram of precisely size for numberOfBlocks. // lets spread it into flat array so its easier to calculate percentile: - let histogramFlat = []; + let histogramFlat: number[] = []; for (const hh of histogramToUse) { histogramFlat = histogramFlat.concat(Array(Math.round(hh.vsize / 25000)).fill(hh.fee)); // division is needed so resulting flat array is not too huge @@ -838,7 +995,7 @@ module.exports.calcEstimateFeeFromFeeHistorgam = function (numberOfBlocks, feeHi return Math.round(percentile(histogramFlat, 0.5) || 1); }; -module.exports.estimateFees = async function () { +export const estimateFees = async function (): Promise<{ fast: number; medium: number; slow: number }> { let histogram; let timeoutId; try { @@ -850,15 +1007,20 @@ module.exports.estimateFees = async function () { clearTimeout(timeoutId); } - if (!histogram) throw new Error('timeout while getting mempool_getFeeHistogram'); - // fetching what electrum (which uses bitcoin core) thinks about fees: - const _fast = await module.exports.estimateFee(1); - const _medium = await module.exports.estimateFee(18); - const _slow = await module.exports.estimateFee(144); + const _fast = await estimateFee(1); + const _medium = await estimateFee(18); + const _slow = await estimateFee(144); + + /** + * sanity check, see + * @see https://github.com/cculianu/Fulcrum/issues/197 + * (fallback to bitcoin core estimates) + */ + if (!histogram || histogram?.[0]?.[0] > 1000) return { fast: _fast, medium: _medium, slow: _slow }; // calculating fast fees from mempool: - const fast = Math.max(2, module.exports.calcEstimateFeeFromFeeHistorgam(1, histogram)); + const fast = Math.max(2, calcEstimateFeeFromFeeHistorgam(1, histogram)); // recalculating medium and slow fees using bitcoincore estimations only like relative weights: // (minimum 1 sat, just for any case) const medium = Math.max(1, Math.round((fast * _medium) / _fast)); @@ -872,7 +1034,7 @@ module.exports.estimateFees = async function () { * @param numberOfBlocks {number} The number of blocks to target for confirmation * @returns {Promise} Satoshis per byte */ -module.exports.estimateFee = async function (numberOfBlocks) { +export const estimateFee = async function (numberOfBlocks: number): Promise { if (!mainClient) throw new Error('Electrum client is not connected'); numberOfBlocks = numberOfBlocks || 1; const coinUnitsPerKilobyte = await mainClient.blockchainEstimatefee(numberOfBlocks); @@ -880,31 +1042,31 @@ module.exports.estimateFee = async function (numberOfBlocks) { return Math.round(new BigNumber(coinUnitsPerKilobyte).dividedBy(1024).multipliedBy(100000000).toNumber()); }; -module.exports.serverFeatures = async function () { +export const serverFeatures = async function () { if (!mainClient) throw new Error('Electrum client is not connected'); return mainClient.server_features(); }; -module.exports.broadcast = async function (hex) { +export const broadcast = async function (hex: string) { if (!mainClient) throw new Error('Electrum client is not connected'); try { - const broadcast = await mainClient.blockchainTransaction_broadcast(hex); - return broadcast; + const res = await mainClient.blockchainTransaction_broadcast(hex); + return res; } catch (error) { return error; } }; -module.exports.broadcastV2 = async function (hex) { +export const broadcastV2 = async function (hex: string): Promise { if (!mainClient) throw new Error('Electrum client is not connected'); return mainClient.blockchainTransaction_broadcast(hex); }; -module.exports.estimateCurrentBlockheight = function () { - if (latestBlockheight) { - const timeDiff = Math.floor(+new Date() / 1000) - latestBlockheightTimestamp; +export const estimateCurrentBlockheight = function (): number { + if (latestBlock.height) { + const timeDiff = Math.floor(+new Date() / 1000) - latestBlock.time; const extraBlocks = Math.floor(timeDiff / (9.93 * 60)); - return latestBlockheight + extraBlocks; + return latestBlock.height + extraBlocks; } const baseTs = 1587570465609; // uS @@ -912,14 +1074,9 @@ module.exports.estimateCurrentBlockheight = function () { return Math.floor(baseHeight + (+new Date() - baseTs) / 1000 / 60 / 9.93); }; -/** - * - * @param height - * @returns {number} Timestamp in seconds - */ -module.exports.calculateBlockTime = function (height) { - if (latestBlockheight) { - return Math.floor(latestBlockheightTimestamp + (height - latestBlockheight) * 9.93 * 60); +export const calculateBlockTime = function (height: number): number { + if (latestBlock.height) { + return Math.floor(latestBlock.time + (height - latestBlock.height) * 9.93 * 60); } const baseTs = 1585837504; // sec @@ -928,28 +1085,17 @@ module.exports.calculateBlockTime = function (height) { }; /** - * - * @param host - * @param tcpPort - * @param sslPort * @returns {Promise} Whether provided host:port is a valid electrum server */ -module.exports.testConnection = async function (host, tcpPort, sslPort) { - const isTorDisabled = await isTorDaemonDisabled(); - const client = new ElectrumClient( - host.endsWith('.onion') && !isTorDisabled ? torrific : global.net, - global.tls, - sslPort || tcpPort, - host, - sslPort ? 'tls' : 'tcp', - ); +export const testConnection = async function (host: string, tcpPort?: number, sslPort?: number): Promise { + const client = new ElectrumClient(net, tls, sslPort || tcpPort, host, sslPort ? 'tls' : 'tcp'); client.onError = () => {}; // mute - let timeoutId = false; + let timeoutId: NodeJS.Timeout | undefined; try { const rez = await Promise.race([ new Promise(resolve => { - timeoutId = setTimeout(() => resolve('timeout'), host.endsWith('.onion') && !isTorDisabled ? 21000 : 5000); + timeoutId = setTimeout(() => resolve('timeout'), 5000); }), client.connect(), ]); @@ -967,27 +1113,19 @@ module.exports.testConnection = async function (host, tcpPort, sslPort) { return false; }; -module.exports.forceDisconnect = () => { - mainClient.close(); +export const forceDisconnect = (): void => { + mainClient?.close(); }; -module.exports.setBatchingDisabled = () => { +export const setBatchingDisabled = () => { disableBatching = true; }; -module.exports.setBatchingEnabled = () => { +export const setBatchingEnabled = () => { disableBatching = false; }; -module.exports.connectMain = connectMain; -module.exports.isDisabled = isDisabled; -module.exports.setDisabled = setDisabled; -module.exports.hardcodedPeers = hardcodedPeers; -module.exports.ELECTRUM_HOST = ELECTRUM_HOST; -module.exports.ELECTRUM_TCP_PORT = ELECTRUM_TCP_PORT; -module.exports.ELECTRUM_SSL_PORT = ELECTRUM_SSL_PORT; -module.exports.ELECTRUM_SERVER_HISTORY = ELECTRUM_SERVER_HISTORY; - -const splitIntoChunks = function (arr, chunkSize) { + +const splitIntoChunks = function (arr: any[], chunkSize: number) { const groups = []; let i; for (i = 0; i < arr.length; i += chunkSize) { @@ -996,97 +1134,13 @@ const splitIntoChunks = function (arr, chunkSize) { return groups; }; -const semVerToInt = function (semver) { +const semVerToInt = function (semver: string): number { if (!semver) return 0; if (semver.split('.').length !== 3) return 0; - const ret = semver.split('.')[0] * 1000000 + semver.split('.')[1] * 1000 + semver.split('.')[2] * 1; + const ret = Number(semver.split('.')[0]) * 1000000 + Number(semver.split('.')[1]) * 1000 + Number(semver.split('.')[2]) * 1; if (isNaN(ret)) return 0; return ret; }; - -function txhexToElectrumTransaction(txhex) { - const tx = bitcoin.Transaction.fromHex(txhex); - - const ret = { - txid: tx.getId(), - hash: tx.getId(), - version: tx.version, - size: Math.ceil(txhex.length / 2), - vsize: tx.virtualSize(), - weight: tx.weight(), - locktime: tx.locktime, - vin: [], - vout: [], - hex: txhex, - blockhash: '', - confirmations: 0, - time: 0, - blocktime: 0, - }; - - if (txhashHeightCache[ret.txid]) { - // got blockheight where this tx was confirmed - ret.confirmations = module.exports.estimateCurrentBlockheight() - txhashHeightCache[ret.txid]; - if (ret.confirmations < 0) { - // ugly fix for when estimator lags behind - ret.confirmations = 1; - } - ret.time = module.exports.calculateBlockTime(txhashHeightCache[ret.txid]); - ret.blocktime = module.exports.calculateBlockTime(txhashHeightCache[ret.txid]); - } - - for (const inn of tx.ins) { - const txinwitness = []; - if (inn.witness[0]) txinwitness.push(inn.witness[0].toString('hex')); - if (inn.witness[1]) txinwitness.push(inn.witness[1].toString('hex')); - - ret.vin.push({ - txid: reverse(inn.hash).toString('hex'), - vout: inn.index, - scriptSig: { hex: inn.script.toString('hex'), asm: '' }, - txinwitness, - sequence: inn.sequence, - }); - } - - let n = 0; - for (const out of tx.outs) { - const value = new BigNumber(out.value).dividedBy(100000000).toNumber(); - let address = false; - let type = false; - - if (SegwitBech32Wallet.scriptPubKeyToAddress(out.script.toString('hex'))) { - address = SegwitBech32Wallet.scriptPubKeyToAddress(out.script.toString('hex')); - type = 'witness_v0_keyhash'; - } else if (SegwitP2SHWallet.scriptPubKeyToAddress(out.script.toString('hex'))) { - address = SegwitP2SHWallet.scriptPubKeyToAddress(out.script.toString('hex')); - type = '???'; // TODO - } else if (LegacyWallet.scriptPubKeyToAddress(out.script.toString('hex'))) { - address = LegacyWallet.scriptPubKeyToAddress(out.script.toString('hex')); - type = '???'; // TODO - } else { - address = TaprootWallet.scriptPubKeyToAddress(out.script.toString('hex')); - type = 'witness_v1_taproot'; - } - - ret.vout.push({ - value, - n, - scriptPubKey: { - asm: '', - hex: out.script.toString('hex'), - reqSigs: 1, // todo - type, - addresses: [address], - }, - }); - n++; - } - return ret; -} - -// exported only to be used in unit tests -module.exports.txhexToElectrumTransaction = txhexToElectrumTransaction; diff --git a/blue_modules/Privacy.android.tsx b/blue_modules/Privacy.android.tsx deleted file mode 100644 index 07c54680d9..0000000000 --- a/blue_modules/Privacy.android.tsx +++ /dev/null @@ -1,29 +0,0 @@ -import { useContext, useEffect } from 'react'; -// @ts-ignore: react-native-obscure is not in the type definition -import Obscure from 'react-native-obscure'; -import { BlueStorageContext } from './storage-context'; -interface PrivacyComponent extends React.FC { - enableBlur: (isPrivacyBlurEnabled: boolean) => void; - disableBlur: () => void; -} - -const Privacy: PrivacyComponent = () => { - const { isPrivacyBlurEnabled } = useContext(BlueStorageContext); - - useEffect(() => { - Obscure.deactivateObscure(); - }, [isPrivacyBlurEnabled]); - - return null; -}; - -Privacy.enableBlur = (isPrivacyBlurEnabled: boolean) => { - if (!isPrivacyBlurEnabled) return; - Obscure.activateObscure(); -}; - -Privacy.disableBlur = () => { - Obscure.deactivateObscure(); -}; - -export default Privacy; diff --git a/blue_modules/Privacy.ios.tsx b/blue_modules/Privacy.ios.tsx deleted file mode 100644 index 877a3131b4..0000000000 --- a/blue_modules/Privacy.ios.tsx +++ /dev/null @@ -1,30 +0,0 @@ -import { useContext, useEffect } from 'react'; -// @ts-ignore: react-native-obscure is not in the type definition -import { enabled } from 'react-native-privacy-snapshot'; -import { BlueStorageContext } from './storage-context'; - -interface PrivacyComponent extends React.FC { - enableBlur: (isPrivacyBlurEnabled: boolean) => void; - disableBlur: () => void; -} - -const Privacy: PrivacyComponent = () => { - const { isPrivacyBlurEnabled } = useContext(BlueStorageContext); - - useEffect(() => { - Privacy.disableBlur(); - }, [isPrivacyBlurEnabled]); - - return null; -}; - -Privacy.enableBlur = (isPrivacyBlurEnabled: boolean) => { - if (!isPrivacyBlurEnabled) return; - enabled(true); -}; - -Privacy.disableBlur = () => { - enabled(false); -}; - -export default Privacy; diff --git a/blue_modules/Privacy.tsx b/blue_modules/Privacy.tsx deleted file mode 100644 index 8feff43244..0000000000 --- a/blue_modules/Privacy.tsx +++ /dev/null @@ -1,21 +0,0 @@ -import React from 'react'; - -interface PrivacyComponent extends React.FC { - enableBlur: () => void; - disableBlur: () => void; -} - -const Privacy: PrivacyComponent = () => { - // Define Privacy's behavior - return null; -}; - -Privacy.enableBlur = () => { - // Define the enableBlur behavior -}; - -Privacy.disableBlur = () => { - // Define the disableBlur behavior -}; - -export default Privacy; diff --git a/blue_modules/WidgetCommunication.ios.js b/blue_modules/WidgetCommunication.ios.js deleted file mode 100644 index e926ec9648..0000000000 --- a/blue_modules/WidgetCommunication.ios.js +++ /dev/null @@ -1,79 +0,0 @@ -import { useContext, useEffect } from 'react'; -import { BlueStorageContext } from './storage-context'; -import DefaultPreference from 'react-native-default-preference'; -import RNWidgetCenter from 'react-native-widget-center'; -import AsyncStorage from '@react-native-async-storage/async-storage'; - -function WidgetCommunication() { - WidgetCommunication.WidgetCommunicationAllWalletsSatoshiBalance = 'WidgetCommunicationAllWalletsSatoshiBalance'; - WidgetCommunication.WidgetCommunicationAllWalletsLatestTransactionTime = 'WidgetCommunicationAllWalletsLatestTransactionTime'; - WidgetCommunication.WidgetCommunicationDisplayBalanceAllowed = 'WidgetCommunicationDisplayBalanceAllowed'; - WidgetCommunication.LatestTransactionIsUnconfirmed = 'WidgetCommunicationLatestTransactionIsUnconfirmed'; - const { wallets, walletsInitialized, isStorageEncrypted } = useContext(BlueStorageContext); - - WidgetCommunication.isBalanceDisplayAllowed = async () => { - try { - const displayBalance = JSON.parse(await AsyncStorage.getItem(WidgetCommunication.WidgetCommunicationDisplayBalanceAllowed)); - if (displayBalance !== null) { - return displayBalance; - } else { - return true; - } - } catch (e) { - return true; - } - }; - - WidgetCommunication.setBalanceDisplayAllowed = async value => { - await AsyncStorage.setItem(WidgetCommunication.WidgetCommunicationDisplayBalanceAllowed, JSON.stringify(value)); - setValues(); - }; - - WidgetCommunication.reloadAllTimelines = () => { - RNWidgetCenter.reloadAllTimelines(); - }; - - const allWalletsBalanceAndTransactionTime = async () => { - if ((await isStorageEncrypted()) || !(await WidgetCommunication.isBalanceDisplayAllowed())) { - return { allWalletsBalance: 0, latestTransactionTime: 0 }; - } else { - let balance = 0; - let latestTransactionTime = 0; - for (const wallet of wallets) { - if (wallet.hideBalance) { - continue; - } - balance += wallet.getBalance(); - if (wallet.getLatestTransactionTimeEpoch() > latestTransactionTime) { - if (wallet.getTransactions()[0].confirmations === 0) { - latestTransactionTime = WidgetCommunication.LatestTransactionIsUnconfirmed; - } else { - latestTransactionTime = wallet.getLatestTransactionTimeEpoch(); - } - } - } - return { allWalletsBalance: balance, latestTransactionTime }; - } - }; - const setValues = async () => { - await DefaultPreference.setName('group.io.bluewallet.bluewallet'); - const { allWalletsBalance, latestTransactionTime } = await allWalletsBalanceAndTransactionTime(); - await DefaultPreference.set(WidgetCommunication.WidgetCommunicationAllWalletsSatoshiBalance, JSON.stringify(allWalletsBalance)); - await DefaultPreference.set( - WidgetCommunication.WidgetCommunicationAllWalletsLatestTransactionTime, - JSON.stringify(latestTransactionTime), - ); - RNWidgetCenter.reloadAllTimelines(); - }; - - useEffect(() => { - if (walletsInitialized) { - setValues(); - } - - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [wallets, walletsInitialized]); - return null; -} - -export default WidgetCommunication; diff --git a/blue_modules/WidgetCommunication.js b/blue_modules/WidgetCommunication.js deleted file mode 100644 index 05cba00d80..0000000000 --- a/blue_modules/WidgetCommunication.js +++ /dev/null @@ -1,8 +0,0 @@ -function WidgetCommunication(props) { - WidgetCommunication.isBalanceDisplayAllowed = () => {}; - WidgetCommunication.setBalanceDisplayAllowed = () => {}; - WidgetCommunication.reloadAllTimelines = () => {}; - return null; -} - -export default WidgetCommunication; diff --git a/blue_modules/aezeed/LICENSE b/blue_modules/aezeed/LICENSE deleted file mode 100644 index b7fe6390c4..0000000000 --- a/blue_modules/aezeed/LICENSE +++ /dev/null @@ -1,21 +0,0 @@ -MIT License - -Copyright (c) 2020 bitcoinjs - -Permission is hereby granted, free of charge, to any person obtaining a copy -of this software and associated documentation files (the "Software"), to deal -in the Software without restriction, including without limitation the rights -to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -copies of the Software, and to permit persons to whom the Software is -furnished to do so, subject to the following conditions: - -The above copyright notice and this permission notice shall be included in all -copies or substantial portions of the Software. - -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE -SOFTWARE. diff --git a/blue_modules/aezeed/README.md b/blue_modules/aezeed/README.md deleted file mode 100644 index e8a32cf3cb..0000000000 --- a/blue_modules/aezeed/README.md +++ /dev/null @@ -1,2 +0,0 @@ -# aezeed -A package for encoding, decoding, and generating mnemonics of the aezeed specification. (WIP) diff --git a/blue_modules/aezeed/package.json b/blue_modules/aezeed/package.json deleted file mode 100644 index d92abf6c23..0000000000 --- a/blue_modules/aezeed/package.json +++ /dev/null @@ -1,85 +0,0 @@ -{ - "_from": "aezeed", - "_id": "aezeed@0.0.4", - "_inBundle": false, - "_integrity": "sha512-KAv2y2AtbqpdtsabCLE+C0G0h4BZLeMHsLCRga3VicYLxD17RflUBJ++c5qdpN6B6fkvK90r6bWg52Z/gMC7gQ==", - "_location": "/aezeed", - "_phantomChildren": {}, - "_requested": { - "type": "tag", - "registry": true, - "raw": "aezeed", - "name": "aezeed", - "escapedName": "aezeed", - "rawSpec": "", - "saveSpec": null, - "fetchSpec": "latest" - }, - "_requiredBy": [ - "#USER", - "/" - ], - "_resolved": "https://registry.npmjs.org/aezeed/-/aezeed-0.0.4.tgz", - "_shasum": "8fce8778d34f5566328f61df7706351cb15873a9", - "_spec": "aezeed", - "_where": "/home/overtorment/Documents/BlueWallet", - "author": { - "name": "Jonathan Underwood" - }, - "bugs": { - "url": "https://github.com/bitcoinjs/aezeed/issues" - }, - "bundleDependencies": false, - "dependencies": { - "aez": "^1.0.1", - "crc-32": "npm:junderw-crc32c@^1.2.0", - "randombytes": "^2.1.0", - "scryptsy": "^2.1.0" - }, - "deprecated": false, - "description": "A package for encoding, decoding, and generating mnemonics of the aezeed specification.", - "devDependencies": { - "@types/jest": "^26.0.10", - "@types/node": "^16.0.0", - "@types/randombytes": "^2.0.0", - "@types/scryptsy": "^2.0.0", - "jest": "^26.4.2", - "prettier": "^2.1.0", - "ts-jest": "^26.2.0", - "tslint": "^6.1.3", - "typescript": "^4.0.2" - }, - "files": [ - "src" - ], - "homepage": "https://github.com/bitcoinjs/aezeed#readme", - "keywords": [ - "aezeed", - "bitcoin", - "lightning", - "lnd" - ], - "license": "MIT", - "main": "src/cipherseed.js", - "name": "aezeed", - "repository": { - "type": "git", - "url": "git+https://github.com/bitcoinjs/aezeed.git" - }, - "scripts": { - "build": "npm run clean && tsc -p tsconfig.json", - "clean": "rm -rf src", - "coverage": "npm run unit -- --coverage", - "format": "npm run prettier -- --write", - "format:ci": "npm run prettier -- --check", - "gitdiff": "git diff --exit-code", - "gitdiff:ci": "npm run build && npm run gitdiff", - "lint": "tslint -p tsconfig.json -c tslint.json", - "prepublishOnly": "npm run test && npm run gitdiff", - "prettier": "prettier 'ts_src/**/*.ts' --single-quote --trailing-comma=all --ignore-path ./.prettierignore", - "test": "npm run build && npm run format:ci && npm run lint && npm run unit", - "unit": "jest --config=jest.json --runInBand" - }, - "types": "src/cipherseed.d.ts", - "version": "0.0.4" -} diff --git a/blue_modules/aezeed/src/cipherseed.d.ts b/blue_modules/aezeed/src/cipherseed.d.ts deleted file mode 100644 index c664c3beb9..0000000000 --- a/blue_modules/aezeed/src/cipherseed.d.ts +++ /dev/null @@ -1,15 +0,0 @@ -/// -export declare class CipherSeed { - entropy: Buffer; - salt: Buffer; - internalVersion: number; - birthday: number; - private static decipher; - static fromMnemonic(mnemonic: string, password?: string): CipherSeed; - static random(): CipherSeed; - static changePassword(mnemonic: string, oldPassword: string | null, newPassword: string): string; - constructor(entropy: Buffer, salt: Buffer, internalVersion?: number, birthday?: number); - get birthDate(): Date; - toMnemonic(password?: string, cipherSeedVersion?: number): string; - private encipher; -} diff --git a/blue_modules/aezeed/src/cipherseed.js b/blue_modules/aezeed/src/cipherseed.js deleted file mode 100644 index 586972dffc..0000000000 --- a/blue_modules/aezeed/src/cipherseed.js +++ /dev/null @@ -1,105 +0,0 @@ -"use strict"; -Object.defineProperty(exports, "__esModule", { value: true }); -exports.CipherSeed = void 0; -const BlueCrypto = require('react-native-blue-crypto'); -const scrypt = require("scryptsy"); -const rng = require("randombytes"); -const mn = require("./mnemonic"); -const params_1 = require("./params"); -const aez = require('aez'); -const crc = require('junderw-crc32c'); -const BITCOIN_GENESIS = new Date('2009-01-03T18:15:05.000Z').getTime(); -const daysSinceGenesis = (time) => Math.floor((time.getTime() - BITCOIN_GENESIS) / params_1.ONE_DAY); - -async function scryptWrapper(secret, salt, N, r, p, dkLen, progressCallback) { - if (BlueCrypto.isAvailable()) { - secret = Buffer.from(secret).toString('hex'); - salt = Buffer.from(salt).toString('hex'); - const hex = await BlueCrypto.scrypt(secret, salt, N, r, p, dkLen); - return Buffer.from(hex, 'hex'); - } else { - // fallback to js implementation - return scrypt(secret, salt, N, r, p, dkLen, progressCallback); - } -} - -class CipherSeed { - constructor(entropy, salt, internalVersion = 0, birthday = daysSinceGenesis(new Date())) { - this.entropy = entropy; - this.salt = salt; - this.internalVersion = internalVersion; - this.birthday = birthday; - if (entropy && entropy.length !== 16) - throw new Error('incorrect entropy length'); - if (salt && salt.length !== 5) - throw new Error('incorrect salt length'); - } - - static async decipher(cipherBuf, password) { - if (cipherBuf[0] >= params_1.PARAMS.length) { - throw new Error('Invalid cipherSeedVersion'); - } - const cipherSeedVersion = cipherBuf[0]; - const params = params_1.PARAMS[cipherSeedVersion]; - const checksum = Buffer.allocUnsafe(4); - const checksumNum = crc.buf(cipherBuf.slice(0, 29)); - checksum.writeInt32BE(checksumNum); - if (!checksum.equals(cipherBuf.slice(29))) { - throw new Error('CRC checksum mismatch'); - } - const salt = cipherBuf.slice(24, 29); - const key = await scryptWrapper(Buffer.from(password, 'utf8'), salt, params.n, params.r, params.p, 32); - const adBytes = Buffer.allocUnsafe(6); - adBytes.writeUInt8(cipherSeedVersion, 0); - salt.copy(adBytes, 1); - const plainText = aez.decrypt(key, null, [adBytes], 4, cipherBuf.slice(1, 24)); - if (plainText === null) - throw new Error('Invalid Password'); - return new CipherSeed(plainText.slice(3, 19), salt, plainText[0], plainText.readUInt16BE(1)); - } - - static async fromMnemonic(mnemonic, password = params_1.DEFAULT_PASSWORD) { - const bytes = mn.mnemonicToBytes(mnemonic); - return await CipherSeed.decipher(bytes, password); - } - - static random() { - return new CipherSeed(rng(16), rng(5)); - } - - static async changePassword(mnemonic, oldPassword, newPassword) { - const pwd = oldPassword === null ? params_1.DEFAULT_PASSWORD : oldPassword; - const cs = await CipherSeed.fromMnemonic(mnemonic, pwd); - return await cs.toMnemonic(newPassword); - } - - get birthDate() { - return new Date(BITCOIN_GENESIS + this.birthday * params_1.ONE_DAY); - } - - async toMnemonic(password = params_1.DEFAULT_PASSWORD, cipherSeedVersion = params_1.CIPHER_SEED_VERSION) { - return mn.mnemonicFromBytes(await this.encipher(password, cipherSeedVersion)); - } - - async encipher(password, cipherSeedVersion) { - const pwBuf = Buffer.from(password, 'utf8'); - const params = params_1.PARAMS[cipherSeedVersion]; - const key = await scryptWrapper(pwBuf, this.salt, params.n, params.r, params.p, 32); - const seedBytes = Buffer.allocUnsafe(19); - seedBytes.writeUInt8(this.internalVersion, 0); - seedBytes.writeUInt16BE(this.birthday, 1); - this.entropy.copy(seedBytes, 3); - const adBytes = Buffer.allocUnsafe(6); - adBytes.writeUInt8(cipherSeedVersion, 0); - this.salt.copy(adBytes, 1); - const cipherText = aez.encrypt(key, null, [adBytes], 4, seedBytes); - const cipherSeedBytes = Buffer.allocUnsafe(33); - cipherSeedBytes.writeUInt8(cipherSeedVersion, 0); - cipherText.copy(cipherSeedBytes, 1); - this.salt.copy(cipherSeedBytes, 24); - const checksumNum = crc.buf(cipherSeedBytes.slice(0, 29)); - cipherSeedBytes.writeInt32BE(checksumNum, 29); - return cipherSeedBytes; - } -} -exports.CipherSeed = CipherSeed; diff --git a/blue_modules/aezeed/src/mnemonic.d.ts b/blue_modules/aezeed/src/mnemonic.d.ts deleted file mode 100644 index 78a0a7d982..0000000000 --- a/blue_modules/aezeed/src/mnemonic.d.ts +++ /dev/null @@ -1,3 +0,0 @@ -/// -export declare function mnemonicFromBytes(bytes: Buffer): string; -export declare function mnemonicToBytes(mnemonic: string): Buffer; diff --git a/blue_modules/aezeed/src/mnemonic.js b/blue_modules/aezeed/src/mnemonic.js deleted file mode 100644 index e0415bb3f6..0000000000 --- a/blue_modules/aezeed/src/mnemonic.js +++ /dev/null @@ -1,2091 +0,0 @@ -"use strict"; -Object.defineProperty(exports, "__esModule", { value: true }); -exports.mnemonicToBytes = exports.mnemonicFromBytes = void 0; -function mnemonicFromBytes(bytes) { - const bits = bytesToBinary(Array.from(bytes)); - const chunks = bits.match(/(.{1,11})/g); - const words = chunks.map((binary) => { - const index = binaryToByte(binary); - return WORDLIST[index]; - }); - return words.join(' '); -} -exports.mnemonicFromBytes = mnemonicFromBytes; -function mnemonicToBytes(mnemonic) { - const INVALID = 'Invalid Mnemonic'; - const words = mnemonic.split(' '); - if (words.length !== 24) - throw new Error(INVALID); - const bits = words - .map((word) => { - const index = WORDLIST.indexOf(word); - if (index === -1) - throw new Error(INVALID); - return lpad(index.toString(2), '0', 11); - }) - .join(''); - const entropyBytes = bits.match(/(.{8})/g).map(binaryToByte); - return Buffer.from(entropyBytes); -} -exports.mnemonicToBytes = mnemonicToBytes; -function bytesToBinary(bytes) { - return bytes.map((x) => lpad(x.toString(2), '0', 8)).join(''); -} -function binaryToByte(bin) { - return parseInt(bin, 2); -} -function lpad(str, padString, length) { - while (str.length < length) - str = padString + str; - return str; -} -const WORDLIST = [ - 'abandon', - 'ability', - 'able', - 'about', - 'above', - 'absent', - 'absorb', - 'abstract', - 'absurd', - 'abuse', - 'access', - 'accident', - 'account', - 'accuse', - 'achieve', - 'acid', - 'acoustic', - 'acquire', - 'across', - 'act', - 'action', - 'actor', - 'actress', - 'actual', - 'adapt', - 'add', - 'addict', - 'address', - 'adjust', - 'admit', - 'adult', - 'advance', - 'advice', - 'aerobic', - 'affair', - 'afford', - 'afraid', - 'again', - 'age', - 'agent', - 'agree', - 'ahead', - 'aim', - 'air', - 'airport', - 'aisle', - 'alarm', - 'album', - 'alcohol', - 'alert', - 'alien', - 'all', - 'alley', - 'allow', - 'almost', - 'alone', - 'alpha', - 'already', - 'also', - 'alter', - 'always', - 'amateur', - 'amazing', - 'among', - 'amount', - 'amused', - 'analyst', - 'anchor', - 'ancient', - 'anger', - 'angle', - 'angry', - 'animal', - 'ankle', - 'announce', - 'annual', - 'another', - 'answer', - 'antenna', - 'antique', - 'anxiety', - 'any', - 'apart', - 'apology', - 'appear', - 'apple', - 'approve', - 'april', - 'arch', - 'arctic', - 'area', - 'arena', - 'argue', - 'arm', - 'armed', - 'armor', - 'army', - 'around', - 'arrange', - 'arrest', - 'arrive', - 'arrow', - 'art', - 'artefact', - 'artist', - 'artwork', - 'ask', - 'aspect', - 'assault', - 'asset', - 'assist', - 'assume', - 'asthma', - 'athlete', - 'atom', - 'attack', - 'attend', - 'attitude', - 'attract', - 'auction', - 'audit', - 'august', - 'aunt', - 'author', - 'auto', - 'autumn', - 'average', - 'avocado', - 'avoid', - 'awake', - 'aware', - 'away', - 'awesome', - 'awful', - 'awkward', - 'axis', - 'baby', - 'bachelor', - 'bacon', - 'badge', - 'bag', - 'balance', - 'balcony', - 'ball', - 'bamboo', - 'banana', - 'banner', - 'bar', - 'barely', - 'bargain', - 'barrel', - 'base', - 'basic', - 'basket', - 'battle', - 'beach', - 'bean', - 'beauty', - 'because', - 'become', - 'beef', - 'before', - 'begin', - 'behave', - 'behind', - 'believe', - 'below', - 'belt', - 'bench', - 'benefit', - 'best', - 'betray', - 'better', - 'between', - 'beyond', - 'bicycle', - 'bid', - 'bike', - 'bind', - 'biology', - 'bird', - 'birth', - 'bitter', - 'black', - 'blade', - 'blame', - 'blanket', - 'blast', - 'bleak', - 'bless', - 'blind', - 'blood', - 'blossom', - 'blouse', - 'blue', - 'blur', - 'blush', - 'board', - 'boat', - 'body', - 'boil', - 'bomb', - 'bone', - 'bonus', - 'book', - 'boost', - 'border', - 'boring', - 'borrow', - 'boss', - 'bottom', - 'bounce', - 'box', - 'boy', - 'bracket', - 'brain', - 'brand', - 'brass', - 'brave', - 'bread', - 'breeze', - 'brick', - 'bridge', - 'brief', - 'bright', - 'bring', - 'brisk', - 'broccoli', - 'broken', - 'bronze', - 'broom', - 'brother', - 'brown', - 'brush', - 'bubble', - 'buddy', - 'budget', - 'buffalo', - 'build', - 'bulb', - 'bulk', - 'bullet', - 'bundle', - 'bunker', - 'burden', - 'burger', - 'burst', - 'bus', - 'business', - 'busy', - 'butter', - 'buyer', - 'buzz', - 'cabbage', - 'cabin', - 'cable', - 'cactus', - 'cage', - 'cake', - 'call', - 'calm', - 'camera', - 'camp', - 'can', - 'canal', - 'cancel', - 'candy', - 'cannon', - 'canoe', - 'canvas', - 'canyon', - 'capable', - 'capital', - 'captain', - 'car', - 'carbon', - 'card', - 'cargo', - 'carpet', - 'carry', - 'cart', - 'case', - 'cash', - 'casino', - 'castle', - 'casual', - 'cat', - 'catalog', - 'catch', - 'category', - 'cattle', - 'caught', - 'cause', - 'caution', - 'cave', - 'ceiling', - 'celery', - 'cement', - 'census', - 'century', - 'cereal', - 'certain', - 'chair', - 'chalk', - 'champion', - 'change', - 'chaos', - 'chapter', - 'charge', - 'chase', - 'chat', - 'cheap', - 'check', - 'cheese', - 'chef', - 'cherry', - 'chest', - 'chicken', - 'chief', - 'child', - 'chimney', - 'choice', - 'choose', - 'chronic', - 'chuckle', - 'chunk', - 'churn', - 'cigar', - 'cinnamon', - 'circle', - 'citizen', - 'city', - 'civil', - 'claim', - 'clap', - 'clarify', - 'claw', - 'clay', - 'clean', - 'clerk', - 'clever', - 'click', - 'client', - 'cliff', - 'climb', - 'clinic', - 'clip', - 'clock', - 'clog', - 'close', - 'cloth', - 'cloud', - 'clown', - 'club', - 'clump', - 'cluster', - 'clutch', - 'coach', - 'coast', - 'coconut', - 'code', - 'coffee', - 'coil', - 'coin', - 'collect', - 'color', - 'column', - 'combine', - 'come', - 'comfort', - 'comic', - 'common', - 'company', - 'concert', - 'conduct', - 'confirm', - 'congress', - 'connect', - 'consider', - 'control', - 'convince', - 'cook', - 'cool', - 'copper', - 'copy', - 'coral', - 'core', - 'corn', - 'correct', - 'cost', - 'cotton', - 'couch', - 'country', - 'couple', - 'course', - 'cousin', - 'cover', - 'coyote', - 'crack', - 'cradle', - 'craft', - 'cram', - 'crane', - 'crash', - 'crater', - 'crawl', - 'crazy', - 'cream', - 'credit', - 'creek', - 'crew', - 'cricket', - 'crime', - 'crisp', - 'critic', - 'crop', - 'cross', - 'crouch', - 'crowd', - 'crucial', - 'cruel', - 'cruise', - 'crumble', - 'crunch', - 'crush', - 'cry', - 'crystal', - 'cube', - 'culture', - 'cup', - 'cupboard', - 'curious', - 'current', - 'curtain', - 'curve', - 'cushion', - 'custom', - 'cute', - 'cycle', - 'dad', - 'damage', - 'damp', - 'dance', - 'danger', - 'daring', - 'dash', - 'daughter', - 'dawn', - 'day', - 'deal', - 'debate', - 'debris', - 'decade', - 'december', - 'decide', - 'decline', - 'decorate', - 'decrease', - 'deer', - 'defense', - 'define', - 'defy', - 'degree', - 'delay', - 'deliver', - 'demand', - 'demise', - 'denial', - 'dentist', - 'deny', - 'depart', - 'depend', - 'deposit', - 'depth', - 'deputy', - 'derive', - 'describe', - 'desert', - 'design', - 'desk', - 'despair', - 'destroy', - 'detail', - 'detect', - 'develop', - 'device', - 'devote', - 'diagram', - 'dial', - 'diamond', - 'diary', - 'dice', - 'diesel', - 'diet', - 'differ', - 'digital', - 'dignity', - 'dilemma', - 'dinner', - 'dinosaur', - 'direct', - 'dirt', - 'disagree', - 'discover', - 'disease', - 'dish', - 'dismiss', - 'disorder', - 'display', - 'distance', - 'divert', - 'divide', - 'divorce', - 'dizzy', - 'doctor', - 'document', - 'dog', - 'doll', - 'dolphin', - 'domain', - 'donate', - 'donkey', - 'donor', - 'door', - 'dose', - 'double', - 'dove', - 'draft', - 'dragon', - 'drama', - 'drastic', - 'draw', - 'dream', - 'dress', - 'drift', - 'drill', - 'drink', - 'drip', - 'drive', - 'drop', - 'drum', - 'dry', - 'duck', - 'dumb', - 'dune', - 'during', - 'dust', - 'dutch', - 'duty', - 'dwarf', - 'dynamic', - 'eager', - 'eagle', - 'early', - 'earn', - 'earth', - 'easily', - 'east', - 'easy', - 'echo', - 'ecology', - 'economy', - 'edge', - 'edit', - 'educate', - 'effort', - 'egg', - 'eight', - 'either', - 'elbow', - 'elder', - 'electric', - 'elegant', - 'element', - 'elephant', - 'elevator', - 'elite', - 'else', - 'embark', - 'embody', - 'embrace', - 'emerge', - 'emotion', - 'employ', - 'empower', - 'empty', - 'enable', - 'enact', - 'end', - 'endless', - 'endorse', - 'enemy', - 'energy', - 'enforce', - 'engage', - 'engine', - 'enhance', - 'enjoy', - 'enlist', - 'enough', - 'enrich', - 'enroll', - 'ensure', - 'enter', - 'entire', - 'entry', - 'envelope', - 'episode', - 'equal', - 'equip', - 'era', - 'erase', - 'erode', - 'erosion', - 'error', - 'erupt', - 'escape', - 'essay', - 'essence', - 'estate', - 'eternal', - 'ethics', - 'evidence', - 'evil', - 'evoke', - 'evolve', - 'exact', - 'example', - 'excess', - 'exchange', - 'excite', - 'exclude', - 'excuse', - 'execute', - 'exercise', - 'exhaust', - 'exhibit', - 'exile', - 'exist', - 'exit', - 'exotic', - 'expand', - 'expect', - 'expire', - 'explain', - 'expose', - 'express', - 'extend', - 'extra', - 'eye', - 'eyebrow', - 'fabric', - 'face', - 'faculty', - 'fade', - 'faint', - 'faith', - 'fall', - 'false', - 'fame', - 'family', - 'famous', - 'fan', - 'fancy', - 'fantasy', - 'farm', - 'fashion', - 'fat', - 'fatal', - 'father', - 'fatigue', - 'fault', - 'favorite', - 'feature', - 'february', - 'federal', - 'fee', - 'feed', - 'feel', - 'female', - 'fence', - 'festival', - 'fetch', - 'fever', - 'few', - 'fiber', - 'fiction', - 'field', - 'figure', - 'file', - 'film', - 'filter', - 'final', - 'find', - 'fine', - 'finger', - 'finish', - 'fire', - 'firm', - 'first', - 'fiscal', - 'fish', - 'fit', - 'fitness', - 'fix', - 'flag', - 'flame', - 'flash', - 'flat', - 'flavor', - 'flee', - 'flight', - 'flip', - 'float', - 'flock', - 'floor', - 'flower', - 'fluid', - 'flush', - 'fly', - 'foam', - 'focus', - 'fog', - 'foil', - 'fold', - 'follow', - 'food', - 'foot', - 'force', - 'forest', - 'forget', - 'fork', - 'fortune', - 'forum', - 'forward', - 'fossil', - 'foster', - 'found', - 'fox', - 'fragile', - 'frame', - 'frequent', - 'fresh', - 'friend', - 'fringe', - 'frog', - 'front', - 'frost', - 'frown', - 'frozen', - 'fruit', - 'fuel', - 'fun', - 'funny', - 'furnace', - 'fury', - 'future', - 'gadget', - 'gain', - 'galaxy', - 'gallery', - 'game', - 'gap', - 'garage', - 'garbage', - 'garden', - 'garlic', - 'garment', - 'gas', - 'gasp', - 'gate', - 'gather', - 'gauge', - 'gaze', - 'general', - 'genius', - 'genre', - 'gentle', - 'genuine', - 'gesture', - 'ghost', - 'giant', - 'gift', - 'giggle', - 'ginger', - 'giraffe', - 'girl', - 'give', - 'glad', - 'glance', - 'glare', - 'glass', - 'glide', - 'glimpse', - 'globe', - 'gloom', - 'glory', - 'glove', - 'glow', - 'glue', - 'goat', - 'goddess', - 'gold', - 'good', - 'goose', - 'gorilla', - 'gospel', - 'gossip', - 'govern', - 'gown', - 'grab', - 'grace', - 'grain', - 'grant', - 'grape', - 'grass', - 'gravity', - 'great', - 'green', - 'grid', - 'grief', - 'grit', - 'grocery', - 'group', - 'grow', - 'grunt', - 'guard', - 'guess', - 'guide', - 'guilt', - 'guitar', - 'gun', - 'gym', - 'habit', - 'hair', - 'half', - 'hammer', - 'hamster', - 'hand', - 'happy', - 'harbor', - 'hard', - 'harsh', - 'harvest', - 'hat', - 'have', - 'hawk', - 'hazard', - 'head', - 'health', - 'heart', - 'heavy', - 'hedgehog', - 'height', - 'hello', - 'helmet', - 'help', - 'hen', - 'hero', - 'hidden', - 'high', - 'hill', - 'hint', - 'hip', - 'hire', - 'history', - 'hobby', - 'hockey', - 'hold', - 'hole', - 'holiday', - 'hollow', - 'home', - 'honey', - 'hood', - 'hope', - 'horn', - 'horror', - 'horse', - 'hospital', - 'host', - 'hotel', - 'hour', - 'hover', - 'hub', - 'huge', - 'human', - 'humble', - 'humor', - 'hundred', - 'hungry', - 'hunt', - 'hurdle', - 'hurry', - 'hurt', - 'husband', - 'hybrid', - 'ice', - 'icon', - 'idea', - 'identify', - 'idle', - 'ignore', - 'ill', - 'illegal', - 'illness', - 'image', - 'imitate', - 'immense', - 'immune', - 'impact', - 'impose', - 'improve', - 'impulse', - 'inch', - 'include', - 'income', - 'increase', - 'index', - 'indicate', - 'indoor', - 'industry', - 'infant', - 'inflict', - 'inform', - 'inhale', - 'inherit', - 'initial', - 'inject', - 'injury', - 'inmate', - 'inner', - 'innocent', - 'input', - 'inquiry', - 'insane', - 'insect', - 'inside', - 'inspire', - 'install', - 'intact', - 'interest', - 'into', - 'invest', - 'invite', - 'involve', - 'iron', - 'island', - 'isolate', - 'issue', - 'item', - 'ivory', - 'jacket', - 'jaguar', - 'jar', - 'jazz', - 'jealous', - 'jeans', - 'jelly', - 'jewel', - 'job', - 'join', - 'joke', - 'journey', - 'joy', - 'judge', - 'juice', - 'jump', - 'jungle', - 'junior', - 'junk', - 'just', - 'kangaroo', - 'keen', - 'keep', - 'ketchup', - 'key', - 'kick', - 'kid', - 'kidney', - 'kind', - 'kingdom', - 'kiss', - 'kit', - 'kitchen', - 'kite', - 'kitten', - 'kiwi', - 'knee', - 'knife', - 'knock', - 'know', - 'lab', - 'label', - 'labor', - 'ladder', - 'lady', - 'lake', - 'lamp', - 'language', - 'laptop', - 'large', - 'later', - 'latin', - 'laugh', - 'laundry', - 'lava', - 'law', - 'lawn', - 'lawsuit', - 'layer', - 'lazy', - 'leader', - 'leaf', - 'learn', - 'leave', - 'lecture', - 'left', - 'leg', - 'legal', - 'legend', - 'leisure', - 'lemon', - 'lend', - 'length', - 'lens', - 'leopard', - 'lesson', - 'letter', - 'level', - 'liar', - 'liberty', - 'library', - 'license', - 'life', - 'lift', - 'light', - 'like', - 'limb', - 'limit', - 'link', - 'lion', - 'liquid', - 'list', - 'little', - 'live', - 'lizard', - 'load', - 'loan', - 'lobster', - 'local', - 'lock', - 'logic', - 'lonely', - 'long', - 'loop', - 'lottery', - 'loud', - 'lounge', - 'love', - 'loyal', - 'lucky', - 'luggage', - 'lumber', - 'lunar', - 'lunch', - 'luxury', - 'lyrics', - 'machine', - 'mad', - 'magic', - 'magnet', - 'maid', - 'mail', - 'main', - 'major', - 'make', - 'mammal', - 'man', - 'manage', - 'mandate', - 'mango', - 'mansion', - 'manual', - 'maple', - 'marble', - 'march', - 'margin', - 'marine', - 'market', - 'marriage', - 'mask', - 'mass', - 'master', - 'match', - 'material', - 'math', - 'matrix', - 'matter', - 'maximum', - 'maze', - 'meadow', - 'mean', - 'measure', - 'meat', - 'mechanic', - 'medal', - 'media', - 'melody', - 'melt', - 'member', - 'memory', - 'mention', - 'menu', - 'mercy', - 'merge', - 'merit', - 'merry', - 'mesh', - 'message', - 'metal', - 'method', - 'middle', - 'midnight', - 'milk', - 'million', - 'mimic', - 'mind', - 'minimum', - 'minor', - 'minute', - 'miracle', - 'mirror', - 'misery', - 'miss', - 'mistake', - 'mix', - 'mixed', - 'mixture', - 'mobile', - 'model', - 'modify', - 'mom', - 'moment', - 'monitor', - 'monkey', - 'monster', - 'month', - 'moon', - 'moral', - 'more', - 'morning', - 'mosquito', - 'mother', - 'motion', - 'motor', - 'mountain', - 'mouse', - 'move', - 'movie', - 'much', - 'muffin', - 'mule', - 'multiply', - 'muscle', - 'museum', - 'mushroom', - 'music', - 'must', - 'mutual', - 'myself', - 'mystery', - 'myth', - 'naive', - 'name', - 'napkin', - 'narrow', - 'nasty', - 'nation', - 'nature', - 'near', - 'neck', - 'need', - 'negative', - 'neglect', - 'neither', - 'nephew', - 'nerve', - 'nest', - 'net', - 'network', - 'neutral', - 'never', - 'news', - 'next', - 'nice', - 'night', - 'noble', - 'noise', - 'nominee', - 'noodle', - 'normal', - 'north', - 'nose', - 'notable', - 'note', - 'nothing', - 'notice', - 'novel', - 'now', - 'nuclear', - 'number', - 'nurse', - 'nut', - 'oak', - 'obey', - 'object', - 'oblige', - 'obscure', - 'observe', - 'obtain', - 'obvious', - 'occur', - 'ocean', - 'october', - 'odor', - 'off', - 'offer', - 'office', - 'often', - 'oil', - 'okay', - 'old', - 'olive', - 'olympic', - 'omit', - 'once', - 'one', - 'onion', - 'online', - 'only', - 'open', - 'opera', - 'opinion', - 'oppose', - 'option', - 'orange', - 'orbit', - 'orchard', - 'order', - 'ordinary', - 'organ', - 'orient', - 'original', - 'orphan', - 'ostrich', - 'other', - 'outdoor', - 'outer', - 'output', - 'outside', - 'oval', - 'oven', - 'over', - 'own', - 'owner', - 'oxygen', - 'oyster', - 'ozone', - 'pact', - 'paddle', - 'page', - 'pair', - 'palace', - 'palm', - 'panda', - 'panel', - 'panic', - 'panther', - 'paper', - 'parade', - 'parent', - 'park', - 'parrot', - 'party', - 'pass', - 'patch', - 'path', - 'patient', - 'patrol', - 'pattern', - 'pause', - 'pave', - 'payment', - 'peace', - 'peanut', - 'pear', - 'peasant', - 'pelican', - 'pen', - 'penalty', - 'pencil', - 'people', - 'pepper', - 'perfect', - 'permit', - 'person', - 'pet', - 'phone', - 'photo', - 'phrase', - 'physical', - 'piano', - 'picnic', - 'picture', - 'piece', - 'pig', - 'pigeon', - 'pill', - 'pilot', - 'pink', - 'pioneer', - 'pipe', - 'pistol', - 'pitch', - 'pizza', - 'place', - 'planet', - 'plastic', - 'plate', - 'play', - 'please', - 'pledge', - 'pluck', - 'plug', - 'plunge', - 'poem', - 'poet', - 'point', - 'polar', - 'pole', - 'police', - 'pond', - 'pony', - 'pool', - 'popular', - 'portion', - 'position', - 'possible', - 'post', - 'potato', - 'pottery', - 'poverty', - 'powder', - 'power', - 'practice', - 'praise', - 'predict', - 'prefer', - 'prepare', - 'present', - 'pretty', - 'prevent', - 'price', - 'pride', - 'primary', - 'print', - 'priority', - 'prison', - 'private', - 'prize', - 'problem', - 'process', - 'produce', - 'profit', - 'program', - 'project', - 'promote', - 'proof', - 'property', - 'prosper', - 'protect', - 'proud', - 'provide', - 'public', - 'pudding', - 'pull', - 'pulp', - 'pulse', - 'pumpkin', - 'punch', - 'pupil', - 'puppy', - 'purchase', - 'purity', - 'purpose', - 'purse', - 'push', - 'put', - 'puzzle', - 'pyramid', - 'quality', - 'quantum', - 'quarter', - 'question', - 'quick', - 'quit', - 'quiz', - 'quote', - 'rabbit', - 'raccoon', - 'race', - 'rack', - 'radar', - 'radio', - 'rail', - 'rain', - 'raise', - 'rally', - 'ramp', - 'ranch', - 'random', - 'range', - 'rapid', - 'rare', - 'rate', - 'rather', - 'raven', - 'raw', - 'razor', - 'ready', - 'real', - 'reason', - 'rebel', - 'rebuild', - 'recall', - 'receive', - 'recipe', - 'record', - 'recycle', - 'reduce', - 'reflect', - 'reform', - 'refuse', - 'region', - 'regret', - 'regular', - 'reject', - 'relax', - 'release', - 'relief', - 'rely', - 'remain', - 'remember', - 'remind', - 'remove', - 'render', - 'renew', - 'rent', - 'reopen', - 'repair', - 'repeat', - 'replace', - 'report', - 'require', - 'rescue', - 'resemble', - 'resist', - 'resource', - 'response', - 'result', - 'retire', - 'retreat', - 'return', - 'reunion', - 'reveal', - 'review', - 'reward', - 'rhythm', - 'rib', - 'ribbon', - 'rice', - 'rich', - 'ride', - 'ridge', - 'rifle', - 'right', - 'rigid', - 'ring', - 'riot', - 'ripple', - 'risk', - 'ritual', - 'rival', - 'river', - 'road', - 'roast', - 'robot', - 'robust', - 'rocket', - 'romance', - 'roof', - 'rookie', - 'room', - 'rose', - 'rotate', - 'rough', - 'round', - 'route', - 'royal', - 'rubber', - 'rude', - 'rug', - 'rule', - 'run', - 'runway', - 'rural', - 'sad', - 'saddle', - 'sadness', - 'safe', - 'sail', - 'salad', - 'salmon', - 'salon', - 'salt', - 'salute', - 'same', - 'sample', - 'sand', - 'satisfy', - 'satoshi', - 'sauce', - 'sausage', - 'save', - 'say', - 'scale', - 'scan', - 'scare', - 'scatter', - 'scene', - 'scheme', - 'school', - 'science', - 'scissors', - 'scorpion', - 'scout', - 'scrap', - 'screen', - 'script', - 'scrub', - 'sea', - 'search', - 'season', - 'seat', - 'second', - 'secret', - 'section', - 'security', - 'seed', - 'seek', - 'segment', - 'select', - 'sell', - 'seminar', - 'senior', - 'sense', - 'sentence', - 'series', - 'service', - 'session', - 'settle', - 'setup', - 'seven', - 'shadow', - 'shaft', - 'shallow', - 'share', - 'shed', - 'shell', - 'sheriff', - 'shield', - 'shift', - 'shine', - 'ship', - 'shiver', - 'shock', - 'shoe', - 'shoot', - 'shop', - 'short', - 'shoulder', - 'shove', - 'shrimp', - 'shrug', - 'shuffle', - 'shy', - 'sibling', - 'sick', - 'side', - 'siege', - 'sight', - 'sign', - 'silent', - 'silk', - 'silly', - 'silver', - 'similar', - 'simple', - 'since', - 'sing', - 'siren', - 'sister', - 'situate', - 'six', - 'size', - 'skate', - 'sketch', - 'ski', - 'skill', - 'skin', - 'skirt', - 'skull', - 'slab', - 'slam', - 'sleep', - 'slender', - 'slice', - 'slide', - 'slight', - 'slim', - 'slogan', - 'slot', - 'slow', - 'slush', - 'small', - 'smart', - 'smile', - 'smoke', - 'smooth', - 'snack', - 'snake', - 'snap', - 'sniff', - 'snow', - 'soap', - 'soccer', - 'social', - 'sock', - 'soda', - 'soft', - 'solar', - 'soldier', - 'solid', - 'solution', - 'solve', - 'someone', - 'song', - 'soon', - 'sorry', - 'sort', - 'soul', - 'sound', - 'soup', - 'source', - 'south', - 'space', - 'spare', - 'spatial', - 'spawn', - 'speak', - 'special', - 'speed', - 'spell', - 'spend', - 'sphere', - 'spice', - 'spider', - 'spike', - 'spin', - 'spirit', - 'split', - 'spoil', - 'sponsor', - 'spoon', - 'sport', - 'spot', - 'spray', - 'spread', - 'spring', - 'spy', - 'square', - 'squeeze', - 'squirrel', - 'stable', - 'stadium', - 'staff', - 'stage', - 'stairs', - 'stamp', - 'stand', - 'start', - 'state', - 'stay', - 'steak', - 'steel', - 'stem', - 'step', - 'stereo', - 'stick', - 'still', - 'sting', - 'stock', - 'stomach', - 'stone', - 'stool', - 'story', - 'stove', - 'strategy', - 'street', - 'strike', - 'strong', - 'struggle', - 'student', - 'stuff', - 'stumble', - 'style', - 'subject', - 'submit', - 'subway', - 'success', - 'such', - 'sudden', - 'suffer', - 'sugar', - 'suggest', - 'suit', - 'summer', - 'sun', - 'sunny', - 'sunset', - 'super', - 'supply', - 'supreme', - 'sure', - 'surface', - 'surge', - 'surprise', - 'surround', - 'survey', - 'suspect', - 'sustain', - 'swallow', - 'swamp', - 'swap', - 'swarm', - 'swear', - 'sweet', - 'swift', - 'swim', - 'swing', - 'switch', - 'sword', - 'symbol', - 'symptom', - 'syrup', - 'system', - 'table', - 'tackle', - 'tag', - 'tail', - 'talent', - 'talk', - 'tank', - 'tape', - 'target', - 'task', - 'taste', - 'tattoo', - 'taxi', - 'teach', - 'team', - 'tell', - 'ten', - 'tenant', - 'tennis', - 'tent', - 'term', - 'test', - 'text', - 'thank', - 'that', - 'theme', - 'then', - 'theory', - 'there', - 'they', - 'thing', - 'this', - 'thought', - 'three', - 'thrive', - 'throw', - 'thumb', - 'thunder', - 'ticket', - 'tide', - 'tiger', - 'tilt', - 'timber', - 'time', - 'tiny', - 'tip', - 'tired', - 'tissue', - 'title', - 'toast', - 'tobacco', - 'today', - 'toddler', - 'toe', - 'together', - 'toilet', - 'token', - 'tomato', - 'tomorrow', - 'tone', - 'tongue', - 'tonight', - 'tool', - 'tooth', - 'top', - 'topic', - 'topple', - 'torch', - 'tornado', - 'tortoise', - 'toss', - 'total', - 'tourist', - 'toward', - 'tower', - 'town', - 'toy', - 'track', - 'trade', - 'traffic', - 'tragic', - 'train', - 'transfer', - 'trap', - 'trash', - 'travel', - 'tray', - 'treat', - 'tree', - 'trend', - 'trial', - 'tribe', - 'trick', - 'trigger', - 'trim', - 'trip', - 'trophy', - 'trouble', - 'truck', - 'true', - 'truly', - 'trumpet', - 'trust', - 'truth', - 'try', - 'tube', - 'tuition', - 'tumble', - 'tuna', - 'tunnel', - 'turkey', - 'turn', - 'turtle', - 'twelve', - 'twenty', - 'twice', - 'twin', - 'twist', - 'two', - 'type', - 'typical', - 'ugly', - 'umbrella', - 'unable', - 'unaware', - 'uncle', - 'uncover', - 'under', - 'undo', - 'unfair', - 'unfold', - 'unhappy', - 'uniform', - 'unique', - 'unit', - 'universe', - 'unknown', - 'unlock', - 'until', - 'unusual', - 'unveil', - 'update', - 'upgrade', - 'uphold', - 'upon', - 'upper', - 'upset', - 'urban', - 'urge', - 'usage', - 'use', - 'used', - 'useful', - 'useless', - 'usual', - 'utility', - 'vacant', - 'vacuum', - 'vague', - 'valid', - 'valley', - 'valve', - 'van', - 'vanish', - 'vapor', - 'various', - 'vast', - 'vault', - 'vehicle', - 'velvet', - 'vendor', - 'venture', - 'venue', - 'verb', - 'verify', - 'version', - 'very', - 'vessel', - 'veteran', - 'viable', - 'vibrant', - 'vicious', - 'victory', - 'video', - 'view', - 'village', - 'vintage', - 'violin', - 'virtual', - 'virus', - 'visa', - 'visit', - 'visual', - 'vital', - 'vivid', - 'vocal', - 'voice', - 'void', - 'volcano', - 'volume', - 'vote', - 'voyage', - 'wage', - 'wagon', - 'wait', - 'walk', - 'wall', - 'walnut', - 'want', - 'warfare', - 'warm', - 'warrior', - 'wash', - 'wasp', - 'waste', - 'water', - 'wave', - 'way', - 'wealth', - 'weapon', - 'wear', - 'weasel', - 'weather', - 'web', - 'wedding', - 'weekend', - 'weird', - 'welcome', - 'west', - 'wet', - 'whale', - 'what', - 'wheat', - 'wheel', - 'when', - 'where', - 'whip', - 'whisper', - 'wide', - 'width', - 'wife', - 'wild', - 'will', - 'win', - 'window', - 'wine', - 'wing', - 'wink', - 'winner', - 'winter', - 'wire', - 'wisdom', - 'wise', - 'wish', - 'witness', - 'wolf', - 'woman', - 'wonder', - 'wood', - 'wool', - 'word', - 'work', - 'world', - 'worry', - 'worth', - 'wrap', - 'wreck', - 'wrestle', - 'wrist', - 'write', - 'wrong', - 'yard', - 'year', - 'yellow', - 'you', - 'young', - 'youth', - 'zebra', - 'zero', - 'zone', - 'zoo', -]; diff --git a/blue_modules/aezeed/src/params.d.ts b/blue_modules/aezeed/src/params.d.ts deleted file mode 100644 index 9f7aefcadd..0000000000 --- a/blue_modules/aezeed/src/params.d.ts +++ /dev/null @@ -1,8 +0,0 @@ -export declare const PARAMS: { - n: number; - r: number; - p: number; -}[]; -export declare const DEFAULT_PASSWORD = "aezeed"; -export declare const CIPHER_SEED_VERSION = 0; -export declare const ONE_DAY: number; diff --git a/blue_modules/aezeed/src/params.js b/blue_modules/aezeed/src/params.js deleted file mode 100644 index b7eca893f8..0000000000 --- a/blue_modules/aezeed/src/params.js +++ /dev/null @@ -1,14 +0,0 @@ -"use strict"; -Object.defineProperty(exports, "__esModule", { value: true }); -exports.ONE_DAY = exports.CIPHER_SEED_VERSION = exports.DEFAULT_PASSWORD = exports.PARAMS = void 0; -exports.PARAMS = [ - { - // version 0 - n: 32768, - r: 8, - p: 1, - }, -]; -exports.DEFAULT_PASSWORD = 'aezeed'; -exports.CIPHER_SEED_VERSION = 0; -exports.ONE_DAY = 24 * 60 * 60 * 1000; diff --git a/blue_modules/analytics.js b/blue_modules/analytics.js deleted file mode 100644 index 0cd2ae61b9..0000000000 --- a/blue_modules/analytics.js +++ /dev/null @@ -1,44 +0,0 @@ -import { getUniqueId } from 'react-native-device-info'; -import Bugsnag from '@bugsnag/react-native'; -const BlueApp = require('../BlueApp'); - -let userHasOptedOut = false; - -if (process.env.NODE_ENV !== 'development') { - Bugsnag.start({ - collectUserIp: false, - user: { - id: getUniqueId(), - }, - onError: function (event) { - return !userHasOptedOut; - }, - }); -} - -BlueApp.isDoNotTrackEnabled().then(value => { - if (value) userHasOptedOut = true; -}); - -const A = async event => {}; - -A.ENUM = { - INIT: 'INIT', - GOT_NONZERO_BALANCE: 'GOT_NONZERO_BALANCE', - GOT_ZERO_BALANCE: 'GOT_ZERO_BALANCE', - CREATED_WALLET: 'CREATED_WALLET', - CREATED_LIGHTNING_WALLET: 'CREATED_LIGHTNING_WALLET', - APP_UNSUSPENDED: 'APP_UNSUSPENDED', - NAVIGATED_TO_WALLETS_HODLHODL: 'NAVIGATED_TO_WALLETS_HODLHODL', -}; - -A.setOptOut = value => { - if (value) userHasOptedOut = true; -}; - -A.logError = errorString => { - console.error(errorString); - Bugsnag.notify(new Error(String(errorString))); -}; - -module.exports = A; diff --git a/blue_modules/analytics.ts b/blue_modules/analytics.ts new file mode 100644 index 0000000000..5da0ac7d93 --- /dev/null +++ b/blue_modules/analytics.ts @@ -0,0 +1,54 @@ +import Bugsnag from '@bugsnag/react-native'; +import { getUniqueId } from 'react-native-device-info'; + +import { BlueApp as BlueAppClass } from '../class'; + +const BlueApp = BlueAppClass.getInstance(); + +/** + * in case Bugsnag was started, but user decided to opt out while using the app, we have this + * flag `userHasOptedOut` and we forbid logging in `onError` handler + * @type {boolean} + */ +let userHasOptedOut: boolean = false; + +(async () => { + const uniqueID = await getUniqueId(); + const doNotTrack = await BlueApp.isDoNotTrackEnabled(); + + if (doNotTrack) { + // dont start Bugsnag at all + return; + } + + Bugsnag.start({ + user: { + id: uniqueID, + }, + onError: function (event) { + return !userHasOptedOut; + }, + }); +})(); + +const A = async (event: string) => {}; + +A.ENUM = { + INIT: 'INIT', + GOT_NONZERO_BALANCE: 'GOT_NONZERO_BALANCE', + GOT_ZERO_BALANCE: 'GOT_ZERO_BALANCE', + CREATED_WALLET: 'CREATED_WALLET', + CREATED_LIGHTNING_WALLET: 'CREATED_LIGHTNING_WALLET', + APP_UNSUSPENDED: 'APP_UNSUSPENDED', +}; + +A.setOptOut = (value: boolean) => { + if (value) userHasOptedOut = true; +}; + +A.logError = (errorString: string) => { + console.error(errorString); + Bugsnag.notify(new Error(String(errorString))); +}; + +export default A; diff --git a/blue_modules/base43.js b/blue_modules/base43.js deleted file mode 100644 index 7bbaa8dd7f..0000000000 --- a/blue_modules/base43.js +++ /dev/null @@ -1,14 +0,0 @@ -const base = require('base-x'); - -const Base43 = { - encode: function () { - throw new Error('not implemented'); - }, - - decode: function (input) { - const x = base('0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZ$*+-./:'); - return x.decode(input).toString('hex'); - }, -}; - -module.exports = Base43; diff --git a/blue_modules/base43.ts b/blue_modules/base43.ts new file mode 100644 index 0000000000..1fe78ba08a --- /dev/null +++ b/blue_modules/base43.ts @@ -0,0 +1,15 @@ +import base from 'base-x'; + +const Base43 = { + encode: function () { + throw new Error('not implemented'); + }, + + decode: function (input: string): string { + const x = base('0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZ$*+-./:'); + const uint8 = x.decode(input); + return Buffer.from(uint8).toString('hex'); + }, +}; + +export default Base43; diff --git a/blue_modules/checksumWords.js b/blue_modules/checksumWords.js new file mode 100644 index 0000000000..f01c216b97 --- /dev/null +++ b/blue_modules/checksumWords.js @@ -0,0 +1,79 @@ +import * as bip39 from 'bip39'; +import createHash from 'create-hash'; + +// partial (11 or 23 word) seed phrase +export function generateChecksumWords(stringSeedPhrase) { + const seedPhrase = stringSeedPhrase.toLowerCase().trim().split(' '); + + if ((seedPhrase.length + 1) % 3 > 0) { + return false; // Partial mnemonic size must be multiple of three words, less one. + } + + const wordList = bip39.wordlists[bip39.getDefaultWordlist()]; + + const concatLenBits = seedPhrase.length * 11; + const concatBits = new Array(concatLenBits); + let wordindex = 0; + for (let i = 0; i < seedPhrase.length; i++) { + const word = seedPhrase[i]; + const ndx = wordList.indexOf(word.toLowerCase()); + if (ndx === -1) return false; + // Set the next 11 bits to the value of the index. + for (let ii = 0; ii < 11; ++ii) { + concatBits[wordindex * 11 + ii] = (ndx & (1 << (10 - ii))) !== 0; // eslint-disable-line no-bitwise + } + ++wordindex; + } + + const checksumLengthBits = (concatLenBits + 11) / 33; + const entropyLengthBits = concatLenBits + 11 - checksumLengthBits; + const varyingLengthBits = entropyLengthBits - concatLenBits; + const numPermutations = 2 ** varyingLengthBits; + + const bitPermutations = new Array(numPermutations); + + for (let i = 0; i < numPermutations; i++) { + if (bitPermutations[i] === undefined || bitPermutations[i] === null) bitPermutations[i] = new Array(varyingLengthBits); + for (let j = 0; j < varyingLengthBits; j++) { + bitPermutations[i][j] = ((i >> j) & 1) === 1; // eslint-disable-line no-bitwise + } + } + + const possibleWords = []; + for (let i = 0; i < bitPermutations.length; i++) { + const bitPermutation = bitPermutations[i]; + const entropyBits = new Array(concatLenBits + varyingLengthBits); + entropyBits.splice(0, 0, ...concatBits); + entropyBits.splice(concatBits.length, 0, ...bitPermutation.slice(0, varyingLengthBits)); + + const entropy = new Array(entropyLengthBits / 8); + for (let ii = 0; ii < entropy.length; ++ii) { + for (let jj = 0; jj < 8; ++jj) { + if (entropyBits[ii * 8 + jj]) { + entropy[ii] |= 1 << (7 - jj); // eslint-disable-line no-bitwise + } + } + } + + const hash = createHash('sha256').update(Buffer.from(entropy)).digest(); + + const hashBits = new Array(hash.length * 8); + for (let iq = 0; iq < hash.length; ++iq) for (let jq = 0; jq < 8; ++jq) hashBits[iq * 8 + jq] = (hash[iq] & (1 << (7 - jq))) !== 0; // eslint-disable-line no-bitwise + + const wordBits = new Array(11); + wordBits.splice(0, 0, ...bitPermutation.slice(0, varyingLengthBits)); + wordBits.splice(varyingLengthBits, 0, ...hashBits.slice(0, checksumLengthBits)); + + let index = 0; + for (let j = 0; j < 11; ++j) { + index <<= 1; // eslint-disable-line no-bitwise + if (wordBits[j]) { + index |= 0x1; // eslint-disable-line no-bitwise + } + } + + possibleWords.push(wordList[index]); + } + + return possibleWords; +} diff --git a/blue_modules/clipboard.ts b/blue_modules/clipboard.ts index 8a8994834f..e26409d7c8 100644 --- a/blue_modules/clipboard.ts +++ b/blue_modules/clipboard.ts @@ -1,42 +1,40 @@ -import { useAsyncStorage } from '@react-native-async-storage/async-storage'; +import AsyncStorage from '@react-native-async-storage/async-storage'; import Clipboard from '@react-native-clipboard/clipboard'; -const BlueClipboard = () => { - const STORAGE_KEY = 'ClipboardReadAllowed'; - const { getItem, setItem } = useAsyncStorage(STORAGE_KEY); +const STORAGE_KEY: string = 'ClipboardReadAllowed'; - const isReadClipboardAllowed = async () => { - try { - const clipboardAccessAllowed = await getItem(); - if (clipboardAccessAllowed === null) { - await setItem(JSON.stringify(true)); - return true; - } - return !!JSON.parse(clipboardAccessAllowed); - } catch { - await setItem(JSON.stringify(true)); +export const isReadClipboardAllowed = async (): Promise => { + try { + const clipboardAccessAllowed = await AsyncStorage.getItem(STORAGE_KEY); + if (clipboardAccessAllowed === null) { + await AsyncStorage.setItem(STORAGE_KEY, JSON.stringify(true)); return true; } - }; + return !!JSON.parse(clipboardAccessAllowed); + } catch { + await AsyncStorage.setItem(STORAGE_KEY, JSON.stringify(true)); + return true; + } +}; - const setReadClipboardAllowed = (value: boolean) => { - setItem(JSON.stringify(!!value)); - }; +export const setReadClipboardAllowed = async (value: boolean): Promise => { + try { + await AsyncStorage.setItem(STORAGE_KEY, JSON.stringify(Boolean(value))); + } catch (error) { + console.error('Failed to set clipboard permission:', error); + throw error; + } +}; - const getClipboardContent = async () => { +export const getClipboardContent = async (): Promise => { + try { const isAllowed = await isReadClipboardAllowed(); - if (isAllowed) { - return Clipboard.getString(); - } else { - return ''; - } - }; + if (!isAllowed) return undefined; - return { - isReadClipboardAllowed, - setReadClipboardAllowed, - getClipboardContent, - }; + const hasString = await Clipboard.hasString(); + return hasString ? await Clipboard.getString() : undefined; + } catch (error) { + console.error('Error accessing clipboard:', error); + return undefined; + } }; - -export default BlueClipboard; diff --git a/blue_modules/constants.js b/blue_modules/constants.js deleted file mode 100644 index 3066fb6b3d..0000000000 --- a/blue_modules/constants.js +++ /dev/null @@ -1,5 +0,0 @@ -/** - * Let's keep config vars, constants and definitions here - */ - -export const groundControlUri = 'https://groundcontrol-bluewallet.herokuapp.com/'; diff --git a/blue_modules/constants.ts b/blue_modules/constants.ts new file mode 100644 index 0000000000..ef45d194d3 --- /dev/null +++ b/blue_modules/constants.ts @@ -0,0 +1,5 @@ +/** + * Let's keep config vars, constants and definitions here + */ + +export const groundControlUri: string = 'https://groundcontrol-bluewallet.herokuapp.com'; diff --git a/blue_modules/currency.js b/blue_modules/currency.js deleted file mode 100644 index 7c3424a43e..0000000000 --- a/blue_modules/currency.js +++ /dev/null @@ -1,260 +0,0 @@ -import AsyncStorage from '@react-native-async-storage/async-storage'; -import DefaultPreference from 'react-native-default-preference'; -import * as RNLocalize from 'react-native-localize'; -import BigNumber from 'bignumber.js'; -import { FiatUnit, getFiatRate } from '../models/fiatUnit'; -import WidgetCommunication from './WidgetCommunication'; - -const PREFERRED_CURRENCY_STORAGE_KEY = 'preferredCurrency'; -const EXCHANGE_RATES_STORAGE_KEY = 'currency'; - -let preferredFiatCurrency = FiatUnit.USD; -let exchangeRates = { LAST_UPDATED_ERROR: false }; -let lastTimeUpdateExchangeRateWasCalled = 0; -let skipUpdateExchangeRate = false; - -const LAST_UPDATED = 'LAST_UPDATED'; - -/** - * Saves to storage preferred currency, whole object - * from `./models/fiatUnit` - * - * @param item {Object} one of the values in `./models/fiatUnit` - * @returns {Promise} - */ -async function setPrefferedCurrency(item) { - await AsyncStorage.setItem(PREFERRED_CURRENCY_STORAGE_KEY, JSON.stringify(item)); - await DefaultPreference.setName('group.io.bluewallet.bluewallet'); - await DefaultPreference.set('preferredCurrency', item.endPointKey); - await DefaultPreference.set('preferredCurrencyLocale', item.locale.replace('-', '_')); - WidgetCommunication.reloadAllTimelines(); -} - -async function getPreferredCurrency() { - const preferredCurrency = await JSON.parse(await AsyncStorage.getItem(PREFERRED_CURRENCY_STORAGE_KEY)); - await DefaultPreference.setName('group.io.bluewallet.bluewallet'); - await DefaultPreference.set('preferredCurrency', preferredCurrency.endPointKey); - await DefaultPreference.set('preferredCurrencyLocale', preferredCurrency.locale.replace('-', '_')); - return preferredCurrency; -} - -async function _restoreSavedExchangeRatesFromStorage() { - try { - exchangeRates = JSON.parse(await AsyncStorage.getItem(EXCHANGE_RATES_STORAGE_KEY)); - if (!exchangeRates) exchangeRates = { LAST_UPDATED_ERROR: false }; - } catch (_) { - exchangeRates = { LAST_UPDATED_ERROR: false }; - } -} - -async function _restoreSavedPreferredFiatCurrencyFromStorage() { - try { - preferredFiatCurrency = JSON.parse(await AsyncStorage.getItem(PREFERRED_CURRENCY_STORAGE_KEY)); - if (preferredFiatCurrency === null) { - throw Error('No Preferred Fiat selected'); - } - - preferredFiatCurrency = FiatUnit[preferredFiatCurrency.endPointKey] || preferredFiatCurrency; - // ^^^ in case configuration in json file changed (and is different from what we stored) we reload it - } catch (_) { - const deviceCurrencies = RNLocalize.getCurrencies(); - if (Object.keys(FiatUnit).some(unit => unit === deviceCurrencies[0])) { - preferredFiatCurrency = FiatUnit[deviceCurrencies[0]]; - } else { - preferredFiatCurrency = FiatUnit.USD; - } - } -} - -/** - * actual function to reach api and get fresh currency exchange rate. checks LAST_UPDATED time and skips entirely - * if called too soon (30min); saves exchange rate (with LAST_UPDATED info) to storage. - * should be called when app thinks its a good time to refresh exchange rate - * - * @return {Promise} - */ -async function updateExchangeRate() { - if (skipUpdateExchangeRate) return; - if (+new Date() - lastTimeUpdateExchangeRateWasCalled <= 10 * 1000) { - // simple debounce so theres no race conditions - return; - } - lastTimeUpdateExchangeRateWasCalled = +new Date(); - - if (+new Date() - exchangeRates[LAST_UPDATED] <= 30 * 60 * 1000) { - // not updating too often - return; - } - console.log('updating exchange rate...'); - - let rate; - try { - rate = await getFiatRate(preferredFiatCurrency.endPointKey); - exchangeRates[LAST_UPDATED] = +new Date(); - exchangeRates['BTC_' + preferredFiatCurrency.endPointKey] = rate; - exchangeRates.LAST_UPDATED_ERROR = false; - await AsyncStorage.setItem(EXCHANGE_RATES_STORAGE_KEY, JSON.stringify(exchangeRates)); - } catch (Err) { - console.log('Error encountered when attempting to update exchange rate...'); - console.warn(Err.message); - rate = JSON.parse(await AsyncStorage.getItem(EXCHANGE_RATES_STORAGE_KEY)); - rate.LAST_UPDATED_ERROR = true; - exchangeRates.LAST_UPDATED_ERROR = true; - await AsyncStorage.setItem(EXCHANGE_RATES_STORAGE_KEY, JSON.stringify(rate)); - throw Err; - } -} - -async function isRateOutdated() { - try { - const rate = JSON.parse(await AsyncStorage.getItem(EXCHANGE_RATES_STORAGE_KEY)); - return rate.LAST_UPDATED_ERROR || +new Date() - rate.LAST_UPDATED >= 31 * 60 * 1000; - } catch { - return true; - } -} - -/** - * this function reads storage and restores current preferred fiat currency & last saved exchange rate, then calls - * updateExchangeRate() to update rates. - * should be called when the app starts and when user changes preferred fiat (with TRUE argument so underlying - * `updateExchangeRate()` would actually update rates via api). - * - * @param clearLastUpdatedTime {boolean} set to TRUE for the underlying - * - * @return {Promise} - */ -async function init(clearLastUpdatedTime = false) { - await _restoreSavedExchangeRatesFromStorage(); - await _restoreSavedPreferredFiatCurrencyFromStorage(); - - if (clearLastUpdatedTime) { - exchangeRates[LAST_UPDATED] = 0; - lastTimeUpdateExchangeRateWasCalled = 0; - } - - return updateExchangeRate(); -} - -function satoshiToLocalCurrency(satoshi, format = true) { - if (!exchangeRates['BTC_' + preferredFiatCurrency.endPointKey]) { - updateExchangeRate(); - return '...'; - } - - let b = new BigNumber(satoshi).dividedBy(100000000).multipliedBy(exchangeRates['BTC_' + preferredFiatCurrency.endPointKey]); - - if (b.isGreaterThanOrEqualTo(0.005) || b.isLessThanOrEqualTo(-0.005)) { - b = b.toFixed(2); - } else { - b = b.toPrecision(2); - } - - if (format === false) return b; - - let formatter; - try { - formatter = new Intl.NumberFormat(preferredFiatCurrency.locale, { - style: 'currency', - currency: preferredFiatCurrency.endPointKey, - minimumFractionDigits: 2, - maximumFractionDigits: 8, - }); - } catch (error) { - console.warn(error); - console.log(error); - formatter = new Intl.NumberFormat(FiatUnit.USD.locale, { - style: 'currency', - currency: preferredFiatCurrency.endPointKey, - minimumFractionDigits: 2, - maximumFractionDigits: 8, - }); - } - - return formatter.format(b); -} - -function BTCToLocalCurrency(bitcoin) { - let sat = new BigNumber(bitcoin); - sat = sat.multipliedBy(100000000).toNumber(); - - return satoshiToLocalCurrency(sat); -} - -async function mostRecentFetchedRate() { - const currencyInformation = JSON.parse(await AsyncStorage.getItem(EXCHANGE_RATES_STORAGE_KEY)); - - const formatter = new Intl.NumberFormat(preferredFiatCurrency.locale, { - style: 'currency', - currency: preferredFiatCurrency.endPointKey, - }); - return { - LastUpdated: currencyInformation[LAST_UPDATED], - Rate: formatter.format(currencyInformation[`BTC_${preferredFiatCurrency.endPointKey}`]), - }; -} - -function satoshiToBTC(satoshi) { - let b = new BigNumber(satoshi); - b = b.dividedBy(100000000); - return b.toString(10); -} - -function btcToSatoshi(btc) { - return new BigNumber(btc).multipliedBy(100000000).toNumber(); -} - -function fiatToBTC(fiatFloat) { - let b = new BigNumber(fiatFloat); - b = b.dividedBy(exchangeRates['BTC_' + preferredFiatCurrency.endPointKey]).toFixed(8); - return b; -} - -function getCurrencySymbol() { - return preferredFiatCurrency.symbol; -} - -/** - * Used to mock data in tests - * - * @param {object} currency, one of FiatUnit.* - */ -function _setPreferredFiatCurrency(currency) { - preferredFiatCurrency = currency; -} - -/** - * Used to mock data in tests - * - * @param {string} pair as expected by rest of this module, e.g 'BTC_JPY' or 'BTC_USD' - * @param {number} rate exchange rate - */ -function _setExchangeRate(pair, rate) { - exchangeRates[pair] = rate; -} - -/** - * Used in unit tests, so the `currency` module wont launch actual http request - */ -function _setSkipUpdateExchangeRate() { - skipUpdateExchangeRate = true; -} - -module.exports.updateExchangeRate = updateExchangeRate; -module.exports.init = init; -module.exports.satoshiToLocalCurrency = satoshiToLocalCurrency; -module.exports.fiatToBTC = fiatToBTC; -module.exports.satoshiToBTC = satoshiToBTC; -module.exports.BTCToLocalCurrency = BTCToLocalCurrency; -module.exports.setPrefferedCurrency = setPrefferedCurrency; -module.exports.getPreferredCurrency = getPreferredCurrency; -module.exports.btcToSatoshi = btcToSatoshi; -module.exports.getCurrencySymbol = getCurrencySymbol; -module.exports._setPreferredFiatCurrency = _setPreferredFiatCurrency; // export it to mock data in tests -module.exports._setExchangeRate = _setExchangeRate; // export it to mock data in tests -module.exports._setSkipUpdateExchangeRate = _setSkipUpdateExchangeRate; // export it to mock data in tests -module.exports.PREFERRED_CURRENCY = PREFERRED_CURRENCY_STORAGE_KEY; -module.exports.EXCHANGE_RATES = EXCHANGE_RATES_STORAGE_KEY; -module.exports.LAST_UPDATED = LAST_UPDATED; -module.exports.mostRecentFetchedRate = mostRecentFetchedRate; -module.exports.isRateOutdated = isRateOutdated; diff --git a/blue_modules/currency.ts b/blue_modules/currency.ts new file mode 100644 index 0000000000..acdd3ba158 --- /dev/null +++ b/blue_modules/currency.ts @@ -0,0 +1,402 @@ +import BigNumber from 'bignumber.js'; +import DefaultPreference from 'react-native-default-preference'; +import * as RNLocalize from 'react-native-localize'; + +import { FiatUnit, FiatUnitType, getFiatRate } from '../models/fiatUnit'; + +const PREFERRED_CURRENCY_STORAGE_KEY = 'preferredCurrency'; +const PREFERRED_CURRENCY_LOCALE_STORAGE_KEY = 'preferredCurrencyLocale'; +const EXCHANGE_RATES_STORAGE_KEY = 'exchangeRates'; +const LAST_UPDATED = 'LAST_UPDATED'; +export const GROUP_IO_BLUEWALLET = 'group.io.bluewallet.bluewallet'; +const BTC_PREFIX = 'BTC_'; + +export interface CurrencyRate { + LastUpdated: Date | null; + Rate: number | string | null; +} + +interface ExchangeRates { + [key: string]: number | boolean | undefined; + LAST_UPDATED_ERROR: boolean; +} + +let preferredFiatCurrency: FiatUnitType = FiatUnit.USD; +let exchangeRates: ExchangeRates = { LAST_UPDATED_ERROR: false }; +let lastTimeUpdateExchangeRateWasCalled: number = 0; +let skipUpdateExchangeRate: boolean = false; + +let currencyFormatter: Intl.NumberFormat | null = null; + +function getCurrencyFormatter(): Intl.NumberFormat { + if ( + !currencyFormatter || + currencyFormatter.resolvedOptions().locale !== preferredFiatCurrency.locale || + currencyFormatter.resolvedOptions().currency !== preferredFiatCurrency.endPointKey + ) { + currencyFormatter = new Intl.NumberFormat(preferredFiatCurrency.locale, { + style: 'currency', + currency: preferredFiatCurrency.endPointKey, + minimumFractionDigits: 2, + maximumFractionDigits: 8, + }); + console.debug('Created new currency formatter for: ', preferredFiatCurrency); + } + return currencyFormatter; +} + +async function setPreferredCurrency(item: FiatUnitType): Promise { + await DefaultPreference.setName(GROUP_IO_BLUEWALLET); + try { + await DefaultPreference.set(PREFERRED_CURRENCY_STORAGE_KEY, item.endPointKey); + await DefaultPreference.set(PREFERRED_CURRENCY_LOCALE_STORAGE_KEY, item.locale.replace('-', '_')); + preferredFiatCurrency = FiatUnit[item.endPointKey]; + currencyFormatter = null; // Remove cached formatter + console.debug('Preferred currency set to:', item); + console.debug('Preferred currency locale set to:', item.locale.replace('-', '_')); + console.debug('Cleared all cached currency formatters'); + } catch (error) { + console.error('Failed to set preferred currency:', error); + throw error; + } + currencyFormatter = null; +} + +async function updateExchangeRate(): Promise { + if (skipUpdateExchangeRate) return; + if (Date.now() - lastTimeUpdateExchangeRateWasCalled <= 10000) { + // simple debounce so there's no race conditions + return; + } + lastTimeUpdateExchangeRateWasCalled = Date.now(); + + const lastUpdated = exchangeRates[LAST_UPDATED] as number | undefined; + if (lastUpdated && Date.now() - lastUpdated <= 30 * 60 * 1000) { + // not updating too often + return; + } + console.log('updating exchange rate...'); + + try { + const rate = await getFiatRate(preferredFiatCurrency.endPointKey); + exchangeRates[LAST_UPDATED] = Date.now(); + exchangeRates[BTC_PREFIX + preferredFiatCurrency.endPointKey] = rate; + exchangeRates.LAST_UPDATED_ERROR = false; + + try { + const exchangeRatesString = JSON.stringify(exchangeRates); + await DefaultPreference.setName(GROUP_IO_BLUEWALLET); + await DefaultPreference.set(EXCHANGE_RATES_STORAGE_KEY, exchangeRatesString); + } catch (error) { + await DefaultPreference.clear(EXCHANGE_RATES_STORAGE_KEY); + exchangeRates = { LAST_UPDATED_ERROR: false }; + } + } catch (error) { + try { + await DefaultPreference.setName(GROUP_IO_BLUEWALLET); + const ratesValue = await DefaultPreference.get(EXCHANGE_RATES_STORAGE_KEY); + let ratesString: string | null = null; + + if (typeof ratesValue === 'string') { + ratesString = ratesValue; + } + + let rate; + if (ratesString) { + try { + rate = JSON.parse(ratesString); + } catch (parseError) { + await DefaultPreference.clear(EXCHANGE_RATES_STORAGE_KEY); + rate = {}; + } + } else { + rate = {}; + } + rate.LAST_UPDATED_ERROR = true; + exchangeRates.LAST_UPDATED_ERROR = true; + await DefaultPreference.set(EXCHANGE_RATES_STORAGE_KEY, JSON.stringify(rate)); + } catch (storageError) { + exchangeRates = { LAST_UPDATED_ERROR: true }; + throw storageError; + } + } +} + +async function getPreferredCurrency(): Promise { + await DefaultPreference.setName(GROUP_IO_BLUEWALLET); + const preferredCurrencyValue = await DefaultPreference.get(PREFERRED_CURRENCY_STORAGE_KEY); + let preferredCurrency: string | null = null; + + if (typeof preferredCurrencyValue === 'string') { + preferredCurrency = preferredCurrencyValue; + } + + if (preferredCurrency) { + try { + if (!FiatUnit[preferredCurrency]) { + throw new Error('Invalid Fiat Unit'); + } + preferredFiatCurrency = FiatUnit[preferredCurrency]; + } catch (error) { + await DefaultPreference.clear(PREFERRED_CURRENCY_STORAGE_KEY); + } + } + + if (!preferredFiatCurrency) { + const deviceCurrencies = RNLocalize.getCurrencies(); + if (deviceCurrencies[0] && FiatUnit[deviceCurrencies[0]]) { + preferredFiatCurrency = FiatUnit[deviceCurrencies[0]]; + } else { + preferredFiatCurrency = FiatUnit.USD; + } + } + + await DefaultPreference.set(PREFERRED_CURRENCY_LOCALE_STORAGE_KEY, preferredFiatCurrency.locale.replace('-', '_')); + return preferredFiatCurrency; +} + +async function _restoreSavedExchangeRatesFromStorage(): Promise { + try { + await DefaultPreference.setName(GROUP_IO_BLUEWALLET); + const ratesValue = await DefaultPreference.get(EXCHANGE_RATES_STORAGE_KEY); + let ratesString: string | null = null; + + if (typeof ratesValue === 'string') { + ratesString = ratesValue; + } + + if (ratesString) { + try { + const parsedRates = JSON.parse(ratesString); + // Atomic update to prevent race conditions + exchangeRates = parsedRates; + } catch (error) { + await DefaultPreference.clear(EXCHANGE_RATES_STORAGE_KEY); + exchangeRates = { LAST_UPDATED_ERROR: false }; + // Add delay before update to prevent rapid consecutive calls + await new Promise(resolve => setTimeout(resolve, 1000)); + await updateExchangeRate(); + } + } else { + exchangeRates = { LAST_UPDATED_ERROR: false }; + } + } catch (error) { + exchangeRates = { LAST_UPDATED_ERROR: false }; + await updateExchangeRate(); + } +} + +async function _restoreSavedPreferredFiatCurrencyFromStorage(): Promise { + try { + await DefaultPreference.setName(GROUP_IO_BLUEWALLET); + const storedCurrencyValue = await DefaultPreference.get(PREFERRED_CURRENCY_STORAGE_KEY); + let storedCurrency: string | null = null; + + if (typeof storedCurrencyValue === 'string') { + storedCurrency = storedCurrencyValue; + } + + if (!storedCurrency) throw new Error('No Preferred Fiat selected'); + + try { + if (!FiatUnit[storedCurrency]) { + throw new Error('Invalid Fiat Unit'); + } + preferredFiatCurrency = FiatUnit[storedCurrency]; + } catch (error) { + await DefaultPreference.clear(PREFERRED_CURRENCY_STORAGE_KEY); + + const deviceCurrencies = RNLocalize.getCurrencies(); + if (deviceCurrencies[0] && FiatUnit[deviceCurrencies[0]]) { + preferredFiatCurrency = FiatUnit[deviceCurrencies[0]]; + } else { + preferredFiatCurrency = FiatUnit.USD; + } + } + } catch (error) { + const deviceCurrencies = RNLocalize.getCurrencies(); + if (deviceCurrencies[0] && FiatUnit[deviceCurrencies[0]]) { + preferredFiatCurrency = FiatUnit[deviceCurrencies[0]]; + } else { + preferredFiatCurrency = FiatUnit.USD; + } + } +} + +async function isRateOutdated(): Promise { + try { + await DefaultPreference.setName(GROUP_IO_BLUEWALLET); + const rateValue = await DefaultPreference.get(EXCHANGE_RATES_STORAGE_KEY); + let rateString: string | null = null; + + if (typeof rateValue === 'string') { + rateString = rateValue; + } + + let rate; + if (rateString) { + try { + rate = JSON.parse(rateString); + } catch (parseError) { + await DefaultPreference.clear(EXCHANGE_RATES_STORAGE_KEY); + rate = {}; + await updateExchangeRate(); + } + } else { + rate = {}; + } + return rate.LAST_UPDATED_ERROR || Date.now() - (rate[LAST_UPDATED] || 0) >= 31 * 60 * 1000; + } catch { + return true; + } +} + +async function restoreSavedPreferredFiatCurrencyAndExchangeFromStorage(): Promise { + await _restoreSavedExchangeRatesFromStorage(); + await _restoreSavedPreferredFiatCurrencyFromStorage(); +} + +async function initCurrencyDaemon(clearLastUpdatedTime: boolean = false): Promise { + await _restoreSavedExchangeRatesFromStorage(); + await _restoreSavedPreferredFiatCurrencyFromStorage(); + + if (clearLastUpdatedTime) { + exchangeRates[LAST_UPDATED] = 0; + lastTimeUpdateExchangeRateWasCalled = 0; + } + + await updateExchangeRate(); +} + +function satoshiToLocalCurrency(satoshi: number, format: boolean = true): string { + const exchangeRateKey = BTC_PREFIX + preferredFiatCurrency.endPointKey; + const exchangeRate = exchangeRates[exchangeRateKey]; + + if (typeof exchangeRate !== 'number') { + updateExchangeRate(); + return '...'; + } + + const btcAmount = new BigNumber(satoshi).dividedBy(100000000); + const convertedAmount = btcAmount.multipliedBy(exchangeRate); + let formattedAmount: string; + + if (convertedAmount.isGreaterThanOrEqualTo(0.005) || convertedAmount.isLessThanOrEqualTo(-0.005)) { + formattedAmount = convertedAmount.toFixed(2); + } else { + formattedAmount = convertedAmount.toPrecision(2); + } + + if (format === false) return formattedAmount; + + try { + return getCurrencyFormatter().format(Number(formattedAmount)); + } catch (error) { + console.error(error); + return formattedAmount; + } +} + +function BTCToLocalCurrency(bitcoin: BigNumber.Value): string { + const sat = new BigNumber(bitcoin).multipliedBy(100000000).toNumber(); + return satoshiToLocalCurrency(sat); +} + +async function mostRecentFetchedRate(): Promise { + try { + await DefaultPreference.setName(GROUP_IO_BLUEWALLET); + const currencyInfoValue = await DefaultPreference.get(EXCHANGE_RATES_STORAGE_KEY); + let currencyInformationString: string | null = null; + + if (typeof currencyInfoValue === 'string') { + currencyInformationString = currencyInfoValue; + } + + let currencyInformation; + if (currencyInformationString) { + try { + currencyInformation = JSON.parse(currencyInformationString); + } catch (parseError) { + await DefaultPreference.clear(EXCHANGE_RATES_STORAGE_KEY); + currencyInformation = {}; + await updateExchangeRate(); + } + } else { + currencyInformation = {}; + } + + const rate = currencyInformation[BTC_PREFIX + preferredFiatCurrency.endPointKey]; + return { + LastUpdated: currencyInformation[LAST_UPDATED] ? new Date(currencyInformation[LAST_UPDATED]) : null, + Rate: rate ? getCurrencyFormatter().format(rate) : '...', + }; + } catch { + return { + LastUpdated: null, + Rate: null, + }; + } +} + +function satoshiToBTC(satoshi: number): string { + return new BigNumber(satoshi).dividedBy(100000000).toString(10); +} + +function btcToSatoshi(btc: BigNumber.Value): number { + return new BigNumber(btc).multipliedBy(100000000).toNumber(); +} + +function fiatToBTC(fiatFloat: number): string { + const exchangeRateKey = BTC_PREFIX + preferredFiatCurrency.endPointKey; + const exchangeRate = exchangeRates[exchangeRateKey]; + + if (typeof exchangeRate !== 'number') { + throw new Error('Exchange rate not available'); + } + + const btcAmount = new BigNumber(fiatFloat).dividedBy(exchangeRate); + return btcAmount.toFixed(8); +} + +function getCurrencySymbol(): string { + return preferredFiatCurrency.symbol; +} + +function formatBTC(btc: BigNumber.Value): string { + return new BigNumber(btc).toFormat(8); +} + +function _setPreferredFiatCurrency(currency: FiatUnitType): void { + preferredFiatCurrency = currency; +} + +function _setExchangeRate(pair: string, rate: number): void { + exchangeRates[pair] = rate; +} + +function _setSkipUpdateExchangeRate(): void { + skipUpdateExchangeRate = true; +} + +export { + _setExchangeRate, + _setPreferredFiatCurrency, + _setSkipUpdateExchangeRate, + BTCToLocalCurrency, + btcToSatoshi, + EXCHANGE_RATES_STORAGE_KEY, + fiatToBTC, + getCurrencySymbol, + getPreferredCurrency, + initCurrencyDaemon, + isRateOutdated, + LAST_UPDATED, + mostRecentFetchedRate, + PREFERRED_CURRENCY_STORAGE_KEY, + restoreSavedPreferredFiatCurrencyAndExchangeFromStorage, + satoshiToBTC, + satoshiToLocalCurrency, + setPreferredCurrency, + updateExchangeRate, + formatBTC, +}; diff --git a/blue_modules/debounce.js b/blue_modules/debounce.js deleted file mode 100644 index dacfdfefd3..0000000000 --- a/blue_modules/debounce.js +++ /dev/null @@ -1,14 +0,0 @@ -// https://levelup.gitconnected.com/debounce-in-javascript-improve-your-applications-performance-5b01855e086 -const debounce = (func, wait) => { - let timeout; - return function executedFunction(...args) { - const later = () => { - timeout = null; - func(...args); - }; - clearTimeout(timeout); - timeout = setTimeout(later, wait); - }; -}; - -export default debounce; diff --git a/blue_modules/debounce.ts b/blue_modules/debounce.ts new file mode 100644 index 0000000000..9e04dbe7b0 --- /dev/null +++ b/blue_modules/debounce.ts @@ -0,0 +1,31 @@ +// https://levelup.gitconnected.com/debounce-in-javascript-improve-your-applications-performance-5b01855e086 +// blue_modules/debounce.ts +type DebouncedFunction void> = { + (this: ThisParameterType, ...args: Parameters): void; + cancel(): void; +}; + +const debounce = void>(func: T, wait: number): DebouncedFunction => { + let timeout: NodeJS.Timeout | null; + const debouncedFunction = function (this: ThisParameterType, ...args: Parameters) { + const later = () => { + timeout = null; + func.apply(this, args); + }; + if (timeout) { + clearTimeout(timeout); + } + timeout = setTimeout(later, wait); + }; + + debouncedFunction.cancel = () => { + if (timeout) { + clearTimeout(timeout); + } + timeout = null; + }; + + return debouncedFunction as DebouncedFunction; +}; + +export default debounce; diff --git a/blue_modules/encryption.js b/blue_modules/encryption.js deleted file mode 100644 index 04d8cb60f9..0000000000 --- a/blue_modules/encryption.js +++ /dev/null @@ -1,22 +0,0 @@ -const CryptoJS = require('crypto-js'); - -module.exports.encrypt = function (data, password) { - if (data.length < 10) throw new Error('data length cant be < 10'); - const ciphertext = CryptoJS.AES.encrypt(data, password); - return ciphertext.toString(); -}; - -module.exports.decrypt = function (data, password) { - const bytes = CryptoJS.AES.decrypt(data, password); - let str = false; - try { - str = bytes.toString(CryptoJS.enc.Utf8); - } catch (e) {} - - // for some reason, sometimes decrypt would succeed with incorrect password and return random couple of characters. - // at least in nodejs environment. so with this little hack we are not alowing to encrypt data that is shorter than - // 10 characters, and thus if decrypted data is less than 10 characters we assume that decrypt actually failed. - if (str.length < 10) return false; - - return str; -}; diff --git a/blue_modules/encryption.ts b/blue_modules/encryption.ts new file mode 100644 index 0000000000..746926a761 --- /dev/null +++ b/blue_modules/encryption.ts @@ -0,0 +1,23 @@ +import AES from 'crypto-js/aes'; +import Utf8 from 'crypto-js/enc-utf8'; + +export function encrypt(data: string, password: string): string { + if (data.length < 10) throw new Error('data length cant be < 10'); + const ciphertext = AES.encrypt(data, password); + return ciphertext.toString(); +} + +export function decrypt(data: string, password: string): string | false { + const bytes = AES.decrypt(data, password); + let str: string | false = false; + try { + str = bytes.toString(Utf8); + } catch (e) {} + + // For some reason, sometimes decrypt would succeed with an incorrect password and return random characters. + // In this TypeScript version, we are not allowing the encryption of data that is shorter than + // 10 characters. If the decrypted data is less than 10 characters, we assume that the decrypt actually failed. + if (str && str.length < 10) return false; + + return str; +} diff --git a/blue_modules/environment.ts b/blue_modules/environment.ts index f0985a6e24..b9301c7016 100644 --- a/blue_modules/environment.ts +++ b/blue_modules/environment.ts @@ -1,41 +1,7 @@ -import AsyncStorage from '@react-native-async-storage/async-storage'; -import { Platform } from 'react-native'; -import { isTablet, getDeviceType } from 'react-native-device-info'; +import { getDeviceType, isTablet as checkIsTablet } from 'react-native-device-info'; +const isTablet: boolean = checkIsTablet(); const isDesktop: boolean = getDeviceType() === 'Desktop'; +const isHandset: boolean = getDeviceType() === 'Handset'; -const getIsTorCapable = (): boolean => { - let capable = true; - if (Platform.OS === 'android' && Platform.Version < 26) { - capable = false; - } else if (isDesktop) { - capable = false; - } - return capable; -}; - -const IS_TOR_DAEMON_DISABLED: string = 'is_tor_daemon_disabled'; - -export async function setIsTorDaemonDisabled(disabled: boolean = true): Promise { - return AsyncStorage.setItem(IS_TOR_DAEMON_DISABLED, disabled ? '1' : ''); -} - -export async function isTorDaemonDisabled(): Promise { - let result: boolean; - try { - const savedValue = await AsyncStorage.getItem(IS_TOR_DAEMON_DISABLED); - if (savedValue === null) { - result = false; - } else { - result = savedValue === '1'; - } - } catch { - result = true; - } - - return result; -} - -export const isHandset: boolean = getDeviceType() === 'Handset'; -export const isTorCapable: boolean = getIsTorCapable(); -export { isDesktop, isTablet }; +export { isDesktop, isHandset, isTablet }; diff --git a/blue_modules/fs.js b/blue_modules/fs.js deleted file mode 100644 index fd9a8d8e2f..0000000000 --- a/blue_modules/fs.js +++ /dev/null @@ -1,210 +0,0 @@ -import { Alert, Linking, PermissionsAndroid, Platform } from 'react-native'; -import RNFS from 'react-native-fs'; -import Share from 'react-native-share'; -import loc from '../loc'; -import DocumentPicker from 'react-native-document-picker'; -import { launchCamera, launchImageLibrary } from 'react-native-image-picker'; -import { presentCameraNotAuthorizedAlert } from '../class/camera'; -import { isDesktop } from '../blue_modules/environment'; -import alert from '../components/Alert'; -const LocalQRCode = require('@remobile/react-native-qrcode-local-image'); - -const writeFileAndExportToAndroidDestionation = async ({ filename, contents, destinationLocalizedString, destination }) => { - const granted = await PermissionsAndroid.request(PermissionsAndroid.PERMISSIONS.WRITE_EXTERNAL_STORAGE, { - title: loc.send.permission_storage_title, - message: loc.send.permission_storage_message, - buttonNeutral: loc.send.permission_storage_later, - buttonNegative: loc._.cancel, - buttonPositive: loc._.ok, - }); - if (granted === PermissionsAndroid.RESULTS.GRANTED || Platform.Version >= 33) { - const filePath = destination + `/${filename}`; - try { - await RNFS.writeFile(filePath, contents); - alert(loc.formatString(loc._.file_saved, { filePath: filename, destination: destinationLocalizedString })); - } catch (e) { - console.log(e); - alert(e.message); - } - } else { - console.log('Storage Permission: Denied'); - Alert.alert(loc.send.permission_storage_title, loc.send.permission_storage_denied_message, [ - { - text: loc.send.open_settings, - onPress: () => { - Linking.openSettings(); - }, - style: 'default', - }, - { text: loc._.cancel, onPress: () => {}, style: 'cancel' }, - ]); - } -}; - -const writeFileAndExport = async function (filename, contents) { - if (Platform.OS === 'ios') { - const filePath = RNFS.TemporaryDirectoryPath + `/${filename}`; - await RNFS.writeFile(filePath, contents); - await Share.open({ - url: 'file://' + filePath, - saveToFiles: isDesktop, - }) - .catch(error => { - console.log(error); - }) - .finally(() => { - RNFS.unlink(filePath); - }); - } else if (Platform.OS === 'android') { - await writeFileAndExportToAndroidDestionation({ - filename, - contents, - destinationLocalizedString: loc._.downloads_folder, - destination: RNFS.DownloadDirectoryPath, - }); - } -}; - -/** - * Opens & reads *.psbt files, and returns base64 psbt. FALSE if something went wrong (wont throw). - * - * @returns {Promise} Base64 PSBT - */ -const openSignedTransaction = async function () { - try { - const res = await DocumentPicker.pickSingle({ - type: Platform.OS === 'ios' ? ['io.bluewallet.psbt', 'io.bluewallet.psbt.txn'] : [DocumentPicker.types.allFiles], - }); - - return await _readPsbtFileIntoBase64(res.uri); - } catch (err) { - if (!DocumentPicker.isCancel(err)) { - alert(loc.send.details_no_signed_tx); - } - } - - return false; -}; - -const _readPsbtFileIntoBase64 = async function (uri) { - const base64 = await RNFS.readFile(uri, 'base64'); - const stringData = Buffer.from(base64, 'base64').toString(); // decode from base64 - if (stringData.startsWith('psbt')) { - // file was binary, but outer code expects base64 psbt, so we return base64 we got from rn-fs; - // most likely produced by Electrum-desktop - return base64; - } else { - // file was a text file, having base64 psbt in there. so we basically have double base64encoded string - // thats why we are returning string that was decoded once; - // most likely produced by Coldcard - return stringData; - } -}; - -const showImagePickerAndReadImage = () => { - return new Promise((resolve, reject) => - launchImageLibrary( - { - title: null, - mediaType: 'photo', - takePhotoButtonTitle: null, - maxHeight: 800, - maxWidth: 600, - selectionLimit: 1, - }, - response => { - if (!response.didCancel) { - const asset = response.assets[0]; - if (asset.uri) { - const uri = asset.uri.toString().replace('file://', ''); - LocalQRCode.decode(uri, (error, result) => { - if (!error) { - resolve(result); - } else { - reject(new Error(loc.send.qr_error_no_qrcode)); - } - }); - } - } - }, - ), - ); -}; - -const takePhotoWithImagePickerAndReadPhoto = () => { - return new Promise((resolve, reject) => - launchCamera( - { - title: null, - mediaType: 'photo', - takePhotoButtonTitle: null, - }, - response => { - if (response.uri) { - const uri = response.uri.toString().replace('file://', ''); - LocalQRCode.decode(uri, (error, result) => { - if (!error) { - resolve(result); - } else { - reject(new Error(loc.send.qr_error_no_qrcode)); - } - }); - } else if (response.error) { - presentCameraNotAuthorizedAlert(response.error); - } - }, - ), - ); -}; - -const showFilePickerAndReadFile = async function () { - try { - const res = await DocumentPicker.pickSingle({ - type: - Platform.OS === 'ios' - ? [ - 'io.bluewallet.psbt', - 'io.bluewallet.psbt.txn', - 'io.bluewallet.backup', - DocumentPicker.types.plainText, - 'public.json', - DocumentPicker.types.images, - ] - : [DocumentPicker.types.allFiles], - }); - - const uri = Platform.OS === 'ios' ? decodeURI(res.uri) : res.uri; - // ^^ some weird difference on how spaces in filenames are treated on ios and android - - let file = false; - if (res.uri.toLowerCase().endsWith('.psbt')) { - // this is either binary file from ElectrumDesktop OR string file with base64 string in there - file = await _readPsbtFileIntoBase64(uri); - return { data: file, uri: decodeURI(res.uri) }; - } - - if (res?.type === DocumentPicker.types.images || res?.type?.startsWith('image/')) { - return new Promise(resolve => { - const uri2 = res.uri.toString().replace('file://', ''); - LocalQRCode.decode(decodeURI(uri2), (error, result) => { - if (!error) { - resolve({ data: result, uri: decodeURI(res.uri) }); - } else { - resolve({ data: false, uri: false }); - } - }); - }); - } - - file = await RNFS.readFile(uri); - return { data: file, uri: decodeURI(res.uri) }; - } catch (err) { - return { data: false, uri: false }; - } -}; - -module.exports.writeFileAndExport = writeFileAndExport; -module.exports.openSignedTransaction = openSignedTransaction; -module.exports.showFilePickerAndReadFile = showFilePickerAndReadFile; -module.exports.showImagePickerAndReadImage = showImagePickerAndReadImage; -module.exports.takePhotoWithImagePickerAndReadPhoto = takePhotoWithImagePickerAndReadPhoto; diff --git a/blue_modules/fs.ts b/blue_modules/fs.ts new file mode 100644 index 0000000000..9e7630d5d5 --- /dev/null +++ b/blue_modules/fs.ts @@ -0,0 +1,216 @@ +import { Alert, Linking, Platform } from 'react-native'; +import DocumentPicker from 'react-native-document-picker'; +import RNFS from 'react-native-fs'; +import { launchImageLibrary, ImagePickerResponse } from 'react-native-image-picker'; +import Share from 'react-native-share'; +import { request, PERMISSIONS } from 'react-native-permissions'; +import presentAlert from '../components/Alert'; +import loc from '../loc'; +import { isDesktop } from './environment'; +import { readFile } from './react-native-bw-file-access'; +import RNQRGenerator from 'rn-qr-generator'; + +const _sanitizeFileName = (fileName: string) => { + // Remove any path delimiters and non-alphanumeric characters except for -, _, and . + return fileName.replace(/[^a-zA-Z0-9\-_.]/g, ''); +}; + +const _shareOpen = async (filePath: string, showShareDialog: boolean = false) => { + try { + await Share.open({ + url: 'file://' + filePath, + saveToFiles: isDesktop || !showShareDialog, + // @ts-ignore: Website claims this propertie exists, but TS cant find it. Send anyways. + useInternalStorage: Platform.OS === 'android', + failOnCancel: false, + }); + } catch (error: any) { + console.log(error); + // If user cancels sharing, we dont want to show an error. for some reason we get 'CANCELLED' string as error + if (error.message !== 'CANCELLED') { + presentAlert({ message: error.message }); + } + } finally { + await RNFS.unlink(filePath); + } +}; + +/** + * Writes a file to fs, and triggers an OS sharing dialog, so user can decide where to put this file (share to cloud + * or perhabs messaging app). Provided filename should be just a file name, NOT a path + */ + +export const writeFileAndExport = async function (fileName: string, contents: string, showShareDialog: boolean = true) { + const sanitizedFileName = _sanitizeFileName(fileName); + try { + if (Platform.OS === 'ios') { + const filePath = `${RNFS.TemporaryDirectoryPath}/${sanitizedFileName}`; + await RNFS.writeFile(filePath, contents); + await _shareOpen(filePath, showShareDialog); + } else if (Platform.OS === 'android') { + const isAndroidVersion33OrAbove = Platform.Version >= 33; + const permissionType = isAndroidVersion33OrAbove ? PERMISSIONS.ANDROID.READ_MEDIA_IMAGES : PERMISSIONS.ANDROID.READ_EXTERNAL_STORAGE; + + const result = await request(permissionType); + if (result === 'granted') { + const filePath = `${RNFS.ExternalDirectoryPath}/${sanitizedFileName}`; + try { + await RNFS.writeFile(filePath, contents); + if (showShareDialog) { + await _shareOpen(filePath); + } else { + presentAlert({ message: loc.formatString(loc.send.file_saved_at_path, { filePath }) }); + } + } catch (e: any) { + presentAlert({ message: e.message }); + } + } else { + Alert.alert(loc.send.permission_storage_title, loc.send.permission_storage_denied_message, [ + { + text: loc.send.open_settings, + onPress: () => { + Linking.openSettings(); + }, + style: 'default', + }, + { text: loc._.cancel, onPress: () => {}, style: 'cancel' }, + ]); + } + } + } catch (error: any) { + presentAlert({ message: error.message }); + } +}; + +/** + * Opens & reads *.psbt files, and returns base64 psbt. FALSE if something went wrong (wont throw). + */ +export const openSignedTransaction = async function (): Promise { + try { + const res = await DocumentPicker.pickSingle({ + type: + Platform.OS === 'ios' + ? ['io.bluewallet.psbt', 'io.bluewallet.psbt.txn', DocumentPicker.types.json] + : [DocumentPicker.types.allFiles], + }); + + return await _readPsbtFileIntoBase64(res.uri); + } catch (err) { + if (!DocumentPicker.isCancel(err)) { + presentAlert({ message: loc.send.details_no_signed_tx }); + } + } + + return false; +}; + +const _readPsbtFileIntoBase64 = async function (uri: string): Promise { + const base64 = await RNFS.readFile(uri, 'base64'); + const stringData = Buffer.from(base64, 'base64').toString(); // decode from base64 + if (stringData.startsWith('psbt')) { + // file was binary, but outer code expects base64 psbt, so we return base64 we got from rn-fs; + // most likely produced by Electrum-desktop + return base64; + } else { + // file was a text file, having base64 psbt in there. so we basically have double base64encoded string + // thats why we are returning string that was decoded once; + // most likely produced by Coldcard + return stringData; + } +}; + +export const showImagePickerAndReadImage = async (): Promise => { + try { + const response: ImagePickerResponse = await launchImageLibrary({ + mediaType: 'photo', + maxHeight: 800, + maxWidth: 600, + selectionLimit: 1, + }); + + if (response.didCancel) { + return undefined; + } else if (response.errorCode) { + throw new Error(response.errorMessage); + } else if (response.assets?.[0]?.uri) { + try { + const result = await RNQRGenerator.detect({ uri: decodeURI(response.assets[0].uri.toString()) }); + return result?.values[0]; + } catch (error) { + console.error(error); + throw new Error(loc.send.qr_error_no_qrcode); + } + } + + return undefined; + } catch (error: any) { + console.error(error); + throw error; + } +}; + +export const showFilePickerAndReadFile = async function (): Promise<{ data: string | false; uri: string | false }> { + try { + const res = await DocumentPicker.pickSingle({ + copyTo: 'cachesDirectory', + type: + Platform.OS === 'ios' + ? [ + 'io.bluewallet.psbt', + 'io.bluewallet.psbt.txn', + 'io.bluewallet.backup', + DocumentPicker.types.plainText, + DocumentPicker.types.json, + DocumentPicker.types.images, + ] + : [DocumentPicker.types.allFiles], + }); + + if (!res.fileCopyUri) { + // to make ts happy, should not need this check here + presentAlert({ message: 'Picking and caching a file failed' }); + return { data: false, uri: false }; + } + + const fileCopyUri = decodeURI(res.fileCopyUri); + + if (res.fileCopyUri.toLowerCase().endsWith('.psbt')) { + // this is either binary file from ElectrumDesktop OR string file with base64 string in there + const file = await _readPsbtFileIntoBase64(fileCopyUri); + return { data: file, uri: decodeURI(res.fileCopyUri) }; + } + + if (res.type === DocumentPicker.types.images || res.type?.startsWith('image/')) { + try { + const uri2 = res.fileCopyUri.replace('file://', ''); + const result = await RNQRGenerator.detect({ uri: decodeURI(uri2) }); + if (result) { + return { data: result.values[0], uri: fileCopyUri }; + } + return { data: false, uri: false }; + } catch (error) { + console.error(error); + return { data: false, uri: false }; + } + } + + const file = await RNFS.readFile(fileCopyUri); + return { data: file, uri: fileCopyUri }; + } catch (err: any) { + if (!DocumentPicker.isCancel(err)) { + presentAlert({ message: err.message }); + } + return { data: false, uri: false }; + } +}; + +export const readFileOutsideSandbox = (filePath: string) => { + if (Platform.OS === 'ios') { + return readFile(filePath); + } else if (Platform.OS === 'android') { + return RNFS.readFile(filePath); + } else { + presentAlert({ message: 'Not implemented for this platform' }); + throw new Error('Not implemented for this platform'); + } +}; diff --git a/blue_modules/hapticFeedback.ts b/blue_modules/hapticFeedback.ts new file mode 100644 index 0000000000..4d9bfd2807 --- /dev/null +++ b/blue_modules/hapticFeedback.ts @@ -0,0 +1,43 @@ +import DeviceInfo, { PowerState } from 'react-native-device-info'; +import ReactNativeHapticFeedback from 'react-native-haptic-feedback'; +import { isDesktop } from './environment'; + +// Define a const enum for HapticFeedbackTypes +export const enum HapticFeedbackTypes { + ImpactLight = 'impactLight', + ImpactMedium = 'impactMedium', + ImpactHeavy = 'impactHeavy', + Selection = 'selection', + NotificationSuccess = 'notificationSuccess', + NotificationWarning = 'notificationWarning', + NotificationError = 'notificationError', +} + +const triggerHapticFeedback = (type: HapticFeedbackTypes) => { + if (isDesktop) return; + DeviceInfo.getPowerState().then((state: Partial) => { + if (!state.lowPowerMode) { + ReactNativeHapticFeedback.trigger(type, { ignoreAndroidSystemSettings: false, enableVibrateFallback: true }); + } else { + console.log('Haptic feedback not triggered due to low power mode.'); + } + }); +}; + +export const triggerSuccessHapticFeedback = () => { + triggerHapticFeedback(HapticFeedbackTypes.NotificationSuccess); +}; + +export const triggerWarningHapticFeedback = () => { + triggerHapticFeedback(HapticFeedbackTypes.NotificationWarning); +}; + +export const triggerErrorHapticFeedback = () => { + triggerHapticFeedback(HapticFeedbackTypes.NotificationError); +}; + +export const triggerSelectionHapticFeedback = () => { + triggerHapticFeedback(HapticFeedbackTypes.Selection); +}; + +export default triggerHapticFeedback; diff --git a/blue_modules/net.js b/blue_modules/net.js deleted file mode 100644 index 44d5c0f3a8..0000000000 --- a/blue_modules/net.js +++ /dev/null @@ -1,110 +0,0 @@ -/** - * @fileOverview adapter for ReactNative TCP module - * This module mimics the nodejs net api and is intended to work in RN environment. - * @see https://github.com/Rapsssito/react-native-tcp-socket - */ - -import TcpSocket from 'react-native-tcp-socket'; - -/** - * Constructor function. Resulting object has to act as it was a real socket (basically - * conform to nodejs/net api) - * - * @constructor - */ -function Socket() { - this._socket = false; // reference to socket thats gona be created later - // defaults: - this._noDelay = true; - - this._listeners = {}; - - // functions not supported by RN module, yet: - this.setTimeout = () => {}; - this.setEncoding = () => {}; - this.setKeepAlive = () => {}; - - // proxying call to real socket object: - this.setNoDelay = noDelay => { - if (this._socket) this._socket.setNoDelay(noDelay); - this._noDelay = noDelay; - }; - - this.connect = (port, host, callback) => { - this._socket = TcpSocket.createConnection( - { - port, - host, - tls: false, - }, - callback, - ); - - this._socket.on('data', data => { - this._passOnEvent('data', data); - }); - this._socket.on('error', data => { - this._passOnEvent('error', data); - }); - this._socket.on('close', data => { - this._passOnEvent('close', data); - }); - this._socket.on('connect', data => { - this._passOnEvent('connect', data); - this._socket.setNoDelay(this._noDelay); - }); - this._socket.on('connection', data => { - this._passOnEvent('connection', data); - }); - }; - - this._passOnEvent = (event, data) => { - this._listeners[event] = this._listeners[event] || []; - for (const savedListener of this._listeners[event]) { - savedListener(data); - } - }; - - this.on = (event, listener) => { - this._listeners[event] = this._listeners[event] || []; - this._listeners[event].push(listener); - }; - - this.removeListener = (event, listener) => { - this._listeners[event] = this._listeners[event] || []; - const newListeners = []; - - let found = false; - for (const savedListener of this._listeners[event]) { - if (savedListener === listener) { - // found our listener - found = true; - // we just skip it - } else { - // other listeners should go back to original array - newListeners.push(savedListener); - } - } - - if (found) { - this._listeners[event] = newListeners; - } else { - // something went wrong, lets just cleanup all listeners - this._listeners[event] = []; - } - }; - - this.end = () => { - this._socket.end(); - }; - - this.destroy = () => { - this._socket.destroy(); - }; - - this.write = data => { - this._socket.write(data); - }; -} - -module.exports.Socket = Socket; diff --git a/blue_modules/noble_ecc.ts b/blue_modules/noble_ecc.ts index 929fdd8865..061b259c4d 100644 --- a/blue_modules/noble_ecc.ts +++ b/blue_modules/noble_ecc.ts @@ -5,12 +5,12 @@ * @see https://github.com/bitcoinjs/tiny-secp256k1/issues/84#issuecomment-1185682315 * @see https://github.com/bitcoinjs/bitcoinjs-lib/issues/1781 */ -import createHash from 'create-hash'; -import { createHmac } from 'crypto'; import * as necc from '@noble/secp256k1'; -import { TinySecp256k1Interface } from 'ecpair/src/ecpair'; import { TinySecp256k1Interface as TinySecp256k1InterfaceBIP32 } from 'bip32/types/bip32'; import { XOnlyPointAddTweakResult } from 'bitcoinjs-lib/src/types'; +import createHash from 'create-hash'; +import { createHmac } from 'crypto'; +import { TinySecp256k1Interface } from 'ecpair/src/ecpair'; export interface TinySecp256k1InterfaceExtended { pointMultiply(p: Uint8Array, tweak: Uint8Array, compressed?: boolean): Uint8Array | null; @@ -20,6 +20,8 @@ export interface TinySecp256k1InterfaceExtended { isXOnlyPoint(p: Uint8Array): boolean; xOnlyPointAddTweak(p: Uint8Array, tweak: Uint8Array): XOnlyPointAddTweakResult | null; + + privateNegate(d: Uint8Array): Uint8Array; } necc.utils.sha256Sync = (...messages: Uint8Array[]): Uint8Array => { @@ -119,7 +121,7 @@ const ecc: TinySecp256k1InterfaceExtended & TinySecp256k1Interface & TinySecp256 return ret; }), - // privateNegate: (d: Uint8Array): Uint8Array => necc.utils.privateNegate(d), + privateNegate: (d: Uint8Array): Uint8Array => necc.utils.privateNegate(d), sign: (h: Uint8Array, d: Uint8Array, e?: Uint8Array): Uint8Array => { return necc.signSync(h, d, { der: false, extraEntropy: e }); diff --git a/blue_modules/notifications.js b/blue_modules/notifications.js index 4b037d2634..d75472aaa6 100644 --- a/blue_modules/notifications.js +++ b/blue_modules/notifications.js @@ -1,440 +1,647 @@ -import PushNotificationIOS from '@react-native-community/push-notification-ios'; -import { Alert, Platform } from 'react-native'; -import Frisbee from 'frisbee'; -import { getApplicationName, getVersion, getSystemName, getSystemVersion, hasGmsSync, hasHmsSync } from 'react-native-device-info'; import AsyncStorage from '@react-native-async-storage/async-storage'; +import PushNotificationIOS from '@react-native-community/push-notification-ios'; +import { AppState, Platform } from 'react-native'; +import { getApplicationName, getSystemName, getSystemVersion, getVersion, hasGmsSync, hasHmsSync } from 'react-native-device-info'; +import { checkNotifications, requestNotifications, RESULTS } from 'react-native-permissions'; +import PushNotification from 'react-native-push-notification'; import loc from '../loc'; +import { groundControlUri } from './constants'; -const PushNotification = require('react-native-push-notification'); -const constants = require('./constants'); const PUSH_TOKEN = 'PUSH_TOKEN'; const GROUNDCONTROL_BASE_URI = 'GROUNDCONTROL_BASE_URI'; const NOTIFICATIONS_STORAGE = 'NOTIFICATIONS_STORAGE'; -const NOTIFICATIONS_NO_AND_DONT_ASK_FLAG = 'NOTIFICATIONS_NO_AND_DONT_ASK_FLAG'; +export const NOTIFICATIONS_NO_AND_DONT_ASK_FLAG = 'NOTIFICATIONS_NO_AND_DONT_ASK_FLAG'; let alreadyConfigured = false; -let baseURI = constants.groundControlUri; - -function Notifications(props) { - async function _setPushToken(token) { - token = JSON.stringify(token); - return AsyncStorage.setItem(PUSH_TOKEN, token); +let baseURI = groundControlUri; + +const checkAndroidNotificationPermission = async () => { + try { + const { status } = await checkNotifications(); + console.debug('Notification permission check:', status); + return status === RESULTS.GRANTED; + } catch (err) { + console.error('Failed to check notification permission:', err); + return false; + } +}; + +export const checkNotificationPermissionStatus = async () => { + try { + const { status } = await checkNotifications(); + return status; + } catch (error) { + console.error('Failed to check notification permissions:', error); + return 'unavailable'; // Return 'unavailable' if the status cannot be retrieved + } +}; + +// Listener to monitor notification permission status changes while app is running +let currentPermissionStatus = 'unavailable'; +const handleAppStateChange = async nextAppState => { + if (nextAppState === 'active') { + const isDisabledByUser = (await AsyncStorage.getItem(NOTIFICATIONS_NO_AND_DONT_ASK_FLAG)) === 'true'; + if (!isDisabledByUser) { + const newPermissionStatus = await checkNotificationPermissionStatus(); + if (newPermissionStatus !== currentPermissionStatus) { + currentPermissionStatus = newPermissionStatus; + if (newPermissionStatus === 'granted') { + await initializeNotifications(); + } + } + } + } +}; + +AppState.addEventListener('change', handleAppStateChange); + +export const cleanUserOptOutFlag = async () => { + return AsyncStorage.removeItem(NOTIFICATIONS_NO_AND_DONT_ASK_FLAG); +}; + +/** + * Should be called when user is most interested in receiving push notifications. + * If we dont have a token it will show alert asking whether + * user wants to receive notifications, and if yes - will configure push notifications. + * FYI, on Android permissions are acquired when app is installed, so basically we dont need to ask, + * we can just call `configure`. On iOS its different, and calling `configure` triggers system's dialog box. + * + * @returns {Promise} TRUE if permissions were obtained, FALSE otherwise + */ +/** + * Attempts to obtain permissions and configure notifications. + * Shows a rationale on Android if permissions are needed. + * + * @returns {Promise} + */ +export const tryToObtainPermissions = async () => { + console.debug('tryToObtainPermissions: Starting user-triggered permission request'); + + if (!isNotificationsCapable) { + console.debug('tryToObtainPermissions: Device not capable'); + return false; } - Notifications.getPushToken = async () => { - try { - let token = await AsyncStorage.getItem(PUSH_TOKEN); - token = JSON.parse(token); - return token; - } catch (_) {} + try { + const rationale = { + title: loc.settings.notifications, + message: loc.notifications.would_you_like_to_receive_notifications, + buttonPositive: loc._.ok, + buttonNegative: loc.notifications.no_and_dont_ask, + }; + + const { status } = await requestNotifications( + ['alert', 'sound', 'badge'], + Platform.OS === 'android' && Platform.Version < 33 ? rationale : undefined, + ); + if (status !== RESULTS.GRANTED) { + console.debug('tryToObtainPermissions: Permission denied'); + return false; + } + return configureNotifications(); + } catch (error) { + console.error('Error requesting notification permissions:', error); return false; - }; + } +}; +/** + * Submits onchain bitcoin addresses and ln invoice preimage hashes to GroundControl server, so later we could + * be notified if they were paid + * + * @param addresses {string[]} + * @param hashes {string[]} + * @param txids {string[]} + * @returns {Promise} Response object from API rest call + */ +export const majorTomToGroundControl = async (addresses, hashes, txids) => { + console.debug('majorTomToGroundControl: Starting notification registration', { + addressCount: addresses?.length, + hashCount: hashes?.length, + txidCount: txids?.length, + }); + + try { + const noAndDontAskFlag = await AsyncStorage.getItem(NOTIFICATIONS_NO_AND_DONT_ASK_FLAG); + if (noAndDontAskFlag === 'true') { + console.warn('User has opted out of notifications.'); + return; + } - Notifications.isNotificationsCapable = hasGmsSync() || hasHmsSync() || Platform.OS !== 'android'; - /** - * Calls `configure`, which tries to obtain push token, save it, and registers all associated with - * notifications callbacks - * - * @returns {Promise} TRUE if acquired token, FALSE if not - */ - const configureNotifications = async function () { - return new Promise(function (resolve) { - PushNotification.configure({ - // (optional) Called when Token is generated (iOS and Android) - onRegister: async function (token) { - console.log('TOKEN:', token); - alreadyConfigured = true; - await _setPushToken(token); - resolve(true); - }, - - // (required) Called when a remote is received or opened, or local notification is opened - onNotification: async function (notification) { - // since we do not know whether we: - // 1) received notification while app is in background (and storage is not decrypted so wallets are not loaded) - // 2) opening this notification right now but storage is still unencrypted - // 3) any of the above but the storage is decrypted, and app wallets are loaded - // - // ...we save notification in internal notifications queue thats gona be processed later (on unsuspend with decrypted storage) - - const payload = Object.assign({}, notification, notification.data); - if (notification.data && notification.data.data) Object.assign(payload, notification.data.data); - delete payload.data; - // ^^^ weird, but sometimes payload data is not in `data` but in root level - console.log('got push notification', payload); - - await Notifications.addNotification(payload); - - // (required) Called when a remote is received or opened, or local notification is opened - notification.finish(PushNotificationIOS.FetchResult.NoData); - - // if user is staring at the app when he receives the notification we process it instantly - // so app refetches related wallet - if (payload.foreground) props.onProcessNotifications(); - }, - - // (optional) Called when Registered Action is pressed and invokeApp is false, if true onNotification will be called (Android) - onAction: function (notification) { - console.log('ACTION:', notification.action); - console.log('NOTIFICATION:', notification); - - // process the action - }, - - // (optional) Called when the user fails to register for remote notifications. Typically occurs when APNS is having issues, or the device is a simulator. (iOS) - onRegistrationError: function (err) { - console.error(err.message, err); - resolve(false); - }, - - // IOS ONLY (optional): default: all - Permissions to register. - permissions: { - alert: true, - badge: true, - sound: true, - }, - - // Should the initial notification be popped automatically - // default: true - popInitialNotification: true, - - /** - * (optional) default: true - * - Specified if permissions (ios) and token (android and ios) will requested or not, - * - if not, you must call PushNotificationsHandler.requestPermissions() later - * - if you are not using remote notification or do not have Firebase installed, use this: - * requestPermissions: Platform.OS === 'ios' - */ - requestPermissions: true, - }); - }); - }; + if (!Array.isArray(addresses) || !Array.isArray(hashes) || !Array.isArray(txids)) { + throw new Error('No addresses, hashes, or txids provided'); + } - Notifications.cleanUserOptOutFlag = async function () { - return AsyncStorage.removeItem(NOTIFICATIONS_NO_AND_DONT_ASK_FLAG); - }; + const pushToken = await getPushToken(); + console.debug('majorTomToGroundControl: Retrieved push token:', !!pushToken); + if (!pushToken || !pushToken.token || !pushToken.os) { + return; + } - /** - * Should be called when user is most interested in receiving push notifications. - * If we dont have a token it will show alert asking whether - * user wants to receive notifications, and if yes - will configure push notifications. - * FYI, on Android permissions are acquired when app is installed, so basically we dont need to ask, - * we can just call `configure`. On iOS its different, and calling `configure` triggers system's dialog box. - * - * @returns {Promise} TRUE if permissions were obtained, FALSE otherwise - */ - Notifications.tryToObtainPermissions = async function () { - if (!Notifications.isNotificationsCapable) return false; - if (await Notifications.getPushToken()) { - // we already have a token, no sense asking again, just configure pushes to register callbacks and we are done - if (!alreadyConfigured) configureNotifications(); // no await so it executes in background while we return TRUE and use token - return true; + const requestBody = JSON.stringify({ + addresses, + hashes, + txids, + token: pushToken.token, + os: pushToken.os, + }); + + let response; + try { + console.debug('majorTomToGroundControl: Sending request to:', `${baseURI}/majorTomToGroundControl`); + response = await fetch(`${baseURI}/majorTomToGroundControl`, { + method: 'POST', + headers: _getHeaders(), + body: requestBody, + }); + } catch (networkError) { + console.error('Network request failed:', networkError); + throw networkError; } - if (await AsyncStorage.getItem(NOTIFICATIONS_NO_AND_DONT_ASK_FLAG)) { - // user doesn't want them - return false; + if (!response.ok) { + throw new Error(`Ground Control request failed with status ${response.status}: ${response.statusText}`); } + const responseText = await response.text(); + if (responseText) { + try { + return JSON.parse(responseText); + } catch (jsonError) { + console.error('Error parsing response JSON:', jsonError); + throw jsonError; + } + } else { + return {}; // Return an empty object if there is no response body + } + } catch (error) { + console.error('Error in majorTomToGroundControl:', error); + throw error; + } +}; + +/** + * Returns a permissions object: + * alert: boolean + * badge: boolean + * sound: boolean + * + * @returns {Promise} + */ +export const checkPermissions = async () => { + try { return new Promise(function (resolve) { - Alert.alert( - loc.settings.notifications, - loc.notifications.would_you_like_to_receive_notifications, - [ - { - text: loc.notifications.no_and_dont_ask, - onPress: () => { - AsyncStorage.setItem(NOTIFICATIONS_NO_AND_DONT_ASK_FLAG, '1'); - resolve(false); - }, - style: 'cancel', - }, - { - text: loc.notifications.ask_me_later, - onPress: () => { - resolve(false); - }, - style: 'cancel', - }, - { - text: loc._.ok, - onPress: async () => { - resolve(await configureNotifications()); - }, - style: 'default', - }, - ], - { cancelable: false }, - ); + PushNotification.checkPermissions(result => { + resolve(result); + }); }); - }; - - function _getHeaders() { - return { + } catch (error) { + console.error('Error checking permissions:', error); + throw error; + } +}; + +/** + * Posts to groundcontrol info whether we want to opt in or out of specific notifications level + * + * @param levelAll {Boolean} + * @returns {Promise<*>} + */ +export const setLevels = async levelAll => { + const pushToken = await getPushToken(); + if (!pushToken || !pushToken.token || !pushToken.os) return; + + try { + const response = await fetch(`${baseURI}/setTokenConfiguration`, { + method: 'POST', headers: { - 'Access-Control-Allow-Origin': '*', 'Content-Type': 'application/json', }, - }; - } - - async function _sleep(ms) { - return new Promise(resolve => setTimeout(resolve, ms)); - } - - /** - * Submits onchain bitcoin addresses and ln invoice preimage hashes to GroundControl server, so later we could - * be notified if they were paid - * - * @param addresses {string[]} - * @param hashes {string[]} - * @param txids {string[]} - * @returns {Promise} Response object from API rest call - */ - Notifications.majorTomToGroundControl = async function (addresses, hashes, txids) { - if (!Array.isArray(addresses) || !Array.isArray(hashes) || !Array.isArray(txids)) - throw new Error('no addresses or hashes or txids provided'); - const pushToken = await Notifications.getPushToken(); - if (!pushToken || !pushToken.token || !pushToken.os) return; - - const api = new Frisbee({ baseURI }); - - return await api.post( - '/majorTomToGroundControl', - Object.assign({}, _getHeaders(), { - body: { - addresses, - hashes, - txids, - token: pushToken.token, - os: pushToken.os, - }, + body: JSON.stringify({ + level_all: !!levelAll, + token: pushToken.token, + os: pushToken.os, }), - ); - }; - - /** - * The opposite of `majorTomToGroundControl` call. - * - * @param addresses {string[]} - * @param hashes {string[]} - * @param txids {string[]} - * @returns {Promise} Response object from API rest call - */ - Notifications.unsubscribe = async function (addresses, hashes, txids) { - if (!Array.isArray(addresses) || !Array.isArray(hashes) || !Array.isArray(txids)) - throw new Error('no addresses or hashes or txids provided'); - const pushToken = await Notifications.getPushToken(); - if (!pushToken || !pushToken.token || !pushToken.os) return; - - const api = new Frisbee({ baseURI }); - - return await api.post( - '/unsubscribe', - Object.assign({}, _getHeaders(), { - body: { - addresses, - hashes, - txids, - token: pushToken.token, - os: pushToken.os, - }, - }), - ); - }; + }); - Notifications.isNotificationsEnabled = async function () { - const levels = await getLevels(); + if (!response.ok) { + throw new Error('Failed to set token configuration: ' + response.statusText); + } - return !!(await Notifications.getPushToken()) && !!levels.level_all; - }; + if (!levelAll) { + console.debug('Disabling notifications as user opted out...'); + await Promise.all([ + new Promise(resolve => PushNotification.removeAllDeliveredNotifications(resolve)), + new Promise(resolve => PushNotification.setApplicationIconBadgeNumber(0, resolve)), + new Promise(resolve => PushNotification.cancelAllLocalNotifications(resolve)), + AsyncStorage.setItem(NOTIFICATIONS_NO_AND_DONT_ASK_FLAG, 'true'), + ]); + console.debug('Notifications disabled successfully'); + } else { + await AsyncStorage.removeItem(NOTIFICATIONS_NO_AND_DONT_ASK_FLAG); // Clear flag when enabling + } + } catch (error) { + console.error('Error setting notification levels:', error); + } +}; + +export const addNotification = async notification => { + let notifications = []; + try { + const stringified = await AsyncStorage.getItem(NOTIFICATIONS_STORAGE); + notifications = JSON.parse(stringified); + if (!Array.isArray(notifications)) notifications = []; + } catch (e) { + console.error(e); + // Start fresh with just the new notification + notifications = []; + } - Notifications.getDefaultUri = function () { - return constants.groundControlUri; - }; + notifications.push(notification); + await AsyncStorage.setItem(NOTIFICATIONS_STORAGE, JSON.stringify(notifications)); +}; - Notifications.saveUri = async function (uri) { - baseURI = uri || constants.groundControlUri; // settign the url to use currently. if not set - use default - return AsyncStorage.setItem(GROUNDCONTROL_BASE_URI, uri); - }; +const postTokenConfig = async () => { + console.debug('postTokenConfig: Starting token configuration'); + const pushToken = await getPushToken(); + console.debug('postTokenConfig: Retrieved push token:', !!pushToken); - Notifications.getSavedUri = async function () { - return AsyncStorage.getItem(GROUNDCONTROL_BASE_URI); - }; + if (!pushToken || !pushToken.token || !pushToken.os) { + console.debug('postTokenConfig: Invalid token or missing OS info'); + return; + } - Notifications.isGroundControlUriValid = async uri => { - const apiCall = new Frisbee({ - baseURI: uri, + try { + const lang = (await AsyncStorage.getItem('lang')) || 'en'; + const appVersion = getSystemName() + ' ' + getSystemVersion() + ';' + getApplicationName() + ' ' + getVersion(); + console.debug('postTokenConfig: Posting configuration', { lang, appVersion }); + + await fetch(`${baseURI}/setTokenConfiguration`, { + method: 'POST', + headers: _getHeaders(), + body: JSON.stringify({ + token: pushToken.token, + os: pushToken.os, + lang, + app_version: appVersion, + }), }); - let response; - try { - response = await Promise.race([apiCall.get('/ping', _getHeaders()), _sleep(2000)]); - } catch (_) {} - - if (!response || !response.body) return false; // either sleep expired or apiCall threw an exception + } catch (e) { + console.error(e); + await AsyncStorage.setItem('lang', 'en'); + throw e; + } +}; - const json = response.body; - if (json.description) return true; +const _setPushToken = async token => { + try { + token = JSON.stringify(token); + return await AsyncStorage.setItem(PUSH_TOKEN, token); + } catch (error) { + console.error('Error setting push token:', error); + throw error; + } +}; + +/** + * Configures notifications. For Android, it will show a native rationale prompt if necessary. + * + * @returns {Promise} + */ +export const configureNotifications = async onProcessNotifications => { + if (alreadyConfigured) { + console.debug('configureNotifications: Already configured, skipping'); + return true; + } - return false; - }; + return new Promise(resolve => { + const handleRegistration = async token => { + if (__DEV__) { + console.debug('configureNotifications: Token received:', token); + } + alreadyConfigured = true; + await _setPushToken(token); + resolve(true); + }; - /** - * Returns a permissions object: - * alert: boolean - * badge: boolean - * sound: boolean - * - * @returns {Promise} - */ - Notifications.checkPermissions = async function () { - return new Promise(function (resolve) { - PushNotification.checkPermissions(result => { - resolve(result); + const handleNotification = async notification => { + // Deep clone to avoid modifying the original object + const payload = structuredClone({ + ...notification, + ...notification.data, }); - }); - }; - /** - * Posts to groundcontrol info whether we want to opt in or out of specific notifications level - * - * @param levelAll {Boolean} - * @returns {Promise<*>} - */ - Notifications.setLevels = async function (levelAll) { - const pushToken = await Notifications.getPushToken(); - if (!pushToken || !pushToken.token || !pushToken.os) return; - - const api = new Frisbee({ baseURI }); - - try { - await api.post( - '/setTokenConfiguration', - Object.assign({}, _getHeaders(), { - body: { - level_all: !!levelAll, - token: pushToken.token, - os: pushToken.os, - }, - }), - ); - } catch (_) {} - }; + if (notification.data?.data) { + const validData = Object.fromEntries(Object.entries(notification.data.data).filter(([_, value]) => value != null)); + Object.assign(payload, validData); + } + payload.data = undefined; - /** - * Queries groundcontrol for token configuration, which contains subscriptions to notification levels - * - * @returns {Promise<{}|*>} - */ - const getLevels = async function () { - const pushToken = await Notifications.getPushToken(); - if (!pushToken || !pushToken.token || !pushToken.os) return; + if (!payload.title && !payload.message) { + console.warn('Notification missing required fields:', payload); + return; + } - const api = new Frisbee({ baseURI }); + await addNotification(payload); + notification.finish(PushNotificationIOS.FetchResult.NoData); - let response; - try { - response = await Promise.race([ - api.post('/getTokenConfiguration', Object.assign({}, _getHeaders(), { body: { token: pushToken.token, os: pushToken.os } })), - _sleep(3000), - ]); - } catch (_) {} - - if (!response || !response.body) return {}; // either sleep expired or apiCall threw an exception + if (payload.foreground && onProcessNotifications) { + await onProcessNotifications(); + } + }; - return response.body; - }; + const configure = async () => { + try { + const { status } = await checkNotifications(); + if (status !== RESULTS.GRANTED) { + console.debug('configureNotifications: Permissions not granted'); + return resolve(false); + } - Notifications.getStoredNotifications = async function () { - let notifications = []; - try { - const stringified = await AsyncStorage.getItem(NOTIFICATIONS_STORAGE); - notifications = JSON.parse(stringified); - if (!Array.isArray(notifications)) notifications = []; - } catch (_) {} + const existingToken = await getPushToken(); + if (existingToken) { + alreadyConfigured = true; + console.debug('Notifications already configured with existing token'); + return resolve(true); + } + + PushNotification.configure({ + onRegister: handleRegistration, + onNotification: handleNotification, + onRegistrationError: error => { + console.error('Registration error:', error); + resolve(false); + }, + permissions: { alert: true, badge: true, sound: true }, + popInitialNotification: true, + }); + } catch (error) { + console.error('Error in configure:', error); + resolve(false); + } + }; - return notifications; - }; + configure(); + }); +}; + +const _sleep = async ms => { + return new Promise(resolve => setTimeout(resolve, ms)); +}; + +/** + * Validates whether the provided GroundControl URI is valid by pinging it. + * + * @param uri {string} + * @returns {Promise} TRUE if valid, FALSE otherwise + */ +export const isGroundControlUriValid = async uri => { + let response; + try { + response = await Promise.race([fetch(`${uri}/ping`, { headers: _getHeaders() }), _sleep(2000)]); + } catch (_) {} + + if (!response) return false; + + const json = await response.json(); + return !!json.description; +}; + +export const isNotificationsCapable = hasGmsSync() || hasHmsSync() || Platform.OS !== 'android'; + +export const getPushToken = async () => { + try { + let token = await AsyncStorage.getItem(PUSH_TOKEN); + token = JSON.parse(token); + return token; + } catch (e) { + console.error(e); + AsyncStorage.removeItem(PUSH_TOKEN); + throw e; + } +}; + +/** + * Queries groundcontrol for token configuration, which contains subscriptions to notification levels + * + * @returns {Promise<{}|*>} + */ +const getLevels = async () => { + const pushToken = await getPushToken(); + if (!pushToken || !pushToken.token || !pushToken.os) return; + + let response; + try { + response = await Promise.race([ + fetch(`${baseURI}/getTokenConfiguration`, { + method: 'POST', + headers: _getHeaders(), + body: JSON.stringify({ + token: pushToken.token, + os: pushToken.os, + }), + }), + _sleep(3000), + ]); + } catch (_) {} + + if (!response) return {}; + + return await response.json(); +}; + +/** + * The opposite of `majorTomToGroundControl` call. + * + * @param addresses {string[]} + * @param hashes {string[]} + * @param txids {string[]} + * @returns {Promise} Response object from API rest call + */ +export const unsubscribe = async (addresses, hashes, txids) => { + if (!Array.isArray(addresses) || !Array.isArray(hashes) || !Array.isArray(txids)) { + throw new Error('No addresses, hashes, or txids provided'); + } - Notifications.addNotification = async function (notification) { - let notifications = []; - try { - const stringified = await AsyncStorage.getItem(NOTIFICATIONS_STORAGE); - notifications = JSON.parse(stringified); - if (!Array.isArray(notifications)) notifications = []; - } catch (_) {} + const token = await getPushToken(); + if (!token?.token || !token?.os) { + console.error('No push token or OS found'); + return; + } - notifications.push(notification); - await AsyncStorage.setItem(NOTIFICATIONS_STORAGE, JSON.stringify(notifications)); - }; + const body = JSON.stringify({ + addresses, + hashes, + txids, + token: token.token, + os: token.os, + }); + + try { + const response = await fetch(`${baseURI}/unsubscribe`, { + method: 'POST', + headers: _getHeaders(), + body, + }); - const postTokenConfig = async function () { - const pushToken = await Notifications.getPushToken(); - if (!pushToken || !pushToken.token || !pushToken.os) return; + if (!response.ok) { + console.error('Failed to unsubscribe:', response.statusText); + return; + } - const api = new Frisbee({ baseURI }); + return response; + } catch (error) { + console.error('Error during unsubscribe:', error); + throw error; + } +}; - try { - const lang = (await AsyncStorage.getItem('lang')) || 'en'; - const appVersion = getSystemName() + ' ' + getSystemVersion() + ';' + getApplicationName() + ' ' + getVersion(); - - await api.post( - '/setTokenConfiguration', - Object.assign({}, _getHeaders(), { - body: { - token: pushToken.token, - os: pushToken.os, - lang, - app_version: appVersion, - }, - }), - ); - } catch (_) {} +const _getHeaders = () => { + return { + 'Access-Control-Allow-Origin': '*', + 'Content-Type': 'application/json', }; +}; - Notifications.clearStoredNotifications = async function () { - try { - await AsyncStorage.setItem(NOTIFICATIONS_STORAGE, JSON.stringify([])); - } catch (_) {} - }; +export const clearStoredNotifications = async () => { + try { + await AsyncStorage.setItem(NOTIFICATIONS_STORAGE, JSON.stringify([])); + } catch (_) {} +}; - Notifications.getDeliveredNotifications = () => { +export const getDeliveredNotifications = () => { + try { return new Promise(resolve => { PushNotification.getDeliveredNotifications(notifications => resolve(notifications)); }); - }; + } catch (error) { + console.error('Error getting delivered notifications:', error); + throw error; + } +}; + +export const removeDeliveredNotifications = (identifiers = []) => { + PushNotification.removeDeliveredNotifications(identifiers); +}; + +export const setApplicationIconBadgeNumber = badges => { + PushNotification.setApplicationIconBadgeNumber(badges); +}; + +export const removeAllDeliveredNotifications = () => { + PushNotification.removeAllDeliveredNotifications(); +}; + +export const getDefaultUri = () => { + return groundControlUri; +}; + +export const saveUri = async uri => { + try { + baseURI = uri || groundControlUri; + await AsyncStorage.setItem(GROUNDCONTROL_BASE_URI, baseURI); + } catch (error) { + console.error('Error saving URI:', error); + throw error; + } +}; - Notifications.removeDeliveredNotifications = (identifiers = []) => { - PushNotification.removeDeliveredNotifications(identifiers); - }; +export const getSavedUri = async () => { + try { + const baseUriStored = await AsyncStorage.getItem(GROUNDCONTROL_BASE_URI); + if (baseUriStored) { + baseURI = baseUriStored; + } + return baseUriStored; + } catch (e) { + console.error(e); + try { + await AsyncStorage.setItem(GROUNDCONTROL_BASE_URI, groundControlUri); + } catch (storageError) { + console.error('Failed to reset URI:', storageError); + } + throw e; + } +}; - Notifications.setApplicationIconBadgeNumber = function (badges) { - PushNotification.setApplicationIconBadgeNumber(badges); - }; +export const isNotificationsEnabled = async () => { + try { + const levels = await getLevels(); + const token = await getPushToken(); + const isDisabledByUser = (await AsyncStorage.getItem(NOTIFICATIONS_NO_AND_DONT_ASK_FLAG)) === 'true'; - Notifications.removeAllDeliveredNotifications = () => { - PushNotification.removeAllDeliveredNotifications(); - }; + // Return true only if we have all requirements and user hasn't opted out + return !isDisabledByUser && !!token && !!levels.level_all; + } catch (error) { + console.log('Error checking notification levels:', error); + return false; + } +}; + +export const getStoredNotifications = async () => { + let notifications = []; + try { + const stringified = await AsyncStorage.getItem(NOTIFICATIONS_STORAGE); + notifications = JSON.parse(stringified); + if (!Array.isArray(notifications)) notifications = []; + } catch (e) { + if (e instanceof SyntaxError) { + console.error('Invalid notifications format:', e); + notifications = []; + await AsyncStorage.setItem(NOTIFICATIONS_STORAGE, '[]'); + } else { + console.error('Error accessing notifications:', e); + throw e; + } + } - // on app launch (load module): - (async () => { - // first, fetching to see if app uses custom GroundControl server, not the default one - try { - const baseUriStored = await AsyncStorage.getItem(GROUNDCONTROL_BASE_URI); - if (baseUriStored) { - baseURI = baseUriStored; - } - } catch (_) {} + return notifications; +}; - // every launch should clear badges: - Notifications.setApplicationIconBadgeNumber(0); +// on app launch (load module): +export const initializeNotifications = async onProcessNotifications => { + console.debug('initializeNotifications: Starting initialization'); + try { + const noAndDontAskFlag = await AsyncStorage.getItem(NOTIFICATIONS_NO_AND_DONT_ASK_FLAG); + console.debug('initializeNotifications: No ask flag status:', noAndDontAskFlag); - if (!(await Notifications.getPushToken())) return; - // if we previously had token that means we already acquired permission from the user and it is safe to call - // `configure` to register callbacks etc - await configureNotifications(); - await postTokenConfig(); - })(); - return null; -} + if (noAndDontAskFlag === 'true') { + console.warn('User has opted out of notifications.'); + return; + } -export default Notifications; + const baseUriStored = await AsyncStorage.getItem(GROUNDCONTROL_BASE_URI); + baseURI = baseUriStored || groundControlUri; + console.debug('Base URI set to:', baseURI); + + setApplicationIconBadgeNumber(0); + + // Only check permissions, never request + currentPermissionStatus = await checkNotificationPermissionStatus(); + console.debug('initializeNotifications: Permission status:', currentPermissionStatus); + + // Handle Android 13+ permissions differently + const canProceed = + Platform.OS === 'android' + ? isNotificationsCapable && (await checkAndroidNotificationPermission()) + : currentPermissionStatus === 'granted'; + + if (canProceed) { + console.debug('initializeNotifications: Can proceed with notification setup'); + const token = await getPushToken(); + + if (token) { + console.debug('initializeNotifications: Existing token found, configuring'); + await configureNotifications(onProcessNotifications); + await postTokenConfig(); + } else { + console.debug('initializeNotifications: No token found, will request permissions'); + await tryToObtainPermissions(); + } + } else { + console.debug('Notifications require user action to enable'); + } + } catch (error) { + console.error('Failed to initialize notifications:', error); + baseURI = groundControlUri; + await AsyncStorage.setItem(GROUNDCONTROL_BASE_URI, groundControlUri).catch(err => console.error('Failed to reset URI:', err)); + } +}; diff --git a/blue_modules/react-native-bw-file-access/.gitignore b/blue_modules/react-native-bw-file-access/.gitignore new file mode 100644 index 0000000000..a1b76a8a6f --- /dev/null +++ b/blue_modules/react-native-bw-file-access/.gitignore @@ -0,0 +1,42 @@ +# OSX +# +.DS_Store +**/package-lock.json +# node.js +# +node_modules/ +npm-debug.log +yarn-error.log + +# Xcode +# +build/ +*.pbxuser +!default.pbxuser +*.mode1v3 +!default.mode1v3 +*.mode2v3 +!default.mode2v3 +*.perspectivev3 +!default.perspectivev3 +xcuserdata +*.xccheckout +*.moved-aside +DerivedData +*.hmap +*.ipa +*.xcuserstate +project.xcworkspace + +# Android/IntelliJ +# +build/ +.idea +.gradle +local.properties +*.iml + +# BUCK +buck-out/ +\.buckd/ +*.keystore diff --git a/blue_modules/react-native-bw-file-access/README.md b/blue_modules/react-native-bw-file-access/README.md new file mode 100644 index 0000000000..3e86cf135c --- /dev/null +++ b/blue_modules/react-native-bw-file-access/README.md @@ -0,0 +1,6 @@ +# react-native-bw-file-access + +A custom package written to allow BlueWallet to open files directly from the Files app in iOS. We make use of `startAccessingSecurityScopedResource()` and `stopAccessingSecurityScopedResource()`. + +Read Apple's documentation to understand more about the Open-in-Place mechanics for accessing files which are not in an apps sandbox environment. +[Link here](https://developer.apple.com/documentation/uikit/documents_data_and_pasteboard/synchronizing_documents_in_the_icloud_environment#3743499). diff --git a/blue_modules/react-native-bw-file-access/index.ts b/blue_modules/react-native-bw-file-access/index.ts new file mode 100644 index 0000000000..6cd5303826 --- /dev/null +++ b/blue_modules/react-native-bw-file-access/index.ts @@ -0,0 +1,11 @@ +// main index.js + +import { NativeModules } from 'react-native'; + +const { BwFileAccess } = NativeModules; + +export function readFile(filePath: string): Promise { + return BwFileAccess.readFileContent(filePath); +} + +export default BwFileAccess; diff --git a/blue_modules/react-native-bw-file-access/ios/BwFileAccess.h b/blue_modules/react-native-bw-file-access/ios/BwFileAccess.h new file mode 100644 index 0000000000..0ecd3f53c1 --- /dev/null +++ b/blue_modules/react-native-bw-file-access/ios/BwFileAccess.h @@ -0,0 +1,7 @@ +// BwFileAccess.h + +#import + +@interface BwFileAccess : NSObject + +@end diff --git a/blue_modules/react-native-bw-file-access/ios/BwFileAccess.m b/blue_modules/react-native-bw-file-access/ios/BwFileAccess.m new file mode 100644 index 0000000000..8081536ebd --- /dev/null +++ b/blue_modules/react-native-bw-file-access/ios/BwFileAccess.m @@ -0,0 +1,33 @@ +// BwFileAccess.m + +#import "BwFileAccess.h" + + +@implementation BwFileAccess + +RCT_EXPORT_MODULE() + +RCT_EXPORT_METHOD(readFileContent:(NSString *)filePath + resolver:(RCTPromiseResolveBlock)resolve + rejecter:(RCTPromiseRejectBlock)reject) +{ + NSURL *fileURL = [NSURL URLWithString:filePath]; + + if ([fileURL startAccessingSecurityScopedResource]) { + NSError *error; + NSData *fileData = [NSData dataWithContentsOfURL:fileURL options:0 error:&error]; + + if (fileData) { + NSString *fileContent = [[NSString alloc] initWithData:fileData encoding:NSUTF8StringEncoding]; + resolve(fileContent); + } else { + reject(@"READ_ERROR", @"Failed to read file", error); + } + + [fileURL stopAccessingSecurityScopedResource]; + } else { + reject(@"ACCESS_ERROR", @"Failed to access security scoped resource", nil); + } +} + +@end diff --git a/blue_modules/react-native-bw-file-access/ios/BwFileAccess.xcodeproj/project.pbxproj b/blue_modules/react-native-bw-file-access/ios/BwFileAccess.xcodeproj/project.pbxproj new file mode 100644 index 0000000000..37bfa0e0c4 --- /dev/null +++ b/blue_modules/react-native-bw-file-access/ios/BwFileAccess.xcodeproj/project.pbxproj @@ -0,0 +1,281 @@ +// !$*UTF8*$! +{ + archiveVersion = 1; + classes = { + }; + objectVersion = 46; + objects = { + +/* Begin PBXCopyFilesBuildPhase section */ + 58B511D91A9E6C8500147676 /* CopyFiles */ = { + isa = PBXCopyFilesBuildPhase; + buildActionMask = 2147483647; + dstPath = "include/$(PRODUCT_NAME)"; + dstSubfolderSpec = 16; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXCopyFilesBuildPhase section */ + +/* Begin PBXFileReference section */ + 134814201AA4EA6300B7C361 /* libBwFileAccess.a */ = {isa = PBXFileReference; explicitFileType = archive.ar; includeInIndex = 0; path = libBwFileAccess.a; sourceTree = BUILT_PRODUCTS_DIR; }; +/* End PBXFileReference section */ + +/* Begin PBXFrameworksBuildPhase section */ + 58B511D81A9E6C8500147676 /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXFrameworksBuildPhase section */ + +/* Begin PBXGroup section */ + 134814211AA4EA7D00B7C361 /* Products */ = { + isa = PBXGroup; + children = ( + 134814201AA4EA6300B7C361 /* libBwFileAccess.a */, + ); + name = Products; + sourceTree = ""; + }; + 58B511D21A9E6C8500147676 = { + isa = PBXGroup; + children = ( + 134814211AA4EA7D00B7C361 /* Products */, + ); + sourceTree = ""; + }; +/* End PBXGroup section */ + +/* Begin PBXNativeTarget section */ + 58B511DA1A9E6C8500147676 /* BwFileAccess */ = { + isa = PBXNativeTarget; + buildConfigurationList = 58B511EF1A9E6C8500147676 /* Build configuration list for PBXNativeTarget "BwFileAccess" */; + buildPhases = ( + 58B511D71A9E6C8500147676 /* Sources */, + 58B511D81A9E6C8500147676 /* Frameworks */, + 58B511D91A9E6C8500147676 /* CopyFiles */, + ); + buildRules = ( + ); + dependencies = ( + ); + name = BwFileAccess; + productName = RCTDataManager; + productReference = 134814201AA4EA6300B7C361 /* libBwFileAccess.a */; + productType = "com.apple.product-type.library.static"; + }; +/* End PBXNativeTarget section */ + +/* Begin PBXProject section */ + 58B511D31A9E6C8500147676 /* Project object */ = { + isa = PBXProject; + attributes = { + LastUpgradeCheck = 0920; + ORGANIZATIONNAME = Facebook; + TargetAttributes = { + 58B511DA1A9E6C8500147676 = { + CreatedOnToolsVersion = 6.1.1; + }; + }; + }; + buildConfigurationList = 58B511D61A9E6C8500147676 /* Build configuration list for PBXProject "BwFileAccess" */; + compatibilityVersion = "Xcode 3.2"; + developmentRegion = en; + hasScannedForEncodings = 0; + knownRegions = ( + en, + Base, + ); + mainGroup = 58B511D21A9E6C8500147676; + productRefGroup = 58B511D21A9E6C8500147676; + projectDirPath = ""; + projectRoot = ""; + targets = ( + 58B511DA1A9E6C8500147676 /* BwFileAccess */, + ); + }; +/* End PBXProject section */ + +/* Begin PBXSourcesBuildPhase section */ + 58B511D71A9E6C8500147676 /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXSourcesBuildPhase section */ + +/* Begin XCBuildConfiguration section */ + 58B511ED1A9E6C8500147676 /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + CLANG_ANALYZER_NONNULL = YES; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++0x"; + CLANG_CXX_LIBRARY = "libc++"; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; + CLANG_WARN_BOOL_CONVERSION = YES; + CLANG_WARN_COMMA = YES; + CLANG_WARN_CONSTANT_CONVERSION = YES; + CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + CLANG_WARN_EMPTY_BODY = YES; + CLANG_WARN_ENUM_CONVERSION = YES; + CLANG_WARN_INFINITE_RECURSION = YES; + CLANG_WARN_INT_CONVERSION = YES; + CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; + CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; + CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_STRICT_PROTOTYPES = YES; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CLANG_WARN_UNREACHABLE_CODE = YES; + CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; + COPY_PHASE_STRIP = NO; + ENABLE_STRICT_OBJC_MSGSEND = YES; + ENABLE_TESTABILITY = YES; + GCC_C_LANGUAGE_STANDARD = gnu99; + GCC_DYNAMIC_NO_PIC = NO; + GCC_NO_COMMON_BLOCKS = YES; + GCC_OPTIMIZATION_LEVEL = 0; + GCC_PREPROCESSOR_DEFINITIONS = ( + "DEBUG=1", + "$(inherited)", + ); + GCC_SYMBOLS_PRIVATE_EXTERN = NO; + GCC_WARN_64_TO_32_BIT_CONVERSION = YES; + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNDECLARED_SELECTOR = YES; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + IPHONEOS_DEPLOYMENT_TARGET = 13.4; + LD_RUNPATH_SEARCH_PATHS = "/usr/lib/swift $(inherited)"; + LIBRARY_SEARCH_PATHS = ( + "\"$(TOOLCHAIN_DIR)/usr/lib/swift/$(PLATFORM_NAME)\"", + "\"$(TOOLCHAIN_DIR)/usr/lib/swift-5.0/$(PLATFORM_NAME)\"", + "\"$(inherited)\"", + ); + MTL_ENABLE_DEBUG_INFO = YES; + ONLY_ACTIVE_ARCH = YES; + SDKROOT = iphoneos; + }; + name = Debug; + }; + 58B511EE1A9E6C8500147676 /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + CLANG_ANALYZER_NONNULL = YES; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++0x"; + CLANG_CXX_LIBRARY = "libc++"; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; + CLANG_WARN_BOOL_CONVERSION = YES; + CLANG_WARN_COMMA = YES; + CLANG_WARN_CONSTANT_CONVERSION = YES; + CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + CLANG_WARN_EMPTY_BODY = YES; + CLANG_WARN_ENUM_CONVERSION = YES; + CLANG_WARN_INFINITE_RECURSION = YES; + CLANG_WARN_INT_CONVERSION = YES; + CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; + CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; + CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_STRICT_PROTOTYPES = YES; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CLANG_WARN_UNREACHABLE_CODE = YES; + CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; + COPY_PHASE_STRIP = YES; + ENABLE_NS_ASSERTIONS = NO; + ENABLE_STRICT_OBJC_MSGSEND = YES; + GCC_C_LANGUAGE_STANDARD = gnu99; + GCC_NO_COMMON_BLOCKS = YES; + GCC_WARN_64_TO_32_BIT_CONVERSION = YES; + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNDECLARED_SELECTOR = YES; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + IPHONEOS_DEPLOYMENT_TARGET = 13.4; + LD_RUNPATH_SEARCH_PATHS = "/usr/lib/swift $(inherited)"; + LIBRARY_SEARCH_PATHS = ( + "\"$(TOOLCHAIN_DIR)/usr/lib/swift/$(PLATFORM_NAME)\"", + "\"$(TOOLCHAIN_DIR)/usr/lib/swift-5.0/$(PLATFORM_NAME)\"", + "\"$(inherited)\"", + ); + MTL_ENABLE_DEBUG_INFO = NO; + SDKROOT = iphoneos; + VALIDATE_PRODUCT = YES; + }; + name = Release; + }; + 58B511F01A9E6C8500147676 /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + HEADER_SEARCH_PATHS = ( + "$(inherited)", + /Applications/Xcode.app/Contents/Developer/Toolchains/XcodeDefault.xctoolchain/usr/include, + "$(SRCROOT)/../../../React/**", + "$(SRCROOT)/../../react-native/React/**", + ); + LIBRARY_SEARCH_PATHS = "$(inherited)"; + OTHER_LDFLAGS = "-ObjC"; + PRODUCT_NAME = BwFileAccess; + SKIP_INSTALL = YES; + }; + name = Debug; + }; + 58B511F11A9E6C8500147676 /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + HEADER_SEARCH_PATHS = ( + "$(inherited)", + /Applications/Xcode.app/Contents/Developer/Toolchains/XcodeDefault.xctoolchain/usr/include, + "$(SRCROOT)/../../../React/**", + "$(SRCROOT)/../../react-native/React/**", + ); + LIBRARY_SEARCH_PATHS = "$(inherited)"; + OTHER_LDFLAGS = "-ObjC"; + PRODUCT_NAME = BwFileAccess; + SKIP_INSTALL = YES; + }; + name = Release; + }; +/* End XCBuildConfiguration section */ + +/* Begin XCConfigurationList section */ + 58B511D61A9E6C8500147676 /* Build configuration list for PBXProject "BwFileAccess" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 58B511ED1A9E6C8500147676 /* Debug */, + 58B511EE1A9E6C8500147676 /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; + 58B511EF1A9E6C8500147676 /* Build configuration list for PBXNativeTarget "BwFileAccess" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 58B511F01A9E6C8500147676 /* Debug */, + 58B511F11A9E6C8500147676 /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; +/* End XCConfigurationList section */ + }; + rootObject = 58B511D31A9E6C8500147676 /* Project object */; +} diff --git a/blue_modules/react-native-bw-file-access/ios/BwFileAccess.xcworkspace/contents.xcworkspacedata b/blue_modules/react-native-bw-file-access/ios/BwFileAccess.xcworkspace/contents.xcworkspacedata new file mode 100644 index 0000000000..a232186952 --- /dev/null +++ b/blue_modules/react-native-bw-file-access/ios/BwFileAccess.xcworkspace/contents.xcworkspacedata @@ -0,0 +1,7 @@ + + + + + diff --git a/blue_modules/react-native-bw-file-access/package.json b/blue_modules/react-native-bw-file-access/package.json new file mode 100644 index 0000000000..965bd35bef --- /dev/null +++ b/blue_modules/react-native-bw-file-access/package.json @@ -0,0 +1,36 @@ +{ + "name": "react-native-bw-file-access", + "title": "React Native Bw File Access", + "version": "1.0.0", + "description": "TODO", + "main": "index.ts", + "homepage": "https://github.com/setavenger/react-native-bw-file-access", + "files": [ + "README.md", + "android", + "index.ts", + "ios", + "react-native-bw-file-access.podspec" + ], + "scripts": { + "test": "echo \"Error: no test specified\" && exit 1" + }, + "repository": { + "type": "git", + "url": "git+https://github.com/setavenger/react-native-bw-file-access.git", + "baseUrl": "https://github.com/setavenger/react-native-bw-file-access" + }, + "keywords": [ + "react-native" + ], + "author": { + "name": "Setor Blagogee" + }, + "license": "MIT", + "licenseFilename": "LICENSE", + "readmeFilename": "README.md", + "peerDependencies": { + "react": ">=16.8.1", + "react-native": ">=0.60.0-rc.0 <1.0.x" + } +} diff --git a/blue_modules/react-native-bw-file-access/react-native-bw-file-access.podspec b/blue_modules/react-native-bw-file-access/react-native-bw-file-access.podspec new file mode 100644 index 0000000000..84ffdcbc0d --- /dev/null +++ b/blue_modules/react-native-bw-file-access/react-native-bw-file-access.podspec @@ -0,0 +1,19 @@ +require "json" + +package = JSON.parse(File.read(File.join(__dir__, "package.json"))) + +Pod::Spec.new do |s| + s.name = "react-native-bw-file-access" + s.version = package["version"] + s.summary = package["description"] + s.homepage = package["homepage"] + s.license = package["license"] + s.authors = package["author"] + + s.platforms = { :ios => "10.0" } + s.source = { :git => "https://github.com/setavenger/react-native-bw-file-access.git", :tag => "#{s.version}" } + + s.source_files = "ios/**/*.{h,m,mm}" + + s.dependency "React-Core" +end \ No newline at end of file diff --git a/blue_modules/showPopupMenu.android.ts b/blue_modules/showPopupMenu.android.ts deleted file mode 100644 index 114615f116..0000000000 --- a/blue_modules/showPopupMenu.android.ts +++ /dev/null @@ -1,32 +0,0 @@ -// @ts-ignore: Ignore -import type { Element } from 'react'; -import { Text, TouchableNativeFeedback, TouchableWithoutFeedback, View, findNodeHandle, UIManager } from 'react-native'; - -type PopupMenuItem = { id?: any; label: string }; -type OnPopupMenuItemSelect = (selectedPopupMenuItem: PopupMenuItem) => void; -type PopupAnchor = Element; -type PopupMenuOptions = { onCancel?: () => void }; - -function showPopupMenu( - items: PopupMenuItem[], - onSelect: OnPopupMenuItemSelect, - anchor: PopupAnchor, - { onCancel }: PopupMenuOptions = {}, -): void { - UIManager.showPopupMenu( - // @ts-ignore: Ignore - findNodeHandle(anchor), - items.map(item => item.label), - function () { - if (onCancel) onCancel(); - }, - function (eventName: 'dismissed' | 'itemSelected', selectedIndex?: number) { - // @ts-ignore: Ignore - if (eventName === 'itemSelected') onSelect(items[selectedIndex]); - else onCancel && onCancel(); - }, - ); -} - -export type { PopupMenuItem, OnPopupMenuItemSelect, PopupMenuOptions }; -export default showPopupMenu; diff --git a/blue_modules/start-and-decrypt.ts b/blue_modules/start-and-decrypt.ts new file mode 100644 index 0000000000..36752c43ac --- /dev/null +++ b/blue_modules/start-and-decrypt.ts @@ -0,0 +1,70 @@ +import { Platform } from 'react-native'; + +import { BlueApp as BlueAppClass } from '../class/'; +import prompt from '../helpers/prompt'; +import { showKeychainWipeAlert } from '../hooks/useBiometrics'; +import loc from '../loc'; + +const BlueApp = BlueAppClass.getInstance(); +// If attempt reaches 10, a wipe keychain option will be provided to the user. +let unlockAttempt = 0; + +export const startAndDecrypt = async (retry?: boolean): Promise => { + console.log('startAndDecrypt'); + if (BlueApp.getWallets().length > 0) { + console.log('App already has some wallets, so we are in already started state, exiting startAndDecrypt'); + return true; + } + await BlueApp.migrateKeys(); + let password: undefined | string; + if (await BlueApp.storageIsEncrypted()) { + do { + password = await prompt((retry && loc._.bad_password) || loc._.enter_password, loc._.storage_is_encrypted, false); + } while (!password); + } + let success = false; + let wasException = false; + try { + success = await BlueApp.loadFromDisk(password); + } catch (error) { + // in case of exception reading from keystore, lets retry instead of assuming there is no storage and + // proceeding with no wallets + console.warn('exception loading from disk:', error); + wasException = true; + } + + if (wasException) { + // retrying, but only once + try { + await new Promise(resolve => setTimeout(resolve, 3000)); // sleep + success = await BlueApp.loadFromDisk(password); + } catch (error) { + console.warn('second exception loading from disk:', error); + } + } + + if (success) { + console.log('loaded from disk'); + // We want to return true to let the UnlockWith screen that its ok to proceed. + return true; + } + + if (password) { + // we had password and yet could not load/decrypt + unlockAttempt++; + if (unlockAttempt < 10 || Platform.OS !== 'ios') { + return startAndDecrypt(true); + } else { + unlockAttempt = 0; + showKeychainWipeAlert(); + // We want to return false to let the UnlockWith screen that it is NOT ok to proceed. + return false; + } + } else { + unlockAttempt = 0; + // Return true because there was no wallet data in keychain. Proceed. + return true; + } +}; + +export default BlueApp; diff --git a/blue_modules/storage-context.js b/blue_modules/storage-context.js deleted file mode 100644 index 01622923af..0000000000 --- a/blue_modules/storage-context.js +++ /dev/null @@ -1,289 +0,0 @@ -import React, { createContext, useEffect, useState } from 'react'; -import { Alert } from 'react-native'; -import ReactNativeHapticFeedback from 'react-native-haptic-feedback'; -import { useAsyncStorage } from '@react-native-async-storage/async-storage'; -import { FiatUnit } from '../models/fiatUnit'; -import Notifications from '../blue_modules/notifications'; -import loc, { STORAGE_KEY as LOC_STORAGE_KEY } from '../loc'; -import { LegacyWallet, WatchOnlyWallet } from '../class'; -import { isTorDaemonDisabled, setIsTorDaemonDisabled } from './environment'; -import alert from '../components/Alert'; -const BlueApp = require('../BlueApp'); -const BlueElectrum = require('./BlueElectrum'); -const currency = require('../blue_modules/currency'); -const A = require('../blue_modules/analytics'); - -const _lastTimeTriedToRefetchWallet = {}; // hashmap of timestamps we _started_ refetching some wallet - -export const WalletTransactionsStatus = { NONE: false, ALL: true }; -export const BlueStorageContext = createContext(); -export const BlueStorageProvider = ({ children }) => { - const [wallets, setWallets] = useState([]); - const [selectedWallet, setSelectedWallet] = useState(''); - const [walletTransactionUpdateStatus, setWalletTransactionUpdateStatus] = useState(WalletTransactionsStatus.NONE); - const [walletsInitialized, setWalletsInitialized] = useState(false); - const [preferredFiatCurrency, _setPreferredFiatCurrency] = useState(FiatUnit.USD); - const [language, _setLanguage] = useState(); - const getPreferredCurrencyAsyncStorage = useAsyncStorage(currency.PREFERRED_CURRENCY).getItem; - const getLanguageAsyncStorage = useAsyncStorage(LOC_STORAGE_KEY).getItem; - const [isHandOffUseEnabled, setIsHandOffUseEnabled] = useState(false); - const [isElectrumDisabled, setIsElectrumDisabled] = useState(true); - const [isTorDisabled, setIsTorDisabled] = useState(false); - const [isPrivacyBlurEnabled, setIsPrivacyBlurEnabled] = useState(true); - - useEffect(() => { - BlueElectrum.isDisabled().then(setIsElectrumDisabled); - isTorDaemonDisabled().then(setIsTorDisabled); - }, []); - - useEffect(() => { - console.log(`Privacy blur: ${isPrivacyBlurEnabled}`); - if (!isPrivacyBlurEnabled) { - alert('Privacy blur has been disabled.'); - } - }, [isPrivacyBlurEnabled]); - - useEffect(() => { - setIsTorDaemonDisabled(isTorDisabled); - }, [isTorDisabled]); - - const setIsHandOffUseEnabledAsyncStorage = value => { - setIsHandOffUseEnabled(value); - return BlueApp.setIsHandoffEnabled(value); - }; - - const saveToDisk = async (force = false) => { - if (BlueApp.getWallets().length === 0 && !force) { - console.log('not saving empty wallets array'); - return; - } - BlueApp.tx_metadata = txMetadata; - await BlueApp.saveToDisk(); - setWallets([...BlueApp.getWallets()]); - txMetadata = BlueApp.tx_metadata; - }; - - useEffect(() => { - setWallets(BlueApp.getWallets()); - }, []); - - useEffect(() => { - (async () => { - try { - const enabledHandoff = await BlueApp.isHandoffEnabled(); - setIsHandOffUseEnabled(!!enabledHandoff); - } catch (_e) { - setIsHandOffUseEnabledAsyncStorage(false); - setIsHandOffUseEnabled(false); - } - })(); - }, []); - - const getPreferredCurrency = async () => { - const item = await getPreferredCurrencyAsyncStorage(); - _setPreferredFiatCurrency(item); - }; - - const setPreferredFiatCurrency = () => { - getPreferredCurrency(); - }; - - const getLanguage = async () => { - const item = await getLanguageAsyncStorage(); - _setLanguage(item); - }; - - const setLanguage = () => { - getLanguage(); - }; - - useEffect(() => { - getPreferredCurrency(); - getLanguageAsyncStorage(); - // eslint-disable-next-line react-hooks/exhaustive-deps - }, []); - - const resetWallets = () => { - setWallets(BlueApp.getWallets()); - }; - - const setWalletsWithNewOrder = wlts => { - BlueApp.wallets = wlts; - saveToDisk(); - }; - - const refreshAllWalletTransactions = async (lastSnappedTo, showUpdateStatusIndicator = true) => { - let noErr = true; - try { - if (showUpdateStatusIndicator) { - setWalletTransactionUpdateStatus(WalletTransactionsStatus.ALL); - } - await BlueElectrum.waitTillConnected(); - const paymentCodesStart = Date.now(); - await fetchSenderPaymentCodes(lastSnappedTo); - const paymentCodesEnd = Date.now(); - console.log('fetch payment codes took', (paymentCodesEnd - paymentCodesStart) / 1000, 'sec'); - const balanceStart = +new Date(); - await fetchWalletBalances(lastSnappedTo); - const balanceEnd = +new Date(); - console.log('fetch balance took', (balanceEnd - balanceStart) / 1000, 'sec'); - const start = +new Date(); - await fetchWalletTransactions(lastSnappedTo); - const end = +new Date(); - console.log('fetch tx took', (end - start) / 1000, 'sec'); - } catch (err) { - noErr = false; - console.warn(err); - } finally { - setWalletTransactionUpdateStatus(WalletTransactionsStatus.NONE); - } - if (noErr) await saveToDisk(); // caching - }; - - const fetchAndSaveWalletTransactions = async walletID => { - const index = wallets.findIndex(wallet => wallet.getID() === walletID); - let noErr = true; - try { - // 5sec debounce: - setWalletTransactionUpdateStatus(walletID); - if (+new Date() - _lastTimeTriedToRefetchWallet[walletID] < 5000) { - console.log('re-fetch wallet happens too fast; NOP'); - return; - } - _lastTimeTriedToRefetchWallet[walletID] = +new Date(); - - await BlueElectrum.waitTillConnected(); - const balanceStart = +new Date(); - await fetchWalletBalances(index); - const balanceEnd = +new Date(); - console.log('fetch balance took', (balanceEnd - balanceStart) / 1000, 'sec'); - const start = +new Date(); - await fetchWalletTransactions(index); - const end = +new Date(); - console.log('fetch tx took', (end - start) / 1000, 'sec'); - } catch (err) { - noErr = false; - console.warn(err); - } finally { - setWalletTransactionUpdateStatus(WalletTransactionsStatus.NONE); - } - if (noErr) await saveToDisk(); // caching - }; - - const addWallet = wallet => { - BlueApp.wallets.push(wallet); - setWallets([...BlueApp.getWallets()]); - }; - - const deleteWallet = wallet => { - BlueApp.deleteWallet(wallet); - setWallets([...BlueApp.getWallets()]); - }; - - const addAndSaveWallet = async w => { - if (wallets.some(i => i.getID() === w.getID())) { - ReactNativeHapticFeedback.trigger('notificationError', { ignoreAndroidSystemSettings: false }); - Alert.alert('', 'This wallet has been previously imported.'); - return; - } - const emptyWalletLabel = new LegacyWallet().getLabel(); - ReactNativeHapticFeedback.trigger('notificationSuccess', { ignoreAndroidSystemSettings: false }); - if (w.getLabel() === emptyWalletLabel) w.setLabel(loc.wallets.import_imported + ' ' + w.typeReadable); - w.setUserHasSavedExport(true); - addWallet(w); - await saveToDisk(); - A(A.ENUM.CREATED_WALLET); - Alert.alert('', w.type === WatchOnlyWallet.type ? loc.wallets.import_success_watchonly : loc.wallets.import_success); - Notifications.majorTomToGroundControl(w.getAllExternalAddresses(), [], []); - // start balance fetching at the background - await w.fetchBalance(); - setWallets([...BlueApp.getWallets()]); - }; - - let txMetadata = BlueApp.tx_metadata || {}; - const getTransactions = BlueApp.getTransactions; - const isAdvancedModeEnabled = BlueApp.isAdvancedModeEnabled; - - const fetchSenderPaymentCodes = BlueApp.fetchSenderPaymentCodes; - const fetchWalletBalances = BlueApp.fetchWalletBalances; - const fetchWalletTransactions = BlueApp.fetchWalletTransactions; - const getBalance = BlueApp.getBalance; - const isStorageEncrypted = BlueApp.storageIsEncrypted; - const startAndDecrypt = BlueApp.startAndDecrypt; - const encryptStorage = BlueApp.encryptStorage; - const sleep = BlueApp.sleep; - const setHodlHodlApiKey = BlueApp.setHodlHodlApiKey; - const getHodlHodlApiKey = BlueApp.getHodlHodlApiKey; - const createFakeStorage = BlueApp.createFakeStorage; - const decryptStorage = BlueApp.decryptStorage; - const isPasswordInUse = BlueApp.isPasswordInUse; - const cachedPassword = BlueApp.cachedPassword; - const setIsAdvancedModeEnabled = BlueApp.setIsAdvancedModeEnabled; - const getHodlHodlSignatureKey = BlueApp.getHodlHodlSignatureKey; - const addHodlHodlContract = BlueApp.addHodlHodlContract; - const getHodlHodlContracts = BlueApp.getHodlHodlContracts; - const setDoNotTrack = BlueApp.setDoNotTrack; - const isDoNotTrackEnabled = BlueApp.isDoNotTrackEnabled; - const getItem = BlueApp.getItem; - const setItem = BlueApp.setItem; - - return ( - - {children} - - ); -}; diff --git a/blue_modules/tls.js b/blue_modules/tls.js deleted file mode 100644 index 1684e71c4d..0000000000 --- a/blue_modules/tls.js +++ /dev/null @@ -1,46 +0,0 @@ -/** - * @fileOverview adapter for ReactNative TCP module - * This module mimics the nodejs tls api and is intended to work in RN environment. - * @see https://github.com/Rapsssito/react-native-tcp-socket - */ - -import TcpSocket from 'react-native-tcp-socket'; - -/** - * Constructor function. Mimicking nodejs/tls api - * - * @constructor - */ -function connect(config, callback) { - const client = TcpSocket.createConnection( - { - port: config.port, - host: config.host, - tls: true, - tlsCheckValidity: config.rejectUnauthorized, - }, - callback, - ); - - // defaults: - this._noDelay = true; - - // functions not supported by RN module, yet: - client.setTimeout = () => {}; - client.setEncoding = () => {}; - client.setKeepAlive = () => {}; - - // we will save `noDelay` and proxy it to socket object when its actually created and connected: - const realSetNoDelay = client.setNoDelay; // reference to real setter - client.setNoDelay = noDelay => { - this._noDelay = noDelay; - }; - - client.on('connect', () => { - realSetNoDelay.apply(client, [this._noDelay]); - }); - - return client; -} - -module.exports.connect = connect; diff --git a/blue_modules/torrific.js b/blue_modules/torrific.js deleted file mode 100644 index bde1b4cf8c..0000000000 --- a/blue_modules/torrific.js +++ /dev/null @@ -1,290 +0,0 @@ -import Tor from 'react-native-tor'; -const tor = Tor({ - bootstrapTimeoutMs: 35000, - numberConcurrentRequests: 1, -}); - -/** - * TOR wrapper mimicking Frisbee interface - */ -class Torsbee { - baseURI = ''; - - static _testConn; - static _resolveReference; - static _rejectReference; - - constructor(opts) { - opts = opts || {}; - this.baseURI = opts.baseURI || this.baseURI; - } - - async get(path, options) { - console.log('TOR: starting...'); - const socksProxy = await tor.startIfNotStarted(); - console.log('TOR: started', await tor.getDaemonStatus(), 'on local port', socksProxy); - if (path.startsWith('/') && this.baseURI.endsWith('/')) { - // oy vey, duplicate slashes - path = path.substr(1); - } - - const response = {}; - try { - const uri = this.baseURI + path; - console.log('TOR: requesting', uri); - const torResponse = await tor.get(uri, options?.headers || {}, true); - response.originalResponse = torResponse; - - if (options?.headers['Content-Type'] === 'application/json' && torResponse.json) { - response.body = torResponse.json; - } else { - response.body = Buffer.from(torResponse.b64Data, 'base64').toString(); - } - } catch (error) { - response.err = error; - console.warn(error); - } - - return response; - } - - async post(path, options) { - console.log('TOR: starting...'); - const socksProxy = await tor.startIfNotStarted(); - console.log('TOR: started', await tor.getDaemonStatus(), 'on local port', socksProxy); - if (path.startsWith('/') && this.baseURI.endsWith('/')) { - // oy vey, duplicate slashes - path = path.substr(1); - } - - const uri = this.baseURI + path; - console.log('TOR: posting to', uri); - - const response = {}; - try { - const torResponse = await tor.post(uri, JSON.stringify(options?.body || {}), options?.headers || {}, true); - response.originalResponse = torResponse; - - if (options?.headers['Content-Type'] === 'application/json' && torResponse.json) { - response.body = torResponse.json; - } else { - response.body = Buffer.from(torResponse.b64Data, 'base64').toString(); - } - } catch (error) { - response.err = error; - console.warn(error); - } - - return response; - } - - testSocket() { - return new Promise((resolve, reject) => { - this.constructor._resolveReference = resolve; - this.constructor._rejectReference = reject; - (async () => { - console.log('testSocket...'); - try { - if (!this.constructor._testConn) { - // no test conenctino exists, creating it... - await tor.startIfNotStarted(); - const target = 'explorerzydxu5ecjrkwceayqybizmpjjznk5izmitf2modhcusuqlid.onion:110'; - this.constructor._testConn = await tor.createTcpConnection({ target }, (data, err) => { - if (err) { - return this.constructor._rejectReference(new Error(err)); - } - const json = JSON.parse(data); - if (!json || typeof json.result === 'undefined') - return this.constructor._rejectReference(new Error('Unexpected response from TOR socket: ' + JSON.stringify(json))); - - // conn.close(); - // instead of closing connect, we will actualy re-cyce existing test connection as we - // saved it into `this.constructor.testConn` - this.constructor._resolveReference(); - }); - - await this.constructor._testConn.write( - `{ "id": 1, "method": "blockchain.scripthash.get_balance", "params": ["716decbe1660861c3d93906cb1d98ee68b154fd4d23aed9783859c1271b52a9c"] }\n`, - ); - } else { - // test connectino exists, so we are reusing it - await this.constructor._testConn.write( - `{ "id": 1, "method": "blockchain.scripthash.get_balance", "params": ["716decbe1660861c3d93906cb1d98ee68b154fd4d23aed9783859c1271b52a9c"] }\n`, - ); - } - } catch (error) { - this.constructor._rejectReference(error); - } - })(); - }); - } -} - -/** - * Wrapper for react-native-tor mimicking Socket class from NET package - */ -class TorSocket { - constructor() { - this._socket = false; - this._listeners = {}; - } - - setTimeout() {} - - setEncoding() {} - - setKeepAlive() {} - - setNoDelay() {} - - on(event, listener) { - this._listeners[event] = this._listeners[event] || []; - this._listeners[event].push(listener); - } - - removeListener(event, listener) { - this._listeners[event] = this._listeners[event] || []; - const newListeners = []; - - let found = false; - for (const savedListener of this._listeners[event]) { - // eslint-disable-next-line eqeqeq - if (savedListener == listener) { - // found our listener - found = true; - // we just skip it - } else { - // other listeners should go back to original array - newListeners.push(savedListener); - } - } - - if (found) { - this._listeners[event] = newListeners; - } else { - // something went wrong, lets just cleanup all listeners - this._listeners[event] = []; - } - } - - connect(port, host, callback) { - console.log('connecting TOR socket...', host, port); - (async () => { - console.log('starting tor...'); - try { - await tor.startIfNotStarted(); - } catch (e) { - console.warn('Could not bootstrap TOR', e); - await tor.stopIfRunning(); - this._passOnEvent('error', 'Could not bootstrap TOR'); - return false; - } - console.log('started tor'); - const iWillConnectISwear = tor.createTcpConnection({ target: host + ':' + port, connectionTimeout: 15000 }, (data, err) => { - if (err) { - console.log('TOR socket onData error: ', err); - // this._passOnEvent('error', err); - return; - } - this._passOnEvent('data', data); - }); - - try { - this._socket = await Promise.race([iWillConnectISwear, new Promise(resolve => setTimeout(resolve, 21000))]); - } catch (e) {} - - if (!this._socket) { - console.log('connecting TOR socket failed'); // either sleep expired or connect threw an exception - await tor.stopIfRunning(); - this._passOnEvent('error', 'connecting TOR socket failed'); - return false; - } - - console.log('TOR socket connected:', host, port); - setTimeout(() => { - this._passOnEvent('connect', true); - callback(); - }, 1000); - })(); - } - - _passOnEvent(event, data) { - this._listeners[event] = this._listeners[event] || []; - for (const savedListener of this._listeners[event]) { - savedListener(data); - } - } - - emit(event, data) {} - - end() { - console.log('trying to close TOR socket'); - if (this._socket && this._socket.close) { - console.log('trying to close TOR socket SUCCESS'); - return this._socket.close(); - } - } - - destroy() {} - - write(data) { - if (this._socket && this._socket.write) { - try { - return this._socket.write(data); - } catch (error) { - console.log('this._socket.write() failed so we are issuing ERROR event', error); - this._passOnEvent('error', error); - } - } else { - console.log('TOR socket write error, socket not connected'); - this._passOnEvent('error', 'TOR socket not connected'); - } - } -} - -module.exports.getDaemonStatus = async () => { - try { - return await tor.getDaemonStatus(); - } catch (_) { - return false; - } -}; - -module.exports.stopIfRunning = async () => { - try { - Torsbee._testConn = false; - return await tor.stopIfRunning(); - } catch (_) { - return false; - } -}; - -module.exports.startIfNotStarted = async () => { - try { - return await tor.startIfNotStarted(); - } catch (_) { - return false; - } -}; - -module.exports.testSocket = async () => { - const c = new Torsbee(); - return c.testSocket(); -}; - -module.exports.testHttp = async () => { - const api = new Torsbee({ - baseURI: 'http://explorerzydxu5ecjrkwceayqybizmpjjznk5izmitf2modhcusuqlid.onion:80/', - }); - const torResponse = await api.get('/api/tx/a84dbcf0d2550f673dda9331eea7cab86b645fd6e12049755c4b47bd238adce9', { - headers: { - 'Content-Type': 'application/json', - }, - }); - const json = torResponse.body; - if (json.txid !== 'a84dbcf0d2550f673dda9331eea7cab86b645fd6e12049755c4b47bd238adce9') - throw new Error('TOR failure, got ' + JSON.stringify(torResponse)); -}; - -module.exports.Torsbee = Torsbee; -module.exports.Socket = TorSocket; diff --git a/blue_modules/ur/index.js b/blue_modules/ur/index.js index 6ae16df28d..561dea9337 100644 --- a/blue_modules/ur/index.js +++ b/blue_modules/ur/index.js @@ -60,7 +60,7 @@ function encodeURv1(arg1, arg2) { /** * * @param str {string} For PSBT, or coordination setup (translates to `bytes`) it expects hex string. For ms cosigner it expects plain json string - * @param len {number} lenght of each fragment + * @param len {number} length of each fragment * @return {string[]} txt fragments ready to be displayed in dynamic QR */ function encodeURv2(str, len) { diff --git a/class/azteco.js b/class/azteco.js deleted file mode 100644 index 78cc4d9e0b..0000000000 --- a/class/azteco.js +++ /dev/null @@ -1,41 +0,0 @@ -import Frisbee from 'frisbee'; -import URL from 'url'; - -export default class Azteco { - /** - * Redeems an Azteco bitcoin voucher. - * - * @param {string[]} voucher - 16-digit voucher code in groups of 4. - * @param {string} address - Bitcoin address to send the redeemed bitcoin to. - * - * @returns {Promise} Successfully redeemed or not. This method does not throw exceptions - */ - static async redeem(voucher, address) { - const api = new Frisbee({ - baseURI: 'https://azte.co/', - }); - const url = `/blue_despatch.php?CODE_1=${voucher[0]}&CODE_2=${voucher[1]}&CODE_3=${voucher[2]}&CODE_4=${voucher[3]}&ADDRESS=${address}`; - - try { - const response = await api.get(url); - return response && response.originalResponse && +response.originalResponse.status === 200; - } catch (_) { - return false; - } - } - - static isRedeemUrl(u) { - return u.startsWith('https://azte.co'); - } - - static getParamsFromUrl(u) { - const urlObject = URL.parse(u, true); // eslint-disable-line n/no-deprecated-api - return { - uri: u, - c1: urlObject.query.c1, - c2: urlObject.query.c2, - c3: urlObject.query.c3, - c4: urlObject.query.c4, - }; - } -} diff --git a/class/azteco.ts b/class/azteco.ts new file mode 100644 index 0000000000..7aa35ed140 --- /dev/null +++ b/class/azteco.ts @@ -0,0 +1,57 @@ +import URL from 'url'; + +export default class Azteco { + /** + * Redeems an Azteco bitcoin voucher. + * + * @param {string[]} voucher - 16-digit voucher code in groups of 4. + * @param {string} address - Bitcoin address to send the redeemed bitcoin to. + * + * @returns {Promise} Successfully redeemed or not. This method does not throw exceptions + */ + static async redeem(voucher: string[], address: string): Promise { + const baseURI = 'https://azte.co/'; + const url = `${baseURI}blue_despatch.php?CODE_1=${voucher[0]}&CODE_2=${voucher[1]}&CODE_3=${voucher[2]}&CODE_4=${voucher[3]}&ADDRESS=${address}`; + + try { + const response = await fetch(url, { + method: 'GET', + }); + return response && response.status === 200; + } catch (_) { + return false; + } + } + + static isRedeemUrl(u: string): boolean { + return u.startsWith('https://azte.co'); + } + + static getParamsFromUrl(u: string) { + const urlObject = URL.parse(u, true); // eslint-disable-line n/no-deprecated-api + + if (urlObject.query.code) { + // check if code is a string + if (typeof urlObject.query.code !== 'string') { + throw new Error('Invalid URL'); + } + + // newer format of the url + return { + uri: u, + c1: urlObject.query.code.substring(0, 4), + c2: urlObject.query.code.substring(4, 8), + c3: urlObject.query.code.substring(8, 12), + c4: urlObject.query.code.substring(12, 16), + }; + } + + return { + uri: u, + c1: urlObject.query.c1, + c2: urlObject.query.c2, + c3: urlObject.query.c3, + c4: urlObject.query.c4, + }; + } +} diff --git a/class/biometrics.js b/class/biometrics.js deleted file mode 100644 index 14d2b36267..0000000000 --- a/class/biometrics.js +++ /dev/null @@ -1,147 +0,0 @@ -import FingerprintScanner from 'react-native-fingerprint-scanner'; -import { Platform, Alert } from 'react-native'; -import PasscodeAuth from 'react-native-passcode-auth'; -import * as NavigationService from '../NavigationService'; -import { StackActions, CommonActions } from '@react-navigation/native'; -import RNSecureKeyStore from 'react-native-secure-key-store'; -import loc from '../loc'; -import { useContext } from 'react'; -import { BlueStorageContext } from '../blue_modules/storage-context'; -import alert from '../components/Alert'; - -function Biometric() { - const { getItem, setItem } = useContext(BlueStorageContext); - Biometric.STORAGEKEY = 'Biometrics'; - Biometric.FaceID = 'Face ID'; - Biometric.TouchID = 'Touch ID'; - Biometric.Biometrics = 'Biometrics'; - - Biometric.isDeviceBiometricCapable = async () => { - try { - const isDeviceBiometricCapable = await FingerprintScanner.isSensorAvailable(); - if (isDeviceBiometricCapable) { - return true; - } - } catch (e) { - console.log('Biometrics isDeviceBiometricCapable failed'); - console.log(e); - Biometric.setBiometricUseEnabled(false); - return false; - } - }; - - Biometric.biometricType = async () => { - try { - const isSensorAvailable = await FingerprintScanner.isSensorAvailable(); - return isSensorAvailable; - } catch (e) { - console.log('Biometrics biometricType failed'); - console.log(e); - } - return false; - }; - - Biometric.isBiometricUseEnabled = async () => { - try { - const enabledBiometrics = await getItem(Biometric.STORAGEKEY); - return !!enabledBiometrics; - } catch (_) {} - - return false; - }; - - Biometric.isBiometricUseCapableAndEnabled = async () => { - const isBiometricUseEnabled = await Biometric.isBiometricUseEnabled(); - const isDeviceBiometricCapable = await Biometric.isDeviceBiometricCapable(); - return isBiometricUseEnabled && isDeviceBiometricCapable; - }; - - Biometric.setBiometricUseEnabled = async value => { - await setItem(Biometric.STORAGEKEY, value === true ? '1' : ''); - }; - - Biometric.unlockWithBiometrics = async () => { - const isDeviceBiometricCapable = await Biometric.isDeviceBiometricCapable(); - if (isDeviceBiometricCapable) { - return new Promise(resolve => { - FingerprintScanner.authenticate({ description: loc.settings.biom_conf_identity, fallbackEnabled: true }) - .then(() => resolve(true)) - .catch(error => { - console.log('Biometrics authentication failed'); - console.log(error); - resolve(false); - }) - .finally(() => FingerprintScanner.release()); - }); - } - return false; - }; - - Biometric.clearKeychain = async () => { - await RNSecureKeyStore.remove('data'); - await RNSecureKeyStore.remove('data_encrypted'); - await RNSecureKeyStore.remove(Biometric.STORAGEKEY); - NavigationService.dispatch(StackActions.replace('WalletsRoot')); - }; - - Biometric.requestDevicePasscode = async () => { - let isDevicePasscodeSupported = false; - try { - isDevicePasscodeSupported = await PasscodeAuth.isSupported(); - if (isDevicePasscodeSupported) { - const isAuthenticated = await PasscodeAuth.authenticate(); - if (isAuthenticated) { - Alert.alert( - loc.settings.encrypt_tstorage, - loc.settings.biom_remove_decrypt, - [ - { text: loc._.cancel, style: 'cancel' }, - { - text: loc._.ok, - onPress: () => Biometric.clearKeychain(), - }, - ], - { cancelable: false }, - ); - } - } - } catch { - isDevicePasscodeSupported = undefined; - } - if (isDevicePasscodeSupported === false) { - alert(loc.settings.biom_no_passcode); - } - }; - - Biometric.showKeychainWipeAlert = () => { - if (Platform.OS === 'ios') { - Alert.alert( - loc.settings.encrypt_tstorage, - loc.settings.biom_10times, - [ - { - text: loc._.cancel, - onPress: () => { - NavigationService.dispatch( - CommonActions.setParams({ - index: 0, - routes: [{ name: 'UnlockWithScreenRoot' }, { params: { unlockOnComponentMount: false } }], - }), - ); - }, - style: 'cancel', - }, - { - text: loc._.ok, - onPress: () => Biometric.requestDevicePasscode(), - style: 'default', - }, - ], - { cancelable: false }, - ); - } - }; - return null; -} - -export default Biometric; diff --git a/class/bip39_wallet_formats.json b/class/bip39_wallet_formats.json index 8fb92c17cf..dc0d77af2d 100644 --- a/class/bip39_wallet_formats.json +++ b/class/bip39_wallet_formats.json @@ -35,6 +35,30 @@ "script_type": "p2wpkh", "iterate_accounts": true }, + { + "description": "Non-standard legacy on BIP84 path", + "derivation_path": "m/84'/0'/0'", + "script_type": "p2pkh", + "iterate_accounts": true + }, + { + "description": "Non-standard compatibility segwit on BIP84 path", + "derivation_path": "m/84'/0'/0'", + "script_type": "p2wpkh-p2sh", + "iterate_accounts": true + }, + { + "description": "Non-standard legacy on BIP49 path", + "derivation_path": "m/49'/0'/0'", + "script_type": "p2pkh", + "iterate_accounts": true + }, + { + "description": "Non-standard native segwit on BIP49 path", + "derivation_path": "m/49'/0'/0'", + "script_type": "p2wpkh", + "iterate_accounts": true + }, { "description": "Copay native segwit", "derivation_path": "m/44'/0'/0'", diff --git a/BlueApp.js b/class/blue-app.ts similarity index 57% rename from BlueApp.js rename to class/blue-app.ts index c0ee48a831..8295addc3f 100644 --- a/BlueApp.js +++ b/class/blue-app.ts @@ -1,60 +1,107 @@ -import Biometric from './class/biometrics'; -import { Platform } from 'react-native'; -import loc from './loc'; import AsyncStorage from '@react-native-async-storage/async-storage'; +import createHash from 'create-hash'; +import DefaultPreference from 'react-native-default-preference'; +import RNFS from 'react-native-fs'; +import Keychain from 'react-native-keychain'; import RNSecureKeyStore, { ACCESSIBLE } from 'react-native-secure-key-store'; -import * as Keychain from 'react-native-keychain'; -import { - HDLegacyBreadwalletWallet, - HDSegwitP2SHWallet, - HDLegacyP2PKHWallet, - WatchOnlyWallet, - LegacyWallet, - SegwitP2SHWallet, - SegwitBech32Wallet, - HDSegwitBech32Wallet, - LightningCustodianWallet, - HDLegacyElectrumSeedP2PKHWallet, - HDSegwitElectrumSeedP2WPKHWallet, - HDAezeedWallet, - MultisigHDWallet, - LightningLdkWallet, - SLIP39SegwitP2SHWallet, - SLIP39LegacyP2PKHWallet, - SLIP39SegwitBech32Wallet, -} from './class/'; -import { randomBytes } from './class/rng'; -import alert from './components/Alert'; - -const encryption = require('./blue_modules/encryption'); -const Realm = require('realm'); -const createHash = require('create-hash'); -let usedBucketNum = false; +import Realm from 'realm'; + +import * as encryption from '../blue_modules/encryption'; +import presentAlert from '../components/Alert'; +import { randomBytes } from './rng'; +import { HDAezeedWallet } from './wallets/hd-aezeed-wallet'; +import { HDLegacyBreadwalletWallet } from './wallets/hd-legacy-breadwallet-wallet'; +import { HDLegacyElectrumSeedP2PKHWallet } from './wallets/hd-legacy-electrum-seed-p2pkh-wallet'; +import { HDLegacyP2PKHWallet } from './wallets/hd-legacy-p2pkh-wallet'; +import { HDSegwitBech32Wallet } from './wallets/hd-segwit-bech32-wallet'; +import { HDSegwitElectrumSeedP2WPKHWallet } from './wallets/hd-segwit-electrum-seed-p2wpkh-wallet'; +import { HDSegwitP2SHWallet } from './wallets/hd-segwit-p2sh-wallet'; +import { LegacyWallet } from './wallets/legacy-wallet'; +import { LightningCustodianWallet } from './wallets/lightning-custodian-wallet'; +import { MultisigHDWallet } from './wallets/multisig-hd-wallet'; +import { SegwitBech32Wallet } from './wallets/segwit-bech32-wallet'; +import { SegwitP2SHWallet } from './wallets/segwit-p2sh-wallet'; +import { SLIP39LegacyP2PKHWallet, SLIP39SegwitBech32Wallet, SLIP39SegwitP2SHWallet } from './wallets/slip39-wallets'; +import { ExtendedTransaction, Transaction, TWallet } from './wallets/types'; +import { WatchOnlyWallet } from './wallets/watch-only-wallet'; +import { getLNDHub } from '../helpers/lndHub'; + +let usedBucketNum: boolean | number = false; let savingInProgress = 0; // its both a flag and a counter of attempts to write to disk -const prompt = require('./helpers/prompt'); -const currency = require('./blue_modules/currency'); -const BlueElectrum = require('./blue_modules/BlueElectrum'); -BlueElectrum.connectMain(); -class AppStorage { +export type TTXMetadata = { + [txid: string]: { + memo?: string; + }; +}; + +export type TCounterpartyMetadata = { + /** + * our contact identifier, such as bip47 payment code + */ + [counterparty: string]: { + /** + * custom human-readable name we assign ourselves + */ + label: string; + /** + * some counterparties cannot be deleted because they sent a notif tx onchain, so we just mark them as hidden when user deletes + */ + hidden?: boolean; + }; +}; + +type TRealmTransaction = { + internal: boolean; + index: number; + tx: string; +}; + +type TBucketStorage = { + wallets: string[]; // array of serialized wallets, not actual wallet objects + tx_metadata: TTXMetadata; + counterparty_metadata: TCounterpartyMetadata; +}; + +const isReactNative = typeof navigator !== 'undefined' && navigator?.product === 'ReactNative'; + +export class BlueApp { static FLAG_ENCRYPTED = 'data_encrypted'; static LNDHUB = 'lndhub'; - static ADVANCED_MODE_ENABLED = 'advancedmodeenabled'; static DO_NOT_TRACK = 'donottrack'; static HANDOFF_STORAGE_KEY = 'HandOff'; - static keys2migrate = [AppStorage.HANDOFF_STORAGE_KEY, AppStorage.DO_NOT_TRACK, AppStorage.ADVANCED_MODE_ENABLED]; + private static _instance: BlueApp | null = null; + + static keys2migrate = [BlueApp.HANDOFF_STORAGE_KEY, BlueApp.DO_NOT_TRACK]; + + public cachedPassword?: false | string; + public tx_metadata: TTXMetadata; + public counterparty_metadata: TCounterpartyMetadata; + public wallets: TWallet[]; constructor() { - /** {Array.} */ this.wallets = []; this.tx_metadata = {}; + this.counterparty_metadata = {}; this.cachedPassword = false; } + static getInstance(): BlueApp { + if (!BlueApp._instance) { + BlueApp._instance = new BlueApp(); + } + + return BlueApp._instance; + } + async migrateKeys() { - if (!(typeof navigator !== 'undefined' && navigator.product === 'ReactNative')) return; - for (const key of this.constructor.keys2migrate) { + // do not migrate keys if we are not in RN env + if (!isReactNative) { + return; + } + + for (const key of BlueApp.keys2migrate) { try { const value = await RNSecureKeyStore.get(key); if (value) { @@ -68,13 +115,9 @@ class AppStorage { /** * Wrapper for storage call. Secure store works only in RN environment. AsyncStorage is * used for cli/tests - * - * @param key - * @param value - * @returns {Promise|Promise | Promise | * | Promise | void} */ - setItem = (key, value) => { - if (typeof navigator !== 'undefined' && navigator.product === 'ReactNative') { + setItem = (key: string, value: any): Promise => { + if (isReactNative) { return RNSecureKeyStore.set(key, value, { accessible: ACCESSIBLE.WHEN_UNLOCKED_THIS_DEVICE_ONLY }); } else { return AsyncStorage.setItem(key, value); @@ -84,28 +127,20 @@ class AppStorage { /** * Wrapper for storage call. Secure store works only in RN environment. AsyncStorage is * used for cli/tests - * - * @param key - * @returns {Promise|*} */ - getItem = key => { - if (typeof navigator !== 'undefined' && navigator.product === 'ReactNative') { + getItem = (key: string): Promise => { + if (isReactNative) { return RNSecureKeyStore.get(key); } else { return AsyncStorage.getItem(key); } }; - /** - * @throws Error - * @param key {string} - * @returns {Promise<*>|null} - */ - getItemWithFallbackToRealm = async key => { + getItemWithFallbackToRealm = async (key: string): Promise => { let value; try { return await this.getItem(key); - } catch (error) { + } catch (error: any) { console.warn('error reading', key, error.message); console.warn('fallback to realm'); const realmKeyValue = await this.openRealmKeyValue(); @@ -113,6 +148,7 @@ class AppStorage { value = obj?.value; realmKeyValue.close(); if (value) { + // @ts-ignore value.length console.warn('successfully recovered', value.length, 'bytes from realm for key', key); return value; } @@ -120,23 +156,23 @@ class AppStorage { } }; - storageIsEncrypted = async () => { + storageIsEncrypted = async (): Promise => { let data; try { - data = await this.getItemWithFallbackToRealm(AppStorage.FLAG_ENCRYPTED); - } catch (error) { - console.warn('error reading `' + AppStorage.FLAG_ENCRYPTED + '` key:', error.message); + data = await this.getItemWithFallbackToRealm(BlueApp.FLAG_ENCRYPTED); + } catch (error: any) { + console.warn('error reading `' + BlueApp.FLAG_ENCRYPTED + '` key:', error.message); return false; } - return !!data; + return Boolean(data); }; - isPasswordInUse = async password => { + isPasswordInUse = async (password: string) => { try { let data = await this.getItem('data'); data = this.decryptData(data, password); - return !!data; + return Boolean(data); } catch (_e) { return false; } @@ -145,12 +181,8 @@ class AppStorage { /** * Iterates through all values of `data` trying to * decrypt each one, and returns first one successfully decrypted - * - * @param data {string} Serialized array - * @param password - * @returns {boolean|string} Either STRING of storage data (which is stringified JSON) or FALSE, which means failure */ - decryptData(data, password) { + decryptData(data: string, password: string): boolean | string { data = JSON.parse(data); let decrypted; let num = 0; @@ -167,19 +199,20 @@ class AppStorage { return false; } - decryptStorage = async password => { + decryptStorage = async (password: string): Promise => { if (password === this.cachedPassword) { this.cachedPassword = undefined; await this.saveToDisk(); this.wallets = []; - this.tx_metadata = []; + this.tx_metadata = {}; + this.counterparty_metadata = {}; return this.loadFromDisk(); } else { throw new Error('Incorrect password. Please, try again.'); } }; - encryptStorage = async password => { + encryptStorage = async (password: string): Promise => { // assuming the storage is not yet encrypted await this.saveToDisk(); let data = await this.getItem('data'); @@ -191,23 +224,23 @@ class AppStorage { data = JSON.stringify(data); this.cachedPassword = password; await this.setItem('data', data); - await this.setItem(AppStorage.FLAG_ENCRYPTED, '1'); + await this.setItem(BlueApp.FLAG_ENCRYPTED, '1'); }; /** * Cleans up all current application data (wallets, tx metadata etc) * Encrypts the bucket and saves it storage - * - * @returns {Promise.} Success or failure */ - createFakeStorage = async fakePassword => { + createFakeStorage = async (fakePassword: string): Promise => { usedBucketNum = false; // resetting currently used bucket so we wont overwrite it this.wallets = []; this.tx_metadata = {}; + this.counterparty_metadata = {}; - const data = { + const data: TBucketStorage = { wallets: [], tx_metadata: {}, + counterparty_metadata: {}, }; let buckets = await this.getItem('data'); @@ -219,21 +252,21 @@ class AppStorage { return (await this.getItem('data')) === bucketsString; }; - hashIt = s => { + hashIt = (s: string): string => { return createHash('sha256').update(s).digest().toString('hex'); }; /** * Returns instace of the Realm database, which is encrypted either by cached user's password OR default password. * Database file is deterministically derived from encryption key. - * - * @returns {Promise} */ - async getRealm() { + async getRealmForTransactions() { + const cacheFolderPath = RNFS.CachesDirectoryPath; // Path to cache folder const password = this.hashIt(this.cachedPassword || 'fyegjitkyf[eqjnc.lf'); const buf = Buffer.from(this.hashIt(password) + this.hashIt(password), 'hex'); const encryptionKey = Int8Array.from(buf); - const path = this.hashIt(this.hashIt(password)) + '-wallettransactions.realm'; + const fileName = this.hashIt(this.hashIt(password)) + '-wallettransactions.realm'; + const path = `${cacheFolderPath}/${fileName}`; // Use cache folder path const schema = [ { @@ -246,20 +279,24 @@ class AppStorage { }, }, ]; + // @ts-ignore schema doesn't match Realm's schema type return Realm.open({ + // @ts-ignore schema doesn't match Realm's schema type schema, path, encryptionKey, + excludeFromIcloudBackup: true, }); } /** - * Returns instace of the Realm database, which is encrypted by device unique id + * Returns instace of the Realm database, which is encrypted by random bytes stored in keychain. * Database file is static. * * @returns {Promise} */ - async openRealmKeyValue() { + async openRealmKeyValue(): Promise { + const cacheFolderPath = RNFS.CachesDirectoryPath; // Path to cache folder const service = 'realm_encryption_key'; let password; const credentials = await Keychain.getGenericPassword({ service }); @@ -273,7 +310,7 @@ class AppStorage { const buf = Buffer.from(password, 'hex'); const encryptionKey = Int8Array.from(buf); - const path = 'keyvalue.realm'; + const path = `${cacheFolderPath}/keyvalue.realm`; // Use cache folder path const schema = [ { @@ -285,14 +322,17 @@ class AppStorage { }, }, ]; + // @ts-ignore schema doesn't match Realm's schema type return Realm.open({ + // @ts-ignore schema doesn't match Realm's schema type schema, path, encryptionKey, + excludeFromIcloudBackup: true, }); } - saveToRealmKeyValue(realmkeyValue, key, value) { + saveToRealmKeyValue(realmkeyValue: Realm, key: string, value: any) { realmkeyValue.write(() => { realmkeyValue.create( 'KeyValue', @@ -312,66 +352,72 @@ class AppStorage { * @param password If present means storage must be decrypted before usage * @returns {Promise.} */ - async loadFromDisk(password) { - let data = await this.getItemWithFallbackToRealm('data'); + async loadFromDisk(password?: string): Promise { + // Wrap inside a try so if anything goes wrong it wont block loadFromDisk from continuing + try { + await this.moveRealmFilesToCacheDirectory(); + } catch (error: any) { + console.warn('moveRealmFilesToCacheDirectory error:', error.message); + } + let dataRaw = await this.getItemWithFallbackToRealm('data'); if (password) { - data = this.decryptData(data, password); - if (data) { + dataRaw = this.decryptData(dataRaw, password); + if (dataRaw) { // password is good, cache it this.cachedPassword = password; } } - if (data !== null) { + if (dataRaw !== null) { let realm; try { - realm = await this.getRealm(); - } catch (error) { - alert(error.message); + realm = await this.getRealmForTransactions(); + } catch (error: any) { + presentAlert({ message: error.message }); } - data = JSON.parse(data); + const data: TBucketStorage = JSON.parse(dataRaw); if (!data.wallets) return false; const wallets = data.wallets; for (const key of wallets) { - // deciding which type is wallet and instatiating correct object + // deciding which type is wallet and instantiating correct object const tempObj = JSON.parse(key); - let unserializedWallet; + let unserializedWallet: TWallet; switch (tempObj.type) { case SegwitBech32Wallet.type: - unserializedWallet = SegwitBech32Wallet.fromJson(key); + unserializedWallet = SegwitBech32Wallet.fromJson(key) as unknown as SegwitBech32Wallet; break; case SegwitP2SHWallet.type: - unserializedWallet = SegwitP2SHWallet.fromJson(key); + unserializedWallet = SegwitP2SHWallet.fromJson(key) as unknown as SegwitP2SHWallet; break; case WatchOnlyWallet.type: - unserializedWallet = WatchOnlyWallet.fromJson(key); + unserializedWallet = WatchOnlyWallet.fromJson(key) as unknown as WatchOnlyWallet; unserializedWallet.init(); if (unserializedWallet.isHd() && !unserializedWallet.isXpubValid()) { continue; } break; case HDLegacyP2PKHWallet.type: - unserializedWallet = HDLegacyP2PKHWallet.fromJson(key); + unserializedWallet = HDLegacyP2PKHWallet.fromJson(key) as unknown as HDLegacyP2PKHWallet; break; case HDSegwitP2SHWallet.type: - unserializedWallet = HDSegwitP2SHWallet.fromJson(key); + unserializedWallet = HDSegwitP2SHWallet.fromJson(key) as unknown as HDSegwitP2SHWallet; break; case HDSegwitBech32Wallet.type: - unserializedWallet = HDSegwitBech32Wallet.fromJson(key); + unserializedWallet = HDSegwitBech32Wallet.fromJson(key) as unknown as HDSegwitBech32Wallet; break; case HDLegacyBreadwalletWallet.type: - unserializedWallet = HDLegacyBreadwalletWallet.fromJson(key); + unserializedWallet = HDLegacyBreadwalletWallet.fromJson(key) as unknown as HDLegacyBreadwalletWallet; break; case HDLegacyElectrumSeedP2PKHWallet.type: - unserializedWallet = HDLegacyElectrumSeedP2PKHWallet.fromJson(key); + unserializedWallet = HDLegacyElectrumSeedP2PKHWallet.fromJson(key) as unknown as HDLegacyElectrumSeedP2PKHWallet; break; case HDSegwitElectrumSeedP2WPKHWallet.type: - unserializedWallet = HDSegwitElectrumSeedP2WPKHWallet.fromJson(key); + unserializedWallet = HDSegwitElectrumSeedP2WPKHWallet.fromJson(key) as unknown as HDSegwitElectrumSeedP2WPKHWallet; break; case MultisigHDWallet.type: - unserializedWallet = MultisigHDWallet.fromJson(key); + unserializedWallet = MultisigHDWallet.fromJson(key) as unknown as MultisigHDWallet; break; case HDAezeedWallet.type: - unserializedWallet = HDAezeedWallet.fromJson(key); + unserializedWallet = HDAezeedWallet.fromJson(key) as unknown as HDAezeedWallet; // migrate password to this.passphrase field // remove this code somewhere in year 2022 if (unserializedWallet.secret.includes(':')) { @@ -380,25 +426,21 @@ class AppStorage { unserializedWallet.passphrase = passphrase; } - break; - case LightningLdkWallet.type: - unserializedWallet = LightningLdkWallet.fromJson(key); break; case SLIP39SegwitP2SHWallet.type: - unserializedWallet = SLIP39SegwitP2SHWallet.fromJson(key); + unserializedWallet = SLIP39SegwitP2SHWallet.fromJson(key) as unknown as SLIP39SegwitP2SHWallet; break; case SLIP39LegacyP2PKHWallet.type: - unserializedWallet = SLIP39LegacyP2PKHWallet.fromJson(key); + unserializedWallet = SLIP39LegacyP2PKHWallet.fromJson(key) as unknown as SLIP39LegacyP2PKHWallet; break; case SLIP39SegwitBech32Wallet.type: - unserializedWallet = SLIP39SegwitBech32Wallet.fromJson(key); + unserializedWallet = SLIP39SegwitBech32Wallet.fromJson(key) as unknown as SLIP39SegwitBech32Wallet; break; case LightningCustodianWallet.type: { - /** @type {LightningCustodianWallet} */ - unserializedWallet = LightningCustodianWallet.fromJson(key); - let lndhub = false; + unserializedWallet = LightningCustodianWallet.fromJson(key) as unknown as LightningCustodianWallet; + let lndhub: false | any = false; try { - lndhub = await AsyncStorage.getItem(AppStorage.LNDHUB); + lndhub = await getLNDHub(); } catch (error) { console.warn(error); } @@ -415,16 +457,21 @@ class AppStorage { unserializedWallet.init(); break; } + case 'lightningLdk': + // since ldk wallets are deprecated and removed, we need to handle a case when such wallet still exists in storage + unserializedWallet = new HDSegwitBech32Wallet(); + unserializedWallet.setSecret(tempObj.secret.replace('ldk://', '')); + break; case LegacyWallet.type: default: - unserializedWallet = LegacyWallet.fromJson(key); + unserializedWallet = LegacyWallet.fromJson(key) as unknown as LegacyWallet; break; } try { if (realm) this.inflateWalletFromRealm(realm, unserializedWallet); - } catch (error) { - alert(error.message); + } catch (error: any) { + presentAlert({ message: error.message }); } // done @@ -432,6 +479,7 @@ class AppStorage { if (!this.wallets.some(wallet => wallet.getID() === ID)) { this.wallets.push(unserializedWallet); this.tx_metadata = data.tx_metadata; + this.counterparty_metadata = data.counterparty_metadata; } } if (realm) realm.close(); @@ -447,16 +495,10 @@ class AppStorage { * * @param wallet {AbstractWallet} */ - deleteWallet = wallet => { + deleteWallet = (wallet: TWallet): void => { const ID = wallet.getID(); const tempWallets = []; - if (wallet.type === LightningLdkWallet.type) { - /** @type {LightningLdkWallet} */ - const ldkwallet = wallet; - ldkwallet.stop().then(ldkwallet.purgeLocalStorage).catch(alert); - } - for (const value of this.wallets) { if (value.getID() === ID) { // the one we should delete @@ -469,39 +511,44 @@ class AppStorage { this.wallets = tempWallets; }; - inflateWalletFromRealm(realm, walletToInflate) { + inflateWalletFromRealm(realm: Realm, walletToInflate: TWallet) { const transactions = realm.objects('WalletTransactions'); - const transactionsForWallet = transactions.filtered(`walletid = "${walletToInflate.getID()}"`); + const transactionsForWallet = transactions.filtered(`walletid = "${walletToInflate.getID()}"`) as unknown as TRealmTransaction[]; for (const tx of transactionsForWallet) { if (tx.internal === false) { - if (walletToInflate._hdWalletInstance) { - walletToInflate._hdWalletInstance._txs_by_external_index[tx.index] = - walletToInflate._hdWalletInstance._txs_by_external_index[tx.index] || []; - walletToInflate._hdWalletInstance._txs_by_external_index[tx.index].push(JSON.parse(tx.tx)); + if ('_hdWalletInstance' in walletToInflate && walletToInflate._hdWalletInstance) { + const hd = walletToInflate._hdWalletInstance; + hd._txs_by_external_index[tx.index] = hd._txs_by_external_index[tx.index] || []; + const transaction = JSON.parse(tx.tx); + hd._txs_by_external_index[tx.index].push(transaction); } else { walletToInflate._txs_by_external_index[tx.index] = walletToInflate._txs_by_external_index[tx.index] || []; - walletToInflate._txs_by_external_index[tx.index].push(JSON.parse(tx.tx)); + const transaction = JSON.parse(tx.tx); + (walletToInflate._txs_by_external_index[tx.index] as Transaction[]).push(transaction); } } else if (tx.internal === true) { - if (walletToInflate._hdWalletInstance) { - walletToInflate._hdWalletInstance._txs_by_internal_index[tx.index] = - walletToInflate._hdWalletInstance._txs_by_internal_index[tx.index] || []; - walletToInflate._hdWalletInstance._txs_by_internal_index[tx.index].push(JSON.parse(tx.tx)); + if ('_hdWalletInstance' in walletToInflate && walletToInflate._hdWalletInstance) { + const hd = walletToInflate._hdWalletInstance; + hd._txs_by_internal_index[tx.index] = hd._txs_by_internal_index[tx.index] || []; + const transaction = JSON.parse(tx.tx); + hd._txs_by_internal_index[tx.index].push(transaction); } else { walletToInflate._txs_by_internal_index[tx.index] = walletToInflate._txs_by_internal_index[tx.index] || []; - walletToInflate._txs_by_internal_index[tx.index].push(JSON.parse(tx.tx)); + const transaction = JSON.parse(tx.tx); + (walletToInflate._txs_by_internal_index[tx.index] as Transaction[]).push(transaction); } } else { if (!Array.isArray(walletToInflate._txs_by_external_index)) walletToInflate._txs_by_external_index = []; walletToInflate._txs_by_external_index = walletToInflate._txs_by_external_index || []; - walletToInflate._txs_by_external_index.push(JSON.parse(tx.tx)); + const transaction = JSON.parse(tx.tx); + (walletToInflate._txs_by_external_index as Transaction[]).push(transaction); } } } - offloadWalletToRealm(realm, wallet) { + offloadWalletToRealm(realm: Realm, wallet: TWallet): void { const id = wallet.getID(); - const walletToSave = wallet._hdWalletInstance ?? wallet; + const walletToSave = ('_hdWalletInstance' in wallet && wallet._hdWalletInstance) || wallet; if (Array.isArray(walletToSave._txs_by_external_index)) { // if this var is an array that means its a single-address wallet class, and this var is a flat array @@ -511,6 +558,7 @@ class AppStorage { const walletTransactionsToDelete = realm.objects('WalletTransactions').filtered(`walletid = '${id}'`); realm.delete(walletTransactionsToDelete); + // @ts-ignore walletToSave._txs_by_external_index is array for (const tx of walletToSave._txs_by_external_index) { realm.create( 'WalletTransactions', @@ -536,6 +584,7 @@ class AppStorage { // insert new ones: for (const index of Object.keys(walletToSave._txs_by_external_index)) { + // @ts-ignore index is number const txs = walletToSave._txs_by_external_index[index]; for (const tx of txs) { realm.create( @@ -552,6 +601,7 @@ class AppStorage { } for (const index of Object.keys(walletToSave._txs_by_internal_index)) { + // @ts-ignore index is number const txs = walletToSave._txs_by_internal_index[index]; for (const tx of txs) { realm.create( @@ -577,57 +627,61 @@ class AppStorage { * * @returns {Promise} Result of storage save */ - async saveToDisk() { + async saveToDisk(): Promise { if (savingInProgress) { console.warn('saveToDisk is in progress'); - if (++savingInProgress > 10) alert('Critical error. Last actions were not saved'); // should never happen + if (++savingInProgress > 10) presentAlert({ message: 'Critical error. Last actions were not saved' }); // should never happen await new Promise(resolve => setTimeout(resolve, 1000 * savingInProgress)); // sleep return this.saveToDisk(); } savingInProgress = 1; try { - const walletsToSave = []; + const walletsToSave: string[] = []; // serialized wallets let realm; try { - realm = await this.getRealm(); - } catch (error) { - alert(error.message); + realm = await this.getRealmForTransactions(); + } catch (error: any) { + presentAlert({ message: error.message }); } for (const key of this.wallets) { if (typeof key === 'boolean') continue; key.prepareForSerialization(); + // @ts-ignore wtf is wallet.current? Does it even exist? delete key.current; const keyCloned = Object.assign({}, key); // stripped-down version of a wallet to save to secure keystore - if (key._hdWalletInstance) keyCloned._hdWalletInstance = Object.assign({}, key._hdWalletInstance); + if ('_hdWalletInstance' in key) { + const k = keyCloned as any & WatchOnlyWallet; + k._hdWalletInstance = Object.assign({}, key._hdWalletInstance); + k._hdWalletInstance._txs_by_external_index = {}; + k._hdWalletInstance._txs_by_internal_index = {}; + } if (realm) this.offloadWalletToRealm(realm, key); // stripping down: if (key._txs_by_external_index) { keyCloned._txs_by_external_index = {}; keyCloned._txs_by_internal_index = {}; } - if (key._hdWalletInstance) { - keyCloned._hdWalletInstance._txs_by_external_index = {}; - keyCloned._hdWalletInstance._txs_by_internal_index = {}; - } - if (keyCloned._bip47_instance) { + if ('_bip47_instance' in keyCloned) { delete keyCloned._bip47_instance; // since it wont be restored into a proper class instance } walletsToSave.push(JSON.stringify({ ...keyCloned, type: keyCloned.type })); } if (realm) realm.close(); - let data = { + + let data: TBucketStorage | string[] /* either a bucket, or an array of encrypted buckets */ = { wallets: walletsToSave, tx_metadata: this.tx_metadata, + counterparty_metadata: this.counterparty_metadata, }; if (this.cachedPassword) { // should find the correct bucket, encrypt and then save let buckets = await this.getItemWithFallbackToRealm('data'); buckets = JSON.parse(buckets); - const newData = []; + const newData: string[] = []; // serialized buckets let num = 0; for (const bucket of buckets) { let decrypted; @@ -653,20 +707,21 @@ class AppStorage { newData.push(encryption.encrypt(JSON.stringify(data), this.cachedPassword)); } } + data = newData; } await this.setItem('data', JSON.stringify(data)); - await this.setItem(AppStorage.FLAG_ENCRYPTED, this.cachedPassword ? '1' : ''); + await this.setItem(BlueApp.FLAG_ENCRYPTED, this.cachedPassword ? '1' : ''); // now, backing up same data in realm: const realmkeyValue = await this.openRealmKeyValue(); this.saveToRealmKeyValue(realmkeyValue, 'data', JSON.stringify(data)); - this.saveToRealmKeyValue(realmkeyValue, AppStorage.FLAG_ENCRYPTED, this.cachedPassword ? '1' : ''); + this.saveToRealmKeyValue(realmkeyValue, BlueApp.FLAG_ENCRYPTED, this.cachedPassword ? '1' : ''); realmkeyValue.close(); - } catch (error) { + } catch (error: any) { console.error('save to disk exception:', error.message); - alert('save to disk exception: ' + error.message); + presentAlert({ message: 'save to disk exception: ' + error.message }); if (error.message.includes('Realm file decryption failed')) { console.warn('purging realm key-value database file'); this.purgeRealmKeyValueFile(); @@ -681,10 +736,8 @@ class AppStorage { * Use getter for a specific wallet to get actual balance. * Returns void. * If index is present then fetch only from this specific wallet - * - * @return {Promise.} */ - fetchWalletBalances = async index => { + fetchWalletBalances = async (index?: number): Promise => { console.log('fetchWalletBalances for wallet#', typeof index === 'undefined' ? '(all)' : index); if (index || index === 0) { let c = 0; @@ -711,17 +764,16 @@ class AppStorage { * blank to fetch from all wallets * @return {Promise.} */ - fetchWalletTransactions = async index => { + fetchWalletTransactions = async (index?: number) => { console.log('fetchWalletTransactions for wallet#', typeof index === 'undefined' ? '(all)' : index); if (index || index === 0) { let c = 0; for (const wallet of this.wallets) { if (c++ === index) { await wallet.fetchTransactions(); - if (wallet.fetchPendingTransactions) { + + if ('fetchPendingTransactions' in wallet) { await wallet.fetchPendingTransactions(); - } - if (wallet.fetchUserInvoices) { await wallet.fetchUserInvoices(); } } @@ -729,29 +781,28 @@ class AppStorage { } else { for (const wallet of this.wallets) { await wallet.fetchTransactions(); - if (wallet.fetchPendingTransactions) { + if ('fetchPendingTransactions' in wallet) { await wallet.fetchPendingTransactions(); - } - if (wallet.fetchUserInvoices) { await wallet.fetchUserInvoices(); } } } }; - fetchSenderPaymentCodes = async index => { + fetchSenderPaymentCodes = async (index?: number) => { console.log('fetchSenderPaymentCodes for wallet#', typeof index === 'undefined' ? '(all)' : index); if (index || index === 0) { + const wallet = this.wallets[index]; try { - if (!(this.wallets[index].allowBIP47() && this.wallets[index].isBIP47Enabled())) return; - await this.wallets[index].fetchBIP47SenderPaymentCodes(); + if (!(wallet.allowBIP47() && wallet.isBIP47Enabled() && 'fetchBIP47SenderPaymentCodes' in wallet)) return; + await wallet.fetchBIP47SenderPaymentCodes(); } catch (error) { console.error('Failed to fetch sender payment codes for wallet', index, error); } } else { for (const wallet of this.wallets) { try { - if (!(wallet.allowBIP47() && wallet.isBIP47Enabled())) continue; + if (!(wallet.allowBIP47() && wallet.isBIP47Enabled() && 'fetchBIP47SenderPaymentCodes' in wallet)) continue; await wallet.fetchBIP47SenderPaymentCodes(); } catch (error) { console.error('Failed to fetch sender payment codes for wallet', wallet.label, error); @@ -760,11 +811,7 @@ class AppStorage { } }; - /** - * - * @returns {Array.} - */ - getWallets = () => { + getWallets = (): TWallet[] => { return this.wallets; }; @@ -772,51 +819,64 @@ class AppStorage { * Getter for all transactions in all wallets. * But if index is provided - only for wallet with corresponding index * - * @param index {Integer|null} Wallet index in this.wallets. Empty (or null) for all wallets. - * @param limit {Integer} How many txs return, starting from the earliest. Default: all of them. - * @param includeWalletsWithHideTransactionsEnabled {Boolean} Wallets' _hideTransactionsInWalletsList property determines wether the user wants this wallet's txs hidden from the main list view. - * @return {Array} + * @param index {number|undefined} Wallet index in this.wallets. Empty (or undef) for all wallets. + * @param limit {number} How many txs return, starting from the earliest. Default: all of them. + * @param includeWalletsWithHideTransactionsEnabled {boolean} Wallets' _hideTransactionsInWalletsList property determines wether the user wants this wallet's txs hidden from the main list view. */ - getTransactions = (index, limit = Infinity, includeWalletsWithHideTransactionsEnabled = false) => { + getTransactions = ( + index?: number, + limit: number = Infinity, + includeWalletsWithHideTransactionsEnabled: boolean = false, + ): ExtendedTransaction[] => { if (index || index === 0) { - let txs = []; + let txs: Transaction[] = []; let c = 0; for (const wallet of this.wallets) { if (c++ === index) { txs = txs.concat(wallet.getTransactions()); + + const txsRet: ExtendedTransaction[] = []; + const walletID = wallet.getID(); + const walletPreferredBalanceUnit = wallet.getPreferredBalanceUnit(); + txs.map(tx => + txsRet.push({ + ...tx, + walletID, + walletPreferredBalanceUnit, + }), + ); + return txsRet; } } - return txs; } - let txs = []; + const txs: ExtendedTransaction[] = []; for (const wallet of this.wallets.filter(w => includeWalletsWithHideTransactionsEnabled || !w.getHideTransactionsInWalletsList())) { - const walletTransactions = wallet.getTransactions(); + const walletTransactions: Transaction[] = wallet.getTransactions(); const walletID = wallet.getID(); + const walletPreferredBalanceUnit = wallet.getPreferredBalanceUnit(); for (const t of walletTransactions) { - t.walletPreferredBalanceUnit = wallet.getPreferredBalanceUnit(); - t.walletID = walletID; + txs.push({ + ...t, + walletID, + walletPreferredBalanceUnit, + }); } - txs = txs.concat(walletTransactions); - } - - for (const t of txs) { - t.sort_ts = +new Date(t.received); } return txs - .sort(function (a, b) { - return b.sort_ts - a.sort_ts; + .sort((a, b) => { + const bTime = new Date(b.received!).getTime(); + const aTime = new Date(a.received!).getTime(); + return bTime - aTime; }) .slice(0, limit); }; /** * Getter for a sum of all balances of all wallets - * - * @return {number} */ - getBalance = () => { + getBalance = (): number => { let finalBalance = 0; for (const wal of this.wallets) { finalBalance += wal.getBalance(); @@ -824,46 +884,48 @@ class AppStorage { return finalBalance; }; - isAdvancedModeEnabled = async () => { + isHandoffEnabled = async (): Promise => { try { - return !!(await AsyncStorage.getItem(AppStorage.ADVANCED_MODE_ENABLED)); + return !!(await AsyncStorage.getItem(BlueApp.HANDOFF_STORAGE_KEY)); } catch (_) {} return false; }; - setIsAdvancedModeEnabled = async value => { - await AsyncStorage.setItem(AppStorage.ADVANCED_MODE_ENABLED, value ? '1' : ''); + setIsHandoffEnabled = async (value: boolean): Promise => { + await AsyncStorage.setItem(BlueApp.HANDOFF_STORAGE_KEY, value ? '1' : ''); }; - isHandoffEnabled = async () => { + isDoNotTrackEnabled = async (): Promise => { try { - return !!(await AsyncStorage.getItem(AppStorage.HANDOFF_STORAGE_KEY)); - } catch (_) {} - return false; - }; - - setIsHandoffEnabled = async value => { - await AsyncStorage.setItem(AppStorage.HANDOFF_STORAGE_KEY, value ? '1' : ''); - }; - - isDoNotTrackEnabled = async () => { - try { - return !!(await AsyncStorage.getItem(AppStorage.DO_NOT_TRACK)); + const keyExists = await AsyncStorage.getItem(BlueApp.DO_NOT_TRACK); + if (keyExists !== null) { + const doNotTrackValue = !!keyExists; + if (doNotTrackValue) { + await DefaultPreference.setName('group.io.bluewallet.bluewallet'); + await DefaultPreference.set(BlueApp.DO_NOT_TRACK, '1'); + AsyncStorage.removeItem(BlueApp.DO_NOT_TRACK); + } else { + return Boolean(await DefaultPreference.get(BlueApp.DO_NOT_TRACK)); + } + } } catch (_) {} - return false; + const doNotTrackValue = await DefaultPreference.get(BlueApp.DO_NOT_TRACK); + return doNotTrackValue === '1' || false; }; - setDoNotTrack = async value => { - await AsyncStorage.setItem(AppStorage.DO_NOT_TRACK, value ? '1' : ''); + setDoNotTrack = async (value: boolean) => { + await DefaultPreference.setName('group.io.bluewallet.bluewallet'); + if (value) { + await DefaultPreference.set(BlueApp.DO_NOT_TRACK, '1'); + } else { + await DefaultPreference.clear(BlueApp.DO_NOT_TRACK); + } }; /** * Simple async sleeper function - * - * @param ms {number} Milliseconds to sleep - * @returns {Promise | Promise<*>>} */ - sleep = ms => { + sleep = (ms: number): Promise => { return new Promise(resolve => setTimeout(resolve, ms)); }; @@ -873,72 +935,38 @@ class AppStorage { path, }); } -} - -const BlueApp = new AppStorage(); -// If attempt reaches 10, a wipe keychain option will be provided to the user. -let unlockAttempt = 0; - -const startAndDecrypt = async retry => { - console.log('startAndDecrypt'); - if (BlueApp.getWallets().length > 0) { - console.log('App already has some wallets, so we are in already started state, exiting startAndDecrypt'); - return true; - } - await BlueApp.migrateKeys(); - let password = false; - if (await BlueApp.storageIsEncrypted()) { - do { - password = await prompt((retry && loc._.bad_password) || loc._.enter_password, loc._.storage_is_encrypted, false); - } while (!password); - } - let success = false; - let wasException = false; - try { - success = await BlueApp.loadFromDisk(password); - } catch (error) { - // in case of exception reading from keystore, lets retry instead of assuming there is no storage and - // proceeding with no wallets - console.warn('exception loading from disk:', error); - wasException = true; - } - if (wasException) { - // retrying, but only once + async moveRealmFilesToCacheDirectory() { + const documentPath = RNFS.DocumentDirectoryPath; // Path to documentPath folder + const cachePath = RNFS.CachesDirectoryPath; // Path to cachePath folder try { - await new Promise(resolve => setTimeout(resolve, 3000)); // sleep - success = await BlueApp.loadFromDisk(password); - } catch (error) { - console.warn('second exception loading from disk:', error); - } - } + if (!(await RNFS.exists(documentPath))) return; // If the documentPath directory does not exist, return (nothing to move) + const files = await RNFS.readDir(documentPath); // Read all files in documentPath directory + if (Array.isArray(files) && files.length === 0) return; // If there are no files, return (nothing to move) + const appRealmFiles = files.filter( + file => file.name.endsWith('.realm') || file.name.endsWith('.realm.lock') || file.name.includes('.realm.management'), + ); - if (success) { - console.log('loaded from disk'); - // We want to return true to let the UnlockWith screen that its ok to proceed. - return true; - } + for (const file of appRealmFiles) { + const filePath = `${documentPath}/${file.name}`; + const newFilePath = `${cachePath}/${file.name}`; + const fileExists = await RNFS.exists(filePath); // Check if the file exists + const cacheFileExists = await RNFS.exists(newFilePath); // Check if the file already exists in the cache directory - if (password) { - // we had password and yet could not load/decrypt - unlockAttempt++; - if (unlockAttempt < 10 || Platform.OS !== 'ios') { - return startAndDecrypt(true); - } else { - unlockAttempt = 0; - Biometric.showKeychainWipeAlert(); - // We want to return false to let the UnlockWith screen that it is NOT ok to proceed. - return false; + if (fileExists) { + if (cacheFileExists) { + await RNFS.unlink(newFilePath); // Delete the file in the cache directory if it exists + console.log(`Existing file removed from cache: ${newFilePath}`); + } + await RNFS.moveFile(filePath, newFilePath); // Move the file + console.log(`Moved Realm file: ${filePath} to ${newFilePath}`); + } else { + console.log(`File does not exist: ${filePath}`); + } + } + } catch (error) { + console.error('Error moving Realm files:', error); + throw new Error(`Error moving Realm files: ${(error as Error).message}`); } - } else { - unlockAttempt = 0; - // Return true because there was no wallet data in keychain. Proceed. - return true; } -}; - -BlueApp.startAndDecrypt = startAndDecrypt; -BlueApp.AppStorage = AppStorage; -currency.init(); - -module.exports = BlueApp; +} diff --git a/class/camera.js b/class/camera.ts similarity index 72% rename from class/camera.js rename to class/camera.ts index d6baab1804..e9c5116796 100644 --- a/class/camera.js +++ b/class/camera.ts @@ -1,8 +1,7 @@ -import { Linking, Alert } from 'react-native'; -import { getSystemName } from 'react-native-device-info'; -import loc from '../loc'; +import { Alert, Linking } from 'react-native'; -const isDesktop = getSystemName() === 'Mac OS X'; +import { isDesktop } from '../blue_modules/environment'; +import loc from '../loc'; export const openPrivacyDesktopSettings = () => { if (isDesktop) { @@ -12,7 +11,7 @@ export const openPrivacyDesktopSettings = () => { } }; -export const presentCameraNotAuthorizedAlert = error => { +export const presentCameraNotAuthorizedAlert = (error: string) => { Alert.alert( loc.errors.error, error, diff --git a/class/contact-list.ts b/class/contact-list.ts new file mode 100644 index 0000000000..f06acf3869 --- /dev/null +++ b/class/contact-list.ts @@ -0,0 +1,42 @@ +import BIP47Factory from '@spsina/bip47'; + +import { SilentPayment } from 'silent-payments'; + +import ecc from '../blue_modules/noble_ecc'; +import * as bitcoin from 'bitcoinjs-lib'; + +export class ContactList { + isBip47PaymentCodeValid(pc: string) { + try { + BIP47Factory(ecc).fromPaymentCode(pc); + return true; + } catch (_) { + return false; + } + } + + isBip352PaymentCodeValid(pc: string) { + return SilentPayment.isPaymentCodeValid(pc); + } + + isPaymentCodeValid(pc: string): boolean { + return this.isBip47PaymentCodeValid(pc) || this.isBip352PaymentCodeValid(pc); + } + + isAddressValid(address: string): boolean { + try { + bitcoin.address.toOutputScript(address); // throws, no? + + if (!address.toLowerCase().startsWith('bc1')) return true; + const decoded = bitcoin.address.fromBech32(address); + if (decoded.version === 0) return true; + if (decoded.version === 1 && decoded.data.length !== 32) return false; + if (decoded.version === 1 && !ecc.isPoint(Buffer.concat([Buffer.from([2]), decoded.data]))) return false; + if (decoded.version > 1) return false; + // ^^^ some day, when versions above 1 will be actually utilized, we would need to unhardcode this + return true; + } catch (e) { + return false; + } + } +} diff --git a/class/deeplink-schema-match.js b/class/deeplink-schema-match.ts similarity index 73% rename from class/deeplink-schema-match.js rename to class/deeplink-schema-match.ts index eb1fd38e76..1b36387ade 100644 --- a/class/deeplink-schema-match.js +++ b/class/deeplink-schema-match.ts @@ -1,17 +1,26 @@ -import { LightningCustodianWallet, WatchOnlyWallet } from './'; -import AsyncStorage from '@react-native-async-storage/async-storage'; -import RNFS from 'react-native-fs'; +import bip21, { TOptions } from 'bip21'; +import * as bitcoin from 'bitcoinjs-lib'; import URL from 'url'; + +import { readFileOutsideSandbox } from '../blue_modules/fs'; import { Chain } from '../models/bitcoinUnits'; -import Lnurl from './lnurl'; +import { WatchOnlyWallet } from './'; import Azteco from './azteco'; -const bitcoin = require('bitcoinjs-lib'); -const bip21 = require('bip21'); -const BlueApp = require('../BlueApp'); -const AppStorage = BlueApp.AppStorage; +import Lnurl from './lnurl'; +import type { TWallet } from './wallets/types'; + +type TCompletionHandlerParams = [string, object]; +type TContext = { + wallets: TWallet[]; + saveToDisk: () => void; + addWallet: (wallet: TWallet) => void; + setSharedCosigner: (cosigner: string) => void; +}; + +type TBothBitcoinAndLightning = { bitcoin: string; lndInvoice: string } | undefined; class DeeplinkSchemaMatch { - static hasSchema(schemaString) { + static hasSchema(schemaString: string): boolean { if (typeof schemaString !== 'string' || schemaString.length <= 0) return false; const lowercaseString = schemaString.trim().toLowerCase(); return ( @@ -31,7 +40,11 @@ class DeeplinkSchemaMatch { * @param event {{url: string}} URL deeplink as passed to app, e.g. `bitcoin:bc1qh6tf004ty7z7un2v5ntu4mkf630545gvhs45u7?amount=666&label=Yo` * @param completionHandler {function} Callback that returns [string, params: object] */ - static navigationRouteFor(event, completionHandler, context = { wallets: [], saveToDisk: () => {}, addWallet: () => {} }) { + static navigationRouteFor( + event: { url: string }, + completionHandler: (args: TCompletionHandlerParams) => void, + context: TContext = { wallets: [], saveToDisk: () => {}, addWallet: () => {}, setSharedCosigner: () => {} }, + ) { if (event.url === null) { return; } @@ -88,7 +101,7 @@ class DeeplinkSchemaMatch { } } } else if (DeeplinkSchemaMatch.isPossiblySignedPSBTFile(event.url)) { - RNFS.readFile(decodeURI(event.url)) + readFileOutsideSandbox(decodeURI(event.url)) .then(file => { if (file) { completionHandler([ @@ -104,8 +117,19 @@ class DeeplinkSchemaMatch { }) .catch(e => console.warn(e)); return; + } else if (DeeplinkSchemaMatch.isPossiblyCosignerFile(event.url)) { + readFileOutsideSandbox(decodeURI(event.url)) + .then(file => { + // checks whether the necessary json keys are present in order to set a cosigner, + // doesn't validate the values this happens later + if (!file || !this.hasNeededJsonKeysForMultiSigSharing(file)) { + return; + } + context.setSharedCosigner(file); + }) + .catch(e => console.warn(e)); } - let isBothBitcoinAndLightning; + let isBothBitcoinAndLightning: TBothBitcoinAndLightning; try { isBothBitcoinAndLightning = DeeplinkSchemaMatch.isBothBitcoinAndLightning(event.url); } catch (e) { @@ -115,7 +139,7 @@ class DeeplinkSchemaMatch { completionHandler([ 'SelectWallet', { - onWalletSelect: (wallet, { navigation }) => { + onWalletSelect: (wallet: TWallet, { navigation }: any) => { navigation.pop(); // close select wallet screen navigation.navigate(...DeeplinkSchemaMatch.isBothBitcoinAndLightningOnWalletSelect(wallet, isBothBitcoinAndLightning)); }, @@ -155,7 +179,7 @@ class DeeplinkSchemaMatch { }, ]); } else if (Lnurl.isLightningAddress(event.url)) { - // this might be not just an email but a lightning addres + // this might be not just an email but a lightning address // @see https://lightningaddress.com completionHandler([ 'ScanLndInvoiceRoot', @@ -190,64 +214,6 @@ class DeeplinkSchemaMatch { (async () => { if (urlObject.protocol === 'bluewallet:' || urlObject.protocol === 'lapp:' || urlObject.protocol === 'blue:') { switch (urlObject.host) { - case 'openlappbrowser': { - console.log('opening LAPP', urlObject.query.url); - // searching for LN wallet: - let haveLnWallet = false; - for (const w of context.wallets) { - if (w.type === LightningCustodianWallet.type) { - haveLnWallet = true; - } - } - - if (!haveLnWallet) { - // need to create one - const w = new LightningCustodianWallet(); - w.setLabel(w.typeReadable); - - try { - const lndhub = await AsyncStorage.getItem(AppStorage.LNDHUB); - if (lndhub) { - w.setBaseURI(lndhub); - w.init(); - } - await w.createAccount(); - await w.authorize(); - } catch (Err) { - // giving up, not doing anything - return; - } - context.addWallet(w); - context.saveToDisk(); - } - - // now, opening lapp browser and navigating it to URL. - // looking for a LN wallet: - let lnWallet; - for (const w of context.wallets) { - if (w.type === LightningCustodianWallet.type) { - lnWallet = w; - break; - } - } - - if (!lnWallet) { - // something went wrong - return; - } - - completionHandler([ - 'LappBrowserRoot', - { - screen: 'LappBrowser', - params: { - walletID: lnWallet.getID(), - url: urlObject.query.url, - }, - }, - ]); - break; - } case 'setelectrumserver': completionHandler([ 'ElectrumSettings', @@ -277,7 +243,7 @@ class DeeplinkSchemaMatch { * @param url {string} * @return {string|boolean} */ - static getServerFromSetElectrumServerAction(url) { + static getServerFromSetElectrumServerAction(url: string): string | false { if (!url.startsWith('bluewallet:setelectrumserver') && !url.startsWith('setelectrumserver')) return false; const splt = url.split('server='); if (splt[1]) return decodeURIComponent(splt[1]); @@ -291,35 +257,42 @@ class DeeplinkSchemaMatch { * @param url {string} * @return {string|boolean} */ - static getUrlFromSetLndhubUrlAction(url) { + static getUrlFromSetLndhubUrlAction(url: string): string | false { if (!url.startsWith('bluewallet:setlndhuburl') && !url.startsWith('setlndhuburl')) return false; const splt = url.split('url='); if (splt[1]) return decodeURIComponent(splt[1]); return false; } - static isTXNFile(filePath) { + static isTXNFile(filePath: string): boolean { return ( (filePath.toLowerCase().startsWith('file:') || filePath.toLowerCase().startsWith('content:')) && filePath.toLowerCase().endsWith('.txn') ); } - static isPossiblySignedPSBTFile(filePath) { + static isPossiblySignedPSBTFile(filePath: string): boolean { return ( (filePath.toLowerCase().startsWith('file:') || filePath.toLowerCase().startsWith('content:')) && filePath.toLowerCase().endsWith('-signed.psbt') ); } - static isPossiblyPSBTFile(filePath) { + static isPossiblyPSBTFile(filePath: string): boolean { return ( (filePath.toLowerCase().startsWith('file:') || filePath.toLowerCase().startsWith('content:')) && filePath.toLowerCase().endsWith('.psbt') ); } - static isBothBitcoinAndLightningOnWalletSelect(wallet, uri) { + static isPossiblyCosignerFile(filePath: string): boolean { + return ( + (filePath.toLowerCase().startsWith('file:') || filePath.toLowerCase().startsWith('content:')) && + filePath.toLowerCase().endsWith('.bwcosigner') + ); + } + + static isBothBitcoinAndLightningOnWalletSelect(wallet: TWallet, uri: any): TCompletionHandlerParams { if (wallet.chain === Chain.ONCHAIN) { return [ 'SendDetailsRoot', @@ -331,7 +304,7 @@ class DeeplinkSchemaMatch { }, }, ]; - } else if (wallet.chain === Chain.OFFCHAIN) { + } else { return [ 'ScanLndInvoiceRoot', { @@ -345,7 +318,7 @@ class DeeplinkSchemaMatch { } } - static isBitcoinAddress(address) { + static isBitcoinAddress(address: string): boolean { address = address.replace('://', ':').replace('bitcoin:', '').replace('BITCOIN:', '').replace('bitcoin=', '').split('?')[0]; let isValidBitcoinAddress = false; try { @@ -357,7 +330,7 @@ class DeeplinkSchemaMatch { return isValidBitcoinAddress; } - static isLightningInvoice(invoice) { + static isLightningInvoice(invoice: string): boolean { let isValidLightningInvoice = false; if ( invoice.toLowerCase().startsWith('lightning:lnb') || @@ -369,19 +342,33 @@ class DeeplinkSchemaMatch { return isValidLightningInvoice; } - static isLnUrl(text) { + static isLnUrl(text: string): boolean { return Lnurl.isLnurl(text); } - static isWidgetAction(text) { + static isWidgetAction(text: string): boolean { return text.startsWith('widget?action='); } - static isBothBitcoinAndLightning(url) { + static hasNeededJsonKeysForMultiSigSharing(str: string): boolean { + let obj; + + // Check if it's a valid JSON + try { + obj = JSON.parse(str); + } catch (e) { + return false; + } + + // Check for the existence and type of the keys + return typeof obj.xfp === 'string' && typeof obj.xpub === 'string' && typeof obj.path === 'string'; + } + + static isBothBitcoinAndLightning(url: string): TBothBitcoinAndLightning { if (url.includes('lightning') && (url.includes('bitcoin') || url.includes('BITCOIN'))) { const txInfo = url.split(/(bitcoin:\/\/|BITCOIN:\/\/|bitcoin:|BITCOIN:|lightning:|lightning=|bitcoin=)+/); - let btc; - let lndInvoice; + let btc: string | false = false; + let lndInvoice: string | false = false; for (const [index, value] of txInfo.entries()) { try { // Inside try-catch. We dont wan't to crash in case of an out-of-bounds error. @@ -413,8 +400,10 @@ class DeeplinkSchemaMatch { return undefined; } - static bip21decode(uri) { - if (!uri) return {}; + static bip21decode(uri?: string) { + if (!uri) { + throw new Error('No URI provided'); + } let replacedUri = uri; for (const replaceMe of ['BITCOIN://', 'bitcoin://', 'BITCOIN:']) { replacedUri = replacedUri.replace(replaceMe, 'bitcoin:'); @@ -423,37 +412,34 @@ class DeeplinkSchemaMatch { return bip21.decode(replacedUri); } - static bip21encode() { - const argumentsArray = Array.from(arguments); - for (const argument of argumentsArray) { - if (String(argument.label).replace(' ', '').length === 0) { - delete argument.label; + static bip21encode(address: string, options: TOptions): string { + for (const key in options) { + if (key === 'label' && String(options[key]).replace(' ', '').length === 0) { + delete options[key]; } - if (!(Number(argument.amount) > 0)) { - delete argument.amount; + if (key === 'amount' && !(Number(options[key]) > 0)) { + delete options[key]; } } - return bip21.encode.apply(bip21, argumentsArray); + return bip21.encode(address, options); } - static decodeBitcoinUri(uri) { - let amount = ''; - let parsedBitcoinUri = null; + static decodeBitcoinUri(uri: string) { + let amount; let address = uri || ''; let memo = ''; let payjoinUrl = ''; try { - parsedBitcoinUri = DeeplinkSchemaMatch.bip21decode(uri); - address = 'address' in parsedBitcoinUri ? parsedBitcoinUri.address : address; + const parsedBitcoinUri = DeeplinkSchemaMatch.bip21decode(uri); + address = parsedBitcoinUri.address ? parsedBitcoinUri.address.toString() : address; if ('options' in parsedBitcoinUri) { - if ('amount' in parsedBitcoinUri.options) { - amount = parsedBitcoinUri.options.amount.toString(); - amount = parsedBitcoinUri.options.amount; + if (parsedBitcoinUri.options.amount) { + amount = Number(parsedBitcoinUri.options.amount); } - if ('label' in parsedBitcoinUri.options) { - memo = parsedBitcoinUri.options.label || memo; + if (parsedBitcoinUri.options.label) { + memo = parsedBitcoinUri.options.label; } - if ('pj' in parsedBitcoinUri.options) { + if (parsedBitcoinUri.options.pj) { payjoinUrl = parsedBitcoinUri.options.pj; } } diff --git a/class/hd-segwit-bech32-transaction.js b/class/hd-segwit-bech32-transaction.js index 758a120a9a..cfff365699 100644 --- a/class/hd-segwit-bech32-transaction.js +++ b/class/hd-segwit-bech32-transaction.js @@ -1,9 +1,9 @@ +import BigNumber from 'bignumber.js'; +import * as bitcoin from 'bitcoinjs-lib'; + +import * as BlueElectrum from '../blue_modules/BlueElectrum'; import { HDSegwitBech32Wallet } from './wallets/hd-segwit-bech32-wallet'; import { SegwitBech32Wallet } from './wallets/segwit-bech32-wallet'; -const bitcoin = require('bitcoinjs-lib'); -const BlueElectrum = require('../blue_modules/BlueElectrum'); -const reverse = require('buffer-reverse'); -const BigNumber = require('bignumber.js'); /** * Represents transaction of a BIP84 wallet. @@ -40,7 +40,7 @@ export class HDSegwitBech32Transaction { * @private */ async _fetchTxhexAndDecode() { - const hexes = await BlueElectrum.multiGetTransactionByTxid([this._txid], 10, false); + const hexes = await BlueElectrum.multiGetTransactionByTxid([this._txid], false, 10); this._txhex = hexes[this._txid]; if (!this._txhex) throw new Error("Transaction can't be found in mempool"); this._txDecoded = bitcoin.Transaction.fromHex(this._txhex); @@ -81,7 +81,7 @@ export class HDSegwitBech32Transaction { * @private */ async _fetchRemoteTx() { - const result = await BlueElectrum.multiGetTransactionByTxid([this._txid || this._txDecoded.getId()]); + const result = await BlueElectrum.multiGetTransactionByTxid([this._txid || this._txDecoded.getId()], true); this._remoteTx = Object.values(result)[0]; } @@ -98,7 +98,7 @@ export class HDSegwitBech32Transaction { /** * Checks that tx belongs to a wallet and also * tx value is < 0, which means its a spending transaction - * definately initiated by us, can be RBF'ed. + * definitely initiated by us, can be RBF'ed. * * @returns {Promise} */ @@ -150,25 +150,25 @@ export class HDSegwitBech32Transaction { const prevInputs = []; for (const inp of this._txDecoded.ins) { - let reversedHash = Buffer.from(reverse(inp.hash)); + let reversedHash = Buffer.from(inp.hash).reverse(); reversedHash = reversedHash.toString('hex'); prevInputs.push(reversedHash); } - const prevTransactions = await BlueElectrum.multiGetTransactionByTxid(prevInputs); + const prevTransactions = await BlueElectrum.multiGetTransactionByTxid(prevInputs, true); // fetched, now lets count how much satoshis went in let wentIn = 0; const utxos = []; for (const inp of this._txDecoded.ins) { - let reversedHash = Buffer.from(reverse(inp.hash)); + let reversedHash = Buffer.from(inp.hash).reverse(); reversedHash = reversedHash.toString('hex'); if (prevTransactions[reversedHash] && prevTransactions[reversedHash].vout && prevTransactions[reversedHash].vout[inp.index]) { let value = prevTransactions[reversedHash].vout[inp.index].value; value = new BigNumber(value).multipliedBy(100000000).toNumber(); wentIn += value; const address = SegwitBech32Wallet.witnessToAddress(inp.witness[inp.witness.length - 1]); - utxos.push({ vout: inp.index, value, txId: reversedHash, address }); + utxos.push({ vout: inp.index, value, txid: reversedHash, address }); } } @@ -206,7 +206,7 @@ export class HDSegwitBech32Transaction { unconfirmedUtxos.push({ vout: outp.n, value, - txId: this._txid || this._txDecoded.getId(), + txid: this._txid || this._txDecoded.getId(), address, }); } @@ -228,7 +228,7 @@ export class HDSegwitBech32Transaction { const spentUtxos = this._wallet.getDerivedUtxoFromOurTransaction(true); for (const inp of this._txDecoded.ins) { - const txidInUtxo = reverse(inp.hash).toString('hex'); + const txidInUtxo = Buffer.from(inp.hash).reverse().toString('hex'); let found = false; for (const spentU of spentUtxos) { diff --git a/class/index.js b/class/index.ts similarity index 95% rename from class/index.js rename to class/index.ts index 9436a9faec..6bf43708e4 100644 --- a/class/index.js +++ b/class/index.ts @@ -1,20 +1,20 @@ +export * from './blue-app'; +export * from './hd-segwit-bech32-transaction'; +export * from './multisig-cosigner'; +export * from './wallets/abstract-hd-wallet'; export * from './wallets/abstract-wallet'; -export * from './wallets/legacy-wallet'; -export * from './wallets/segwit-bech32-wallet'; -export * from './wallets/taproot-wallet'; -export * from './wallets/segwit-p2sh-wallet'; -export * from './wallets/hd-segwit-p2sh-wallet'; +export * from './wallets/hd-aezeed-wallet'; export * from './wallets/hd-legacy-breadwallet-wallet'; +export * from './wallets/hd-legacy-electrum-seed-p2pkh-wallet'; export * from './wallets/hd-legacy-p2pkh-wallet'; -export * from './wallets/watch-only-wallet'; -export * from './wallets/lightning-custodian-wallet'; -export * from './wallets/lightning-ldk-wallet'; -export * from './wallets/abstract-hd-wallet'; export * from './wallets/hd-segwit-bech32-wallet'; -export * from './wallets/hd-legacy-electrum-seed-p2pkh-wallet'; export * from './wallets/hd-segwit-electrum-seed-p2wpkh-wallet'; -export * from './wallets/hd-aezeed-wallet'; +export * from './wallets/hd-segwit-p2sh-wallet'; +export * from './wallets/legacy-wallet'; +export * from './wallets/lightning-custodian-wallet'; export * from './wallets/multisig-hd-wallet'; +export * from './wallets/segwit-bech32-wallet'; +export * from './wallets/segwit-p2sh-wallet'; export * from './wallets/slip39-wallets'; -export * from './hd-segwit-bech32-transaction'; -export * from './multisig-cosigner'; +export * from './wallets/taproot-wallet'; +export * from './wallets/watch-only-wallet'; diff --git a/class/lnurl.js b/class/lnurl.ts similarity index 62% rename from class/lnurl.js rename to class/lnurl.ts index 6e23959e44..1435d66b30 100644 --- a/class/lnurl.js +++ b/class/lnurl.ts @@ -1,14 +1,53 @@ import { bech32 } from 'bech32'; import bolt11 from 'bolt11'; -import { isTorDaemonDisabled } from '../blue_modules/environment'; -import { parse } from 'url'; // eslint-disable-line n/no-deprecated-api +import createHash from 'create-hash'; import { createHmac } from 'crypto'; +import CryptoJS from 'crypto-js'; +// @ts-ignore theres no types for secp256k1 import secp256k1 from 'secp256k1'; -const CryptoJS = require('crypto-js'); -const createHash = require('create-hash'); -const torrific = require('../blue_modules/torrific'); +import { parse } from 'url'; // eslint-disable-line n/no-deprecated-api + const ONION_REGEX = /^(http:\/\/[^/:@]+\.onion(?::\d{1,5})?)(\/.*)?$/; // regex for onion URL +interface LnurlPayServicePayload { + callback: string; + fixed: boolean; + min: number; + max: number; + domain: string; + metadata: string; + description?: string; + image?: string; + amount: number; + commentAllowed?: number; +} + +interface LnurlPayServiceBolt11Payload { + pr: string; + successAction?: any; + disposable?: boolean; + tag: string; + metadata: any; + minSendable: number; + maxSendable: number; + callback: string; + commentAllowed: number; +} + +interface DecodedInvoice { + destination: string; + num_satoshis: string; + num_millisatoshis: string; + timestamp: string; + fallback_addr: string; + route_hints: any[]; + payment_hash?: string; + description_hash?: string; + cltv_expiry?: string; + expiry?: string; + description?: string; +} + /** * @see https://github.com/btcontract/lnurl-rfc/blob/master/lnurl-pay.md */ @@ -17,15 +56,21 @@ export default class Lnurl { static TAG_WITHDRAW_REQUEST = 'withdrawRequest'; // type of LNURL static TAG_LOGIN_REQUEST = 'login'; // type of LNURL - constructor(url, AsyncStorage) { - this._lnurl = url; + private _lnurl: string; + private _lnurlPayServiceBolt11Payload: LnurlPayServiceBolt11Payload | false; + private _lnurlPayServicePayload: LnurlPayServicePayload | false; + private _AsyncStorage: any; + private _preimage: string | false; + + constructor(url: string | false, AsyncStorage?: any) { + this._lnurl = url || ''; this._lnurlPayServiceBolt11Payload = false; this._lnurlPayServicePayload = false; this._AsyncStorage = AsyncStorage; this._preimage = false; } - static findlnurl(bodyOfText) { + static findlnurl(bodyOfText: string): string | null { const res = /^(?:http.*[&?]lightning=|lightning:)?(lnurl1[02-9ac-hj-np-z]+)/.exec(bodyOfText.toLowerCase()); if (res) { return res[1]; @@ -33,7 +78,7 @@ export default class Lnurl { return null; } - static getUrlFromLnurl(lnurlExample) { + static getUrlFromLnurl(lnurlExample: string): string | false { const found = Lnurl.findlnurl(lnurlExample); if (!found) { if (Lnurl.isLightningAddress(lnurlExample)) { @@ -50,27 +95,22 @@ export default class Lnurl { return Buffer.from(bech32.fromWords(decoded.words)).toString(); } - static isLnurl(url) { + static isLnurl(url: string): boolean { return Lnurl.findlnurl(url) !== null; } - static isOnionUrl(url) { + static isOnionUrl(url: string): boolean { return Lnurl.parseOnionUrl(url) !== null; } - static parseOnionUrl(url) { + static parseOnionUrl(url: string): [string, string] | null { const match = url.match(ONION_REGEX); if (match === null) return null; const [, baseURI, path] = match; return [baseURI, path]; } - async fetchGet(url) { - const parsedOnionUrl = Lnurl.parseOnionUrl(url); - if (parsedOnionUrl) { - return _fetchGetTor(parsedOnionUrl); - } - + async fetchGet(url: string): Promise { const resp = await fetch(url, { method: 'GET' }); if (resp.status >= 300) { throw new Error('Bad response from server'); @@ -82,14 +122,14 @@ export default class Lnurl { return reply; } - decodeInvoice(invoice) { + decodeInvoice(invoice: string): DecodedInvoice { const { payeeNodeKey, tags, satoshis, millisatoshis, timestamp } = bolt11.decode(invoice); - const decoded = { - destination: payeeNodeKey, + const decoded: DecodedInvoice = { + destination: payeeNodeKey ?? '', num_satoshis: satoshis ? satoshis.toString() : '0', num_millisatoshis: millisatoshis ? millisatoshis.toString() : '0', - timestamp: timestamp.toString(), + timestamp: timestamp?.toString() ?? '', fallback_addr: '', route_hints: [], }; @@ -98,10 +138,10 @@ export default class Lnurl { const { tagName, data } = tags[i]; switch (tagName) { case 'payment_hash': - decoded.payment_hash = data; + decoded.payment_hash = String(data); break; case 'purpose_commit_hash': - decoded.description_hash = data; + decoded.description_hash = String(data); break; case 'min_final_cltv_expiry': decoded.cltv_expiry = data.toString(); @@ -110,21 +150,21 @@ export default class Lnurl { decoded.expiry = data.toString(); break; case 'description': - decoded.description = data; + decoded.description = String(data); break; } } if (!decoded.expiry) decoded.expiry = '3600'; // default - if (parseInt(decoded.num_satoshis, 10) === 0 && decoded.num_millisatoshis > 0) { - decoded.num_satoshis = (decoded.num_millisatoshis / 1000).toString(); + if (parseInt(decoded.num_satoshis, 10) === 0 && parseInt(decoded.num_millisatoshis, 10) > 0) { + decoded.num_satoshis = (parseInt(decoded.num_millisatoshis, 10) / 1000).toString(); } return decoded; } - async requestBolt11FromLnurlPayService(amountSat, comment = '') { + async requestBolt11FromLnurlPayService(amountSat: number, comment: string = ''): Promise { if (!this._lnurlPayServicePayload) throw new Error('this._lnurlPayServicePayload is not set'); if (!this._lnurlPayServicePayload.callback) throw new Error('this._lnurlPayServicePayload.callback is not set'); if (amountSat < this._lnurlPayServicePayload.min || amountSat > this._lnurlPayServicePayload.max) @@ -138,21 +178,19 @@ export default class Lnurl { ); const nonce = Math.floor(Math.random() * 2e16).toString(16); const separator = this._lnurlPayServicePayload.callback.indexOf('?') === -1 ? '?' : '&'; - if (this.getCommentAllowed() && comment && comment.length > this.getCommentAllowed()) { - comment = comment.substr(0, this.getCommentAllowed()); + if (this.getCommentAllowed() && comment && comment.length > (this.getCommentAllowed() as number)) { + comment = comment.substr(0, this.getCommentAllowed() as number); } if (comment) comment = `&comment=${encodeURIComponent(comment)}`; const urlToFetch = this._lnurlPayServicePayload.callback + separator + 'amount=' + Math.floor(amountSat * 1000) + '&nonce=' + nonce + comment; - this._lnurlPayServiceBolt11Payload = await this.fetchGet(urlToFetch); - if (this._lnurlPayServiceBolt11Payload.status === 'ERROR') - throw new Error(this._lnurlPayServiceBolt11Payload.reason || 'requestBolt11FromLnurlPayService() error'); + this._lnurlPayServiceBolt11Payload = (await this.fetchGet(urlToFetch)) as LnurlPayServiceBolt11Payload; // check pr description_hash, amount etc: const decoded = this.decodeInvoice(this._lnurlPayServiceBolt11Payload.pr); const metadataHash = createHash('sha256').update(this._lnurlPayServicePayload.metadata).digest('hex'); if (metadataHash !== decoded.description_hash) { - throw new Error(`Invoice description_hash doesn't match metadata.`); + console.log(`Invoice description_hash doesn't match metadata.`); } if (parseInt(decoded.num_satoshis, 10) !== Math.round(amountSat)) { throw new Error(`Invoice doesn't match specified amount, got ${decoded.num_satoshis}, expected ${Math.round(amountSat)}`); @@ -161,11 +199,12 @@ export default class Lnurl { return this._lnurlPayServiceBolt11Payload; } - async callLnurlPayService() { + async callLnurlPayService(): Promise { if (!this._lnurl) throw new Error('this._lnurl is not set'); const url = Lnurl.getUrlFromLnurl(this._lnurl); + if (!url) throw new Error('Invalid LNURL'); // calling the url - const reply = await this.fetchGet(url); + const reply = (await this.fetchGet(url)) as LnurlPayServiceBolt11Payload; if (reply.tag !== Lnurl.TAG_PAY_REQUEST) { throw new Error('lnurl-pay expected, found tag ' + reply.tag); @@ -174,8 +213,8 @@ export default class Lnurl { const data = reply; // parse metadata and extract things from it - let image; - let description; + let image: string | undefined; + let description: string | undefined; const kvs = JSON.parse(data.metadata); for (let i = 0; i < kvs.length; i++) { const [k, v] = kvs[i]; @@ -191,14 +230,15 @@ export default class Lnurl { } // setting the payment screen with the parameters - const min = Math.ceil((data.minSendable || 0) / 1000); - const max = Math.floor(data.maxSendable / 1000); + const min = Math.ceil((data.minSendable ?? 0) / 1000); + const max = Math.floor((data.maxSendable ?? 0) / 1000); this._lnurlPayServicePayload = { callback: data.callback, fixed: min === max, min, max, + // @ts-ignore idk domain: data.callback.match(/^(https|http):\/\/([^/]+)\//)[2], metadata: data.metadata, description, @@ -210,7 +250,7 @@ export default class Lnurl { return this._lnurlPayServicePayload; } - async loadSuccessfulPayment(paymentHash) { + async loadSuccessfulPayment(paymentHash: string): Promise { if (!paymentHash) throw new Error('No paymentHash provided'); let data; try { @@ -230,7 +270,7 @@ export default class Lnurl { return true; } - async storeSuccess(paymentHash, preimage) { + async storeSuccess(paymentHash: string, preimage: string | { data: Buffer }): Promise { if (typeof preimage === 'object') { preimage = Buffer.from(preimage.data).toString('hex'); } @@ -247,35 +287,39 @@ export default class Lnurl { ); } - getSuccessAction() { - return this._lnurlPayServiceBolt11Payload.successAction; + getSuccessAction(): any | undefined { + return this._lnurlPayServiceBolt11Payload && 'successAction' in this._lnurlPayServiceBolt11Payload + ? this._lnurlPayServiceBolt11Payload.successAction + : undefined; } - getDomain() { - return this._lnurlPayServicePayload.domain; + getDomain(): string | undefined { + return this._lnurlPayServicePayload ? this._lnurlPayServicePayload.domain : undefined; } - getDescription() { - return this._lnurlPayServicePayload.description; + getDescription(): string | undefined { + return this._lnurlPayServicePayload ? this._lnurlPayServicePayload.description : undefined; } - getImage() { - return this._lnurlPayServicePayload.image; + getImage(): string | undefined { + return this._lnurlPayServicePayload ? this._lnurlPayServicePayload.image : undefined; } - getLnurl() { + getLnurl(): string { return this._lnurl; } - getDisposable() { - return this._lnurlPayServiceBolt11Payload.disposable; + getDisposable(): boolean | undefined { + return this._lnurlPayServiceBolt11Payload && 'disposable' in this._lnurlPayServiceBolt11Payload + ? this._lnurlPayServiceBolt11Payload.disposable + : undefined; } - getPreimage() { + getPreimage(): string | false { return this._preimage; } - static decipherAES(ciphertextBase64, preimageHex, ivBase64) { + static decipherAES(ciphertextBase64: string, preimageHex: string, ivBase64: string): string { const iv = CryptoJS.enc.Base64.parse(ivBase64); const key = CryptoJS.enc.Hex.parse(preimageHex); return CryptoJS.AES.decrypt(Buffer.from(ciphertextBase64, 'base64').toString('hex'), key, { @@ -285,27 +329,30 @@ export default class Lnurl { }).toString(CryptoJS.enc.Utf8); } - getCommentAllowed() { - return this?._lnurlPayServicePayload?.commentAllowed ? parseInt(this._lnurlPayServicePayload.commentAllowed, 10) : false; + getCommentAllowed(): number | false { + if (!this._lnurlPayServicePayload) return false; + return this._lnurlPayServicePayload.commentAllowed ? parseInt(this._lnurlPayServicePayload.commentAllowed.toString(), 10) : false; } - getMin() { - return this?._lnurlPayServicePayload?.min ? parseInt(this._lnurlPayServicePayload.min, 10) : false; + getMin(): number | false { + if (!this._lnurlPayServicePayload) return false; + return this._lnurlPayServicePayload.min ? parseInt(this._lnurlPayServicePayload.min.toString(), 10) : false; } - getMax() { - return this?._lnurlPayServicePayload?.max ? parseInt(this._lnurlPayServicePayload.max, 10) : false; + getMax(): number | false { + if (!this._lnurlPayServicePayload) return false; + return this._lnurlPayServicePayload.max ? parseInt(this._lnurlPayServicePayload.max.toString(), 10) : false; } - getAmount() { + getAmount(): number | false { return this.getMin(); } - authenticate(secret) { + authenticate(secret: string): Promise { return new Promise((resolve, reject) => { if (!this._lnurl) throw new Error('this._lnurl is not set'); - const url = parse(Lnurl.getUrlFromLnurl(this._lnurl), true); + const url = parse(Lnurl.getUrlFromLnurl(this._lnurl) || '', true); const hmac = createHmac('sha256', secret); hmac.on('readable', async () => { @@ -314,7 +361,7 @@ export default class Lnurl { if (!privateKey) return; const privateKeyBuf = Buffer.from(privateKey, 'hex'); const publicKey = secp256k1.publicKeyCreate(privateKeyBuf); - const signatureObj = secp256k1.sign(Buffer.from(url.query.k1, 'hex'), privateKeyBuf); + const signatureObj = secp256k1.sign(Buffer.from(url.query.k1 as string, 'hex'), privateKeyBuf); const derSignature = secp256k1.signatureExport(signatureObj.signature); const reply = await this.fetchGet(`${url.href}&sig=${derSignature.toString('hex')}&key=${publicKey.toString('hex')}`); @@ -332,35 +379,10 @@ export default class Lnurl { }); } - static isLightningAddress(address) { + static isLightningAddress(address: string) { // ensure only 1 `@` present: if (address.split('@').length !== 2) return false; const splitted = address.split('@'); return !!splitted[0].trim() && !!splitted[1].trim(); } } - -async function _fetchGetTor(parsedOnionUrl) { - const torDaemonDisabled = await isTorDaemonDisabled(); - if (torDaemonDisabled) { - throw new Error('Tor onion url support disabled'); - } - const [baseURI, path] = parsedOnionUrl; - const tor = new torrific.Torsbee({ - baseURI, - }); - const response = await tor.get(path || '/', { - headers: { - 'Access-Control-Allow-Origin': '*', - 'Content-Type': 'application/json', - }, - }); - const json = response.body; - if (typeof json === 'undefined' || response.err) { - throw new Error('Bad response from server: ' + response.err + ' ' + JSON.stringify(response.body)); - } - if (json.status === 'ERROR') { - throw new Error('Reply from server: ' + json.reason); - } - return json; -} diff --git a/class/multisig-cosigner.js b/class/multisig-cosigner.ts similarity index 93% rename from class/multisig-cosigner.js rename to class/multisig-cosigner.ts index bf15b24f07..64eb6dc321 100644 --- a/class/multisig-cosigner.js +++ b/class/multisig-cosigner.ts @@ -1,16 +1,21 @@ -import b58 from 'bs58check'; -import { MultisigHDWallet } from './wallets/multisig-hd-wallet'; import BIP32Factory from 'bip32'; +import b58 from 'bs58check'; + import ecc from '../blue_modules/noble_ecc'; +import { MultisigHDWallet } from './wallets/multisig-hd-wallet'; +import assert from 'assert'; const bip32 = BIP32Factory(ecc); export class MultisigCosigner { - constructor(data) { + private _data: string; + private _fp: string = ''; + private _xpub: string = ''; + private _path: string = ''; + private _valid: boolean = false; + private _cosigners: any[]; + + constructor(data: string) { this._data = data; - this._fp = false; - this._xpub = false; - this._path = false; - this._valid = false; this._cosigners = []; // is it plain simple Zpub/Ypub/xpub? @@ -69,6 +74,7 @@ export class MultisigCosigner { // a bit more logic here: according to the formal BIP48 spec, this xpub field _can_ start with 'xpub', but // the actual type of segwit can be inferred from the path + assert(this._xpub); if ( this._xpub.startsWith('xpub') && [MultisigHDWallet.PATH_NATIVE_SEGWIT, MultisigHDWallet.PATH_WRAPPED_SEGWIT].includes(this._path) @@ -125,7 +131,7 @@ export class MultisigCosigner { } } - static isXpubValid(key) { + static isXpubValid(key: string) { let xpub; try { @@ -138,7 +144,7 @@ export class MultisigCosigner { return false; } - static exportToJson(xfp, xpub, path) { + static exportToJson(xfp: string, xpub: string, path: string) { return JSON.stringify({ xfp, xpub, diff --git a/class/on-app-launch.js b/class/on-app-launch.js deleted file mode 100644 index 18c5ca0add..0000000000 --- a/class/on-app-launch.js +++ /dev/null @@ -1,45 +0,0 @@ -import AsyncStorage from '@react-native-async-storage/async-storage'; -const BlueApp = require('../BlueApp'); - -export default class OnAppLaunch { - static STORAGE_KEY = 'ONAPP_LAUNCH_SELECTED_DEFAULT_WALLET_KEY'; - - static async isViewAllWalletsEnabled() { - try { - const selectedDefaultWallet = await AsyncStorage.getItem(OnAppLaunch.STORAGE_KEY); - return selectedDefaultWallet === '' || selectedDefaultWallet === null; - } catch (_e) { - return true; - } - } - - static async setViewAllWalletsEnabled(value) { - if (!value) { - const selectedDefaultWallet = await OnAppLaunch.getSelectedDefaultWallet(); - if (!selectedDefaultWallet) { - const firstWallet = BlueApp.getWallets()[0]; - await OnAppLaunch.setSelectedDefaultWallet(firstWallet.getID()); - } - } else { - await AsyncStorage.setItem(OnAppLaunch.STORAGE_KEY, ''); - } - } - - static async getSelectedDefaultWallet() { - let selectedWallet = false; - try { - const selectedWalletID = JSON.parse(await AsyncStorage.getItem(OnAppLaunch.STORAGE_KEY)); - selectedWallet = BlueApp.getWallets().find(wallet => wallet.getID() === selectedWalletID); - if (!selectedWallet) { - await AsyncStorage.setItem(OnAppLaunch.STORAGE_KEY, ''); - } - } catch (_e) { - return false; - } - return selectedWallet; - } - - static async setSelectedDefaultWallet(value) { - await AsyncStorage.setItem(OnAppLaunch.STORAGE_KEY, JSON.stringify(value)); - } -} diff --git a/class/payjoin-transaction.js b/class/payjoin-transaction.ts similarity index 62% rename from class/payjoin-transaction.js rename to class/payjoin-transaction.ts index 33362be925..dfd121fea7 100644 --- a/class/payjoin-transaction.js +++ b/class/payjoin-transaction.ts @@ -1,16 +1,24 @@ import * as bitcoin from 'bitcoinjs-lib'; -import ReactNativeHapticFeedback from 'react-native-haptic-feedback'; -import alert from '../components/Alert'; import { ECPairFactory } from 'ecpair'; + +import triggerHapticFeedback, { HapticFeedbackTypes } from '../blue_modules/hapticFeedback'; import ecc from '../blue_modules/noble_ecc'; +import presentAlert from '../components/Alert'; +import { HDSegwitBech32Wallet } from './wallets/hd-segwit-bech32-wallet'; +import assert from 'assert'; const ECPair = ECPairFactory(ecc); -const delay = milliseconds => new Promise(resolve => setTimeout(resolve, milliseconds)); +const delay = (milliseconds: number) => new Promise(resolve => setTimeout(resolve, milliseconds)); // Implements IPayjoinClientWallet // https://github.com/bitcoinjs/payjoin-client/blob/master/ts_src/wallet.ts export default class PayjoinTransaction { - constructor(psbt, broadcast, wallet) { + private _psbt: bitcoin.Psbt; + private _broadcast: (txhex: string) => Promise; + private _wallet: HDSegwitBech32Wallet; + private _payjoinPsbt: any; + + constructor(psbt: bitcoin.Psbt, broadcast: (txhex: string) => Promise, wallet: HDSegwitBech32Wallet) { this._psbt = psbt; this._broadcast = broadcast; this._wallet = wallet; @@ -23,6 +31,7 @@ export default class PayjoinTransaction { for (const [index, input] of unfinalized.data.inputs.entries()) { delete input.finalScriptWitness; + assert(input.witnessUtxo, 'Internal error: input.witnessUtxo is not set'); const address = bitcoin.address.fromOutputScript(input.witnessUtxo.script); const wif = this._wallet._getWifForAddress(address); const keyPair = ECPair.fromWIF(wif); @@ -36,16 +45,17 @@ export default class PayjoinTransaction { /** * Doesnt conform to spec but needed for user-facing wallet software to find out txid of payjoined transaction * - * @returns {boolean|Psbt} + * @returns {Psbt} */ getPayjoinPsbt() { return this._payjoinPsbt; } - async signPsbt(payjoinPsbt) { + async signPsbt(payjoinPsbt: bitcoin.Psbt) { // Do this without relying on private methods for (const [index, input] of payjoinPsbt.data.inputs.entries()) { + assert(input.witnessUtxo, 'Internal error: input.witnessUtxo is not set'); const address = bitcoin.address.fromOutputScript(input.witnessUtxo.script); try { const wif = this._wallet._getWifForAddress(address); @@ -57,30 +67,30 @@ export default class PayjoinTransaction { return this._payjoinPsbt; } - async broadcastTx(txHex) { + async broadcastTx(txHex: string) { try { const result = await this._broadcast(txHex); if (!result) { throw new Error(`Broadcast failed`); } return ''; - } catch (e) { + } catch (e: any) { return 'Error: ' + e.message; } } - async scheduleBroadcastTx(txHex, milliseconds) { + async scheduleBroadcastTx(txHex: string, milliseconds: number) { delay(milliseconds).then(async () => { const result = await this.broadcastTx(txHex); if (result === '') { // TODO: Improve the wording of this error message - ReactNativeHapticFeedback.trigger('notificationError', { ignoreAndroidSystemSettings: false }); - alert('Something was wrong with the payjoin transaction, the original transaction sucessfully broadcast.'); + triggerHapticFeedback(HapticFeedbackTypes.NotificationError); + presentAlert({ message: 'Something was wrong with the payjoin transaction, the original transaction successfully broadcast.' }); } }); } - async isOwnOutputScript(outputScript) { + async isOwnOutputScript(outputScript: Buffer) { const address = bitcoin.address.fromOutputScript(outputScript); return this._wallet.weOwnAddress(address); diff --git a/class/quick-actions.js b/class/quick-actions.js deleted file mode 100644 index 0414b6b6b5..0000000000 --- a/class/quick-actions.js +++ /dev/null @@ -1,85 +0,0 @@ -import QuickActions from 'react-native-quick-actions'; -import { Platform } from 'react-native'; -import { formatBalance } from '../loc'; -import AsyncStorage from '@react-native-async-storage/async-storage'; -import { useContext, useEffect } from 'react'; -import { BlueStorageContext } from '../blue_modules/storage-context'; - -function DeviceQuickActions() { - DeviceQuickActions.STORAGE_KEY = 'DeviceQuickActionsEnabled'; - const { wallets, walletsInitialized, isStorageEncrypted, preferredFiatCurrency } = useContext(BlueStorageContext); - - useEffect(() => { - if (walletsInitialized) { - isStorageEncrypted() - .then(value => { - if (value) { - QuickActions.clearShortcutItems(); - } else { - setQuickActions(); - } - }) - .catch(() => QuickActions.clearShortcutItems()); - } - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [wallets, walletsInitialized, preferredFiatCurrency]); - - DeviceQuickActions.setEnabled = (enabled = true) => { - return AsyncStorage.setItem(DeviceQuickActions.STORAGE_KEY, JSON.stringify(enabled)).then(() => { - if (!enabled) { - QuickActions.clearShortcutItems(); - } else { - setQuickActions(); - } - }); - }; - - DeviceQuickActions.popInitialAction = async () => { - const data = await QuickActions.popInitialAction(); - return data; - }; - - DeviceQuickActions.getEnabled = async () => { - try { - const isEnabled = await AsyncStorage.getItem(DeviceQuickActions.STORAGE_KEY); - if (isEnabled === null) { - await DeviceQuickActions.setEnabled(JSON.stringify(true)); - return true; - } - return !!JSON.parse(isEnabled); - } catch { - return true; - } - }; - - const setQuickActions = async () => { - if (await DeviceQuickActions.getEnabled()) { - QuickActions.isSupported((error, _supported) => { - if (error === null) { - const shortcutItems = []; - for (const wallet of wallets.slice(0, 4)) { - shortcutItems.push({ - type: 'Wallets', // Required - title: wallet.getLabel(), // Optional, if empty, `type` will be used instead - subtitle: - wallet.hideBalance || wallet.getBalance() <= 0 - ? '' - : formatBalance(Number(wallet.getBalance()), wallet.getPreferredBalanceUnit(), true), - userInfo: { - url: `bluewallet://wallet/${wallet.getID()}`, // Provide any custom data like deep linking URL - }, - icon: Platform.select({ android: 'quickactions', ios: 'bookmark' }), - }); - } - QuickActions.setShortcutItems(shortcutItems); - } - }); - } else { - QuickActions.clearShortcutItems(); - } - }; - - return null; -} - -export default DeviceQuickActions; diff --git a/class/quick-actions.windows.js b/class/quick-actions.windows.js deleted file mode 100644 index 842d0f50fa..0000000000 --- a/class/quick-actions.windows.js +++ /dev/null @@ -1,15 +0,0 @@ -function DeviceQuickActions() { - DeviceQuickActions.STORAGE_KEY = 'DeviceQuickActionsEnabled'; - - DeviceQuickActions.setEnabled = () => {}; - - DeviceQuickActions.getEnabled = async () => { - return false; - }; - - DeviceQuickActions.popInitialAction = () => {}; - - return null; -} - -export default DeviceQuickActions; diff --git a/class/synced-async-storage.ts b/class/synced-async-storage.ts index dee8059812..e9144e7571 100644 --- a/class/synced-async-storage.ts +++ b/class/synced-async-storage.ts @@ -1,9 +1,8 @@ import AsyncStorage from '@react-native-async-storage/async-storage'; - -const SHA256 = require('crypto-js/sha256'); -const ENCHEX = require('crypto-js/enc-hex'); -const ENCUTF8 = require('crypto-js/enc-utf8'); -const AES = require('crypto-js/aes'); +import AES from 'crypto-js/aes'; +import ENCHEX from 'crypto-js/enc-hex'; +import ENCUTF8 from 'crypto-js/enc-utf8'; +import SHA256 from 'crypto-js/sha256'; export default class SyncedAsyncStorage { defaultBaseUrl = 'https://bytes-store.herokuapp.com'; @@ -86,7 +85,7 @@ export default class SyncedAsyncStorage { console.log('saved, seq num:', text); resolve(text); }) - .catch(reason => reject(reason)); + .catch((reason: Error) => reject(reason)); }); } diff --git a/class/wallet-gradient.js b/class/wallet-gradient.ts similarity index 76% rename from class/wallet-gradient.js rename to class/wallet-gradient.ts index f6bd1db8d6..b2c5d99e32 100644 --- a/class/wallet-gradient.js +++ b/class/wallet-gradient.ts @@ -1,40 +1,38 @@ -import { LegacyWallet } from './wallets/legacy-wallet'; -import { HDSegwitP2SHWallet } from './wallets/hd-segwit-p2sh-wallet'; -import { LightningCustodianWallet } from './wallets/lightning-custodian-wallet'; +import { useTheme } from '../components/themes'; +import { HDAezeedWallet } from './wallets/hd-aezeed-wallet'; import { HDLegacyBreadwalletWallet } from './wallets/hd-legacy-breadwallet-wallet'; +import { HDLegacyElectrumSeedP2PKHWallet } from './wallets/hd-legacy-electrum-seed-p2pkh-wallet'; import { HDLegacyP2PKHWallet } from './wallets/hd-legacy-p2pkh-wallet'; -import { WatchOnlyWallet } from './wallets/watch-only-wallet'; import { HDSegwitBech32Wallet } from './wallets/hd-segwit-bech32-wallet'; -import { SegwitBech32Wallet } from './wallets/segwit-bech32-wallet'; -import { HDLegacyElectrumSeedP2PKHWallet } from './wallets/hd-legacy-electrum-seed-p2pkh-wallet'; import { HDSegwitElectrumSeedP2WPKHWallet } from './wallets/hd-segwit-electrum-seed-p2wpkh-wallet'; +import { HDSegwitP2SHWallet } from './wallets/hd-segwit-p2sh-wallet'; +import { LegacyWallet } from './wallets/legacy-wallet'; +import { LightningCustodianWallet } from './wallets/lightning-custodian-wallet'; // Missing import import { MultisigHDWallet } from './wallets/multisig-hd-wallet'; -import { HDAezeedWallet } from './wallets/hd-aezeed-wallet'; -import { LightningLdkWallet } from './wallets/lightning-ldk-wallet'; -import { SLIP39LegacyP2PKHWallet, SLIP39SegwitP2SHWallet, SLIP39SegwitBech32Wallet } from './wallets/slip39-wallets'; -import { useTheme } from '@react-navigation/native'; +import { SegwitBech32Wallet } from './wallets/segwit-bech32-wallet'; +import { SLIP39LegacyP2PKHWallet, SLIP39SegwitBech32Wallet, SLIP39SegwitP2SHWallet } from './wallets/slip39-wallets'; +import { WatchOnlyWallet } from './wallets/watch-only-wallet'; export default class WalletGradient { - static hdSegwitP2SHWallet = ['#007AFF', '#0040FF']; - static hdSegwitBech32Wallet = ['#6CD9FC', '#44BEE5']; - static segwitBech32Wallet = ['#6CD9FC', '#44BEE5']; - static watchOnlyWallet = ['#474646', '#282828']; - static legacyWallet = ['#37E8C0', '#15BE98']; - static hdLegacyP2PKHWallet = ['#FD7478', '#E73B40']; - static hdLegacyBreadWallet = ['#fe6381', '#f99c42']; - static multisigHdWallet = ['#1ce6eb', '#296fc5', '#3500A2']; - static defaultGradients = ['#B770F6', '#9013FE']; - static lightningCustodianWallet = ['#F1AA07', '#FD7E37']; - static aezeedWallet = ['#8584FF', '#5351FB']; - static ldkWallet = ['#8584FF', '#5351FB']; + static hdSegwitP2SHWallet: string[] = ['#007AFF', '#0040FF']; + static hdSegwitBech32Wallet: string[] = ['#6CD9FC', '#44BEE5']; + static segwitBech32Wallet: string[] = ['#6CD9FC', '#44BEE5']; + static watchOnlyWallet: string[] = ['#474646', '#282828']; + static legacyWallet: string[] = ['#37E8C0', '#15BE98']; + static hdLegacyP2PKHWallet: string[] = ['#FD7478', '#E73B40']; + static hdLegacyBreadWallet: string[] = ['#fe6381', '#f99c42']; + static multisigHdWallet: string[] = ['#1ce6eb', '#296fc5', '#3500A2']; + static defaultGradients: string[] = ['#B770F6', '#9013FE']; + static lightningCustodianWallet: string[] = ['#F1AA07', '#FD7E37']; // Corrected property with missing colors + static aezeedWallet: string[] = ['#8584FF', '#5351FB']; static createWallet = () => { const { colors } = useTheme(); return colors.lightButton; }; - static gradientsFor(type) { - let gradient; + static gradientsFor(type: string): string[] { + let gradient: string[]; switch (type) { case WatchOnlyWallet.type: gradient = WalletGradient.watchOnlyWallet; @@ -59,9 +57,6 @@ export default class WalletGradient { case SLIP39SegwitBech32Wallet.type: gradient = WalletGradient.hdSegwitBech32Wallet; break; - case LightningCustodianWallet.type: - gradient = WalletGradient.lightningCustodianWallet; - break; case SegwitBech32Wallet.type: gradient = WalletGradient.segwitBech32Wallet; break; @@ -71,8 +66,8 @@ export default class WalletGradient { case HDAezeedWallet.type: gradient = WalletGradient.aezeedWallet; break; - case LightningLdkWallet.type: - gradient = WalletGradient.ldkWallet; + case LightningCustodianWallet.type: + gradient = WalletGradient.lightningCustodianWallet; break; default: gradient = WalletGradient.defaultGradients; @@ -81,8 +76,8 @@ export default class WalletGradient { return gradient; } - static linearGradientProps(type) { - let props; + static linearGradientProps(type: string) { + let props: any; switch (type) { case MultisigHDWallet.type: /* Example @@ -96,8 +91,8 @@ export default class WalletGradient { return props; } - static headerColorFor(type) { - let gradient; + static headerColorFor(type: string): string { + let gradient: string[]; switch (type) { case WatchOnlyWallet.type: gradient = WalletGradient.watchOnlyWallet; @@ -128,14 +123,11 @@ export default class WalletGradient { case MultisigHDWallet.type: gradient = WalletGradient.multisigHdWallet; break; - case LightningCustodianWallet.type: - gradient = WalletGradient.lightningCustodianWallet; - break; case HDAezeedWallet.type: gradient = WalletGradient.aezeedWallet; break; - case LightningLdkWallet.type: - gradient = WalletGradient.ldkWallet; + case LightningCustodianWallet.type: + gradient = WalletGradient.lightningCustodianWallet; break; default: gradient = WalletGradient.defaultGradients; diff --git a/class/wallet-import.js b/class/wallet-import.ts similarity index 76% rename from class/wallet-import.js rename to class/wallet-import.ts index 5367980df4..2455c1e6c6 100644 --- a/class/wallet-import.js +++ b/class/wallet-import.ts @@ -1,6 +1,7 @@ -import wif from 'wif'; import bip38 from 'bip38'; +import wif from 'wif'; +import loc from '../loc'; import { HDAezeedWallet, HDLegacyBreadwalletWallet, @@ -11,52 +12,82 @@ import { HDSegwitP2SHWallet, LegacyWallet, LightningCustodianWallet, - LightningLdkWallet, MultisigHDWallet, + SegwitBech32Wallet, + SegwitP2SHWallet, SLIP39LegacyP2PKHWallet, SLIP39SegwitBech32Wallet, SLIP39SegwitP2SHWallet, - SegwitBech32Wallet, - SegwitP2SHWallet, WatchOnlyWallet, } from '.'; -import loc from '../loc'; import bip39WalletFormats from './bip39_wallet_formats.json'; // https://github.com/spesmilo/electrum/blob/master/electrum/bip39_wallet_formats.json import bip39WalletFormatsBlueWallet from './bip39_wallet_formats_bluewallet.json'; +import type { TWallet } from './wallets/types'; // https://github.com/bitcoinjs/bip32/blob/master/ts-src/bip32.ts#L43 -export const validateBip32 = path => path.match(/^(m\/)?(\d+'?\/)*\d+'?$/) !== null; +export const validateBip32 = (path: string) => path.match(/^(m\/)?(\d+'?\/)*\d+'?$/) !== null; + +type TStatus = { + cancelled: boolean; + stopped: boolean; + wallets: TWallet[]; +}; + +export type TImport = { + promise: Promise; + stop: () => void; +}; /** * Function that starts wallet search and import process. It has async generator inside, so * that the process can be stoped at any time. It reporst all the progress through callbacks. * - * @param askPassphrase {bool} If true import process will call onPassword callback for wallet with optional password. - * @param searchAccounts {bool} If true import process will scan for all known derivation path from bip39_wallet_formats.json. If false it will use limited version. + * @param askPassphrase {boolean} If true import process will call onPassword callback for wallet with optional password. + * @param searchAccounts {boolean} If true import process will scan for all known derivation path from bip39_wallet_formats.json. If false it will use limited version. * @param onProgress {function} Callback to report scanning progress * @param onWallet {function} Callback to report wallet found * @param onPassword {function} Callback to ask for password if needed * @returns {{promise: Promise, stop: function}} */ -const startImport = (importTextOrig, askPassphrase = false, searchAccounts = false, onProgress, onWallet, onPassword) => { +const startImport = ( + importTextOrig: string, + askPassphrase: boolean = false, + searchAccounts: boolean = false, + offline: boolean = false, + onProgress: (name: string) => void, + onWallet: (wallet: TWallet) => void, + onPassword: (title: string, text: string) => Promise, +): TImport => { // state - let promiseResolve; - let promiseReject; + let promiseResolve: (arg: TStatus) => void; + let promiseReject: (reason?: any) => void; let running = true; // if you put it to false, internal generator stops - const wallets = []; - const promise = new Promise((resolve, reject) => { + const wallets: TWallet[] = []; + const promise = new Promise((resolve, reject) => { promiseResolve = resolve; promiseReject = reject; }); + // helpers + // in offline mode all wallets are considered used + const wasUsed = async (wallet: TWallet): Promise => { + if (offline) return true; + return wallet.wasEverUsed(); + }; + const fetch = async (wallet: TWallet, balance: boolean = false, transactions: boolean = false) => { + if (offline) return; + if (balance) await wallet.fetchBalance(); + if (transactions) await wallet.fetchTransactions(); + }; + // actions - const reportProgress = name => { + const reportProgress = (name: string) => { onProgress(name); }; - const reportFinish = (cancelled, stopped) => { + const reportFinish = (cancelled: boolean = false, stopped: boolean = false) => { promiseResolve({ cancelled, stopped, wallets }); }; - const reportWallet = wallet => { + const reportWallet = (wallet: TWallet) => { if (wallets.some(w => w.getID() === wallet.getID())) return; // do not add duplicates wallets.push(wallet); onWallet(wallet); @@ -134,7 +165,7 @@ const startImport = (importTextOrig, askPassphrase = false, searchAccounts = fal } // is it bip38 encrypted - if (text.startsWith('6P')) { + if (text.startsWith('6P') && password) { const decryptedKey = await bip38.decryptAsync(text, password); if (decryptedKey) { @@ -147,7 +178,7 @@ const startImport = (importTextOrig, askPassphrase = false, searchAccounts = fal const ms = new MultisigHDWallet(); ms.setSecret(text); if (ms.getN() > 0 && ms.getM() > 0) { - await ms.fetchBalance(); + await fetch(ms, true, false); yield { wallet: ms }; } @@ -161,30 +192,23 @@ const startImport = (importTextOrig, askPassphrase = false, searchAccounts = fal lnd.setSecret(split[0]); } await lnd.init(); - await lnd.authorize(); - await lnd.fetchTransactions(); - await lnd.fetchUserInvoices(); - await lnd.fetchPendingTransactions(); - await lnd.fetchBalance(); - yield { wallet: lnd }; - } - - // is it LDK? - yield { progress: 'lightning' }; - if (text.startsWith('ldk://')) { - const ldk = new LightningLdkWallet(); - ldk.setSecret(text); - if (ldk.valid()) { - await ldk.init(); - yield { wallet: ldk }; + if (!offline) { + await lnd.authorize(); + await lnd.fetchTransactions(); + await lnd.fetchUserInvoices(); + await lnd.fetchPendingTransactions(); + await lnd.fetchBalance(); } + yield { wallet: lnd }; } // check bip39 wallets yield { progress: 'bip39' }; const hd2 = new HDSegwitBech32Wallet(); hd2.setSecret(text); - hd2.setPassphrase(password); + if (password) { + hd2.setPassphrase(password); + } if (hd2.validateMnemonic()) { let walletFound = false; // by default we don't try all the paths and options @@ -214,10 +238,12 @@ const startImport = (importTextOrig, askPassphrase = false, searchAccounts = fal for (const path of paths) { const wallet = new WalletClass(); wallet.setSecret(text); - wallet.setPassphrase(password); + if (password) { + wallet.setPassphrase(password); + } wallet.setDerivationPath(path); yield { progress: `bip39 ${i.script_type} ${path}` }; - if (await wallet.wasEverUsed()) { + if (await wasUsed(wallet)) { yield { wallet }; walletFound = true; } else { @@ -230,15 +256,18 @@ const startImport = (importTextOrig, askPassphrase = false, searchAccounts = fal // to decide which one is it let's compare number of transactions const m0Legacy = new HDLegacyP2PKHWallet(); m0Legacy.setSecret(text); - m0Legacy.setPassphrase(password); + if (password) { + m0Legacy.setPassphrase(password); + } m0Legacy.setDerivationPath("m/0'"); yield { progress: "bip39 p2pkh m/0'" }; // BRD doesn't support passphrase and only works with 12 words seeds - if (!password && text.split(' ').length === 12) { + // do not try to guess BRD wallet in offline mode + if (!password && text.split(' ').length === 12 && !offline) { const brd = new HDLegacyBreadwalletWallet(); brd.setSecret(text); - if (await m0Legacy.wasEverUsed()) { + if (await wasUsed(m0Legacy)) { await m0Legacy.fetchBalance(); await m0Legacy.fetchTransactions(); yield { progress: 'BRD' }; @@ -252,7 +281,7 @@ const startImport = (importTextOrig, askPassphrase = false, searchAccounts = fal walletFound = true; } } else { - if (await m0Legacy.wasEverUsed()) { + if (await wasUsed(m0Legacy)) { yield { wallet: m0Legacy }; walletFound = true; } @@ -262,7 +291,6 @@ const startImport = (importTextOrig, askPassphrase = false, searchAccounts = fal if (!walletFound) { yield { wallet: hd2 }; } - // return; } yield { progress: 'wif' }; @@ -275,17 +303,17 @@ const startImport = (importTextOrig, askPassphrase = false, searchAccounts = fal yield { progress: 'wif p2wpkh' }; const segwitBech32Wallet = new SegwitBech32Wallet(); segwitBech32Wallet.setSecret(text); - if (await segwitBech32Wallet.wasEverUsed()) { + if (await wasUsed(segwitBech32Wallet)) { // yep, its single-address bech32 wallet - await segwitBech32Wallet.fetchBalance(); + await fetch(segwitBech32Wallet, true); walletFound = true; yield { wallet: segwitBech32Wallet }; } yield { progress: 'wif p2wpkh-p2sh' }; - if (await segwitWallet.wasEverUsed()) { + if (await wasUsed(segwitWallet)) { // yep, its single-address p2wpkh wallet - await segwitWallet.fetchBalance(); + await fetch(segwitWallet, true); walletFound = true; yield { wallet: segwitWallet }; } @@ -294,9 +322,9 @@ const startImport = (importTextOrig, askPassphrase = false, searchAccounts = fal yield { progress: 'wif p2pkh' }; const legacyWallet = new LegacyWallet(); legacyWallet.setSecret(text); - if (await legacyWallet.wasEverUsed()) { + if (await wasUsed(legacyWallet)) { // yep, its single-address legacy wallet - await legacyWallet.fetchBalance(); + await fetch(legacyWallet, true); walletFound = true; yield { wallet: legacyWallet }; } @@ -314,8 +342,7 @@ const startImport = (importTextOrig, askPassphrase = false, searchAccounts = fal const legacyWallet = new LegacyWallet(); legacyWallet.setSecret(text); if (legacyWallet.getAddress()) { - await legacyWallet.fetchBalance(); - await legacyWallet.fetchTransactions(); + await fetch(legacyWallet, true, true); yield { wallet: legacyWallet }; } @@ -324,7 +351,7 @@ const startImport = (importTextOrig, askPassphrase = false, searchAccounts = fal const watchOnly = new WatchOnlyWallet(); watchOnly.setSecret(text); if (watchOnly.valid()) { - await watchOnly.fetchBalance(); + await fetch(watchOnly, true); yield { wallet: watchOnly }; } @@ -332,7 +359,9 @@ const startImport = (importTextOrig, askPassphrase = false, searchAccounts = fal yield { progress: 'electrum p2wpkh-p2sh' }; const el1 = new HDSegwitElectrumSeedP2WPKHWallet(); el1.setSecret(text); - el1.setPassphrase(password); + if (password) { + el1.setPassphrase(password); + } if (el1.validateMnemonic()) { yield { wallet: el1 }; // not fetching txs or balances, fuck it, yolo, life is too short } @@ -341,7 +370,9 @@ const startImport = (importTextOrig, askPassphrase = false, searchAccounts = fal yield { progress: 'electrum p2pkh' }; const el2 = new HDLegacyElectrumSeedP2PKHWallet(); el2.setSecret(text); - el2.setPassphrase(password); + if (password) { + el2.setPassphrase(password); + } if (el2.validateMnemonic()) { yield { wallet: el2 }; // not fetching txs or balances, fuck it, yolo, life is too short } @@ -350,39 +381,44 @@ const startImport = (importTextOrig, askPassphrase = false, searchAccounts = fal yield { progress: 'aezeed' }; const aezeed2 = new HDAezeedWallet(); aezeed2.setSecret(text); - aezeed2.setPassphrase(password); + if (password) { + aezeed2.setPassphrase(password); + } if (await aezeed2.validateMnemonicAsync()) { yield { wallet: aezeed2 }; // not fetching txs or balances, fuck it, yolo, life is too short } - // if it is multi-line string, then it is probably SLIP39 wallet - // each line - one share + // Let's try SLIP39 yield { progress: 'SLIP39' }; - if (text.includes('\n')) { - const s1 = new SLIP39SegwitP2SHWallet(); - s1.setSecret(text); + const s1 = new SLIP39SegwitP2SHWallet(); + s1.setSecret(text); - if (s1.validateMnemonic()) { - yield { progress: 'SLIP39 p2wpkh-p2sh' }; + if (s1.validateMnemonic()) { + yield { progress: 'SLIP39 p2wpkh-p2sh' }; + if (password) { s1.setPassphrase(password); - if (await s1.wasEverUsed()) { - yield { wallet: s1 }; - } + } + if (await wasUsed(s1)) { + yield { wallet: s1 }; + } - yield { progress: 'SLIP39 p2pkh' }; - const s2 = new SLIP39LegacyP2PKHWallet(); + yield { progress: 'SLIP39 p2pkh' }; + const s2 = new SLIP39LegacyP2PKHWallet(); + if (password) { s2.setPassphrase(password); - s2.setSecret(text); - if (await s2.wasEverUsed()) { - yield { wallet: s2 }; - } + } + s2.setSecret(text); + if (await wasUsed(s2)) { + yield { wallet: s2 }; + } - yield { progress: 'SLIP39 p2wpkh' }; - const s3 = new SLIP39SegwitBech32Wallet(); - s3.setSecret(text); + yield { progress: 'SLIP39 p2wpkh' }; + const s3 = new SLIP39SegwitBech32Wallet(); + s3.setSecret(text); + if (password) { s3.setPassphrase(password); - yield { wallet: s3 }; } + yield { wallet: s3 }; } // is it BC-UR payload with multiple accounts? @@ -411,6 +447,7 @@ const startImport = (importTextOrig, askPassphrase = false, searchAccounts = fal if (next.value?.progress) reportProgress(next.value.progress); if (next.value?.wallet) reportWallet(next.value.wallet); if (next.done) break; // break if generator has been finished + await new Promise(resolve => setTimeout(resolve, 1)); // try not to block the thread } reportFinish(); })().catch(e => { diff --git a/class/wallets/abstract-hd-electrum-wallet.ts b/class/wallets/abstract-hd-electrum-wallet.ts index 463401dcc0..4bc50eecb0 100644 --- a/class/wallets/abstract-hd-electrum-wallet.ts +++ b/class/wallets/abstract-hd-electrum-wallet.ts @@ -1,27 +1,25 @@ /* eslint react/prop-types: "off", @typescript-eslint/ban-ts-comment: "off", camelcase: "off" */ -import * as bip39 from 'bip39'; +import BIP47Factory, { BIP47Interface } from '@spsina/bip47'; +import assert from 'assert'; import BigNumber from 'bignumber.js'; -import b58 from 'bs58check'; import BIP32Factory, { BIP32Interface } from 'bip32'; - -import { ECPairInterface } from 'ecpair/src/ecpair'; +import * as bip39 from 'bip39'; +import * as bitcoin from 'bitcoinjs-lib'; import { Psbt, Transaction as BTransaction } from 'bitcoinjs-lib'; -import { CoinSelectReturnInput, CoinSelectTarget } from 'coinselect'; -import ecc from '../../blue_modules/noble_ecc'; - -import BIP47Factory, { BIP47Interface } from '@spsina/bip47'; +import b58 from 'bs58check'; +import { CoinSelectOutput, CoinSelectReturnInput } from 'coinselect'; import { ECPairFactory } from 'ecpair'; +import { ECPairInterface } from 'ecpair/src/ecpair'; +import * as BlueElectrum from '../../blue_modules/BlueElectrum'; +import { ElectrumHistory } from '../../blue_modules/BlueElectrum'; +import ecc from '../../blue_modules/noble_ecc'; import { randomBytes } from '../rng'; import { AbstractHDWallet } from './abstract-hd-wallet'; -import { CreateTransactionResult, CreateTransactionUtxo, Transaction, Utxo } from './types'; -import { ElectrumHistory } from '../../blue_modules/BlueElectrum'; -import type BlueElectrumNs from '../../blue_modules/BlueElectrum'; +import { CreateTransactionResult, CreateTransactionTarget, CreateTransactionUtxo, Transaction, Utxo } from './types'; +import { SilentPayment, UTXOType as SPUTXOType, UTXO as SPUTXO } from 'silent-payments'; const ECPair = ECPairFactory(ecc); -const bitcoin = require('bitcoinjs-lib'); -const BlueElectrum: typeof BlueElectrumNs = require('../../blue_modules/BlueElectrum'); -const reverse = require('buffer-reverse'); const bip32 = BIP32Factory(ecc); const bip47 = BIP47Factory(ecc); @@ -34,10 +32,14 @@ type BalanceByIndex = { * Electrum - means that it utilizes Electrum protocol for blockchain data */ export class AbstractHDElectrumWallet extends AbstractHDWallet { - static type = 'abstract'; - static typeReadable = 'abstract'; + static readonly type = 'abstract'; + static readonly typeReadable = 'abstract'; static defaultRBFSequence = 2147483648; // 1 << 31, minimum for replaceable transactions as per BIP68 static finalRBFSequence = 4294967295; // 0xFFFFFFFF + // @ts-ignore: override + public readonly type = AbstractHDElectrumWallet.type; + // @ts-ignore: override + public readonly typeReadable = AbstractHDElectrumWallet.typeReadable; _balances_by_external_index: Record; _balances_by_internal_index: Record; @@ -52,10 +54,44 @@ export class AbstractHDElectrumWallet extends AbstractHDWallet { // BIP47 _enable_BIP47: boolean; _payment_code: string; - _sender_payment_codes: string[]; - _addresses_by_payment_code: Record; - _next_free_payment_code_address_index: Record; + + /** + * payment codes of people who can pay us + */ + _receive_payment_codes: string[]; + + /** + * payment codes of people whom we can pay + */ + _send_payment_codes: string[]; + + /** + * joint addresses with remote counterparties, to receive funds + */ + _addresses_by_payment_code_receive: Record; + + /** + * receive index + */ + _next_free_payment_code_address_index_receive: Record; + + /** + * joint addresses with remote counterparties, whom we can send funds + */ + _addresses_by_payment_code_send: Record; + + /** + * send index + */ + _next_free_payment_code_address_index_send: Record; + + /** + * this is where we put transactions related to our PC receive addresses. this is both + * incoming transactions AND outgoing transactions (when we spend those funds) + * + */ _txs_by_payment_code_index: Record; + _balances_by_payment_code_index: Record; _bip47_instance?: BIP47Interface; @@ -72,11 +108,14 @@ export class AbstractHDElectrumWallet extends AbstractHDWallet { // BIP47 this._enable_BIP47 = false; this._payment_code = ''; - this._sender_payment_codes = []; - this._next_free_payment_code_address_index = {}; + this._receive_payment_codes = []; + this._send_payment_codes = []; + this._next_free_payment_code_address_index_receive = {}; this._txs_by_payment_code_index = {}; + this._addresses_by_payment_code_send = {}; + this._next_free_payment_code_address_index_send = {}; this._balances_by_payment_code_index = {}; - this._addresses_by_payment_code = {}; + this._addresses_by_payment_code_receive = {}; } /** @@ -90,7 +129,7 @@ export class AbstractHDElectrumWallet extends AbstractHDWallet { for (const bal of Object.values(this._balances_by_internal_index)) { ret += bal.c; } - for (const pc of this._sender_payment_codes) { + for (const pc of this._receive_payment_codes) { ret += this._getBalancesByPaymentCodeIndex(pc).c; } return ret + (this.getUnconfirmedBalance() < 0 ? this.getUnconfirmedBalance() : 0); @@ -108,7 +147,7 @@ export class AbstractHDElectrumWallet extends AbstractHDWallet { for (const bal of Object.values(this._balances_by_internal_index)) { ret += bal.u; } - for (const pc of this._sender_payment_codes) { + for (const pc of this._receive_payment_codes) { ret += this._getBalancesByPaymentCodeIndex(pc).u; } return ret; @@ -120,9 +159,10 @@ export class AbstractHDElectrumWallet extends AbstractHDWallet { } async generateFromEntropy(user: Buffer) { - const random = await randomBytes(user.length < 32 ? 32 - user.length : 0); - const buf = Buffer.concat([user, random], 32); - this.secret = bip39.entropyToMnemonic(buf.toString('hex')); + if (user.length !== 32 && user.length !== 16) { + throw new Error('Entropy has to be 16 or 32 bytes long'); + } + this.secret = bip39.entropyToMnemonic(user.toString('hex')); } _getExternalWIFByIndex(index: number): string | false { @@ -294,8 +334,8 @@ export class AbstractHDElectrumWallet extends AbstractHDWallet { } // next, bip47 addresses - for (const pc of this._sender_payment_codes) { - for (let c = 0; c < this._getNextFreePaymentCodeAddress(pc) + this.gap_limit; c++) { + for (const pc of this._receive_payment_codes) { + for (let c = 0; c < this._getNextFreePaymentCodeIndexReceive(pc) + this.gap_limit; c++) { let hasUnconfirmed = false; this._txs_by_payment_code_index[pc] = this._txs_by_payment_code_index[pc] || {}; this._txs_by_payment_code_index[pc][c] = this._txs_by_payment_code_index[pc][c] || []; @@ -303,7 +343,7 @@ export class AbstractHDElectrumWallet extends AbstractHDWallet { hasUnconfirmed = hasUnconfirmed || !tx.confirmations || tx.confirmations < 7; if (hasUnconfirmed || this._txs_by_payment_code_index[pc][c].length === 0 || this._balances_by_payment_code_index[pc].u !== 0) { - addresses2fetch.push(this._getBIP47Address(pc, c)); + addresses2fetch.push(this._getBIP47AddressReceive(pc, c)); } } } @@ -318,17 +358,18 @@ export class AbstractHDElectrumWallet extends AbstractHDWallet { } // next, batch fetching each txid we got - const txdatas = await BlueElectrum.multiGetTransactionByTxid(Object.keys(txs)); + const txdatas = await BlueElectrum.multiGetTransactionByTxid(Object.keys(txs), true); // now, tricky part. we collect all transactions from inputs (vin), and batch fetch them too. // then we combine all this data (we need inputs to see source addresses and amounts) const vinTxids = []; for (const txdata of Object.values(txdatas)) { for (const vin of txdata.vin) { - vinTxids.push(vin.txid); + vin.txid && vinTxids.push(vin.txid); + // ^^^^ not all inputs have txid, some of them are Coinbase (newly-created coins) } } - const vintxdatas = await BlueElectrum.multiGetTransactionByTxid(vinTxids); + const vintxdatas = await BlueElectrum.multiGetTransactionByTxid(vinTxids, true); // fetched all transactions from our inputs. now we need to combine it. // iterating all _our_ transactions: @@ -354,13 +395,14 @@ export class AbstractHDElectrumWallet extends AbstractHDWallet { for (let c = 0; c < this.next_free_change_address_index + this.gap_limit; c++) { this._txs_by_internal_index[c] = this._txs_by_internal_index[c].filter(tx => !!tx.confirmations); } - for (const pc of this._sender_payment_codes) { - for (let c = 0; c < this._getNextFreePaymentCodeAddress(pc) + this.gap_limit; c++) { + for (const pc of this._receive_payment_codes) { + for (let c = 0; c < this._getNextFreePaymentCodeIndexReceive(pc) + this.gap_limit; c++) { this._txs_by_payment_code_index[pc][c] = this._txs_by_payment_code_index[pc][c].filter(tx => !!tx.confirmations); } } - // now, we need to put transactions in all relevant `cells` of internal hashmaps: this._txs_by_internal_index && this._txs_by_external_index + // now, we need to put transactions in all relevant `cells` of internal hashmaps: + // this._txs_by_internal_index, this._txs_by_external_index & this._txs_by_payment_code_index for (let c = 0; c < this.next_free_address_index + this.gap_limit; c++) { for (const tx of Object.values(txdatas)) { @@ -444,11 +486,12 @@ export class AbstractHDElectrumWallet extends AbstractHDWallet { } } - for (const pc of this._sender_payment_codes) { - for (let c = 0; c < this._getNextFreePaymentCodeAddress(pc) + this.gap_limit; c++) { + for (const pc of this._receive_payment_codes) { + for (let c = 0; c < this._getNextFreePaymentCodeIndexReceive(pc) + this.gap_limit; c++) { for (const tx of Object.values(txdatas)) { - for (const vin of tx.vin) { - if (vin.addresses && vin.addresses.indexOf(this._getBIP47Address(pc, c)) !== -1) { + // since we are iterating PCs who can pay us, we can completely ignore `tx.vin` and only iterate `tx.vout` + for (const vout of tx.vout) { + if (vout.scriptPubKey.addresses && vout.scriptPubKey.addresses.indexOf(this._getBIP47AddressReceive(pc, c)) !== -1) { // this TX is related to our address this._txs_by_payment_code_index[pc] = this._txs_by_payment_code_index[pc] || {}; this._txs_by_payment_code_index[pc][c] = this._txs_by_payment_code_index[pc][c] || []; @@ -466,25 +509,6 @@ export class AbstractHDElectrumWallet extends AbstractHDWallet { if (!replaced) this._txs_by_payment_code_index[pc][c].push(clonedTx); } } - for (const vout of tx.vout) { - if (vout.scriptPubKey.addresses && vout.scriptPubKey.addresses.indexOf(this._getBIP47Address(pc, c)) !== -1) { - // this TX is related to our address - this._txs_by_payment_code_index[pc] = this._txs_by_payment_code_index[pc] || {}; - this._txs_by_payment_code_index[pc][c] = this._txs_by_payment_code_index[pc][c] || []; - const { vin: txVin, vout: txVout, ...txRest } = tx; - const clonedTx = { ...txRest, inputs: txVin.slice(0), outputs: txVout.slice(0) }; - - // trying to replace tx if it exists already (because it has lower confirmations, for example) - let replaced = false; - for (let cc = 0; cc < this._txs_by_internal_index[c].length; cc++) { - if (this._txs_by_internal_index[c][cc].txid === clonedTx.txid) { - replaced = true; - this._txs_by_internal_index[c][cc] = clonedTx; - } - } - if (!replaced) this._txs_by_internal_index[c].push(clonedTx); - } - } } } } @@ -501,8 +525,8 @@ export class AbstractHDElectrumWallet extends AbstractHDWallet { for (const addressTxs of Object.values(this._txs_by_internal_index)) { txs = txs.concat(addressTxs); } - if (this._sender_payment_codes) { - for (const pc of this._sender_payment_codes) { + if (this._receive_payment_codes) { + for (const pc of this._receive_payment_codes) { if (this._txs_by_payment_code_index[pc]) for (const addressTxs of Object.values(this._txs_by_payment_code_index[pc])) { txs = txs.concat(addressTxs); @@ -521,10 +545,10 @@ export class AbstractHDElectrumWallet extends AbstractHDWallet { for (let c = 0; c < this.next_free_change_address_index + 1; c++) { ownedAddressesHashmap[this._getInternalAddressByIndex(c)] = true; } - if (this._sender_payment_codes) - for (const pc of this._sender_payment_codes) { - for (let c = 0; c < this._getNextFreePaymentCodeAddress(pc) + 1; c++) { - ownedAddressesHashmap[this._getBIP47Address(pc, c)] = true; + if (this._receive_payment_codes) + for (const pc of this._receive_payment_codes) { + for (let c = 0; c < this._getNextFreePaymentCodeIndexReceive(pc) + 1; c++) { + ownedAddressesHashmap[this._getBIP47AddressReceive(pc, c)] = true; } } // hack: in case this code is called from LegacyWallet: @@ -551,6 +575,10 @@ export class AbstractHDElectrumWallet extends AbstractHDWallet { tx.value += new BigNumber(vout.value).multipliedBy(100000000).toNumber(); } } + + if (this.allowBIP47() && this.isBIP47Enabled()) { + tx.counterparty = this.getBip47CounterpartyByTx(tx); + } ret.push(tx); } @@ -657,7 +685,7 @@ export class AbstractHDElectrumWallet extends AbstractHDWallet { const generateChunkAddresses = (chunkNum: number) => { const ret = []; for (let c = this.gap_limit * chunkNum; c < this.gap_limit * (chunkNum + 1); c++) { - ret.push(this._getBIP47Address(paymentCode, c)); + ret.push(this._getBIP47AddressReceive(paymentCode, c)); } return ret; }; @@ -686,7 +714,7 @@ export class AbstractHDElectrumWallet extends AbstractHDWallet { c < Number(lastChunkWithUsedAddressesNum) * this.gap_limit + this.gap_limit; c++ ) { - const address = this._getBIP47Address(paymentCode, c); + const address = this._getBIP47AddressReceive(paymentCode, c); if (lastHistoriesWithUsedAddresses[address] && lastHistoriesWithUsedAddresses[address].length > 0) { lastUsedIndex = Math.max(c, lastUsedIndex) + 1; // point to next, which is supposed to be unused } @@ -702,9 +730,9 @@ export class AbstractHDElectrumWallet extends AbstractHDWallet { // doing binary search for last used address: this.next_free_change_address_index = await this._binarySearchIterationForInternalAddress(1000); this.next_free_address_index = await this._binarySearchIterationForExternalAddress(1000); - if (this._sender_payment_codes) { - for (const pc of this._sender_payment_codes) { - this._next_free_payment_code_address_index[pc] = await this._binarySearchIterationForBIP47Address(pc, 1000); + if (this._receive_payment_codes) { + for (const pc of this._receive_payment_codes) { + this._next_free_payment_code_address_index_receive[pc] = await this._binarySearchIterationForBIP47Address(pc, 1000); } } } // end rescanning fresh wallet @@ -728,13 +756,13 @@ export class AbstractHDElectrumWallet extends AbstractHDWallet { for (let c = this.next_free_change_address_index; c < this.next_free_change_address_index + this.gap_limit; c++) { lagAddressesToFetch.push(this._getInternalAddressByIndex(c)); } - for (const pc of this._sender_payment_codes) { + for (const pc of this._receive_payment_codes) { for ( - let c = this._next_free_payment_code_address_index[pc]; - c < this._next_free_payment_code_address_index[pc] + this.gap_limit; + let c = this._next_free_payment_code_address_index_receive[pc]; + c < this._next_free_payment_code_address_index_receive[pc] + this.gap_limit; c++ ) { - lagAddressesToFetch.push(this._getBIP47Address(pc, c)); + lagAddressesToFetch.push(this._getBIP47AddressReceive(pc, c)); } } @@ -756,16 +784,16 @@ export class AbstractHDElectrumWallet extends AbstractHDWallet { } } - for (const pc of this._sender_payment_codes) { + for (const pc of this._receive_payment_codes) { for ( - let c = this._next_free_payment_code_address_index[pc]; - c < this._next_free_payment_code_address_index[pc] + this.gap_limit; + let c = this._next_free_payment_code_address_index_receive[pc]; + c < this._next_free_payment_code_address_index_receive[pc] + this.gap_limit; c++ ) { - const address = this._getBIP47Address(pc, c); + const address = this._getBIP47AddressReceive(pc, c); if (txs[address] && Array.isArray(txs[address]) && txs[address].length > 0) { // whoa, someone uses our wallet outside! better catch up - this._next_free_payment_code_address_index[pc] = c + 1; + this._next_free_payment_code_address_index_receive[pc] = c + 1; } } } @@ -788,9 +816,9 @@ export class AbstractHDElectrumWallet extends AbstractHDWallet { addresses2fetch.push(this._getInternalAddressByIndex(c)); } - for (const pc of this._sender_payment_codes) { - for (let c = 0; c < this._next_free_payment_code_address_index[pc] + this.gap_limit; c++) { - addresses2fetch.push(this._getBIP47Address(pc, c)); + for (const pc of this._receive_payment_codes) { + for (let c = 0; c < this._next_free_payment_code_address_index_receive[pc] + this.gap_limit; c++) { + addresses2fetch.push(this._getBIP47AddressReceive(pc, c)); } } @@ -838,11 +866,11 @@ export class AbstractHDElectrumWallet extends AbstractHDWallet { } } - for (const pc of this._sender_payment_codes) { + for (const pc of this._receive_payment_codes) { let confirmed = 0; let unconfirmed = 0; - for (let c = 0; c < this._getNextFreePaymentCodeAddress(pc) + this.gap_limit; c++) { - const addr = this._getBIP47Address(pc, c); + for (let c = 0; c < this._getNextFreePaymentCodeIndexReceive(pc) + this.gap_limit; c++) { + const addr = this._getBIP47AddressReceive(pc, c); if (balances.addresses[addr].confirmed || balances.addresses[addr].unconfirmed) { confirmed = confirmed + balances.addresses[addr].confirmed; unconfirmed = unconfirmed + balances.addresses[addr].unconfirmed; @@ -857,7 +885,7 @@ export class AbstractHDElectrumWallet extends AbstractHDWallet { this._lastBalanceFetch = +new Date(); } - async fetchUtxo() { + async fetchUtxo(): Promise { // fetching utxo of addresses that only have some balance let addressess = []; @@ -873,10 +901,10 @@ export class AbstractHDElectrumWallet extends AbstractHDWallet { } } - for (const pc of this._sender_payment_codes) { - for (let c = 0; c < this._next_free_payment_code_address_index[pc] + this.gap_limit; c++) { + for (const pc of this._receive_payment_codes) { + for (let c = 0; c < this._next_free_payment_code_address_index_receive[pc] + this.gap_limit; c++) { if (this._balances_by_payment_code_index?.[pc]?.c > 0) { - addressess.push(this._getBIP47Address(pc, c)); + addressess.push(this._getBIP47AddressReceive(pc, c)); } } } @@ -893,10 +921,10 @@ export class AbstractHDElectrumWallet extends AbstractHDWallet { } } - for (const pc of this._sender_payment_codes) { - for (let c = 0; c < this._next_free_payment_code_address_index[pc] + this.gap_limit; c++) { + for (const pc of this._receive_payment_codes) { + for (let c = 0; c < this._next_free_payment_code_address_index_receive[pc] + this.gap_limit; c++) { if (this._balances_by_payment_code_index?.[pc]?.u > 0) { - addressess.push(this._getBIP47Address(pc, c)); + addressess.push(this._getBIP47AddressReceive(pc, c)); } } } @@ -913,17 +941,13 @@ export class AbstractHDElectrumWallet extends AbstractHDWallet { this._utxo = this._utxo.concat(arr); } - // backward compatibility TODO: remove when we make sure `.utxo` is not used - this.utxo = this._utxo; // this belongs in `.getUtxo()` - for (const u of this.utxo) { - u.txid = u.txId; - u.amount = u.value; + for (const u of this._utxo) { u.wif = this._getWifForAddress(u.address); if (!u.confirmations && u.height) u.confirmations = BlueElectrum.estimateCurrentBlockheight() - u.height; } - this.utxo = this.utxo.sort((a, b) => Number(a.amount) - Number(b.amount)); + this._utxo = this._utxo.sort((a, b) => Number(a.value) - Number(b.value)); // more consistent, so txhex in unit tests wont change } @@ -932,10 +956,8 @@ export class AbstractHDElectrumWallet extends AbstractHDWallet { * [ { height: 0, * value: 666, * address: 'string', - * txId: 'string', * vout: 1, * txid: 'string', - * amount: 666, * wif: 'string', * confirmations: 0 } ] * @@ -968,9 +990,9 @@ export class AbstractHDElectrumWallet extends AbstractHDWallet { for (let c = 0; c < this.next_free_change_address_index + 1; c++) { ownedAddressesHashmap[this._getInternalAddressByIndex(c)] = true; } - for (const pc of this._sender_payment_codes) { - for (let c = 0; c < this._getNextFreePaymentCodeAddress(pc) + 1; c++) { - ownedAddressesHashmap[this._getBIP47Address(pc, c)] = true; + for (const pc of this._receive_payment_codes) { + for (let c = 0; c < this._getNextFreePaymentCodeIndexReceive(pc) + 1; c++) { + ownedAddressesHashmap[this._getBIP47AddressReceive(pc, c)] = true; } } @@ -984,11 +1006,9 @@ export class AbstractHDElectrumWallet extends AbstractHDWallet { const value = new BigNumber(output.value).multipliedBy(100000000).toNumber(); utxos.push({ txid: tx.txid, - txId: tx.txid, vout: output.n, address: String(address), value, - amount: value, confirmations: tx.confirmations, wif: false, height: BlueElectrum.estimateCurrentBlockheight() - (tx.confirmations ?? 0), @@ -1028,10 +1048,10 @@ export class AbstractHDElectrumWallet extends AbstractHDWallet { for (let c = 0; c < this.next_free_change_address_index + this.gap_limit; c++) { if (this._getInternalAddressByIndex(c) === address) return path + '/1/' + c; } - for (const pc of this._sender_payment_codes) { - for (let c = 0; c < this._getNextFreePaymentCodeAddress(pc) + this.gap_limit; c++) { + for (const pc of this._receive_payment_codes) { + for (let c = 0; c < this._getNextFreePaymentCodeIndexReceive(pc) + this.gap_limit; c++) { // not technically correct but well, to have at least somethign in PSBT... - if (this._getBIP47Address(pc, c) === address) return "m/47'/0'/0'/" + c; + if (this._getBIP47AddressReceive(pc, c) === address) return "m/47'/0'/0'/" + c; } } @@ -1050,9 +1070,9 @@ export class AbstractHDElectrumWallet extends AbstractHDWallet { for (let c = 0; c < this.next_free_change_address_index + this.gap_limit; c++) { if (this._getInternalAddressByIndex(c) === address) return this._getNodePubkeyByIndex(1, c); } - for (const pc of this._sender_payment_codes) { - for (let c = 0; c < this._getNextFreePaymentCodeAddress(pc) + this.gap_limit; c++) { - if (this._getBIP47Address(pc, c) === address) return this._getBIP47PubkeyByIndex(pc, c); + for (const pc of this._receive_payment_codes) { + for (let c = 0; c < this._getNextFreePaymentCodeIndexReceive(pc) + this.gap_limit; c++) { + if (this._getBIP47AddressReceive(pc, c) === address) return this._getBIP47PubkeyByIndex(pc, c); } } @@ -1072,9 +1092,9 @@ export class AbstractHDElectrumWallet extends AbstractHDWallet { for (let c = 0; c < this.next_free_change_address_index + this.gap_limit; c++) { if (this._getInternalAddressByIndex(c) === address) return this._getWIFByIndex(true, c); } - for (const pc of this._sender_payment_codes) { - for (let c = 0; c < this._getNextFreePaymentCodeAddress(pc) + this.gap_limit; c++) { - if (this._getBIP47Address(pc, c) === address) return this._getBIP47WIF(pc, c); + for (const pc of this._receive_payment_codes) { + for (let c = 0; c < this._getNextFreePaymentCodeIndexReceive(pc) + this.gap_limit; c++) { + if (this._getBIP47AddressReceive(pc, c) === address) return this._getBIP47WIF(pc, c); } } return false; @@ -1094,9 +1114,9 @@ export class AbstractHDElectrumWallet extends AbstractHDWallet { for (let c = 0; c < this.next_free_change_address_index + this.gap_limit; c++) { if (this._getInternalAddressByIndex(c) === cleanAddress) return true; } - for (const pc of this._sender_payment_codes) { - for (let c = 0; c < this._getNextFreePaymentCodeAddress(pc) + this.gap_limit; c++) { - if (this._getBIP47Address(pc, c) === address) return true; + for (const pc of this._receive_payment_codes) { + for (let c = 0; c < this._getNextFreePaymentCodeIndexReceive(pc) + this.gap_limit; c++) { + if (this._getBIP47AddressReceive(pc, c) === address) return true; } } return false; @@ -1104,7 +1124,7 @@ export class AbstractHDElectrumWallet extends AbstractHDWallet { /** * - * @param utxos {Array.<{vout: Number, value: Number, txId: String, address: String}>} List of spendable utxos + * @param utxos {Array.<{vout: Number, value: Number, txid: String, address: String}>} List of spendable utxos * @param targets {Array.<{value: Number, address: String}>} Where coins are going. If theres only 1 target and that target has no value - this will send MAX to that address (respecting fee rate) * @param feeRate {Number} satoshi per byte * @param changeAddress {String} Excessive coins will go back to that address @@ -1115,12 +1135,12 @@ export class AbstractHDElectrumWallet extends AbstractHDWallet { */ createTransaction( utxos: CreateTransactionUtxo[], - targets: CoinSelectTarget[], + targets: CreateTransactionTarget[], feeRate: number, changeAddress: string, - sequence: number, + sequence: number = AbstractHDElectrumWallet.defaultRBFSequence, skipSigning = false, - masterFingerprint: number, + masterFingerprint: number = 0, ): CreateTransactionResult { if (targets.length === 0) throw new Error('No destination provided'); // compensating for coinselect inability to deal with segwit inputs, and overriding script length for proper vbytes calculation @@ -1135,13 +1155,43 @@ export class AbstractHDElectrumWallet extends AbstractHDWallet { } for (const t of targets) { - if (t.address.startsWith('bc1')) { + if (t.address && t.address.startsWith('bc1')) { // in case address is non-typical and takes more bytes than coinselect library anticipates by default t.script = { length: bitcoin.address.toOutputScript(t.address).length + 3 }; } + + if (t.script?.hex) { + // setting length for coinselect lib manually as it is not aware of our field `hex` + t.script.length = t.script.hex.length / 2 - 4; + } } - const { inputs, outputs, fee } = this.coinselect(utxos, targets, feeRate, changeAddress); + let { inputs, outputs, fee } = this.coinselect(utxos, targets, feeRate); + + const hasSilentPaymentOutput: boolean = !!outputs.find(o => o.address?.startsWith('sp1')); + if (hasSilentPaymentOutput) { + if (!this.allowSilentPaymentSend()) { + throw new Error('This wallet can not send to SilentPayment address'); + } + + // for a single wallet all utxos gona be the same type, so we define it only once: + let utxoType: SPUTXOType = 'non-eligible'; + switch (this.segwitType) { + case 'p2sh(p2wpkh)': + utxoType = 'p2sh-p2wpkh'; + break; + case 'p2wpkh': + utxoType = 'p2wpkh'; + break; + default: + // @ts-ignore override + if (this.type === 'HDlegacyP2PKH') utxoType = 'p2pkh'; + } + + const spUtxos: SPUTXO[] = inputs.map(u => ({ ...u, utxoType, wif: u.wif! })); + const sp = new SilentPayment(); + outputs = sp.createTransaction(spUtxos, outputs) as CoinSelectOutput[]; + } sequence = sequence || AbstractHDElectrumWallet.defaultRBFSequence; let psbt = new bitcoin.Psbt(); @@ -1168,7 +1218,7 @@ export class AbstractHDElectrumWallet extends AbstractHDWallet { let masterFingerprintHex = Number(masterFingerprint).toString(16); if (masterFingerprintHex.length < 8) masterFingerprintHex = '0' + masterFingerprintHex; // conversion without explicit zero might result in lost byte const hexBuffer = Buffer.from(masterFingerprintHex, 'hex'); - masterFingerprintBuffer = Buffer.from(reverse(hexBuffer)); + masterFingerprintBuffer = Buffer.from(hexBuffer).reverse(); } else { masterFingerprintBuffer = Buffer.from([0x00, 0x00, 0x00, 0x00]); } @@ -1179,9 +1229,10 @@ export class AbstractHDElectrumWallet extends AbstractHDWallet { }); outputs.forEach(output => { - // if output has no address - this is change output + // if output has no address - this is change output or a custom script output let change = false; - if (!output.address) { + // @ts-ignore + if (!output.address && !output.script?.hex) { change = true; output.address = changeAddress; } @@ -1194,7 +1245,7 @@ export class AbstractHDElectrumWallet extends AbstractHDWallet { let masterFingerprintHex = Number(masterFingerprint).toString(16); if (masterFingerprintHex.length < 8) masterFingerprintHex = '0' + masterFingerprintHex; // conversion without explicit zero might result in lost byte const hexBuffer = Buffer.from(masterFingerprintHex, 'hex'); - masterFingerprintBuffer = Buffer.from(reverse(hexBuffer)); + masterFingerprintBuffer = Buffer.from(hexBuffer).reverse(); } else { masterFingerprintBuffer = Buffer.from([0x00, 0x00, 0x00, 0x00]); } @@ -1202,8 +1253,16 @@ export class AbstractHDElectrumWallet extends AbstractHDWallet { // this is not correct fingerprint, as we dont know realfingerprint - we got zpub with 84/0, but fingerpting // should be from root. basically, fingerprint should be provided from outside by user when importing zpub + if (output.address?.startsWith('PM')) { + // ok its BIP47 payment code, so we need to unwrap a joint address for the receiver and use it instead: + output.address = this._getNextFreePaymentCodeAddressSend(output.address); + // ^^^ trusting that notification transaction is in place + } + psbt.addOutput({ address: output.address, + // @ts-ignore types from bitcoinjs are not exported so we cant define outputData separately and add fields conditionally (either address or script should be present) + script: output.script?.hex ? Buffer.from(output.script.hex, 'hex') : undefined, value: output.value, bip32Derivation: change && path && pubkey @@ -1242,10 +1301,12 @@ export class AbstractHDElectrumWallet extends AbstractHDWallet { throw new Error('Internal error: pubkey or path are invalid'); } const p2wpkh = bitcoin.payments.p2wpkh({ pubkey }); + if (!p2wpkh.output) { + throw new Error('Internal error: could not create p2wpkh output during _addPsbtInput'); + } psbt.addInput({ - // @ts-ignore - hash: input.txid || input.txId, + hash: input.txid, index: input.vout, sequence, bip32Derivation: [ @@ -1292,15 +1353,27 @@ export class AbstractHDElectrumWallet extends AbstractHDWallet { * Creates Segwit Bech32 Bitcoin address */ _nodeToBech32SegwitAddress(hdNode: BIP32Interface): string { - return bitcoin.payments.p2wpkh({ + const { address } = bitcoin.payments.p2wpkh({ pubkey: hdNode.publicKey, - }).address; + }); + + if (!address) { + throw new Error('Could not create address in _nodeToBech32SegwitAddress'); + } + + return address; } _nodeToLegacyAddress(hdNode: BIP32Interface): string { - return bitcoin.payments.p2pkh({ + const { address } = bitcoin.payments.p2pkh({ pubkey: hdNode.publicKey, - }).address; + }); + + if (!address) { + throw new Error('Could not create address in _nodeToLegacyAddress'); + } + + return address; } /** @@ -1310,6 +1383,11 @@ export class AbstractHDElectrumWallet extends AbstractHDWallet { const { address } = bitcoin.payments.p2sh({ redeem: bitcoin.payments.p2wpkh({ pubkey: hdNode.publicKey }), }); + + if (!address) { + throw new Error('Could not create address in _nodeToP2shSegwitAddress'); + } + return address; } @@ -1360,6 +1438,17 @@ export class AbstractHDElectrumWallet extends AbstractHDWallet { ret.push(this._getExternalAddressByIndex(c)); } + if (this.allowBIP47() && this.isBIP47Enabled()) { + // returning BIP47 joint addresses with everyone who can pay us because they are kinda our 'external' aka 'receive' addresses + + for (const pc of this._receive_payment_codes) { + for (let c = 0; c < this._getNextFreePaymentCodeIndexReceive(pc) + this.gap_limit / 4; c++) { + // ^^^ not full gap limit to reduce computation (theoretically, there should not be gaps at all) + ret.push(this._getBIP47AddressReceive(pc, c)); + } + } + } + return ret; } @@ -1427,7 +1516,7 @@ export class AbstractHDElectrumWallet extends AbstractHDWallet { /** * @param seed {Buffer} Buffer object with seed - * @returns {string} Hex string of fingerprint derived from mnemonics. Always has lenght of 8 chars and correct leading zeroes. All caps + * @returns {string} Hex string of fingerprint derived from mnemonics. Always has length of 8 chars and correct leading zeroes. All caps */ static seedToFingerprint(seed: Buffer) { const root = bip32.fromSeed(seed); @@ -1440,13 +1529,13 @@ export class AbstractHDElectrumWallet extends AbstractHDWallet { * @param mnemonic {string} Mnemonic phrase (12 or 24 words) * @returns {string} Hex fingerprint */ - static mnemonicToFingerprint(mnemonic: string, passphrase: string) { + static mnemonicToFingerprint(mnemonic: string, passphrase?: string) { const seed = bip39.mnemonicToSeedSync(mnemonic, passphrase); return AbstractHDElectrumWallet.seedToFingerprint(seed); } /** - * @returns {string} Hex string of fingerprint derived from wallet mnemonics. Always has lenght of 8 chars and correct leading zeroes + * @returns {string} Hex string of fingerprint derived from wallet mnemonics. Always has length of 8 chars and correct leading zeroes */ getMasterFingerprintHex() { const seed = this._getSeed(); @@ -1473,6 +1562,145 @@ export class AbstractHDElectrumWallet extends AbstractHDWallet { return this._bip47_instance; } + /** + * find and return _existing_ notification transaction for the given payment code + * (i.e. if it exists - we notified in the past and dont need to notify again) + */ + getBIP47NotificationTransaction(receiverPaymentCode: string): Transaction | undefined { + const publicBip47 = BIP47Factory(ecc).fromPaymentCode(receiverPaymentCode); + const remoteNotificationAddress = publicBip47.getNotificationAddress(); + + for (const tx of this.getTransactions()) { + for (const output of tx.outputs) { + if (output.scriptPubKey?.addresses?.includes(remoteNotificationAddress)) return tx; + // ^^^ if in the past we sent a tx to his notification address - most likely that was a proper notification + // transaction with OP_RETURN. + // but not gona verify it here, will just trust it + } + } + } + + /** + * return BIP47 payment code of the counterparty of this transaction (someone who paid us, or someone we paid) + * or undefined if it was a non-BIP47 transaction + */ + getBip47CounterpartyByTxid(txid: string): string | undefined { + const foundTx = this.getTransactions().find(tx => tx.txid === txid); + if (foundTx) { + return this.getBip47CounterpartyByTx(foundTx); + } + return undefined; + } + + /** + * return BIP47 payment code of the counterparty of this transaction (someone who paid us, or someone we paid) + * or undefined if it was a non-BIP47 transaction + */ + getBip47CounterpartyByTx(tx: Transaction): string | undefined { + for (const pc of Object.keys(this._txs_by_payment_code_index)) { + // iterating all payment codes + + for (const txs of Object.values(this._txs_by_payment_code_index[pc])) { + for (const tx2 of txs) { + if (tx2.txid === tx.txid) { + return pc; // found it! + } + } + } + } + + // checking txs we sent to counterparties + + for (const pc of this._send_payment_codes) { + for (const out of tx.outputs) { + for (const address of out.scriptPubKey?.addresses ?? []) { + if (this._addresses_by_payment_code_send[pc] && Object.values(this._addresses_by_payment_code_send[pc]).includes(address)) { + // found it! + return pc; + } + } + } + } + + return undefined; // found nothing + } + + createBip47NotificationTransaction(utxos: CreateTransactionUtxo[], receiverPaymentCode: string, feeRate: number, changeAddress: string) { + const aliceBip47 = BIP47Factory(ecc).fromBip39Seed(this.getSecret(), undefined, this.getPassphrase()); + const bobBip47 = BIP47Factory(ecc).fromPaymentCode(receiverPaymentCode); + assert(utxos[0], 'No UTXO'); + assert(utxos[0].wif, 'No UTXO WIF'); + + // constructing targets: notification address, _dummy_ payload (+potential change might be added later) + + const targetsTemp: CreateTransactionTarget[] = []; + targetsTemp.push({ + address: bobBip47.getNotificationAddress(), + value: 546, // minimum permissible utxo size + }); + targetsTemp.push({ + value: 0, + script: { + hex: Buffer.alloc(83).toString('hex'), // no `address` here, its gonabe op_return. but we pass dummy data here with a correct size just to choose utxo + }, + }); + + // creating temp transaction so that utxo can be selected: + + const { inputs: inputsTemp } = this.createTransaction( + utxos, + targetsTemp, + feeRate, + changeAddress, + AbstractHDElectrumWallet.defaultRBFSequence, + false, + 0, + ); + assert(inputsTemp?.[0]?.wif, 'inputsTemp?.[0]?.wif assert failed'); + + // utxo selected. lets create op_return payload using the correct (first!) utxo and correct targets with that payload + + const keyPair = ECPair.fromWIF(inputsTemp[0].wif); + const outputNumber = Buffer.from('00000000', 'hex'); + outputNumber.writeUInt32LE(inputsTemp[0].vout); + const blindedPaymentCode = aliceBip47.getBlindedPaymentCode( + bobBip47, + keyPair.privateKey as Buffer, + // txid is reversed, as well as output number + Buffer.from(inputsTemp[0].txid, 'hex').reverse().toString('hex') + outputNumber.toString('hex'), + ); + + // targets: + + const targets: CreateTransactionTarget[] = []; + targets.push({ + address: bobBip47.getNotificationAddress(), + value: 546, // minimum permissible utxo size + }); + targets.push({ + value: 0, + script: { + hex: '6a4c50' + blindedPaymentCode, // no `address` here, only script (which is OP_RETURN + data payload) + }, + }); + + // finally a transaction: + + const { tx, outputs, inputs, fee, psbt } = this.createTransaction( + utxos, + targets, + feeRate, + changeAddress, + AbstractHDElectrumWallet.defaultRBFSequence, + false, + 0, + ); + assert(inputs && inputs[0] && inputs[0].wif, 'inputs && inputs[0] && inputs[0].wif assert failed'); + assert(inputs[0].txid === inputsTemp[0].txid, 'inputs[0].txid === inputsTemp[0].txid assert failed'); // making sure that no funky business happened under the hood (its supposed to stay the same) + + return { tx, inputs, outputs, fee, psbt }; + } + getBIP47PaymentCode(): string { if (!this._payment_code) { this._payment_code = this.getBIP47FromSeed().getSerializedPaymentCode(); @@ -1486,17 +1714,21 @@ export class AbstractHDElectrumWallet extends AbstractHDWallet { return bip47Local.getNotificationAddress(); } + /** + * check our notification address, and decypher all payment codes people notified us + * about (so they can pay us) + */ async fetchBIP47SenderPaymentCodes(): Promise { const bip47_instance = this.getBIP47FromSeed(); const address = bip47_instance.getNotificationAddress(); const histories = await BlueElectrum.multiGetHistoryByAddress([address]); const txHashes = histories[address].map(({ tx_hash }) => tx_hash); - const txHexs = await BlueElectrum.multiGetTransactionByTxid(txHashes, 50, false); + const txHexs = await BlueElectrum.multiGetTransactionByTxid(txHashes, false); for (const txHex of Object.values(txHexs)) { try { const paymentCode = bip47_instance.getPaymentCodeFromRawNotificationTransaction(txHex); - if (this._sender_payment_codes.includes(paymentCode)) continue; // already have it + if (this._receive_payment_codes.includes(paymentCode)) continue; // already have it // final check if PC is even valid (could've been constructed by a buggy code, and our code would crash with that): try { @@ -1505,8 +1737,8 @@ export class AbstractHDElectrumWallet extends AbstractHDWallet { continue; } - this._sender_payment_codes.push(paymentCode); - this._next_free_payment_code_address_index[paymentCode] = 0; // initialize + this._receive_payment_codes.push(paymentCode); + this._next_free_payment_code_address_index_receive[paymentCode] = 0; // initialize this._balances_by_payment_code_index[paymentCode] = { c: 0, u: 0 }; } catch (e) { // do nothing @@ -1514,19 +1746,66 @@ export class AbstractHDElectrumWallet extends AbstractHDWallet { } } + /** + * for counterparties we can pay, we sync shared addresses to find the one we havent used yet. + * this method could benefit from rewriting in batch requests, but not necessary - its only going to be called + * once in a while (when user decides to pay a given counterparty again) + */ + async syncBip47ReceiversAddresses(receiverPaymentCode: string) { + this._next_free_payment_code_address_index_send[receiverPaymentCode] = + this._next_free_payment_code_address_index_send[receiverPaymentCode] || 0; // init + + for (let c = this._next_free_payment_code_address_index_send[receiverPaymentCode]; c < 999999; c++) { + const address = this._getBIP47AddressSend(receiverPaymentCode, c); + + this._addresses_by_payment_code_send[receiverPaymentCode] = this._addresses_by_payment_code_send[receiverPaymentCode] || {}; // init + this._addresses_by_payment_code_send[receiverPaymentCode][c] = address; + const histories = await BlueElectrum.multiGetHistoryByAddress([address]); + if (histories?.[address]?.length > 0) { + // address is used; + continue; + } + + // empty address, stop here, we found our latest index and filled array with shared addresses + this._next_free_payment_code_address_index_send[receiverPaymentCode] = c; + break; + } + } + + /** + * payment codes of people who can pay us + */ getBIP47SenderPaymentCodes(): string[] { - return this._sender_payment_codes; + return this._receive_payment_codes; + } + + /** + * payment codes of people whom we can pay + */ + getBIP47ReceiverPaymentCodes(): string[] { + return this._send_payment_codes; + } + + /** + * adding counterparty whom we can pay. trusting that notificaton transaction is in place already + */ + addBIP47Receiver(paymentCode: string) { + if (this._send_payment_codes.includes(paymentCode)) return; // duplicates + this._send_payment_codes.push(paymentCode); } _hdNodeToAddress(hdNode: BIP32Interface): string { return this._nodeToBech32SegwitAddress(hdNode); } - _getBIP47Address(paymentCode: string, index: number): string { - if (!this._addresses_by_payment_code[paymentCode]) this._addresses_by_payment_code[paymentCode] = []; + /** + * returns joint addresses to receive coins with a given counterparty + */ + _getBIP47AddressReceive(paymentCode: string, index: number): string { + if (!this._addresses_by_payment_code_receive[paymentCode]) this._addresses_by_payment_code_receive[paymentCode] = []; - if (this._addresses_by_payment_code[paymentCode][index]) { - return this._addresses_by_payment_code[paymentCode][index]; + if (this._addresses_by_payment_code_receive[paymentCode][index]) { + return this._addresses_by_payment_code_receive[paymentCode][index]; } const bip47_instance = this.getBIP47FromSeed(); @@ -1535,12 +1814,38 @@ export class AbstractHDElectrumWallet extends AbstractHDWallet { const hdNode = bip47_instance.getPaymentWallet(remotePaymentNode, index); const address = this._hdNodeToAddress(hdNode); this._address_to_wif_cache[address] = hdNode.toWIF(); - this._addresses_by_payment_code[paymentCode][index] = address; + this._addresses_by_payment_code_receive[paymentCode][index] = address; + return address; + } + + /** + * returns joint addresses to send coins to + */ + _getBIP47AddressSend(paymentCode: string, index: number): string { + if (!this._addresses_by_payment_code_send[paymentCode]) this._addresses_by_payment_code_send[paymentCode] = []; + + if (this._addresses_by_payment_code_send[paymentCode][index]) { + // cache hit + return this._addresses_by_payment_code_send[paymentCode][index]; + } + + const hdNode = this.getBIP47FromSeed().getReceiveWallet(BIP47Factory(ecc).fromPaymentCode(paymentCode).getPaymentCodeNode(), index); + const address = this._hdNodeToAddress(hdNode); + this._addresses_by_payment_code_send[paymentCode][index] = address; return address; } - _getNextFreePaymentCodeAddress(paymentCode: string) { - return this._next_free_payment_code_address_index[paymentCode] || 0; + _getNextFreePaymentCodeIndexReceive(paymentCode: string) { + return this._next_free_payment_code_address_index_receive[paymentCode] || 0; + } + + /** + * when sending funds to a payee, this method will return next unused joint address for him. + * this method assumes that we synced our payee via `syncBip47ReceiversAddresses()` + */ + _getNextFreePaymentCodeAddressSend(paymentCode: string) { + this._next_free_payment_code_address_index_send[paymentCode] = this._next_free_payment_code_address_index_send[paymentCode] || 0; + return this._getBIP47AddressSend(paymentCode, this._next_free_payment_code_address_index_send[paymentCode]); } _getBalancesByPaymentCodeIndex(paymentCode: string): BalanceByIndex { diff --git a/class/wallets/abstract-hd-wallet.ts b/class/wallets/abstract-hd-wallet.ts index 50e528a6a0..9dc65a2668 100644 --- a/class/wallets/abstract-hd-wallet.ts +++ b/class/wallets/abstract-hd-wallet.ts @@ -1,8 +1,9 @@ -import { LegacyWallet } from './legacy-wallet'; -import * as bip39 from 'bip39'; import { BIP32Interface } from 'bip32'; +import * as bip39 from 'bip39'; + import * as bip39custom from '../../blue_modules/bip39'; -import BlueElectrum from '../../blue_modules/BlueElectrum'; +import * as BlueElectrum from '../../blue_modules/BlueElectrum'; +import { LegacyWallet } from './legacy-wallet'; import { Transaction } from './types'; type AbstractHDWalletStatics = { @@ -13,8 +14,12 @@ type AbstractHDWalletStatics = { * @deprecated */ export class AbstractHDWallet extends LegacyWallet { - static type = 'abstract'; - static typeReadable = 'abstract'; + static readonly type = 'abstract'; + static readonly typeReadable = 'abstract'; + // @ts-ignore: override + public readonly type = AbstractHDWallet.type; + // @ts-ignore: override + public readonly typeReadable = AbstractHDWallet.typeReadable; next_free_address_index: number; next_free_change_address_index: number; diff --git a/class/wallets/abstract-wallet.ts b/class/wallets/abstract-wallet.ts index f48a5bd454..931c4f1865 100644 --- a/class/wallets/abstract-wallet.ts +++ b/class/wallets/abstract-wallet.ts @@ -1,14 +1,8 @@ -import { BitcoinUnit, Chain } from '../../models/bitcoinUnits'; import b58 from 'bs58check'; import createHash from 'create-hash'; -import { CreateTransactionResult, CreateTransactionUtxo, Transaction, Utxo } from './types'; -type WalletStatics = { - type: string; - typeReadable: string; - segwitType?: 'p2wpkh' | 'p2sh(p2wpkh)'; - derivationPath?: string; -}; +import { BitcoinUnit, Chain } from '../../models/bitcoinUnits'; +import { CreateTransactionResult, CreateTransactionUtxo, Transaction, Utxo } from './types'; type WalletWithPassphrase = AbstractWallet & { getPassphrase: () => string }; type UtxoMetadata = { @@ -17,8 +11,12 @@ type UtxoMetadata = { }; export class AbstractWallet { - static type = 'abstract'; - static typeReadable = 'abstract'; + static readonly type = 'abstract'; + static readonly typeReadable = 'abstract'; + // @ts-ignore: override + public readonly type = AbstractWallet.type; + // @ts-ignore: override + public readonly typeReadable = AbstractWallet.typeReadable; static fromJson(obj: string): AbstractWallet { const obj2 = JSON.parse(obj); @@ -31,8 +29,6 @@ export class AbstractWallet { return temp; } - type: string; - typeReadable: string; segwitType?: 'p2wpkh' | 'p2sh(p2wpkh)'; _derivationPath?: string; label: string; @@ -40,7 +36,7 @@ export class AbstractWallet { balance: number; unconfirmed_balance: number; _address: string | false; - utxo: Utxo[]; + _utxo: Utxo[]; _lastTxFetch: number; _lastBalanceFetch: number; preferredBalanceUnit: BitcoinUnit; @@ -50,20 +46,15 @@ export class AbstractWallet { _hideTransactionsInWalletsList: boolean; _utxoMetadata: Record; use_with_hardware_wallet: boolean; - masterFingerprint: number | false; + masterFingerprint: number; constructor() { - const Constructor = this.constructor as unknown as WalletStatics; - - this.type = Constructor.type; - this.typeReadable = Constructor.typeReadable; - this.segwitType = Constructor.segwitType; this.label = ''; this.secret = ''; // private key or recovery phrase this.balance = 0; this.unconfirmed_balance = 0; this._address = false; // cache - this.utxo = []; + this._utxo = []; this._lastTxFetch = 0; this._lastBalanceFetch = 0; this.preferredBalanceUnit = BitcoinUnit.BTC; @@ -73,7 +64,7 @@ export class AbstractWallet { this._hideTransactionsInWalletsList = false; this._utxoMetadata = {}; this.use_with_hardware_wallet = false; - this.masterFingerprint = false; + this.masterFingerprint = 0; } /** @@ -163,11 +154,11 @@ export class AbstractWallet { return true; } - allowRBF(): boolean { + allowSilentPaymentSend(): boolean { return false; } - allowHodlHodlTrading(): boolean { + allowRBF(): boolean { return false; } @@ -219,6 +210,7 @@ export class AbstractWallet { } setSecret(newSecret: string): this { + const origSecret = newSecret; this.secret = newSecret.trim().replace('bitcoin:', '').replace('BITCOIN:', ''); if (this.secret.startsWith('BC1')) this.secret = this.secret.toLowerCase(); @@ -238,7 +230,7 @@ export class AbstractWallet { if (derivationPath.startsWith("m/84'/0'/") && this.secret.toLowerCase().startsWith('xpub')) { // need to convert xpub to zpub - this.secret = this._xpubToZpub(this.secret); + this.secret = this._xpubToZpub(this.secret.split('/')[0]); } if (derivationPath.startsWith("m/49'/0'/") && this.secret.toLowerCase().startsWith('xpub')) { @@ -260,7 +252,7 @@ export class AbstractWallet { parsedSecret = JSON.parse(newSecret); } if (parsedSecret && parsedSecret.keystore && parsedSecret.keystore.xpub) { - let masterFingerprint: number | false = false; + let masterFingerprint: number = 0; if (parsedSecret.keystore.ckcc_xfp) { // It is a ColdCard Hardware Wallet masterFingerprint = Number(parsedSecret.keystore.ckcc_xfp); @@ -288,6 +280,7 @@ export class AbstractWallet { ? parsedSecret.AccountKeyPath : `m/${parsedSecret.AccountKeyPath}`; if (parsedSecret.CoboVaultFirmwareVersion) this.use_with_hardware_wallet = true; + return this; } } catch (_) {} @@ -322,6 +315,31 @@ export class AbstractWallet { } } + // is it new-wasabi.json exported from coldcard? + try { + const json = JSON.parse(origSecret); + if (json.MasterFingerprint && json.ExtPubKey) { + // technically we should allow choosing which format user wants, BIP44 / BIP49 / BIP84, but meh... + this.secret = this._xpubToZpub(json.ExtPubKey); + const mfp = Buffer.from(json.MasterFingerprint, 'hex').reverse().toString('hex'); + this.masterFingerprint = parseInt(mfp, 16); + return this; + } + } catch (_) {} + + // is it sparrow-export ? + try { + const json = JSON.parse(origSecret); + if (json.chain && json.chain === 'BTC' && json.xfp && json.bip84) { + // technically we should allow choosing which format user wants, BIP44 / BIP49 / BIP84, but meh... + this.secret = json.bip84._pub; + const mfp = Buffer.from(json.xfp, 'hex').reverse().toString('hex'); + this.masterFingerprint = parseInt(mfp, 16); + this._derivationPath = json.bip84.deriv; + return this; + } + } catch (_) {} + return this; } @@ -351,7 +369,7 @@ export class AbstractWallet { /** * - * @param utxos {Array.<{vout: Number, value: Number, txId: String, address: String}>} List of spendable utxos + * @param utxos {Array.<{vout: Number, value: Number, txid: String, address: String}>} List of spendable utxos * @param targets {Array.<{value: Number, address: String}>} Where coins are going. If theres only 1 target and that target has no value - this will send MAX to that address (respecting fee rate) * @param feeRate {Number} satoshi per byte * @param changeAddress {String} Excessive coins will go back to that address diff --git a/class/wallets/hd-aezeed-wallet.js b/class/wallets/hd-aezeed-wallet.ts similarity index 79% rename from class/wallets/hd-aezeed-wallet.js rename to class/wallets/hd-aezeed-wallet.ts index 593e86c0ab..9e561fb3af 100644 --- a/class/wallets/hd-aezeed-wallet.js +++ b/class/wallets/hd-aezeed-wallet.ts @@ -1,10 +1,11 @@ -import { AbstractHDElectrumWallet } from './abstract-hd-electrum-wallet'; -import b58 from 'bs58check'; +import { CipherSeed } from 'aezeed'; import BIP32Factory from 'bip32'; +import * as bitcoin from 'bitcoinjs-lib'; +import b58 from 'bs58check'; + import ecc from '../../blue_modules/noble_ecc'; +import { AbstractHDElectrumWallet } from './abstract-hd-electrum-wallet'; -const bitcoin = require('bitcoinjs-lib'); -const { CipherSeed } = require('aezeed'); const bip32 = BIP32Factory(ecc); /** @@ -18,12 +19,18 @@ const bip32 = BIP32Factory(ecc); * @see https://github.com/lightningnetwork/lnd/blob/master/keychain/derivation.go */ export class HDAezeedWallet extends AbstractHDElectrumWallet { - static type = 'HDAezeedWallet'; - static typeReadable = 'HD Aezeed'; - static segwitType = 'p2wpkh'; - static derivationPath = "m/84'/0'/0'"; - - setSecret(newSecret) { + static readonly type = 'HDAezeedWallet'; + static readonly typeReadable = 'HD Aezeed'; + public readonly segwitType = 'p2wpkh'; + static readonly derivationPath = "m/84'/0'/0'"; + // @ts-ignore: override + public readonly type = HDAezeedWallet.type; + // @ts-ignore: override + public readonly typeReadable = HDAezeedWallet.typeReadable; + + private _entropyHex?: string; + + setSecret(newSecret: string): this { this.secret = newSecret.trim(); this.secret = this.secret.replace(/[^a-zA-Z0-9]/g, ' ').replace(/\s+/g, ' '); return this; @@ -55,7 +62,7 @@ export class HDAezeedWallet extends AbstractHDElectrumWallet { return this._xpub; } - validateMnemonic() { + validateMnemonic(): boolean { throw new Error('Use validateMnemonicAsync()'); } @@ -75,7 +82,7 @@ export class HDAezeedWallet extends AbstractHDElectrumWallet { try { const cipherSeed1 = await CipherSeed.fromMnemonic(this.secret, passphrase); this._entropyHex = cipherSeed1.entropy.toString('hex'); // save cache - } catch (error) { + } catch (error: any) { return error.message === 'Invalid Password'; } return false; @@ -97,7 +104,7 @@ export class HDAezeedWallet extends AbstractHDElectrumWallet { return node.derive(1); } - _getInternalAddressByIndex(index) { + _getInternalAddressByIndex(index: number): string { index = index * 1; // cast to int if (this.internal_addresses_cache[index]) return this.internal_addresses_cache[index]; // cache hit @@ -106,11 +113,14 @@ export class HDAezeedWallet extends AbstractHDElectrumWallet { const address = bitcoin.payments.p2wpkh({ pubkey: this._node1.derive(index).publicKey, }).address; + if (!address) { + throw new Error('Internal error: no address in _getInternalAddressByIndex'); + } return (this.internal_addresses_cache[index] = address); } - _getExternalAddressByIndex(index) { + _getExternalAddressByIndex(index: number): string { index = index * 1; // cast to int if (this.external_addresses_cache[index]) return this.external_addresses_cache[index]; // cache hit @@ -119,11 +129,14 @@ export class HDAezeedWallet extends AbstractHDElectrumWallet { const address = bitcoin.payments.p2wpkh({ pubkey: this._node0.derive(index).publicKey, }).address; + if (!address) { + throw new Error('Internal error: no address in _getExternalAddressByIndex'); + } return (this.external_addresses_cache[index] = address); } - _getWIFByIndex(internal, index) { + _getWIFByIndex(internal: boolean, index: number): string | false { if (!this.secret) return false; const root = bip32.fromSeed(this._getEntropyCached()); const path = `m/84'/0'/0'/${internal ? 1 : 0}/${index}`; @@ -132,7 +145,7 @@ export class HDAezeedWallet extends AbstractHDElectrumWallet { return child.toWIF(); } - _getNodePubkeyByIndex(node, index) { + _getNodePubkeyByIndex(node: number, index: number) { index = index * 1; // cast to int if (node === 0 && !this._node0) { @@ -143,13 +156,15 @@ export class HDAezeedWallet extends AbstractHDElectrumWallet { this._node1 = this._getNode1(); } - if (node === 0) { + if (node === 0 && this._node0) { return this._node0.derive(index).publicKey; } - if (node === 1) { + if (node === 1 && this._node1) { return this._node1.derive(index).publicKey; } + + throw new Error('Internal error: this._node0 or this._node1 is undefined'); } getIdentityPubkey() { @@ -165,10 +180,6 @@ export class HDAezeedWallet extends AbstractHDElectrumWallet { return true; } - allowHodlHodlTrading() { - return true; - } - allowRBF() { return true; } diff --git a/class/wallets/hd-legacy-breadwallet-wallet.js b/class/wallets/hd-legacy-breadwallet-wallet.ts similarity index 71% rename from class/wallets/hd-legacy-breadwallet-wallet.js rename to class/wallets/hd-legacy-breadwallet-wallet.ts index 5a6d5e9291..f08419e72b 100644 --- a/class/wallets/hd-legacy-breadwallet-wallet.js +++ b/class/wallets/hd-legacy-breadwallet-wallet.ts @@ -1,10 +1,14 @@ +import BIP32Factory, { BIP32Interface } from 'bip32'; import * as bitcoinjs from 'bitcoinjs-lib'; -import { HDLegacyP2PKHWallet } from './hd-legacy-p2pkh-wallet'; -import { AbstractHDElectrumWallet } from './abstract-hd-electrum-wallet'; -import BIP32Factory from 'bip32'; +import { Psbt } from 'bitcoinjs-lib'; +import { CoinSelectReturnInput } from 'coinselect'; + +import * as BlueElectrum from '../../blue_modules/BlueElectrum'; +import { ElectrumHistory } from '../../blue_modules/BlueElectrum'; import ecc from '../../blue_modules/noble_ecc'; +import { AbstractHDElectrumWallet } from './abstract-hd-electrum-wallet'; +import { HDLegacyP2PKHWallet } from './hd-legacy-p2pkh-wallet'; -const BlueElectrum = require('../../blue_modules/BlueElectrum'); const bip32 = BIP32Factory(ecc); /** @@ -12,32 +16,46 @@ const bip32 = BIP32Factory(ecc); * In particular, Breadwallet-compatible (Legacy addresses) */ export class HDLegacyBreadwalletWallet extends HDLegacyP2PKHWallet { - static type = 'HDLegacyBreadwallet'; - static typeReadable = 'HD Legacy Breadwallet (P2PKH)'; - static derivationPath = "m/0'"; + static readonly type = 'HDLegacyBreadwallet'; + static readonly typeReadable = 'HD Legacy Breadwallet (P2PKH)'; + // @ts-ignore: override + public readonly type = HDLegacyBreadwalletWallet.type; + // @ts-ignore: override + public readonly typeReadable = HDLegacyBreadwalletWallet.typeReadable; + static readonly derivationPath = "m/0'"; // track address index at which wallet switched to segwit - _external_segwit_index = null; - _internal_segwit_index = null; + _external_segwit_index: number | null = null; + _internal_segwit_index: number | null = null; // we need a separate function without external_addresses_cache to use in binarySearch - _calcNodeAddressByIndex(node, index, p2wpkh = false) { - let _node; + _calcNodeAddressByIndex(node: number, index: number, p2wpkh: boolean = false) { + let _node: BIP32Interface | undefined; if (node === 0) { _node = this._node0 || (this._node0 = bip32.fromBase58(this.getXpub()).derive(node)); } if (node === 1) { _node = this._node1 || (this._node1 = bip32.fromBase58(this.getXpub()).derive(node)); } + + if (!_node) { + throw new Error('Internal error: this._node0 or this._node1 is undefined'); + } + const pubkey = _node.derive(index).publicKey; const address = p2wpkh ? bitcoinjs.payments.p2wpkh({ pubkey }).address : bitcoinjs.payments.p2pkh({ pubkey }).address; + + if (!address) { + throw new Error('Internal error: no address in _calcNodeAddressByIndex'); + } + return address; } // this function is different from HDLegacyP2PKHWallet._getNodeAddressByIndex. // It takes _external_segwit_index _internal_segwit_index for account // and starts to generate segwit addresses if index more than them - _getNodeAddressByIndex(node, index) { + _getNodeAddressByIndex(node: number, index: number): string { index = index * 1; // cast to int if (node === 0) { if (this.external_addresses_cache[index]) return this.external_addresses_cache[index]; // cache hit @@ -64,6 +82,8 @@ export class HDLegacyBreadwalletWallet extends HDLegacyP2PKHWallet { if (node === 1) { return (this.internal_addresses_cache[index] = address); } + + throw new Error('Internal error: unknown node'); } async fetchBalance() { @@ -96,8 +116,8 @@ export class HDLegacyBreadwalletWallet extends HDLegacyP2PKHWallet { } } - async _binarySearchIteration(startIndex, endIndex, node = 0, p2wpkh = false) { - const gerenateChunkAddresses = chunkNum => { + async _binarySearchIteration(startIndex: number, endIndex: number, node: number = 0, p2wpkh: boolean = false) { + const gerenateChunkAddresses = (chunkNum: number) => { const ret = []; for (let c = this.gap_limit * chunkNum; c < this.gap_limit * (chunkNum + 1); c++) { ret.push(this._calcNodeAddressByIndex(node, c, p2wpkh)); @@ -105,11 +125,11 @@ export class HDLegacyBreadwalletWallet extends HDLegacyP2PKHWallet { return ret; }; - let lastChunkWithUsedAddressesNum = null; - let lastHistoriesWithUsedAddresses = null; + let lastChunkWithUsedAddressesNum: number; + let lastHistoriesWithUsedAddresses: Record; for (let c = 0; c < Math.round(endIndex / this.gap_limit); c++) { const histories = await BlueElectrum.multiGetHistoryByAddress(gerenateChunkAddresses(c)); - if (this.constructor._getTransactionsFromHistories(histories).length > 0) { + if (AbstractHDElectrumWallet._getTransactionsFromHistories(histories).length > 0) { // in this particular chunk we have used addresses lastChunkWithUsedAddressesNum = c; lastHistoriesWithUsedAddresses = histories; @@ -121,11 +141,11 @@ export class HDLegacyBreadwalletWallet extends HDLegacyP2PKHWallet { let lastUsedIndex = startIndex; - if (lastHistoriesWithUsedAddresses) { + if (lastHistoriesWithUsedAddresses!) { // now searching for last used address in batch lastChunkWithUsedAddressesNum for ( - let c = lastChunkWithUsedAddressesNum * this.gap_limit; - c < lastChunkWithUsedAddressesNum * this.gap_limit + this.gap_limit; + let c = lastChunkWithUsedAddressesNum! * this.gap_limit; + c < lastChunkWithUsedAddressesNum! * this.gap_limit + this.gap_limit; c++ ) { const address = this._calcNodeAddressByIndex(node, c, p2wpkh); @@ -138,11 +158,11 @@ export class HDLegacyBreadwalletWallet extends HDLegacyP2PKHWallet { return lastUsedIndex; } - _addPsbtInput(psbt, input, sequence, masterFingerprintBuffer) { + _addPsbtInput(psbt: Psbt, input: CoinSelectReturnInput, sequence: number, masterFingerprintBuffer: Buffer) { // hack to use // AbstractHDElectrumWallet._addPsbtInput for bech32 address // HDLegacyP2PKHWallet._addPsbtInput for legacy address - const ProxyClass = input.address.startsWith('bc1') ? AbstractHDElectrumWallet : HDLegacyP2PKHWallet; + const ProxyClass = input?.address?.startsWith('bc1') ? AbstractHDElectrumWallet : HDLegacyP2PKHWallet; const proxy = new ProxyClass(); return proxy._addPsbtInput.apply(this, [psbt, input, sequence, masterFingerprintBuffer]); } diff --git a/class/wallets/hd-legacy-electrum-seed-p2pkh-wallet.js b/class/wallets/hd-legacy-electrum-seed-p2pkh-wallet.ts similarity index 68% rename from class/wallets/hd-legacy-electrum-seed-p2pkh-wallet.js rename to class/wallets/hd-legacy-electrum-seed-p2pkh-wallet.ts index ea46112434..6ce6aac804 100644 --- a/class/wallets/hd-legacy-electrum-seed-p2pkh-wallet.js +++ b/class/wallets/hd-legacy-electrum-seed-p2pkh-wallet.ts @@ -1,13 +1,18 @@ -import { HDLegacyP2PKHWallet } from './hd-legacy-p2pkh-wallet'; import BIP32Factory from 'bip32'; +import * as bitcoin from 'bitcoinjs-lib'; +import * as mn from 'electrum-mnemonic'; + import ecc from '../../blue_modules/noble_ecc'; +import { HDLegacyP2PKHWallet } from './hd-legacy-p2pkh-wallet'; -const bitcoin = require('bitcoinjs-lib'); -const mn = require('electrum-mnemonic'); const bip32 = BIP32Factory(ecc); - const PREFIX = mn.PREFIXES.standard; +type SeedOpts = { + prefix?: string; + passphrase?: string; +}; + /** * ElectrumSeed means that instead of BIP39 seed format it works with the format invented by Electrum wallet. Otherwise * its a regular HD wallet that has all the properties of parent class. @@ -15,9 +20,13 @@ const PREFIX = mn.PREFIXES.standard; * @see https://electrum.readthedocs.io/en/latest/seedphrase.html */ export class HDLegacyElectrumSeedP2PKHWallet extends HDLegacyP2PKHWallet { - static type = 'HDlegacyElectrumSeedP2PKH'; - static typeReadable = 'HD Legacy Electrum (BIP32 P2PKH)'; - static derivationPath = 'm'; + static readonly type = 'HDlegacyElectrumSeedP2PKH'; + static readonly typeReadable = 'HD Legacy Electrum (BIP32 P2PKH)'; + // @ts-ignore: override + public readonly type = HDLegacyElectrumSeedP2PKHWallet.type; + // @ts-ignore: override + public readonly typeReadable = HDLegacyElectrumSeedP2PKHWallet.typeReadable; + static readonly derivationPath = 'm'; validateMnemonic() { return mn.validateMnemonic(this.secret, PREFIX); @@ -35,14 +44,14 @@ export class HDLegacyElectrumSeedP2PKHWallet extends HDLegacyP2PKHWallet { if (this._xpub) { return this._xpub; // cache hit } - const args = { prefix: PREFIX }; + const args: SeedOpts = { prefix: PREFIX }; if (this.passphrase) args.passphrase = this.passphrase; const root = bip32.fromSeed(mn.mnemonicToSeedSync(this.secret, args)); this._xpub = root.neutered().toBase58(); return this._xpub; } - _getInternalAddressByIndex(index) { + _getInternalAddressByIndex(index: number) { index = index * 1; // cast to int if (this.internal_addresses_cache[index]) return this.internal_addresses_cache[index]; // cache hit @@ -50,11 +59,14 @@ export class HDLegacyElectrumSeedP2PKHWallet extends HDLegacyP2PKHWallet { const address = bitcoin.payments.p2pkh({ pubkey: node.derive(1).derive(index).publicKey, }).address; + if (!address) { + throw new Error('Internal error: no address in _getInternalAddressByIndex'); + } return (this.internal_addresses_cache[index] = address); } - _getExternalAddressByIndex(index) { + _getExternalAddressByIndex(index: number) { index = index * 1; // cast to int if (this.external_addresses_cache[index]) return this.external_addresses_cache[index]; // cache hit @@ -62,13 +74,16 @@ export class HDLegacyElectrumSeedP2PKHWallet extends HDLegacyP2PKHWallet { const address = bitcoin.payments.p2pkh({ pubkey: node.derive(0).derive(index).publicKey, }).address; + if (!address) { + throw new Error('Internal error: no address in _getExternalAddressByIndex'); + } return (this.external_addresses_cache[index] = address); } - _getWIFByIndex(internal, index) { + _getWIFByIndex(internal: boolean, index: number): string | false { if (!this.secret) return false; - const args = { prefix: PREFIX }; + const args: SeedOpts = { prefix: PREFIX }; if (this.passphrase) args.passphrase = this.passphrase; const root = bip32.fromSeed(mn.mnemonicToSeedSync(this.secret, args)); const path = `m/${internal ? 1 : 0}/${index}`; @@ -77,7 +92,7 @@ export class HDLegacyElectrumSeedP2PKHWallet extends HDLegacyP2PKHWallet { return child.toWIF(); } - _getNodePubkeyByIndex(node, index) { + _getNodePubkeyByIndex(node: number, index: number) { index = index * 1; // cast to int if (node === 0 && !this._node0) { @@ -92,12 +107,14 @@ export class HDLegacyElectrumSeedP2PKHWallet extends HDLegacyP2PKHWallet { this._node1 = hdNode.derive(node); } - if (node === 0) { + if (node === 0 && this._node0) { return this._node0.derive(index).publicKey; } - if (node === 1) { + if (node === 1 && this._node1) { return this._node1.derive(index).publicKey; } + + throw new Error('Internal error: this._node0 or this._node1 is undefined'); } } diff --git a/class/wallets/hd-legacy-p2pkh-wallet.js b/class/wallets/hd-legacy-p2pkh-wallet.ts similarity index 62% rename from class/wallets/hd-legacy-p2pkh-wallet.js rename to class/wallets/hd-legacy-p2pkh-wallet.ts index 088db99b5b..1baece664f 100644 --- a/class/wallets/hd-legacy-p2pkh-wallet.js +++ b/class/wallets/hd-legacy-p2pkh-wallet.ts @@ -1,8 +1,12 @@ -import { AbstractHDElectrumWallet } from './abstract-hd-electrum-wallet'; -import BIP32Factory from 'bip32'; +import BIP32Factory, { BIP32Interface } from 'bip32'; +import { Psbt } from 'bitcoinjs-lib'; +import { CoinSelectReturnInput } from 'coinselect'; + +import * as BlueElectrum from '../../blue_modules/BlueElectrum'; import ecc from '../../blue_modules/noble_ecc'; +import { AbstractHDElectrumWallet } from './abstract-hd-electrum-wallet'; + const bip32 = BIP32Factory(ecc); -const BlueElectrum = require('../../blue_modules/BlueElectrum'); /** * HD Wallet (BIP39). @@ -10,9 +14,13 @@ const BlueElectrum = require('../../blue_modules/BlueElectrum'); * @see https://github.com/bitcoin/bips/blob/master/bip-0044.mediawiki */ export class HDLegacyP2PKHWallet extends AbstractHDElectrumWallet { - static type = 'HDlegacyP2PKH'; - static typeReadable = 'HD Legacy (BIP44 P2PKH)'; - static derivationPath = "m/44'/0'/0'"; + static readonly type = 'HDlegacyP2PKH'; + static readonly typeReadable = 'HD Legacy (BIP44 P2PKH)'; + // @ts-ignore: override + public readonly type = HDLegacyP2PKHWallet.type; + // @ts-ignore: override + public readonly typeReadable = HDLegacyP2PKHWallet.typeReadable; + static readonly derivationPath = "m/44'/0'/0'"; allowSend() { return true; @@ -46,37 +54,41 @@ export class HDLegacyP2PKHWallet extends AbstractHDElectrumWallet { const root = bip32.fromSeed(seed); const path = this.getDerivationPath(); + if (!path) { + throw new Error('Internal error: no path'); + } const child = root.derivePath(path).neutered(); this._xpub = child.toBase58(); return this._xpub; } - _hdNodeToAddress(hdNode) { + _hdNodeToAddress(hdNode: BIP32Interface): string { return this._nodeToLegacyAddress(hdNode); } - async fetchUtxo() { + async fetchUtxo(): Promise { await super.fetchUtxo(); // now we need to fetch txhash for each input as required by PSBT const txhexes = await BlueElectrum.multiGetTransactionByTxid( this.getUtxo().map(x => x.txid), - 50, false, ); - const newUtxos = []; for (const u of this.getUtxo()) { if (txhexes[u.txid]) u.txhex = txhexes[u.txid]; - newUtxos.push(u); } - - return newUtxos; } - _addPsbtInput(psbt, input, sequence, masterFingerprintBuffer) { + _addPsbtInput(psbt: Psbt, input: CoinSelectReturnInput, sequence: number, masterFingerprintBuffer: Buffer) { + if (!input.address) { + throw new Error('Internal error: no address on Utxo during _addPsbtInput()'); + } const pubkey = this._getPubkeyByAddress(input.address); - const path = this._getDerivationPathByAddress(input.address, 44); + const path = this._getDerivationPathByAddress(input.address); + if (!pubkey || !path) { + throw new Error('Internal error: pubkey or path are invalid'); + } if (!input.txhex) throw new Error('UTXO is missing txhex of the input, which is required by PSBT for non-segwit input'); @@ -97,4 +109,8 @@ export class HDLegacyP2PKHWallet extends AbstractHDElectrumWallet { return psbt; } + + allowSilentPaymentSend(): boolean { + return true; + } } diff --git a/class/wallets/hd-segwit-bech32-wallet.js b/class/wallets/hd-segwit-bech32-wallet.ts similarity index 62% rename from class/wallets/hd-segwit-bech32-wallet.js rename to class/wallets/hd-segwit-bech32-wallet.ts index e86d158ed7..bb51a22c20 100644 --- a/class/wallets/hd-segwit-bech32-wallet.js +++ b/class/wallets/hd-segwit-bech32-wallet.ts @@ -6,19 +6,19 @@ import { AbstractHDElectrumWallet } from './abstract-hd-electrum-wallet'; * @see https://github.com/bitcoin/bips/blob/master/bip-0084.mediawiki */ export class HDSegwitBech32Wallet extends AbstractHDElectrumWallet { - static type = 'HDsegwitBech32'; - static typeReadable = 'HD SegWit (BIP84 Bech32 Native)'; - static segwitType = 'p2wpkh'; - static derivationPath = "m/84'/0'/0'"; + static readonly type = 'HDsegwitBech32'; + static readonly typeReadable = 'HD SegWit (BIP84 Bech32 Native)'; + // @ts-ignore: override + public readonly type = HDSegwitBech32Wallet.type; + // @ts-ignore: override + public readonly typeReadable = HDSegwitBech32Wallet.typeReadable; + public readonly segwitType = 'p2wpkh'; + static readonly derivationPath = "m/84'/0'/0'"; allowSend() { return true; } - allowHodlHodlTrading() { - return true; - } - allowRBF() { return true; } @@ -50,4 +50,8 @@ export class HDSegwitBech32Wallet extends AbstractHDElectrumWallet { allowBIP47() { return true; } + + allowSilentPaymentSend(): boolean { + return true; + } } diff --git a/class/wallets/hd-segwit-electrum-seed-p2wpkh-wallet.js b/class/wallets/hd-segwit-electrum-seed-p2wpkh-wallet.ts similarity index 71% rename from class/wallets/hd-segwit-electrum-seed-p2wpkh-wallet.js rename to class/wallets/hd-segwit-electrum-seed-p2wpkh-wallet.ts index 469830a10e..f06310c148 100644 --- a/class/wallets/hd-segwit-electrum-seed-p2wpkh-wallet.js +++ b/class/wallets/hd-segwit-electrum-seed-p2wpkh-wallet.ts @@ -1,14 +1,19 @@ -import b58 from 'bs58check'; -import { HDSegwitBech32Wallet } from './hd-segwit-bech32-wallet'; import BIP32Factory from 'bip32'; +import * as bitcoin from 'bitcoinjs-lib'; +import b58 from 'bs58check'; +import * as mn from 'electrum-mnemonic'; + import ecc from '../../blue_modules/noble_ecc'; +import { HDSegwitBech32Wallet } from './hd-segwit-bech32-wallet'; -const bitcoin = require('bitcoinjs-lib'); -const mn = require('electrum-mnemonic'); const bip32 = BIP32Factory(ecc); - const PREFIX = mn.PREFIXES.segwit; +type SeedOpts = { + prefix?: string; + passphrase?: string; +}; + /** * ElectrumSeed means that instead of BIP39 seed format it works with the format invented by Electrum wallet. Otherwise * its a regular HD wallet that has all the properties of parent class. @@ -16,9 +21,13 @@ const PREFIX = mn.PREFIXES.segwit; * @see https://electrum.readthedocs.io/en/latest/seedphrase.html */ export class HDSegwitElectrumSeedP2WPKHWallet extends HDSegwitBech32Wallet { - static type = 'HDSegwitElectrumSeedP2WPKHWallet'; - static typeReadable = 'HD Electrum (BIP32 P2WPKH)'; - static derivationPath = "m/0'"; + static readonly type = 'HDSegwitElectrumSeedP2WPKHWallet'; + static readonly typeReadable = 'HD Electrum (BIP32 P2WPKH)'; + // @ts-ignore: override + public readonly type = HDSegwitElectrumSeedP2WPKHWallet.type; + // @ts-ignore: override + public readonly typeReadable = HDSegwitElectrumSeedP2WPKHWallet.typeReadable; + static readonly derivationPath = "m/0'"; validateMnemonic() { return mn.validateMnemonic(this.secret, PREFIX); @@ -36,7 +45,7 @@ export class HDSegwitElectrumSeedP2WPKHWallet extends HDSegwitBech32Wallet { if (this._xpub) { return this._xpub; // cache hit } - const args = { prefix: PREFIX }; + const args: SeedOpts = { prefix: PREFIX }; if (this.passphrase) args.passphrase = this.passphrase; const root = bip32.fromSeed(mn.mnemonicToSeedSync(this.secret, args)); const xpub = root.derivePath("m/0'").neutered().toBase58(); @@ -50,7 +59,7 @@ export class HDSegwitElectrumSeedP2WPKHWallet extends HDSegwitBech32Wallet { return this._xpub; } - _getInternalAddressByIndex(index) { + _getInternalAddressByIndex(index: number) { index = index * 1; // cast to int if (this.internal_addresses_cache[index]) return this.internal_addresses_cache[index]; // cache hit @@ -59,11 +68,14 @@ export class HDSegwitElectrumSeedP2WPKHWallet extends HDSegwitBech32Wallet { const address = bitcoin.payments.p2wpkh({ pubkey: node.derive(1).derive(index).publicKey, }).address; + if (!address) { + throw new Error('Internal error: no address in _getInternalAddressByIndex'); + } return (this.internal_addresses_cache[index] = address); } - _getExternalAddressByIndex(index) { + _getExternalAddressByIndex(index: number) { index = index * 1; // cast to int if (this.external_addresses_cache[index]) return this.external_addresses_cache[index]; // cache hit @@ -72,13 +84,16 @@ export class HDSegwitElectrumSeedP2WPKHWallet extends HDSegwitBech32Wallet { const address = bitcoin.payments.p2wpkh({ pubkey: node.derive(0).derive(index).publicKey, }).address; + if (!address) { + throw new Error('Internal error: no address in _getExternalAddressByIndex'); + } return (this.external_addresses_cache[index] = address); } - _getWIFByIndex(internal, index) { + _getWIFByIndex(internal: boolean, index: number): string | false { if (!this.secret) return false; - const args = { prefix: PREFIX }; + const args: SeedOpts = { prefix: PREFIX }; if (this.passphrase) args.passphrase = this.passphrase; const root = bip32.fromSeed(mn.mnemonicToSeedSync(this.secret, args)); const path = `m/0'/${internal ? 1 : 0}/${index}`; @@ -87,7 +102,7 @@ export class HDSegwitElectrumSeedP2WPKHWallet extends HDSegwitBech32Wallet { return child.toWIF(); } - _getNodePubkeyByIndex(node, index) { + _getNodePubkeyByIndex(node: number, index: number) { index = index * 1; // cast to int if (node === 0 && !this._node0) { @@ -102,13 +117,15 @@ export class HDSegwitElectrumSeedP2WPKHWallet extends HDSegwitBech32Wallet { this._node1 = hdNode.derive(node); } - if (node === 0) { + if (node === 0 && this._node0) { return this._node0.derive(index).publicKey; } - if (node === 1) { + if (node === 1 && this._node1) { return this._node1.derive(index).publicKey; } + + throw new Error('Internal error: this._node0 or this._node1 is undefined'); } isSegwit() { diff --git a/class/wallets/hd-segwit-p2sh-wallet.js b/class/wallets/hd-segwit-p2sh-wallet.ts similarity index 63% rename from class/wallets/hd-segwit-p2sh-wallet.js rename to class/wallets/hd-segwit-p2sh-wallet.ts index 5ed8171502..2c849bff29 100644 --- a/class/wallets/hd-segwit-p2sh-wallet.js +++ b/class/wallets/hd-segwit-p2sh-wallet.ts @@ -1,9 +1,13 @@ +import BIP32Factory, { BIP32Interface } from 'bip32'; +import * as bitcoin from 'bitcoinjs-lib'; +import { Psbt } from 'bitcoinjs-lib'; import b58 from 'bs58check'; -import { AbstractHDElectrumWallet } from './abstract-hd-electrum-wallet'; -import BIP32Factory from 'bip32'; +import { CoinSelectReturnInput } from 'coinselect'; + import ecc from '../../blue_modules/noble_ecc'; +import { AbstractHDElectrumWallet } from './abstract-hd-electrum-wallet'; + const bip32 = BIP32Factory(ecc); -const bitcoin = require('bitcoinjs-lib'); /** * HD Wallet (BIP39). @@ -11,10 +15,14 @@ const bitcoin = require('bitcoinjs-lib'); * @see https://github.com/bitcoin/bips/blob/master/bip-0049.mediawiki */ export class HDSegwitP2SHWallet extends AbstractHDElectrumWallet { - static type = 'HDsegwitP2SH'; - static typeReadable = 'HD SegWit (BIP49 P2SH)'; - static segwitType = 'p2sh(p2wpkh)'; - static derivationPath = "m/49'/0'/0'"; + static readonly type = 'HDsegwitP2SH'; + static readonly typeReadable = 'HD SegWit (BIP49 P2SH)'; + // @ts-ignore: override + public readonly type = HDSegwitP2SHWallet.type; + // @ts-ignore: override + public readonly typeReadable = HDSegwitP2SHWallet.typeReadable; + public readonly segwitType = 'p2sh(p2wpkh)'; + static readonly derivationPath = "m/49'/0'/0'"; allowSend() { return true; @@ -28,10 +36,6 @@ export class HDSegwitP2SHWallet extends AbstractHDElectrumWallet { return true; } - allowHodlHodlTrading() { - return true; - } - allowMasterFingerprint() { return true; } @@ -40,7 +44,7 @@ export class HDSegwitP2SHWallet extends AbstractHDElectrumWallet { return true; } - _hdNodeToAddress(hdNode) { + _hdNodeToAddress(hdNode: BIP32Interface): string { return this._nodeToP2shSegwitAddress(hdNode); } @@ -59,6 +63,9 @@ export class HDSegwitP2SHWallet extends AbstractHDElectrumWallet { const root = bip32.fromSeed(seed); const path = this.getDerivationPath(); + if (!path) { + throw new Error('Internal error: no path'); + } const child = root.derivePath(path).neutered(); const xpub = child.toBase58(); @@ -71,11 +78,20 @@ export class HDSegwitP2SHWallet extends AbstractHDElectrumWallet { return this._xpub; } - _addPsbtInput(psbt, input, sequence, masterFingerprintBuffer) { + _addPsbtInput(psbt: Psbt, input: CoinSelectReturnInput, sequence: number, masterFingerprintBuffer: Buffer) { + if (!input.address) { + throw new Error('Internal error: no address on Utxo during _addPsbtInput()'); + } const pubkey = this._getPubkeyByAddress(input.address); const path = this._getDerivationPathByAddress(input.address); + if (!pubkey || !path) { + throw new Error('Internal error: pubkey or path are invalid'); + } const p2wpkh = bitcoin.payments.p2wpkh({ pubkey }); const p2sh = bitcoin.payments.p2sh({ redeem: p2wpkh }); + if (!p2sh.output) { + throw new Error('Internal error: no p2sh.output during _addPsbtInput()'); + } psbt.addInput({ hash: input.txid, @@ -90,7 +106,7 @@ export class HDSegwitP2SHWallet extends AbstractHDElectrumWallet { ], witnessUtxo: { script: p2sh.output, - value: input.amount || input.value, + value: input.value, }, redeemScript: p2wpkh.output, }); @@ -101,4 +117,8 @@ export class HDSegwitP2SHWallet extends AbstractHDElectrumWallet { isSegwit() { return true; } + + allowSilentPaymentSend(): boolean { + return true; + } } diff --git a/class/wallets/legacy-wallet.ts b/class/wallets/legacy-wallet.ts index e39ae85809..b3b0657cea 100644 --- a/class/wallets/legacy-wallet.ts +++ b/class/wallets/legacy-wallet.ts @@ -1,16 +1,16 @@ import BigNumber from 'bignumber.js'; -import bitcoinMessage from 'bitcoinjs-message'; -import { randomBytes } from '../rng'; -import { AbstractWallet } from './abstract-wallet'; -import { HDSegwitBech32Wallet } from '..'; import * as bitcoin from 'bitcoinjs-lib'; -import * as BlueElectrum from '../../blue_modules/BlueElectrum'; -import coinSelect, { CoinSelectOutput, CoinSelectReturnInput, CoinSelectTarget, CoinSelectUtxo } from 'coinselect'; +import bitcoinMessage from 'bitcoinjs-message'; +import coinSelect, { CoinSelectOutput, CoinSelectReturnInput, CoinSelectTarget } from 'coinselect'; import coinSelectSplit from 'coinselect/split'; -import { CreateTransactionResult, CreateTransactionUtxo, Transaction, Utxo } from './types'; import { ECPairAPI, ECPairFactory, Signer } from 'ecpair'; +import * as BlueElectrum from '../../blue_modules/BlueElectrum'; import ecc from '../../blue_modules/noble_ecc'; +import { HDSegwitBech32Wallet } from '..'; +import { randomBytes } from '../rng'; +import { AbstractWallet } from './abstract-wallet'; +import { CreateTransactionResult, CreateTransactionTarget, CreateTransactionUtxo, Transaction, Utxo } from './types'; const ECPair: ECPairAPI = ECPairFactory(ecc); bitcoin.initEccLib(ecc); @@ -19,8 +19,12 @@ bitcoin.initEccLib(ecc); * (legacy P2PKH compressed) */ export class LegacyWallet extends AbstractWallet { - static type = 'legacy'; - static typeReadable = 'Legacy (P2PKH)'; + static readonly type = 'legacy'; + static readonly typeReadable = 'Legacy (P2PKH)'; + // @ts-ignore: override + public readonly type = LegacyWallet.type; + // @ts-ignore: override + public readonly typeReadable = LegacyWallet.typeReadable; _txs_by_external_index: Transaction[] = []; _txs_by_internal_index: Transaction[] = []; @@ -59,24 +63,12 @@ export class LegacyWallet extends AbstractWallet { } async generateFromEntropy(user: Buffer): Promise { - let i = 0; - do { - i += 1; - const random = await randomBytes(user.length < 32 ? 32 - user.length : 0); - const buf = Buffer.concat([user, random], 32); - try { - this.secret = ECPair.fromPrivateKey(buf).toWIF(); - return; - } catch (e) { - if (i === 5) throw e; - } - } while (true); + if (user.length !== 32) { + throw new Error('Entropy should be 32 bytes'); + } + this.secret = ECPair.fromPrivateKey(user).toWIF(); } - /** - * - * @returns {string} - */ getAddress(): string | false { if (this._address) return this._address; let address; @@ -131,26 +123,25 @@ export class LegacyWallet extends AbstractWallet { const address = this.getAddress(); if (!address) throw new Error('LegacyWallet: Invalid address'); const utxos = await BlueElectrum.multiGetUtxoByAddress([address]); - this.utxo = []; + this._utxo = []; for (const arr of Object.values(utxos)) { - this.utxo = this.utxo.concat(arr); + this._utxo = this._utxo.concat(arr); } // now we need to fetch txhash for each input as required by PSBT if (LegacyWallet.type !== this.type) return; // but only for LEGACY single-address wallets const txhexes = await BlueElectrum.multiGetTransactionByTxid( - this.utxo.map(u => u.txId), - 50, + this._utxo.map(u => u.txid), false, ); const newUtxos = []; - for (const u of this.utxo) { - if (txhexes[u.txId]) u.txhex = txhexes[u.txId]; + for (const u of this._utxo) { + if (txhexes[u.txid]) u.txhex = txhexes[u.txid]; newUtxos.push(u); } - this.utxo = newUtxos; + this._utxo = newUtxos; } catch (error) { console.warn(error); } @@ -161,10 +152,8 @@ export class LegacyWallet extends AbstractWallet { * [ { height: 0, * value: 666, * address: 'string', - * txId: 'string', * vout: 1, * txid: 'string', - * amount: 666, * wif: 'string', * confirmations: 0 } ] * @@ -173,8 +162,7 @@ export class LegacyWallet extends AbstractWallet { */ getUtxo(respectFrozen = false): Utxo[] { let ret: Utxo[] = []; - for (const u of this.utxo) { - if (u.txId) u.txid = u.txId; + for (const u of this._utxo) { if (!u.confirmations && u.height) u.confirmations = BlueElectrum.estimateCurrentBlockheight() - u.height; ret.push(u); } @@ -211,11 +199,9 @@ export class LegacyWallet extends AbstractWallet { const value = new BigNumber(output.value).multipliedBy(100000000).toNumber(); utxos.push({ txid: tx.txid, - txId: tx.txid, vout: output.n, address, value, - amount: value, confirmations: tx.confirmations, wif: false, height: BlueElectrum.estimateCurrentBlockheight() - (tx.confirmations ?? 0), @@ -280,7 +266,7 @@ export class LegacyWallet extends AbstractWallet { // is safe because in that case our cache is filled // next, batch fetching each txid we got - const txdatas = await BlueElectrum.multiGetTransactionByTxid(Object.keys(txs)); + const txdatas = await BlueElectrum.multiGetTransactionByTxid(Object.keys(txs), true); const transactions = Object.values(txdatas); // now, tricky part. we collect all transactions from inputs (vin), and batch fetch them too. @@ -288,10 +274,11 @@ export class LegacyWallet extends AbstractWallet { const vinTxids = []; for (const txdata of transactions) { for (const vin of txdata.vin) { - vinTxids.push(vin.txid); + vin.txid && vinTxids.push(vin.txid); + // ^^^^ not all inputs have txid, some of them are Coinbase (newly-created coins) } } - const vintxdatas = await BlueElectrum.multiGetTransactionByTxid(vinTxids); + const vintxdatas = await BlueElectrum.multiGetTransactionByTxid(vinTxids, true); // fetched all transactions from our inputs. now we need to combine it. // iterating all _our_ transactions: @@ -374,24 +361,21 @@ export class LegacyWallet extends AbstractWallet { } coinselect( - utxos: CoinSelectUtxo[], - targets: CoinSelectTarget[], + utxos: CreateTransactionUtxo[], + targets: CreateTransactionTarget[], feeRate: number, - changeAddress: string, ): { inputs: CoinSelectReturnInput[]; outputs: CoinSelectOutput[]; fee: number; } { - if (!changeAddress) throw new Error('No change address provided'); - let algo = coinSelect; // if targets has output without a value, we want send MAX to it if (targets.some(i => !('value' in i))) { algo = coinSelectSplit; } - const { inputs, outputs, fee } = algo(utxos, targets, feeRate); + const { inputs, outputs, fee } = algo(utxos, targets as CoinSelectTarget[], feeRate); // .inputs and .outputs will be undefined if no solution was found if (!inputs || !outputs) { @@ -403,7 +387,7 @@ export class LegacyWallet extends AbstractWallet { /** * - * @param utxos {Array.<{vout: Number, value: Number, txId: String, address: String, txhex: String, }>} List of spendable utxos + * @param utxos {Array.<{vout: Number, value: Number, txid: String, address: String, txhex: String, }>} List of spendable utxos * @param targets {Array.<{value: Number, address: String}>} Where coins are going. If theres only 1 target and that target has no value - this will send MAX to that address (respecting fee rate) * @param feeRate {Number} satoshi per byte * @param changeAddress {String} Excessive coins will go back to that address @@ -422,18 +406,19 @@ export class LegacyWallet extends AbstractWallet { masterFingerprint: number, ): CreateTransactionResult { if (targets.length === 0) throw new Error('No destination provided'); - const { inputs, outputs, fee } = this.coinselect(utxos, targets, feeRate, changeAddress); + const { inputs, outputs, fee } = this.coinselect(utxos, targets, feeRate); sequence = sequence || 0xffffffff; // disable RBF by default const psbt = new bitcoin.Psbt(); let c = 0; const values: Record = {}; let keyPair: Signer | null = null; + if (!skipSigning) { + // skiping signing related stuff + keyPair = ECPair.fromWIF(this.secret); // secret is WIF + } + inputs.forEach(input => { - if (!skipSigning) { - // skiping signing related stuff - keyPair = ECPair.fromWIF(this.secret); // secret is WIF - } values[c] = input.value; c++; diff --git a/class/wallets/lightning-custodian-wallet.js b/class/wallets/lightning-custodian-wallet.ts similarity index 68% rename from class/wallets/lightning-custodian-wallet.js rename to class/wallets/lightning-custodian-wallet.ts index 9def152da5..3bc9ead1d1 100644 --- a/class/wallets/lightning-custodian-wallet.js +++ b/class/wallets/lightning-custodian-wallet.ts @@ -1,27 +1,34 @@ -import { LegacyWallet } from './legacy-wallet'; -import Frisbee from 'frisbee'; import bolt11 from 'bolt11'; import { BitcoinUnit, Chain } from '../../models/bitcoinUnits'; -import { isTorDaemonDisabled } from '../../blue_modules/environment'; -const torrific = require('../../blue_modules/torrific'); -export class LightningCustodianWallet extends LegacyWallet { - static type = 'lightningCustodianWallet'; - static typeReadable = 'Lightning'; +import { LegacyWallet } from './legacy-wallet'; - constructor(props) { - super(props); - this.setBaseURI(); // no args to init with default value +export class LightningCustodianWallet extends LegacyWallet { + static readonly type = 'lightningCustodianWallet'; + static readonly typeReadable = 'Lightning'; + static readonly subtitleReadable = 'LNDhub'; + // @ts-ignore: override + public readonly type = LightningCustodianWallet.type; + // @ts-ignore: override + public readonly typeReadable = LightningCustodianWallet.typeReadable; + + baseURI?: string; + refresh_token: string = ''; + access_token: string = ''; + _refresh_token_created_ts: number = 0; + _access_token_created_ts: number = 0; + refill_addressess: string[] = []; + pending_transactions_raw: any[] = []; + transactions_raw: any[] = []; + user_invoices_raw: any[] = []; + info_raw = false; + preferredBalanceUnit = BitcoinUnit.SATS; + chain = Chain.OFFCHAIN; + last_paid_invoice_result?: any; + decoded_invoice_raw?: any; + + constructor() { + super(); this.init(); - this.refresh_token = ''; - this.access_token = ''; - this._refresh_token_created_ts = 0; - this._access_token_created_ts = 0; - this.refill_addressess = []; - this.pending_transactions_raw = []; - this.user_invoices_raw = []; - this.info_raw = false; - this.preferredBalanceUnit = BitcoinUnit.SATS; - this.chain = Chain.OFFCHAIN; } /** @@ -29,8 +36,8 @@ export class LightningCustodianWallet extends LegacyWallet { * * @param URI */ - setBaseURI(URI) { - this.baseURI = URI; + setBaseURI(URI: string | undefined) { + this.baseURI = URI?.endsWith('/') ? URI.slice(0, -1) : URI; } getBaseURI() { @@ -41,11 +48,11 @@ export class LightningCustodianWallet extends LegacyWallet { return true; } - getAddress() { + getAddress(): string | false { if (this.refill_addressess.length > 0) { return this.refill_addressess[0]; } else { - return undefined; + return false; } } @@ -61,8 +68,9 @@ export class LightningCustodianWallet extends LegacyWallet { return (+new Date() - this._lastTxFetch) / 1000 > 300; // 5 min } - static fromJson(param) { + static fromJson(param: any) { const obj = super.fromJson(param); + // @ts-ignore: local init obj.init(); return obj; } @@ -71,17 +79,6 @@ export class LightningCustodianWallet extends LegacyWallet { // un-cache refill onchain addresses on cold start. should help for cases when certain lndhub // is turned off permanently, so users cant pull refill address from cache and send money to a black hole this.refill_addressess = []; - - this._api = new Frisbee({ - baseURI: this.baseURI, - }); - const isTorDisabled = await isTorDaemonDisabled(); - - if (!isTorDisabled && this.baseURI && this.baseURI?.indexOf('.onion') !== -1) { - this._api = new torrific.Torsbee({ - baseURI: this.baseURI, - }); - } } accessTokenExpired() { @@ -92,34 +89,37 @@ export class LightningCustodianWallet extends LegacyWallet { return (+new Date() - this._refresh_token_created_ts) / 1000 >= 3600 * 24 * 7; // 7d } - generate() { + generate(): Promise { // nop + return Promise.resolve(); } - async createAccount(isTest) { - const response = await this._api.post('/create', { - body: { partnerid: 'bluewallet', accounttype: (isTest && 'test') || 'common' }, + async createAccount(isTest: boolean = false) { + const response = await fetch(this.baseURI + '/create', { + method: 'POST', + body: JSON.stringify({ partnerid: 'bluewallet', accounttype: (isTest && 'test') || 'common' }), headers: { 'Access-Control-Allow-Origin': '*', 'Content-Type': 'application/json' }, }); - const json = response.body; - if (typeof json === 'undefined') { - throw new Error('API failure: ' + response.err + ' ' + JSON.stringify(response.body)); + const json = await response.json(); + if (!json) { + throw new Error('API failure: ' + response.statusText); } - if (json && json.error) { + if (json.error) { throw new Error('API error: ' + (json.message ? json.message : json.error) + ' (code ' + json.code + ')'); } if (!json.login || !json.password) { - throw new Error('API unexpected response: ' + JSON.stringify(response.body)); + throw new Error('API unexpected response: ' + JSON.stringify(json)); } this.secret = 'lndhub://' + json.login + ':' + json.password; } - async payInvoice(invoice, freeAmount = 0) { - const response = await this._api.post('/payinvoice', { - body: { invoice, amount: freeAmount }, + async payInvoice(invoice: string, freeAmount: number = 0) { + const response = await fetch(this.baseURI + '/payinvoice', { + method: 'POST', + body: JSON.stringify({ invoice, amount: freeAmount }), headers: { 'Access-Control-Allow-Origin': '*', 'Content-Type': 'application/json', @@ -127,22 +127,12 @@ export class LightningCustodianWallet extends LegacyWallet { }, }); - if (response.originalResponse && typeof response.originalResponse === 'string') { - try { - response.originalResponse = JSON.parse(response.originalResponse); - } catch (_) {} + const json = await response.json(); + if (!json) { + throw new Error('API failure: ' + response.statusText); } - if (response.originalResponse && response.originalResponse.status && response.originalResponse.status === 503) { - throw new Error('Payment is in transit'); - } - - const json = response.body; - if (typeof json === 'undefined') { - throw new Error('API failure: ' + response.err + ' ' + JSON.stringify(response.originalResponse)); - } - - if (json && json.error) { + if (json.error) { throw new Error('API error: ' + json.message + ' (code ' + json.code + ')'); } @@ -154,22 +144,23 @@ export class LightningCustodianWallet extends LegacyWallet { * * @return {Promise.} */ - async getUserInvoices(limit = false) { + async getUserInvoices(limit: number | false = false) { let limitString = ''; - if (limit) limitString = '?limit=' + parseInt(limit, 10); - const response = await this._api.get('/getuserinvoices' + limitString, { + if (limit) limitString = '?limit=' + parseInt(limit as unknown as string, 10); + const response = await fetch(this.baseURI + '/getuserinvoices' + limitString, { + method: 'GET', headers: { 'Access-Control-Allow-Origin': '*', 'Content-Type': 'application/json', Authorization: 'Bearer' + ' ' + this.access_token, }, }); - const json = response.body; - if (typeof json === 'undefined') { - throw new Error('API failure: ' + response.err + ' ' + JSON.stringify(response.originalResponse)); + const json = await response.json(); + if (!json) { + throw new Error('API failure: ' + response.statusText); } - if (json && json.error) { + if (json.error) { throw new Error('API error: ' + json.message + ' (code ' + json.code + ')'); } @@ -192,7 +183,7 @@ export class LightningCustodianWallet extends LegacyWallet { } } - this.user_invoices_raw = json.sort(function (a, b) { + this.user_invoices_raw = json.sort(function (a: { timestamp: number }, b: { timestamp: number }) { return a.timestamp - b.timestamp; }); @@ -209,34 +200,35 @@ export class LightningCustodianWallet extends LegacyWallet { await this.getUserInvoices(); } - isInvoiceGeneratedByWallet(paymentRequest) { + isInvoiceGeneratedByWallet(paymentRequest: string) { return this.user_invoices_raw.some(invoice => invoice.payment_request === paymentRequest); } - weOwnAddress(address) { + weOwnAddress(address: string) { return this.refill_addressess.some(refillAddress => address === refillAddress); } - async addInvoice(amt, memo) { - const response = await this._api.post('/addinvoice', { - body: { amt: amt + '', memo }, + async addInvoice(amt: number, memo: string) { + const response = await fetch(this.baseURI + '/addinvoice', { + method: 'POST', + body: JSON.stringify({ amt: amt + '', memo }), headers: { 'Access-Control-Allow-Origin': '*', 'Content-Type': 'application/json', Authorization: 'Bearer' + ' ' + this.access_token, }, }); - const json = response.body; - if (typeof json === 'undefined') { - throw new Error('API failure: ' + response.err + ' ' + JSON.stringify(response.originalResponse)); + const json = await response.json(); + if (!json) { + throw new Error('API failure: ' + response.statusText); } - if (json && json.error) { + if (json.error) { throw new Error('API error: ' + json.message + ' (code ' + json.code + ')'); } if (!json.r_hash || !json.pay_req) { - throw new Error('API unexpected response: ' + JSON.stringify(response.body)); + throw new Error('API unexpected response: ' + JSON.stringify(json)); } return json.pay_req; @@ -257,22 +249,23 @@ export class LightningCustodianWallet extends LegacyWallet { login = this.secret.replace('lndhub://', '').split(':')[0]; password = this.secret.replace('lndhub://', '').split(':')[1]; } - const response = await this._api.post('/auth?type=auth', { - body: { login, password }, + const response = await fetch(this.baseURI + '/auth?type=auth', { + method: 'POST', + body: JSON.stringify({ login, password }), headers: { 'Access-Control-Allow-Origin': '*', 'Content-Type': 'application/json' }, }); - const json = response.body; - if (typeof json === 'undefined') { - throw new Error('API failure: ' + response.err + ' ' + JSON.stringify(response.body)); + const json = await response.json(); + if (!json) { + throw new Error('API failure: ' + response.statusText); } - if (json && json.error) { + if (json.error) { throw new Error('API error: ' + json.message + ' (code ' + json.code + ')'); } if (!json.access_token || !json.refresh_token) { - throw new Error('API unexpected response: ' + JSON.stringify(response.body)); + throw new Error('API unexpected response: ' + JSON.stringify(json)); } this.refresh_token = json.refresh_token; @@ -304,22 +297,23 @@ export class LightningCustodianWallet extends LegacyWallet { } async refreshAcessToken() { - const response = await this._api.post('/auth?type=refresh_token', { - body: { refresh_token: this.refresh_token }, + const response = await fetch(this.baseURI + '/auth?type=refresh_token', { + method: 'POST', + body: JSON.stringify({ refresh_token: this.refresh_token }), headers: { 'Access-Control-Allow-Origin': '*', 'Content-Type': 'application/json' }, }); - const json = response.body; - if (typeof json === 'undefined') { - throw new Error('API failure: ' + response.err + ' ' + JSON.stringify(response.body)); + const json = await response.json(); + if (!json) { + throw new Error('API failure: ' + response.statusText); } - if (json && json.error) { + if (json.error) { throw new Error('API error: ' + json.message + ' (code ' + json.code + ')'); } if (!json.access_token || !json.refresh_token) { - throw new Error('API unexpected response: ' + JSON.stringify(response.body)); + throw new Error('API unexpected response: ' + JSON.stringify(json)); } this.refresh_token = json.refresh_token; @@ -329,7 +323,8 @@ export class LightningCustodianWallet extends LegacyWallet { } async fetchBtcAddress() { - const response = await this._api.get('/getbtc', { + const response = await fetch(this.baseURI + '/getbtc', { + method: 'GET', headers: { 'Access-Control-Allow-Origin': '*', 'Content-Type': 'application/json', @@ -337,12 +332,12 @@ export class LightningCustodianWallet extends LegacyWallet { }, }); - const json = response.body; - if (typeof json === 'undefined') { - throw new Error('API failure: ' + response.err + ' ' + JSON.stringify(response.body)); + const json = await response.json(); + if (!json) { + throw new Error('API failure: ' + response.statusText); } - if (json && json.error) { + if (json.error) { throw new Error('API error: ' + json.message + ' (code ' + json.code + ')'); } @@ -368,12 +363,9 @@ export class LightningCustodianWallet extends LegacyWallet { } getTransactions() { - let txs = []; - this.pending_transactions_raw = this.pending_transactions_raw || []; - this.user_invoices_raw = this.user_invoices_raw || []; - this.transactions_raw = this.transactions_raw || []; + let txs: any = []; txs = txs.concat(this.pending_transactions_raw.slice(), this.transactions_raw.slice().reverse(), this.user_invoices_raw.slice()); // slice so array is cloned - // transforming to how wallets/list screen expects it + for (const tx of txs) { tx.walletID = this.getID(); if (tx.amount) { @@ -386,7 +378,7 @@ export class LightningCustodianWallet extends LegacyWallet { if (typeof tx.amt !== 'undefined' && typeof tx.fee !== 'undefined') { // lnd tx outgoing - tx.value = parseInt((tx.amt * 1 + tx.fee * 1) * -1, 10); + tx.value = (tx.amt * 1 + tx.fee * 1) * -1; } if (tx.type === 'paid_invoice') { @@ -407,13 +399,14 @@ export class LightningCustodianWallet extends LegacyWallet { tx.received = new Date(tx.timestamp * 1000).toString(); } - return txs.sort(function (a, b) { + return txs.sort(function (a: { timestamp: number }, b: { timestamp: number }) { return b.timestamp - a.timestamp; }); } async fetchPendingTransactions() { - const response = await this._api.get('/getpending', { + const response = await fetch(this.baseURI + '/getpending', { + method: 'GET', headers: { 'Access-Control-Allow-Origin': '*', 'Content-Type': 'application/json', @@ -421,12 +414,12 @@ export class LightningCustodianWallet extends LegacyWallet { }, }); - const json = response.body; - if (typeof json === 'undefined') { - throw new Error('API failure: ' + response.err + ' ' + JSON.stringify(response)); + const json = await response.json(); + if (!json) { + throw new Error('API failure: ' + response.statusText); } - if (json && json.error) { + if (json.error) { throw new Error('API error: ' + json.message + ' (code ' + json.code + ')'); } @@ -441,7 +434,8 @@ export class LightningCustodianWallet extends LegacyWallet { queryRes += '?limit=' + limit; queryRes += '&offset=' + offset; - const response = await this._api.get('/gettxs' + queryRes, { + const response = await fetch(this.baseURI + '/gettxs' + queryRes, { + method: 'GET', headers: { 'Access-Control-Allow-Origin': '*', 'Content-Type': 'application/json', @@ -449,17 +443,17 @@ export class LightningCustodianWallet extends LegacyWallet { }, }); - const json = response.body; - if (typeof json === 'undefined') { - throw new Error('API failure: ' + response.err + ' ' + JSON.stringify(response.body)); + const json = await response.json(); + if (!json) { + throw new Error('API failure: ' + response.statusText); } - if (json && json.error) { + if (json.error) { throw new Error('API error: ' + json.message + ' (code ' + json.code + ')'); } if (!Array.isArray(json)) { - throw new Error('API unexpected response: ' + JSON.stringify(response.body)); + throw new Error('API unexpected response: ' + JSON.stringify(json)); } this._lastTxFetch = +new Date(); @@ -470,10 +464,11 @@ export class LightningCustodianWallet extends LegacyWallet { return this.balance; } - async fetchBalance(noRetry) { + async fetchBalance(noRetry?: boolean): Promise { await this.checkLogin(); - const response = await this._api.get('/balance', { + const response = await fetch(this.baseURI + '/balance', { + method: 'GET', headers: { 'Access-Control-Allow-Origin': '*', 'Content-Type': 'application/json', @@ -481,12 +476,12 @@ export class LightningCustodianWallet extends LegacyWallet { }, }); - const json = response.body; - if (typeof json === 'undefined') { - throw new Error('API failure: ' + response.err + ' ' + JSON.stringify(response.body)); + const json = await response.json(); + if (!json) { + throw new Error('API failure: ' + response.statusText); } - if (json && json.error) { + if (json.error) { if (json.code * 1 === 1 && !noRetry) { await this.authorize(); return this.fetchBalance(true); @@ -495,10 +490,9 @@ export class LightningCustodianWallet extends LegacyWallet { } if (!json.BTC || typeof json.BTC.AvailableBalance === 'undefined') { - throw new Error('API unexpected response: ' + JSON.stringify(response.body)); + throw new Error('API unexpected response: ' + JSON.stringify(json)); } - this.balance_raw = json; this.balance = json.BTC.AvailableBalance; this._lastBalanceFetch = +new Date(); } @@ -519,14 +513,14 @@ export class LightningCustodianWallet extends LegacyWallet { * @param invoice BOLT invoice string * @return {payment_hash: string} */ - decodeInvoice(invoice) { + decodeInvoice(invoice: string) { const { payeeNodeKey, tags, satoshis, millisatoshis, timestamp } = bolt11.decode(invoice); - const decoded = { + const decoded: any = { destination: payeeNodeKey, num_satoshis: satoshis ? satoshis.toString() : '0', num_millisatoshis: millisatoshis ? millisatoshis.toString() : '0', - timestamp: timestamp.toString(), + timestamp: timestamp?.toString() ?? '0', fallback_addr: '', route_hints: [], }; @@ -562,7 +556,8 @@ export class LightningCustodianWallet extends LegacyWallet { } async fetchInfo() { - const response = await this._api.get('/getinfo', { + const response = await fetch(this.baseURI + '/getinfo', { + method: 'GET', headers: { 'Access-Control-Allow-Origin': '*', 'Content-Type': 'application/json', @@ -570,46 +565,40 @@ export class LightningCustodianWallet extends LegacyWallet { }, }); - const json = response.body; - if (typeof json === 'undefined') { - throw new Error('API failure: ' + response.err + ' ' + JSON.stringify(response.body)); + const json = await response.json(); + if (!json) { + throw new Error('API failure: ' + response.statusText); } - if (json && json.error) { + if (json.error) { throw new Error('API error: ' + json.message + ' (code ' + json.code + ')'); } if (!json.identity_pubkey) { - throw new Error('API unexpected response: ' + JSON.stringify(response.body)); - } - this.info_raw = json; - } - - static async isValidNodeAddress(address) { - const isTorDisabled = await isTorDaemonDisabled(); - const isTor = address.indexOf('.onion') !== -1; - const apiCall = - isTor && !isTorDisabled - ? new torrific.Torsbee({ - baseURI: address, - }) - : new Frisbee({ - baseURI: address, - }); - const response = await apiCall.get('/getinfo', { + throw new Error('API unexpected response: ' + JSON.stringify(json)); + } + } + + static async isValidNodeAddress(address: string): Promise { + const normalizedAddress = new URL('/getinfo', address.replace(/([^:]\/)\/+/g, '$1')); + + const response = await fetch(normalizedAddress.toString(), { + method: 'GET', headers: { 'Access-Control-Allow-Origin': '*', 'Content-Type': 'application/json', }, }); - const json = response.body; - if (typeof json === 'undefined') { - throw new Error('API failure: ' + response.err + ' ' + JSON.stringify(response.body)); + + const json = await response.json(); + if (!json) { + throw new Error('API failure: ' + response.statusText); } - if (json && json.code && json.code !== 1) { + if (json.code && json.code !== 1) { throw new Error('API error: ' + json.message + ' (code ' + json.code + ')'); } + return true; } @@ -637,10 +626,11 @@ export class LightningCustodianWallet extends LegacyWallet { * @param invoice BOLT invoice string * @return {Promise.} */ - async decodeInvoiceRemote(invoice) { + async decodeInvoiceRemote(invoice: string) { await this.checkLogin(); - const response = await this._api.get('/decodeinvoice?invoice=' + invoice, { + const response = await fetch(this.baseURI + '/decodeinvoice?invoice=' + invoice, { + method: 'GET', headers: { 'Access-Control-Allow-Origin': '*', 'Content-Type': 'application/json', @@ -648,23 +638,23 @@ export class LightningCustodianWallet extends LegacyWallet { }, }); - const json = response.body; - if (typeof json === 'undefined') { - throw new Error('API failure: ' + response.err + ' ' + JSON.stringify(response.body)); + const json = await response.json(); + if (!json) { + throw new Error('API failure: ' + response.statusText); } - if (json && json.error) { + if (json.error) { throw new Error('API error: ' + json.message + ' (code ' + json.code + ')'); } if (!json.payment_hash) { - throw new Error('API unexpected response: ' + JSON.stringify(response.body)); + throw new Error('API unexpected response: ' + JSON.stringify(json)); } return (this.decoded_invoice_raw = json); } - weOwnTransaction(txid) { + weOwnTransaction(txid: string) { for (const tx of this.getTransactions()) { if (tx && tx.payment_hash && tx.payment_hash === txid) return true; } @@ -672,7 +662,7 @@ export class LightningCustodianWallet extends LegacyWallet { return false; } - authenticate(lnurl) { + authenticate(lnurl: any) { return lnurl.authenticate(this.secret); } } diff --git a/class/wallets/lightning-ldk-wallet.ts b/class/wallets/lightning-ldk-wallet.ts deleted file mode 100644 index 8a84ab68f2..0000000000 --- a/class/wallets/lightning-ldk-wallet.ts +++ /dev/null @@ -1,691 +0,0 @@ -import RNFS from 'react-native-fs'; -import { BitcoinUnit, Chain } from '../../models/bitcoinUnits'; -import RnLdk from 'rn-ldk/src/index'; -import { LightningCustodianWallet } from './lightning-custodian-wallet'; -import SyncedAsyncStorage from '../synced-async-storage'; -import { randomBytes } from '../rng'; -import * as bip39 from 'bip39'; -import { HDSegwitBech32Wallet } from './hd-segwit-bech32-wallet'; -import bolt11 from 'bolt11'; -import { SegwitBech32Wallet } from './segwit-bech32-wallet'; -import alert from '../../components/Alert'; -const bitcoin = require('bitcoinjs-lib'); - -export class LightningLdkWallet extends LightningCustodianWallet { - static type = 'lightningLdk'; - static typeReadable = 'Lightning LDK'; - private _listChannels: any[] = []; - private _listPayments: any[] = []; - private _listInvoices: any[] = []; - private _nodeConnectionDetailsCache: any = {}; // pubkey -> {pubkey, host, port, ts} - private _refundAddressScriptHex: string = ''; - private _lastTimeBlockchainCheckedTs: number = 0; - private _unwrapFirstExternalAddressFromMnemonicsCache: string = ''; - private static _predefinedNodes: Record = { - Bitrefill: '03d607f3e69fd032524a867b288216bfab263b6eaee4e07783799a6fe69bb84fac@3.237.23.179:9735', - 'OpenNode.com': '03abf6f44c355dec0d5aa155bdbdd6e0c8fefe318eff402de65c6eb2e1be55dc3e@3.132.230.42:9735', - Fold: '02816caed43171d3c9854e3b0ab2cf0c42be086ff1bd4005acc2a5f7db70d83774@35.238.153.25:9735', - 'Moon (paywithmoon.com)': '025f1456582e70c4c06b61d5c8ed3ce229e6d0db538be337a2dc6d163b0ebc05a5@52.86.210.65:9735', - 'coingate.com': '0242a4ae0c5bef18048fbecf995094b74bfb0f7391418d71ed394784373f41e4f3@3.124.63.44:9735', - 'Blockstream Store': '02df5ffe895c778e10f7742a6c5b8a0cefbe9465df58b92fadeb883752c8107c8f@35.232.170.67:9735', - ACINQ: '03864ef025fde8fb587d989186ce6a4a186895ee44a926bfc370e2c366597a3f8f@3.33.236.230:9735', - }; - - static getPredefinedNodes() { - return LightningLdkWallet._predefinedNodes; - } - - static pubkeyToAlias(pubkeyHex: string) { - for (const key of Object.keys(LightningLdkWallet._predefinedNodes)) { - const val = LightningLdkWallet._predefinedNodes[key]; - if (val.startsWith(pubkeyHex)) return key; - } - - return pubkeyHex; - } - - constructor(props: any) { - super(props); - this.preferredBalanceUnit = BitcoinUnit.SATS; - this.chain = Chain.OFFCHAIN; - this.user_invoices_raw = []; // compatibility with other lightning wallet class - } - - valid() { - try { - const entropy = bip39.mnemonicToEntropy(this.secret.replace('ldk://', '')); - return entropy.length === 64 || entropy.length === 32; - } catch (_) {} - - return false; - } - - async stop() { - return RnLdk.stop(); - } - - async wipeLndDir() {} - - async listPeers() { - return RnLdk.listPeers(); - } - - async listChannels() { - try { - // exception might be in case of incompletely-started LDK. then just ignore and return cached version - this._listChannels = await RnLdk.listChannels(); - } catch (_) {} - - return this._listChannels; - } - - async getLndTransactions() { - return []; - } - - async getInfo() { - const identityPubkey = await RnLdk.getNodeId(); - return { - identityPubkey, - }; - } - - allowSend() { - return true; - } - - timeToCheckBlockchain() { - return +new Date() - this._lastTimeBlockchainCheckedTs > 5 * 60 * 1000; // 5 min, half of block time - } - - async fundingStateStepFinalize(txhex: string) { - return RnLdk.openChannelStep2(txhex); - } - - async getMaturingBalance(): Promise { - return RnLdk.getMaturingBalance(); - } - - async getMaturingHeight(): Promise { - return RnLdk.getMaturingHeight(); - } - - /** - * Probes getNodeId() call. if its available - LDK has started - * - * @return {Promise} - */ - async isStarted() { - let rez; - try { - rez = await Promise.race([new Promise(resolve => setTimeout(() => resolve('timeout'), 1000)), RnLdk.getNodeId()]); - } catch (_) {} - - if (rez === 'timeout' || !rez) { - return false; - } - - return true; - } - - /** - * Waiter till getNodeId() starts to respond. Returns true if it eventually does, - * false in case of timeout. - * - * @return {Promise} - */ - async waitTillStarted() { - for (let c = 0; c < 30; c++) { - if (await this.isStarted()) return true; - await new Promise(resolve => setTimeout(resolve, 500)); // sleep - } - - return false; - } - - async openChannel(pubkeyHex: string, host: string, amountSats: number, privateChannel: boolean) { - let triedToConnect = false; - let port = 9735; - - if (host.includes(':')) { - const splitted = host.split(':'); - host = splitted[0]; - port = +splitted[1]; - } - - for (let c = 0; c < 20; c++) { - const peers = await this.listPeers(); - if (peers.includes(pubkeyHex)) { - // all good, connected, lets open channel - return await RnLdk.openChannelStep1(pubkeyHex, +amountSats); - } - - if (!triedToConnect) { - triedToConnect = true; - await RnLdk.connectPeer(pubkeyHex, host, +port); - } - - await new Promise(resolve => setTimeout(resolve, 500)); // sleep - } - - throw new Error('timeout waiting for peer connection'); - } - - async connectPeer(pubkeyHex: string, host: string, port: number) { - return RnLdk.connectPeer(pubkeyHex, host, +port); - } - - async lookupNodeConnectionDetailsByPubkey(pubkey: string) { - // first, trying cache: - if (this._nodeConnectionDetailsCache[pubkey] && +new Date() - this._nodeConnectionDetailsCache[pubkey].ts < 4 * 7 * 24 * 3600 * 1000) { - // cache hit - return this._nodeConnectionDetailsCache[pubkey]; - } - - // doing actual fetch and filling cache: - const response = await fetch(`https://1ml.com/node/${pubkey}/json`); - const json = await response.json(); - if (json && json.addresses && Array.isArray(json.addresses)) { - for (const address of json.addresses) { - if (address.network === 'tcp') { - const ret = { - pubkey, - host: address.addr.split(':')[0], - port: parseInt(address.addr.split(':')[1], 10), - }; - - this._nodeConnectionDetailsCache[pubkey] = Object.assign({}, ret, { ts: +new Date() }); - - return ret; - } - } - } - } - - getAddress() { - return undefined; - } - - getSecret() { - return this.secret; - } - - timeToRefreshBalance() { - return (+new Date() - this._lastBalanceFetch) / 1000 > 300; // 5 min - } - - timeToRefreshTransaction() { - return (+new Date() - this._lastTxFetch) / 1000 > 300; // 5 min - } - - async generate() { - const buf = await randomBytes(16); - this.secret = 'ldk://' + bip39.entropyToMnemonic(buf.toString('hex')); - } - - getEntropyHex() { - let ret = bip39.mnemonicToEntropy(this.secret.replace('ldk://', '')); - while (ret.length < 64) ret = '0' + ret; - return ret; - } - - getStorageNamespace() { - return RnLdk.getStorage().namespace; - } - - static async _decodeInvoice(invoice: string) { - return bolt11.decode(invoice); - } - - static async _script2address(scriptHex: string) { - return bitcoin.address.fromOutputScript(Buffer.from(scriptHex, 'hex')); - } - - async selftest() { - await RnLdk.getStorage().selftest(); - await RnLdk.selftest(); - } - - async init() { - if (!this.getSecret()) return; - console.warn('starting ldk'); - - try { - // providing simple functions that RnLdk would otherwise rely on 3rd party APIs - RnLdk.provideDecodeInvoiceFunc(LightningLdkWallet._decodeInvoice); - RnLdk.provideScript2addressFunc(LightningLdkWallet._script2address); - const syncedStorage = new SyncedAsyncStorage(this.getEntropyHex()); - // await syncedStorage.selftest(); - // await RnLdk.selftest(); - // console.warn('selftest passed'); - await syncedStorage.synchronize(); - - RnLdk.setStorage(syncedStorage); - if (this._refundAddressScriptHex) { - await RnLdk.setRefundAddressScript(this._refundAddressScriptHex); - } else { - // fallback, unwrapping address from bip39 mnemonic we have - const address = this.unwrapFirstExternalAddressFromMnemonics(); - await this.setRefundAddress(address); - } - await RnLdk.start(this.getEntropyHex(), RNFS.DocumentDirectoryPath); - - this._execInBackground(this.reestablishChannels); - if (this.timeToCheckBlockchain()) this._execInBackground(this.checkBlockchain); - } catch (error: any) { - alert('LDK init error: ' + error.message); - } - } - - unwrapFirstExternalAddressFromMnemonics() { - if (this._unwrapFirstExternalAddressFromMnemonicsCache) return this._unwrapFirstExternalAddressFromMnemonicsCache; // cache hit - const hd = new HDSegwitBech32Wallet(); - hd.setSecret(this.getSecret().replace('ldk://', '')); - const address = hd._getExternalAddressByIndex(0); - this._unwrapFirstExternalAddressFromMnemonicsCache = address; - return address; - } - - unwrapFirstExternalWIFFromMnemonics() { - const hd = new HDSegwitBech32Wallet(); - hd.setSecret(this.getSecret().replace('ldk://', '')); - return hd._getExternalWIFByIndex(0); - } - - async checkBlockchain() { - this._lastTimeBlockchainCheckedTs = +new Date(); - return RnLdk.checkBlockchain(); - } - - async payInvoice(invoice: string, freeAmount = 0) { - const decoded = this.decodeInvoice(invoice); - - // if its NOT zero amount invoice, we forcefully reset passed amount argument so underlying LDK code - // would extract amount from bolt11 - if (decoded.num_satoshis && parseInt(decoded.num_satoshis, 10) > 0) freeAmount = 0; - - if (await this.channelsNeedReestablish()) { - await this.reestablishChannels(); - await this.waitForAtLeastOneChannelBecomeActive(); - } - - const result = await RnLdk.payInvoice(invoice, freeAmount); - if (!result) throw new Error('Failed'); - - // ok, it was sent. now, waiting for an event that it was _actually_ paid: - for (let c = 0; c < 60; c++) { - await new Promise(resolve => setTimeout(resolve, 500)); // sleep - - for (const sentPayment of RnLdk.sentPayments || []) { - const paidHash = LightningLdkWallet.preimage2hash(sentPayment.payment_preimage); - if (paidHash === decoded.payment_hash) { - this._listPayments = this._listPayments || []; - this._listPayments.push( - Object.assign({}, sentPayment, { - memo: decoded.description || 'Lightning payment', - value: (freeAmount || decoded.num_satoshis) * -1, - received: +new Date(), - payment_preimage: sentPayment.payment_preimage, - payment_hash: decoded.payment_hash, - }), - ); - return; - } - } - - for (const failedPayment of RnLdk.failedPayments || []) { - if (failedPayment.payment_hash === decoded.payment_hash) throw new Error(JSON.stringify(failedPayment)); - } - } - - // no? lets just throw timeout error - throw new Error('Payment timeout'); - } - - /** - * In case user initiated channel opening, and then lost peer connection (i.e. app went in background for an - * extended period of time), when user gets back to the app the channel might already have enough confirmations, - * but will never be acknowledged as 'established' by LDK until peer reconnects so that ldk & peer can negotiate and - * agree that channel is now established - */ - async reconnectPeersWithPendingChannels() { - const peers = await RnLdk.listPeers(); - const peers2reconnect: Record = {}; - if (this._listChannels) { - for (const channel of this._listChannels) { - if (!channel.is_funding_locked) { - // pending channel - if (!peers.includes(channel.remote_node_id)) peers2reconnect[channel.remote_node_id] = true; - } - } - } - - for (const pubkey of Object.keys(peers2reconnect)) { - const { host, port } = await this.lookupNodeConnectionDetailsByPubkey(pubkey); - await this.connectPeer(pubkey, host, port); - } - } - - async getUserInvoices(limit = false) { - const newInvoices: any[] = []; - let found = false; - - // okay, so the idea is that `this._listInvoices` is a persistant storage of invoices, while - // `RnLdk.receivedPayments` is only a temp storage of emited events - - // we iterate through all stored invoices - for (const invoice of this._listInvoices) { - const newInvoice = Object.assign({}, invoice); - - // iterate through events of received payments - for (const receivedPayment of RnLdk.receivedPayments || []) { - if (receivedPayment.payment_hash === invoice.payment_hash) { - // match! this particular payment was paid - newInvoice.ispaid = true; - newInvoice.value = Math.floor(parseInt(receivedPayment.amt, 10) / 1000); - found = true; - } - } - - newInvoices.push(newInvoice); - } - - // overwrite stored array if flag was set - if (found) this._listInvoices = newInvoices; - - return this._listInvoices; - } - - isInvoiceGeneratedByWallet(paymentRequest: string) { - return Boolean(this?._listInvoices?.some(invoice => invoice.payment_request === paymentRequest)); - } - - weOwnAddress(address: string) { - return false; - } - - async addInvoice(amtSat: number, memo: string) { - if (await this.channelsNeedReestablish()) { - await this.reestablishChannels(); - await this.waitForAtLeastOneChannelBecomeActive(); - } - - if (this.getReceivableBalance() < amtSat) throw new Error('You dont have enough inbound capacity'); - - const paymentRequest = await RnLdk.addInvoice(amtSat * 1000, memo); - if (!paymentRequest) return false; - - const decoded = this.decodeInvoice(paymentRequest); - - this._listInvoices = this._listInvoices || []; - const tx = { - payment_request: paymentRequest, - ispaid: false, - timestamp: +new Date(), - expire_time: 3600 * 1000, - amt: amtSat, - type: 'user_invoice', - payment_hash: decoded.payment_hash, - description: memo || '', - }; - this._listInvoices.push(tx); - - return paymentRequest; - } - - async getAddressAsync() { - throw new Error('getAddressAsync: Not implemented'); - } - - async allowOnchainAddress(): Promise { - throw new Error('allowOnchainAddress: Not implemented'); - } - - getTransactions() { - const ret = []; - - for (const payment of this?._listPayments || []) { - const newTx = Object.assign({}, payment, { - type: 'paid_invoice', - walletID: this.getID(), - }); - ret.push(newTx); - } - - // ############################################ - - for (const invoice of this?._listInvoices || []) { - const tx = { - payment_request: invoice.payment_request, - ispaid: invoice.ispaid, - received: invoice.timestamp, - type: invoice.type, - value: invoice.value || invoice.amt, - memo: invoice.description, - timestamp: invoice.timestamp, // important - expire_time: invoice.expire_time, // important - walletID: this.getID(), - }; - - if (tx.ispaid || invoice.timestamp + invoice.expire_time > +new Date()) { - // expired non-paid invoices are not shown - ret.push(tx); - } - } - - ret.sort(function (a, b) { - return b.received - a.received; - }); - - return ret; - } - - async fetchTransactions() { - if (this.timeToCheckBlockchain()) { - try { - // exception might be in case of incompletely-started LDK - this._listChannels = await RnLdk.listChannels(); - await this.checkBlockchain(); - // ^^^ will be executed if above didnt throw exceptions, which means ldk fully started. - // we need this for a case when app returns from background if it was in bg for a really long time. - // ldk needs to update it's blockchain data, and this is practically the only place where it can - // do that (except on cold start) - } catch (_) {} - } - - try { - await this.reconnectPeersWithPendingChannels(); - } catch (error: any) { - console.log('fetchTransactions failed'); - console.log(error.message); - } - - await this.getUserInvoices(); // it internally updates paid user invoices - } - - getBalance() { - let sum = 0; - if (this._listChannels) { - for (const channel of this._listChannels) { - if (!channel.is_funding_locked) continue; // pending channel - sum += Math.floor(parseInt(channel.outbound_capacity_msat, 10) / 1000); - } - } - - return sum; - } - - getReceivableBalance() { - let sum = 0; - if (this._listChannels) { - for (const channel of this._listChannels) { - if (!channel.is_funding_locked) continue; // pending channel - sum += Math.floor(parseInt(channel.inbound_capacity_msat, 10) / 1000); - } - } - return sum; - } - - /** - * This method checks if there is balance on first unwapped address we have. - * This address is a fallback in case user has _no_ other wallets to withdraw onchain coins to, so closed-channel - * funds land on this address. Ofcourse, if user provided us a withdraw address, it should be stored in - * `this._refundAddressScriptHex` and its balance frankly is not our concern. - * - * @return {Promise<{confirmedBalance: number}>} - */ - async walletBalance() { - let confirmedSat = 0; - if (this._unwrapFirstExternalAddressFromMnemonicsCache) { - const response = await fetch('https://blockstream.info/api/address/' + this._unwrapFirstExternalAddressFromMnemonicsCache + '/utxo'); - const json = await response.json(); - if (json && Array.isArray(json)) { - for (const utxo of json) { - if (utxo?.status?.confirmed) { - confirmedSat += parseInt(utxo.value, 10); - } - } - } - } - - return { confirmedBalance: confirmedSat }; - } - - async fetchBalance() { - await this.listChannels(); // updates channels - } - - async claimCoins(address: string) { - console.log('unwrapping wif...'); - const wif = this.unwrapFirstExternalWIFFromMnemonics(); - const wallet = new SegwitBech32Wallet(); - wallet.setSecret(String(wif)); - console.log('fetching balance...'); - await wallet.fetchUtxo(); - console.log(wallet.getBalance(), wallet.getUtxo()); - console.log('creating transation...'); - const { tx } = wallet.createTransaction(wallet.getUtxo(), [{ address }], 2, address, 0, false, 0); - if (!tx) throw new Error('claimCoins: could not create transaction'); - console.log('broadcasting...'); - return await wallet.broadcastTx(tx.toHex()); - } - - async fetchInfo() { - throw new Error('fetchInfo: Not implemented'); - } - - allowReceive() { - return true; - } - - async closeChannel(fundingTxidHex: string, force = false) { - return force ? await RnLdk.closeChannelForce(fundingTxidHex) : await RnLdk.closeChannelCooperatively(fundingTxidHex); - } - - getLatestTransactionTime(): string | 0 { - if (this.getTransactions().length === 0) { - return 0; - } - let max = -1; - for (const tx of this.getTransactions()) { - if (tx.received) max = Math.max(tx.received, max); - } - return new Date(max).toString(); - } - - async getLogs() { - return RnLdk.getLogs() - .map(log => log.line) - .join('\n'); - } - - async getLogsWithTs() { - return RnLdk.getLogs() - .map(log => log.ts + ' ' + log.line) - .join('\n'); - } - - async fetchPendingTransactions() {} - - async fetchUserInvoices() { - await this.getUserInvoices(); - } - - static preimage2hash(preimageHex: string): string { - const hash = bitcoin.crypto.sha256(Buffer.from(preimageHex, 'hex')); - return hash.toString('hex'); - } - - async reestablishChannels() { - const connectedInThisRun: any = {}; - for (const channel of await this.listChannels()) { - if (channel.is_usable) continue; // already connected..? - if (connectedInThisRun[channel.remote_node_id]) continue; // already tried to reconnect (in case there are several channels with the same node) - const { pubkey, host, port } = await this.lookupNodeConnectionDetailsByPubkey(channel.remote_node_id); - await this.connectPeer(pubkey, host, port); - connectedInThisRun[pubkey] = true; - } - } - - async channelsNeedReestablish() { - const freshListChannels = await this.listChannels(); - const active = freshListChannels.filter(chan => !!chan.is_usable && chan.is_funding_locked).length; - return freshListChannels.length !== +active; - } - - async waitForAtLeastOneChannelBecomeActive() { - const active = (await this.listChannels()).filter(chan => !!chan.is_usable).length; - - for (let c = 0; c < 10; c++) { - await new Promise(resolve => setTimeout(resolve, 500)); // sleep - const freshListChannels = await this.listChannels(); - const active2 = freshListChannels.filter(chan => !!chan.is_usable).length; - if (freshListChannels.length === +active2) return true; // all active kek - - if (freshListChannels.length === 0) return true; // no channels at all - if (+active2 > +active) return true; // something became active, lets ret - } - - return false; - } - - async setRefundAddress(address: string) { - const script = bitcoin.address.toOutputScript(address); - this._refundAddressScriptHex = script.toString('hex'); - await RnLdk.setRefundAddressScript(this._refundAddressScriptHex); - } - - static async getVersion() { - return RnLdk.getVersion(); - } - - static getPackageVersion() { - return RnLdk.getPackageVersion(); - } - - getChannelsClosedEvents() { - return RnLdk.channelsClosed; - } - - async purgeLocalStorage() { - return RnLdk.getStorage().purgeLocalStorage(); - } - - /** - * executes async function in background, so calling code can return immediately, while catching all thrown exceptions - * and showing them in alert() instead of propagating them up - * - * @param func {function} Async functino to execute - * @private - */ - _execInBackground(func: () => void) { - const that = this; - (async () => { - try { - await func.call(that); - } catch (error: any) { - alert('_execInBackground error:' + error.message); - } - })(); - } -} diff --git a/class/wallets/multisig-hd-wallet.js b/class/wallets/multisig-hd-wallet.ts similarity index 79% rename from class/wallets/multisig-hd-wallet.js rename to class/wallets/multisig-hd-wallet.ts index 88506d73d3..7f886e9187 100644 --- a/class/wallets/multisig-hd-wallet.js +++ b/class/wallets/multisig-hd-wallet.ts @@ -1,24 +1,54 @@ -import { AbstractHDElectrumWallet } from './abstract-hd-electrum-wallet'; +import BIP32Factory, { BIP32Interface } from 'bip32'; import * as bip39 from 'bip39'; +import * as bitcoin from 'bitcoinjs-lib'; +import { Psbt, Transaction } from 'bitcoinjs-lib'; import b58 from 'bs58check'; -import { decodeUR } from '../../blue_modules/ur'; +import { CoinSelectReturnInput, CoinSelectTarget } from 'coinselect'; +import createHash from 'create-hash'; import { ECPairFactory } from 'ecpair'; -import BIP32Factory from 'bip32'; +import * as mn from 'electrum-mnemonic'; + +import * as BlueElectrum from '../../blue_modules/BlueElectrum'; import ecc from '../../blue_modules/noble_ecc'; +import { decodeUR } from '../../blue_modules/ur'; +import { AbstractHDElectrumWallet } from './abstract-hd-electrum-wallet'; +import { CreateTransactionResult, CreateTransactionUtxo } from './types'; + const ECPair = ECPairFactory(ecc); -const BlueElectrum = require('../../blue_modules/BlueElectrum'); const bip32 = BIP32Factory(ecc); -const bitcoin = require('bitcoinjs-lib'); -const createHash = require('create-hash'); -const reverse = require('buffer-reverse'); -const mn = require('electrum-mnemonic'); -const electrumSegwit = passphrase => ({ +type SeedOpts = { + prefix: string; + passphrase?: string; +}; + +type TBip32Derivation = { + masterFingerprint: Buffer; + path: string; + pubkey: Buffer; +}[]; + +type TOutputData = + | { + bip32Derivation: TBip32Derivation; + redeemScript: Buffer; + } + | { + bip32Derivation: TBip32Derivation; + witnessScript: Buffer; + } + | { + bip32Derivation: TBip32Derivation; + redeemScript: Buffer; + witnessScript: Buffer; + }; + +const electrumSegwit = (passphrase?: string): SeedOpts => ({ prefix: mn.PREFIXES.segwit, ...(passphrase ? { passphrase } : {}), }); -const electrumStandart = passphrase => ({ +const electrumStandart = (passphrase?: string): SeedOpts => ({ prefix: mn.PREFIXES.standard, ...(passphrase ? { passphrase } : {}), }); @@ -26,8 +56,12 @@ const electrumStandart = passphrase => ({ const ELECTRUM_SEED_PREFIX = 'electrumseed:'; export class MultisigHDWallet extends AbstractHDElectrumWallet { - static type = 'HDmultisig'; - static typeReadable = 'Multisig Vault'; + static readonly type = 'HDmultisig'; + static readonly typeReadable = 'Multisig Vault'; + // @ts-ignore: override + public readonly type = MultisigHDWallet.type; + // @ts-ignore: override + public readonly typeReadable = MultisigHDWallet.typeReadable; static FORMAT_P2WSH = 'p2wsh'; static FORMAT_P2SH_P2WSH = 'p2sh-p2wsh'; @@ -38,19 +72,17 @@ export class MultisigHDWallet extends AbstractHDElectrumWallet { static PATH_WRAPPED_SEGWIT = "m/48'/0'/0'/1'"; static PATH_LEGACY = "m/45'"; - constructor() { - super(); - this._m = 0; // minimum required signatures so spend (m out of n) - this._cosigners = []; // array of xpubs or mnemonic seeds - this._cosignersFingerprints = []; // array of according fingerprints (if any provided) - this._cosignersCustomPaths = []; // array of according paths (if any provided) - this._cosignersPassphrases = []; // array of according passphrases (if any provided) - this._derivationPath = ''; - this._isNativeSegwit = false; - this._isWrappedSegwit = false; - this._isLegacy = false; - this.gap_limit = 10; - } + private _m: number = 0; // minimum required signatures so spend (m out of n) + private _cosigners: string[] = []; // array of xpubs or mnemonic seeds + private _cosignersFingerprints: string[] = []; // array of according fingerprints (if any provided) + private _cosignersCustomPaths: string[] = []; // array of according paths (if any provided) + private _cosignersPassphrases: (string | undefined)[] = []; // array of according passphrases (if any provided) + private _isNativeSegwit: boolean = false; + private _isWrappedSegwit: boolean = false; + private _isLegacy: boolean = false; + private _nodes: BIP32Interface[][] = []; + public _derivationPath: string = ''; + public gap_limit: number = 20; isLegacy() { return this._isLegacy; @@ -76,25 +108,25 @@ export class MultisigHDWallet extends AbstractHDElectrumWallet { this._isLegacy = true; } - setM(m) { + setM(m: number) { this._m = m; } /** * @returns {number} How many minumim signatures required to authorize a spend */ - getM() { + getM(): number { return this._m; } /** * @returns {number} Total count of cosigners */ - getN() { + getN(): number { return this._cosigners.length; } - setDerivationPath(path) { + setDerivationPath(path: string) { this._derivationPath = path; switch (this._derivationPath) { case "m/48'/0'/0'/2'": @@ -112,33 +144,33 @@ export class MultisigHDWallet extends AbstractHDElectrumWallet { } } - getCustomDerivationPathForCosigner(index) { + getCustomDerivationPathForCosigner(index: number): string | false { if (index === 0) throw new Error('cosigners indexation starts from 1'); if (index > this.getN()) return false; - return this._cosignersCustomPaths[index - 1] || this.getDerivationPath(); + return this._cosignersCustomPaths[index - 1] || this.getDerivationPath()!; } - getCosigner(index) { + getCosigner(index: number) { if (index === 0) throw new Error('cosigners indexation starts from 1'); return this._cosigners[index - 1]; } - getFingerprint(index) { + getFingerprint(index: number) { if (index === 0) throw new Error('cosigners fingerprints indexation starts from 1'); return this._cosignersFingerprints[index - 1]; } - getCosignerForFingerprint(fp) { + getCosignerForFingerprint(fp: string) { const index = this._cosignersFingerprints.indexOf(fp); return this._cosigners[index]; } - getPassphrase(index) { + getCosignerPassphrase(index: number) { if (index === 0) throw new Error('cosigners indexation starts from 1'); return this._cosignersPassphrases[index - 1]; } - static isXpubValid(key) { + static isXpubValid(key: string): boolean { let xpub; try { @@ -151,7 +183,7 @@ export class MultisigHDWallet extends AbstractHDElectrumWallet { return false; } - static isXprvValid(xprv) { + static isXprvValid(xprv: string): boolean { try { xprv = MultisigHDWallet.convertMultisigXprvToRegularXprv(xprv); bip32.fromBase58(xprv); @@ -168,12 +200,12 @@ export class MultisigHDWallet extends AbstractHDElectrumWallet { * @param path {string} Custom path (if any) for cosigner that is added as mnemonics * @param passphrase {string} BIP38 Passphrase (if any) */ - addCosigner(key, fingerprint, path, passphrase) { + addCosigner(key: string, fingerprint?: string, path?: string, passphrase?: string) { if (MultisigHDWallet.isXpubString(key) && !fingerprint) { throw new Error('fingerprint is required when adding cosigner as xpub (watch-only)'); } - if (path && !this.constructor.isPathValid(path)) { + if (path && !MultisigHDWallet.isPathValid(path)) { throw new Error('path is not valid'); } @@ -214,13 +246,13 @@ export class MultisigHDWallet extends AbstractHDElectrumWallet { if (passphrase) this._cosignersPassphrases[index] = passphrase; } - static convertMultisigXprvToRegularXprv(Zprv) { + static convertMultisigXprvToRegularXprv(Zprv: string) { let data = b58.decode(Zprv); data = data.slice(4); return b58.encode(Buffer.concat([Buffer.from('0488ade4', 'hex'), data])); } - static convertXprvToXpub(xprv) { + static convertXprvToXpub(xprv: string) { const restored = bip32.fromBase58(MultisigHDWallet.convertMultisigXprvToRegularXprv(xprv)); return restored.neutered().toBase58(); } @@ -228,15 +260,15 @@ export class MultisigHDWallet extends AbstractHDElectrumWallet { /** * Stored cosigner can be EITHER xpub (or Zpub or smth), OR mnemonic phrase. This method converts it to xpub * - * @param cosigner {string} Zpub (or similar) or mnemonic seed + * @param index {number} * @returns {string} xpub * @private */ - _getXpubFromCosigner(cosigner) { + protected _getXpubFromCosignerIndex(index: number) { + let cosigner: string = this._cosigners[index]; if (MultisigHDWallet.isXprvString(cosigner)) cosigner = MultisigHDWallet.convertXprvToXpub(cosigner); let xpub = cosigner; if (!MultisigHDWallet.isXpubString(cosigner)) { - const index = this._cosigners.indexOf(cosigner); xpub = MultisigHDWallet.seedToXpub( cosigner, this._cosignersCustomPaths[index] || this._derivationPath, @@ -246,7 +278,7 @@ export class MultisigHDWallet extends AbstractHDElectrumWallet { return this._zpubToXpub(xpub); } - _getExternalAddressByIndex(index) { + _getExternalAddressByIndex(index: number) { if (!this._m) throw new Error('m is not set'); index = +index; if (this.external_addresses_cache[index]) return this.external_addresses_cache[index]; // cache hit @@ -256,15 +288,14 @@ export class MultisigHDWallet extends AbstractHDElectrumWallet { return address; } - _getAddressFromNode(nodeIndex, index) { + _getAddressFromNode(nodeIndex: number, index: number) { const pubkeys = []; - for (const [cosignerIndex, cosigner] of this._cosigners.entries()) { - this._nodes = this._nodes || []; + for (const [cosignerIndex] of this._cosigners.entries()) { this._nodes[nodeIndex] = this._nodes[nodeIndex] || []; let _node; if (!this._nodes[nodeIndex][cosignerIndex]) { - const xpub = this._getXpubFromCosigner(cosigner); + const xpub = this._getXpubFromCosignerIndex(cosignerIndex); const hdNode = bip32.fromBase58(xpub); _node = hdNode.derive(nodeIndex); this._nodes[nodeIndex][cosignerIndex] = _node; @@ -281,18 +312,27 @@ export class MultisigHDWallet extends AbstractHDElectrumWallet { redeem: bitcoin.payments.p2ms({ m: this._m, pubkeys: MultisigHDWallet.sortBuffers(pubkeys) }), }), }); + if (!address) { + throw new Error('Internal error: could not make p2sh address'); + } return address; } else if (this.isNativeSegwit()) { const { address } = bitcoin.payments.p2wsh({ redeem: bitcoin.payments.p2ms({ m: this._m, pubkeys: MultisigHDWallet.sortBuffers(pubkeys) }), }); + if (!address) { + throw new Error('Internal error: could not make p2wsh address'); + } return address; } else if (this.isLegacy()) { const { address } = bitcoin.payments.p2sh({ redeem: bitcoin.payments.p2ms({ m: this._m, pubkeys: MultisigHDWallet.sortBuffers(pubkeys) }), }); + if (!address) { + throw new Error('Internal error: could not make p2sh address'); + } return address; } else { @@ -300,7 +340,7 @@ export class MultisigHDWallet extends AbstractHDElectrumWallet { } } - _getInternalAddressByIndex(index) { + _getInternalAddressByIndex(index: number) { if (!this._m) throw new Error('m is not set'); index = +index; if (this.internal_addresses_cache[index]) return this.internal_addresses_cache[index]; // cache hit @@ -310,7 +350,7 @@ export class MultisigHDWallet extends AbstractHDElectrumWallet { return address; } - static seedToXpub(mnemonic, path, passphrase) { + static seedToXpub(mnemonic: string, path: string, passphrase?: string): string { let seed; if (mnemonic.startsWith(ELECTRUM_SEED_PREFIX)) { seed = MultisigHDWallet.convertElectrumMnemonicToSeed(mnemonic, passphrase); @@ -331,7 +371,7 @@ export class MultisigHDWallet extends AbstractHDElectrumWallet { * @param xpub {string} Any kind of xpub, including zpub etc since we are only swapping the prefix bytes * @returns {string} */ - convertXpubToMultisignatureXpub(xpub) { + convertXpubToMultisignatureXpub(xpub: string): string { let data = b58.decode(xpub); data = data.slice(4); if (this.isNativeSegwit()) { @@ -343,7 +383,7 @@ export class MultisigHDWallet extends AbstractHDElectrumWallet { return xpub; } - convertXprvToMultisignatureXprv(xpub) { + convertXprvToMultisignatureXprv(xpub: string): string { let data = b58.decode(xpub); data = data.slice(4); if (this.isNativeSegwit()) { @@ -355,11 +395,11 @@ export class MultisigHDWallet extends AbstractHDElectrumWallet { return xpub; } - static isXpubString(xpub) { + static isXpubString(xpub: string): boolean { return ['xpub', 'ypub', 'zpub', 'Ypub', 'Zpub'].includes(xpub.substring(0, 4)); } - static isXprvString(xpub) { + static isXprvString(xpub: string): boolean { return ['xprv', 'yprv', 'zprv', 'Yprv', 'Zprv'].includes(xpub.substring(0, 4)); } @@ -369,7 +409,7 @@ export class MultisigHDWallet extends AbstractHDElectrumWallet { * @param xfp {number} For example 64392470 * @returns {string} For example 168DD603 */ - static ckccXfp2fingerprint(xfp) { + static ckccXfp2fingerprint(xfp: string | number): string { let masterFingerprintHex = Number(xfp).toString(16); while (masterFingerprintHex.length < 8) masterFingerprintHex = '0' + masterFingerprintHex; // conversion without explicit zero might result in lost byte @@ -400,15 +440,15 @@ export class MultisigHDWallet extends AbstractHDElectrumWallet { ret += 'Policy: ' + this.getM() + ' of ' + this.getN() + '\n'; let hasCustomPaths = 0; - const customPaths = {}; + const customPaths: Record = {}; for (let index = 0; index < this.getN(); index++) { if (this._cosignersCustomPaths[index]) hasCustomPaths++; if (this._cosignersCustomPaths[index]) customPaths[this._cosignersCustomPaths[index]] = 1; } let printedGlobalDerivation = false; - - if (this.getDerivationPath()) customPaths[this.getDerivationPath()] = 1; + const derivationPath = this.getDerivationPath(); + if (derivationPath) customPaths[derivationPath] = 1; if (Object.keys(customPaths).length === 1) { // we have exactly one path, for everyone. lets just print it for (const path of Object.keys(customPaths)) { @@ -442,7 +482,7 @@ export class MultisigHDWallet extends AbstractHDElectrumWallet { // if we printed global derivation and this cosigned _has_ derivation and its different from global - we print it ; // or we print it if cosigner _has_ some derivation set and we did not print global } - if (this.constructor.isXpubString(this._cosigners[index])) { + if (MultisigHDWallet.isXpubString(this._cosigners[index])) { ret += this._cosignersFingerprints[index] + ': ' + this._cosigners[index] + '\n'; } else { if (coordinationSetup) { @@ -468,9 +508,9 @@ export class MultisigHDWallet extends AbstractHDElectrumWallet { return ret; } - setSecret(secret) { + setSecret(secret: string) { if (secret.toUpperCase().startsWith('UR:BYTES')) { - const decoded = decodeUR([secret]); + const decoded = decodeUR([secret]) as string; const b = Buffer.from(decoded, 'hex'); secret = b.toString(); } @@ -482,7 +522,7 @@ export class MultisigHDWallet extends AbstractHDElectrumWallet { } catch (_) {} if (json && json.xfp && json.p2wsh_deriv && json.p2wsh) { this.addCosigner(json.p2wsh, json.xfp); // technically we dont need deriv (json.p2wsh_deriv), since cosigner is already an xpub - return; + return this; } // is it electrum json? @@ -513,7 +553,7 @@ export class MultisigHDWallet extends AbstractHDElectrumWallet { } // coldcard & cobo txt format: - let customPathForCurrentCosigner = false; + let customPathForCurrentCosigner: string | undefined; for (const line of secret.split('\n')) { const [key, value] = line.split(':'); @@ -552,7 +592,7 @@ export class MultisigHDWallet extends AbstractHDElectrumWallet { customPathForCurrentCosigner = value.trim(); } else if (key === 'seed') { const [seed, passphrase] = value.split(' - '); - this.addCosigner(seed.trim(), false, customPathForCurrentCosigner, passphrase); + this.addCosigner(seed.trim(), undefined, customPathForCurrentCosigner, passphrase); } break; } @@ -648,15 +688,17 @@ export class MultisigHDWallet extends AbstractHDElectrumWallet { } for (const pk of json.extendedPublicKeys) { - const path = this.constructor.isPathValid(json.bip32Path) ? json.bip32Path : "m/1'"; + const path = MultisigHDWallet.isPathValid(json.bip32Path) ? json.bip32Path : "m/1'"; this.addCosigner(pk.xpub, pk.xfp ?? '00000000', path); } } if (!this.getLabel()) this.setLabel('Multisig vault'); + + return this; } - _getDerivationPathByAddressWithCustomPath(address, customPathPrefix) { + _getDerivationPathByAddressWithCustomPath(address: string, customPathPrefix: string | undefined) { const path = customPathPrefix || this._derivationPath; for (let c = 0; c < this.next_free_address_index + this.gap_limit; c++) { if (this._getExternalAddressByIndex(c) === address) return path + '/0/' + c; @@ -668,22 +710,26 @@ export class MultisigHDWallet extends AbstractHDElectrumWallet { return false; } - _getWifForAddress(address) { + _getWifForAddress(address: string): string { + // @ts-ignore not applicable in multisig return false; } - _getPubkeyByAddress(address) { + _getPubkeyByAddress(address: string): false | Buffer { throw new Error('Not applicable in multisig'); } - _getDerivationPathByAddress(address) { + _getDerivationPathByAddress(address: string): string { throw new Error('Not applicable in multisig'); } - _addPsbtInput(psbt, input, sequence, masterFingerprintBuffer) { + _addPsbtInput(psbt: Psbt, input: CoinSelectReturnInput, sequence: number, masterFingerprintBuffer?: Buffer) { const bip32Derivation = []; // array per each pubkey thats gona be used const pubkeys = []; - for (const [cosignerIndex, cosigner] of this._cosigners.entries()) { + for (const [cosignerIndex] of this._cosigners.entries()) { + if (!input.address) { + throw new Error('Could not find address in input'); + } const path = this._getDerivationPathByAddressWithCustomPath( input.address, this._cosignersCustomPaths[cosignerIndex] || this._derivationPath, @@ -691,7 +737,11 @@ export class MultisigHDWallet extends AbstractHDElectrumWallet { // ^^ path resembles _custom path_, if provided by user during setup, otherwise default path for wallet type gona be used const masterFingerprint = Buffer.from(this._cosignersFingerprints[cosignerIndex], 'hex'); - const xpub = this._getXpubFromCosigner(cosigner); + if (!path) { + throw new Error('Could not find derivation path for address ' + input.address); + } + + const xpub = this._getXpubFromCosignerIndex(cosignerIndex); const hdNode0 = bip32.fromBase58(xpub); const splt = path.split('/'); const internal = +splt[splt.length - 2]; @@ -707,16 +757,21 @@ export class MultisigHDWallet extends AbstractHDElectrumWallet { }); } + if (!input.txhex) { + throw new Error('Electrum server didnt provide txhex to properly create PSBT transaction'); + } + if (this.isNativeSegwit()) { const p2wsh = bitcoin.payments.p2wsh({ redeem: bitcoin.payments.p2ms({ m: this._m, pubkeys: MultisigHDWallet.sortBuffers(pubkeys) }), }); + if (!p2wsh.redeem || !p2wsh.output) { + throw new Error('Could not create p2wsh output'); + } const witnessScript = p2wsh.redeem.output; - if (!input.txhex) throw new Error('Electrum server didnt provide txhex to properly create PSBT transaction'); - psbt.addInput({ - hash: input.txId, + hash: input.txid, index: input.vout, sequence, bip32Derivation, @@ -735,11 +790,15 @@ export class MultisigHDWallet extends AbstractHDElectrumWallet { redeem: bitcoin.payments.p2ms({ m: this._m, pubkeys: MultisigHDWallet.sortBuffers(pubkeys) }), }), }); + if (!p2shP2wsh?.redeem?.redeem?.output || !p2shP2wsh?.redeem?.output || !p2shP2wsh.output) { + throw new Error('Could not create p2sh-p2wsh output'); + } + const witnessScript = p2shP2wsh.redeem.redeem.output; const redeemScript = p2shP2wsh.redeem.output; psbt.addInput({ - hash: input.txId, + hash: input.txid, index: input.vout, sequence, bip32Derivation, @@ -757,9 +816,12 @@ export class MultisigHDWallet extends AbstractHDElectrumWallet { const p2sh = bitcoin.payments.p2sh({ redeem: bitcoin.payments.p2ms({ m: this._m, pubkeys: MultisigHDWallet.sortBuffers(pubkeys) }), }); + if (!p2sh?.redeem?.output) { + throw new Error('Could not create p2sh output'); + } const redeemScript = p2sh.redeem.output; psbt.addInput({ - hash: input.txId, + hash: input.txid, index: input.vout, sequence, bip32Derivation, @@ -773,18 +835,22 @@ export class MultisigHDWallet extends AbstractHDElectrumWallet { return psbt; } - _getOutputDataForChange(outputData) { - const bip32Derivation = []; // array per each pubkey thats gona be used + _getOutputDataForChange(address: string): TOutputData { + const bip32Derivation: TBip32Derivation = []; // array per each pubkey thats gona be used const pubkeys = []; - for (const [cosignerIndex, cosigner] of this._cosigners.entries()) { + for (const [cosignerIndex] of this._cosigners.entries()) { const path = this._getDerivationPathByAddressWithCustomPath( - outputData.address, + address, this._cosignersCustomPaths[cosignerIndex] || this._derivationPath, ); // ^^ path resembles _custom path_, if provided by user during setup, otherwise default path for wallet type gona be used const masterFingerprint = Buffer.from(this._cosignersFingerprints[cosignerIndex], 'hex'); - const xpub = this._getXpubFromCosigner(cosigner); + if (!path) { + throw new Error('Could not find derivation path for address ' + address); + } + + const xpub = this._getXpubFromCosignerIndex(cosignerIndex); const hdNode0 = bip32.fromBase58(xpub); const splt = path.split('/'); const internal = +splt[splt.length - 2]; @@ -800,30 +866,51 @@ export class MultisigHDWallet extends AbstractHDElectrumWallet { }); } - outputData.bip32Derivation = bip32Derivation; - if (this.isLegacy()) { const p2sh = bitcoin.payments.p2ms({ m: this._m, pubkeys: MultisigHDWallet.sortBuffers(pubkeys) }); - outputData.redeemScript = p2sh.output; - } else if (this.isWrappedSegwit()) { + if (!p2sh.output) { + throw new Error('Could not create redeemScript'); + } + return { + bip32Derivation, + redeemScript: p2sh.output, + }; + } + + if (this.isWrappedSegwit()) { const p2shP2wsh = bitcoin.payments.p2sh({ redeem: bitcoin.payments.p2wsh({ redeem: bitcoin.payments.p2ms({ m: this._m, pubkeys: MultisigHDWallet.sortBuffers(pubkeys) }), }), }); - outputData.witnessScript = p2shP2wsh.redeem.redeem.output; - outputData.redeemScript = p2shP2wsh.redeem.output; - } else if (this.isNativeSegwit()) { + const witnessScript = p2shP2wsh?.redeem?.redeem?.output; + const redeemScript = p2shP2wsh?.redeem?.output; + if (!witnessScript || !redeemScript) { + throw new Error('Could not create redeemScript or witnessScript'); + } + return { + bip32Derivation, + witnessScript, + redeemScript, + }; + } + + if (this.isNativeSegwit()) { // not needed by coldcard, apparently..? const p2wsh = bitcoin.payments.p2wsh({ redeem: bitcoin.payments.p2ms({ m: this._m, pubkeys: MultisigHDWallet.sortBuffers(pubkeys) }), }); - outputData.witnessScript = p2wsh.redeem.output; - } else { - throw new Error('dont know how to add change output'); + const witnessScript = p2wsh?.redeem?.output; + if (!witnessScript) { + throw new Error('Could not create witnessScript'); + } + return { + bip32Derivation, + witnessScript, + }; } - return outputData; + throw new Error('dont know how to add change output'); } howManySignaturesCanWeMake() { @@ -838,23 +925,40 @@ export class MultisigHDWallet extends AbstractHDElectrumWallet { /** * @inheritDoc */ - createTransaction(utxos, targets, feeRate, changeAddress, sequence, skipSigning = false, masterFingerprint) { + createTransaction( + utxos: CreateTransactionUtxo[], + targets: CoinSelectTarget[], + feeRate: number, + changeAddress: string, + sequence: number, + skipSigning = false, + masterFingerprint: number, + ): CreateTransactionResult { if (targets.length === 0) throw new Error('No destination provided'); if (this.howManySignaturesCanWeMake() === 0) skipSigning = true; // overriding script length for proper vbytes calculation for (const u of utxos) { - u.script = u.script || {}; + if (u.script?.length) { + continue; + } + if (this.isNativeSegwit()) { - u.script.length = u.script.length || Math.ceil((8 + this.getM() * 74 + this.getN() * 34) / 4); + u.script = { + length: Math.ceil((8 + this.getM() * 74 + this.getN() * 34) / 4), + }; } else if (this.isWrappedSegwit()) { - u.script.length = u.script.length || 35 + Math.ceil((8 + this.getM() * 74 + this.getN() * 34) / 4); + u.script = { + length: 35 + Math.ceil((8 + this.getM() * 74 + this.getN() * 34) / 4), + }; } else { - u.script.length = u.script.length || 9 + this.getM() * 74 + this.getN() * 34; + u.script = { + length: 9 + this.getM() * 74 + this.getN() * 34, + }; } } - const { inputs, outputs, fee } = this.coinselect(utxos, targets, feeRate, changeAddress); + const { inputs, outputs, fee } = this.coinselect(utxos, targets, feeRate); sequence = sequence || AbstractHDElectrumWallet.defaultRBFSequence; let psbt = new bitcoin.Psbt(); @@ -868,18 +972,23 @@ export class MultisigHDWallet extends AbstractHDElectrumWallet { outputs.forEach(output => { // if output has no address - this is change output let change = false; - if (!output.address) { + let address: string | undefined = output.address; + if (!address) { change = true; output.address = changeAddress; + address = changeAddress; } - let outputData = { - address: output.address, + let outputData: Parameters[0] = { + address, value: output.value, }; if (change) { - outputData = this._getOutputDataForChange(outputData); + outputData = { + ...outputData, + ...this._getOutputDataForChange(address), + }; } psbt.addOutput(outputData); @@ -915,7 +1024,7 @@ export class MultisigHDWallet extends AbstractHDElectrumWallet { return { tx, inputs, outputs, fee, psbt }; } - static convertElectrumMnemonicToSeed(cosigner, passphrase) { + static convertElectrumMnemonicToSeed(cosigner: string, passphrase?: string) { let seed; try { seed = mn.mnemonicToSeedSync(cosigner.replace(ELECTRUM_SEED_PREFIX, ''), electrumSegwit(passphrase)); @@ -931,20 +1040,18 @@ export class MultisigHDWallet extends AbstractHDElectrumWallet { /** * @see https://github.com/bitcoin/bips/blob/master/bip-0067.mediawiki - * - * @param bufArr {Array.} - * @returns {Array.} */ - static sortBuffers(bufArr) { + static sortBuffers(bufArr: Buffer[]): Buffer[] { return bufArr.sort(Buffer.compare); } prepareForSerialization() { // deleting structures that cant be serialized + // @ts-ignore I dont want to make it optional delete this._nodes; } - static isPathValid(path) { + static isPathValid(path: string): boolean { const root = bip32.fromSeed(Buffer.alloc(32)); try { root.derivePath(path); @@ -966,7 +1073,6 @@ export class MultisigHDWallet extends AbstractHDElectrumWallet { // now we need to fetch txhash for each input as required by PSBT const txhexes = await BlueElectrum.multiGetTransactionByTxid( this.getUtxo(true).map(x => x.txid), - 50, false, ); @@ -984,9 +1090,9 @@ export class MultisigHDWallet extends AbstractHDElectrumWallet { return createHash('sha256').update(string2hash).digest().toString('hex'); } - calculateFeeFromPsbt(psbt) { + calculateFeeFromPsbt(psbt: Psbt) { let goesIn = 0; - const cacheUtxoAmounts = {}; + const cacheUtxoAmounts: { [key: string]: number } = {}; for (const inp of psbt.data.inputs) { if (inp.witnessUtxo && inp.witnessUtxo.value) { // segwit input @@ -994,7 +1100,7 @@ export class MultisigHDWallet extends AbstractHDElectrumWallet { } else if (inp.nonWitnessUtxo) { // non-segwit input // lets parse this transaction and cache how much each input was worth - const inputTx = bitcoin.Transaction.fromHex(inp.nonWitnessUtxo); + const inputTx = bitcoin.Transaction.fromBuffer(inp.nonWitnessUtxo); let index = 0; for (const out of inputTx.outs) { cacheUtxoAmounts[inputTx.getId() + ':' + index] = out.value; @@ -1007,7 +1113,7 @@ export class MultisigHDWallet extends AbstractHDElectrumWallet { // means we failed to get amounts that go in previously, so lets use utxo amounts cache we've build // from non-segwit inputs for (const inp of psbt.txInputs) { - const cacheKey = reverse(inp.hash).toString('hex') + ':' + inp.index; + const cacheKey = Buffer.from(inp.hash).reverse().toString('hex') + ':' + inp.index; if (cacheUtxoAmounts[cacheKey]) goesIn += cacheUtxoAmounts[cacheKey]; } } @@ -1020,7 +1126,7 @@ export class MultisigHDWallet extends AbstractHDElectrumWallet { return goesIn - goesOut; } - calculateHowManySignaturesWeHaveFromPsbt(psbt) { + calculateHowManySignaturesWeHaveFromPsbt(psbt: Psbt) { let sigsHave = 0; for (const inp of psbt.data.inputs) { sigsHave = Math.max(sigsHave, inp.partialSig?.length || 0); @@ -1034,11 +1140,8 @@ export class MultisigHDWallet extends AbstractHDElectrumWallet { /** * Tries to signs passed psbt object (by reference). If there are enough signatures - tries to finalize psbt * and returns Transaction (ready to extract hex) - * - * @param psbt {Psbt} - * @returns {{ tx: Transaction }} */ - cosignPsbt(psbt) { + cosignPsbt(psbt: Psbt): { tx: Transaction | false } { for (let cc = 0; cc < psbt.inputCount; cc++) { for (const [cosignerIndex, cosigner] of this._cosigners.entries()) { if (MultisigHDWallet.isXpubString(cosigner)) continue; @@ -1080,7 +1183,7 @@ export class MultisigHDWallet extends AbstractHDElectrumWallet { // ^^^ we assume that counterparty has Zpub for specified derivation path // if hdRoot.depth !== 0 than this hdnode was recovered from xprv and it already has been set to root path const child = hdRoot.derivePath(path); - if (psbt.inputHasPubkey(cc, child.publicKey)) { + if (child.privateKey && psbt.inputHasPubkey(cc, child.publicKey)) { const keyPair = ECPair.fromPrivateKey(child.privateKey); try { psbt.signInput(cc, keyPair); @@ -1091,22 +1194,18 @@ export class MultisigHDWallet extends AbstractHDElectrumWallet { } } - let tx = false; if (this.calculateHowManySignaturesWeHaveFromPsbt(psbt) >= this.getM()) { - tx = psbt.finalizeAllInputs().extractTransaction(); + const tx = psbt.finalizeAllInputs().extractTransaction(); + return { tx }; } - return { tx }; + return { tx: false }; } /** * Looks up xpub cosigner by index, and repalces it with seed + passphrase - * - * @param externalIndex {number} - * @param mnemonic {string} - * @param passphrase {string} */ - replaceCosignerXpubWithSeed(externalIndex, mnemonic, passphrase) { + replaceCosignerXpubWithSeed(externalIndex: number, mnemonic: string, passphrase?: string) { const index = externalIndex - 1; const fingerprint = this._cosignersFingerprints[index]; if (!MultisigHDWallet.isXpubValid(this._cosigners[index])) throw new Error('This cosigner doesnt contain valid xpub'); @@ -1120,10 +1219,8 @@ export class MultisigHDWallet extends AbstractHDElectrumWallet { /** * Looks up cosigner with seed by index, and repalces it with xpub - * - * @param externalIndex {number} */ - replaceCosignerSeedWithXpub(externalIndex) { + replaceCosignerSeedWithXpub(externalIndex: number) { const index = externalIndex - 1; const mnemonics = this._cosigners[index]; if (!bip39.validateMnemonic(mnemonics)) throw new Error('This cosigner doesnt contain valid xpub mnemonic phrase'); @@ -1134,7 +1231,7 @@ export class MultisigHDWallet extends AbstractHDElectrumWallet { this._cosignersPassphrases[index] = undefined; } - deleteCosigner(fp) { + deleteCosigner(fp: string) { const foundIndex = this._cosignersFingerprints.indexOf(fp); if (foundIndex === -1) throw new Error('Cant find cosigner by fingerprint'); @@ -1163,9 +1260,9 @@ export class MultisigHDWallet extends AbstractHDElectrumWallet { } getFormat() { - if (this.isNativeSegwit()) return this.constructor.FORMAT_P2WSH; - if (this.isWrappedSegwit()) return this.constructor.FORMAT_P2SH_P2WSH; - if (this.isLegacy()) return this.constructor.FORMAT_P2SH; + if (this.isNativeSegwit()) return MultisigHDWallet.FORMAT_P2WSH; + if (this.isWrappedSegwit()) return MultisigHDWallet.FORMAT_P2SH_P2WSH; + if (this.isLegacy()) return MultisigHDWallet.FORMAT_P2SH; throw new Error('This should never happen'); } @@ -1174,7 +1271,7 @@ export class MultisigHDWallet extends AbstractHDElectrumWallet { * @param fp {string} Exactly 8 chars of hex * @return {boolean} */ - static isFpValid(fp) { + static isFpValid(fp: string) { if (fp.length !== 8) return false; return /^[0-9A-F]{8}$/i.test(fp); } @@ -1187,7 +1284,7 @@ export class MultisigHDWallet extends AbstractHDElectrumWallet { * @param xpub * @return {boolean} */ - static isXpubForMultisig(xpub) { + static isXpubForMultisig(xpub: string): boolean { return ['xpub', 'Ypub', 'Zpub'].includes(xpub.substring(0, 4)); } diff --git a/class/wallets/segwit-bech32-wallet.js b/class/wallets/segwit-bech32-wallet.ts similarity index 63% rename from class/wallets/segwit-bech32-wallet.js rename to class/wallets/segwit-bech32-wallet.ts index e184dcdc60..17fa159ae0 100644 --- a/class/wallets/segwit-bech32-wallet.js +++ b/class/wallets/segwit-bech32-wallet.ts @@ -1,15 +1,23 @@ -import { LegacyWallet } from './legacy-wallet'; +import * as bitcoin from 'bitcoinjs-lib'; +import { CoinSelectTarget } from 'coinselect'; import { ECPairFactory } from 'ecpair'; + import ecc from '../../blue_modules/noble_ecc'; +import { LegacyWallet } from './legacy-wallet'; +import { CreateTransactionResult, CreateTransactionUtxo } from './types'; + const ECPair = ECPairFactory(ecc); -const bitcoin = require('bitcoinjs-lib'); export class SegwitBech32Wallet extends LegacyWallet { - static type = 'segwitBech32'; - static typeReadable = 'P2 WPKH'; - static segwitType = 'p2wpkh'; - - getAddress() { + static readonly type = 'segwitBech32'; + static readonly typeReadable = 'P2 WPKH'; + // @ts-ignore: override + public readonly type = SegwitBech32Wallet.type; + // @ts-ignore: override + public readonly typeReadable = SegwitBech32Wallet.typeReadable; + public readonly segwitType = 'p2wpkh'; + + getAddress(): string | false { if (this._address) return this._address; let address; try { @@ -24,18 +32,20 @@ export class SegwitBech32Wallet extends LegacyWallet { } catch (err) { return false; } - this._address = address; + this._address = address ?? false; return this._address; } - static witnessToAddress(witness) { + static witnessToAddress(witness: string): string | false { try { - const pubKey = Buffer.from(witness, 'hex'); - return bitcoin.payments.p2wpkh({ - pubkey: pubKey, - network: bitcoin.networks.bitcoin, - }).address; + const pubkey = Buffer.from(witness, 'hex'); + return ( + bitcoin.payments.p2wpkh({ + pubkey, + network: bitcoin.networks.bitcoin, + }).address ?? false + ); } catch (_) { return false; } @@ -47,41 +57,50 @@ export class SegwitBech32Wallet extends LegacyWallet { * @param scriptPubKey * @returns {boolean|string} Either bech32 address or false */ - static scriptPubKeyToAddress(scriptPubKey) { + static scriptPubKeyToAddress(scriptPubKey: string): string | false { try { const scriptPubKey2 = Buffer.from(scriptPubKey, 'hex'); - return bitcoin.payments.p2wpkh({ - output: scriptPubKey2, - network: bitcoin.networks.bitcoin, - }).address; + return ( + bitcoin.payments.p2wpkh({ + output: scriptPubKey2, + network: bitcoin.networks.bitcoin, + }).address ?? false + ); } catch (_) { return false; } } - createTransaction(utxos, targets, feeRate, changeAddress, sequence, skipSigning = false, masterFingerprint) { + createTransaction( + utxos: CreateTransactionUtxo[], + targets: CoinSelectTarget[], + feeRate: number, + changeAddress: string, + sequence: number, + skipSigning = false, + masterFingerprint: number, + ): CreateTransactionResult { if (targets.length === 0) throw new Error('No destination provided'); // compensating for coinselect inability to deal with segwit inputs, and overriding script length for proper vbytes calculation for (const u of utxos) { u.script = { length: 27 }; } - const { inputs, outputs, fee } = this.coinselect(utxos, targets, feeRate, changeAddress); + const { inputs, outputs, fee } = this.coinselect(utxos, targets, feeRate); sequence = sequence || 0xffffffff; // disable RBF by default const psbt = new bitcoin.Psbt(); let c = 0; - const values = {}; - let keyPair; + const values: Record = {}; + const keyPair = ECPair.fromWIF(this.secret); inputs.forEach(input => { - if (!skipSigning) { - // skiping signing related stuff - keyPair = ECPair.fromWIF(this.secret); // secret is WIF - } values[c] = input.value; c++; const pubkey = keyPair.publicKey; const p2wpkh = bitcoin.payments.p2wpkh({ pubkey }); + if (!p2wpkh.output) { + throw new Error('Internal error: no p2wpkh.output during createTransaction()'); + } psbt.addInput({ hash: input.txid, diff --git a/class/wallets/segwit-p2sh-wallet.js b/class/wallets/segwit-p2sh-wallet.ts similarity index 71% rename from class/wallets/segwit-p2sh-wallet.js rename to class/wallets/segwit-p2sh-wallet.ts index d0e28a558f..13bee0f6d6 100644 --- a/class/wallets/segwit-p2sh-wallet.js +++ b/class/wallets/segwit-p2sh-wallet.ts @@ -1,8 +1,12 @@ -import { LegacyWallet } from './legacy-wallet'; +import * as bitcoin from 'bitcoinjs-lib'; +import { CoinSelectTarget } from 'coinselect'; import { ECPairFactory } from 'ecpair'; + import ecc from '../../blue_modules/noble_ecc'; +import { LegacyWallet } from './legacy-wallet'; +import { CreateTransactionResult, CreateTransactionUtxo } from './types'; + const ECPair = ECPairFactory(ecc); -const bitcoin = require('bitcoinjs-lib'); /** * Creates Segwit P2SH Bitcoin address @@ -10,21 +14,23 @@ const bitcoin = require('bitcoinjs-lib'); * @param network * @returns {String} */ -function pubkeyToP2shSegwitAddress(pubkey, network) { - network = network || bitcoin.networks.bitcoin; +function pubkeyToP2shSegwitAddress(pubkey: Buffer): string | false { const { address } = bitcoin.payments.p2sh({ - redeem: bitcoin.payments.p2wpkh({ pubkey, network }), - network, + redeem: bitcoin.payments.p2wpkh({ pubkey }), }); - return address; + return address ?? false; } export class SegwitP2SHWallet extends LegacyWallet { - static type = 'segwitP2SH'; - static typeReadable = 'SegWit (P2SH)'; - static segwitType = 'p2sh(p2wpkh)'; - - static witnessToAddress(witness) { + static readonly type = 'segwitP2SH'; + static readonly typeReadable = 'SegWit (P2SH)'; + // @ts-ignore: override + public readonly type = SegwitP2SHWallet.type; + // @ts-ignore: override + public readonly typeReadable = SegwitP2SHWallet.typeReadable; + public readonly segwitType = 'p2sh(p2wpkh)'; + + static witnessToAddress(witness: string): string | false { try { const pubKey = Buffer.from(witness, 'hex'); return pubkeyToP2shSegwitAddress(pubKey); @@ -39,19 +45,21 @@ export class SegwitP2SHWallet extends LegacyWallet { * @param scriptPubKey * @returns {boolean|string} Either p2sh address or false */ - static scriptPubKeyToAddress(scriptPubKey) { + static scriptPubKeyToAddress(scriptPubKey: string): string | false { try { const scriptPubKey2 = Buffer.from(scriptPubKey, 'hex'); - return bitcoin.payments.p2sh({ - output: scriptPubKey2, - network: bitcoin.networks.bitcoin, - }).address; + return ( + bitcoin.payments.p2sh({ + output: scriptPubKey2, + network: bitcoin.networks.bitcoin, + }).address ?? false + ); } catch (_) { return false; } } - getAddress() { + getAddress(): string | false { if (this._address) return this._address; let address; try { @@ -72,7 +80,7 @@ export class SegwitP2SHWallet extends LegacyWallet { /** * - * @param utxos {Array.<{vout: Number, value: Number, txId: String, address: String, txhex: String, }>} List of spendable utxos + * @param utxos {Array.<{vout: Number, value: Number, txid: String, address: String, txhex: String, }>} List of spendable utxos * @param targets {Array.<{value: Number, address: String}>} Where coins are going. If theres only 1 target and that target has no value - this will send MAX to that address (respecting fee rate) * @param feeRate {Number} satoshi per byte * @param changeAddress {String} Excessive coins will go back to that address @@ -81,30 +89,37 @@ export class SegwitP2SHWallet extends LegacyWallet { * @param masterFingerprint {number} Decimal number of wallet's master fingerprint * @returns {{outputs: Array, tx: Transaction, inputs: Array, fee: Number, psbt: Psbt}} */ - createTransaction(utxos, targets, feeRate, changeAddress, sequence, skipSigning = false, masterFingerprint) { + createTransaction( + utxos: CreateTransactionUtxo[], + targets: CoinSelectTarget[], + feeRate: number, + changeAddress: string, + sequence: number, + skipSigning = false, + masterFingerprint: number, + ): CreateTransactionResult { if (targets.length === 0) throw new Error('No destination provided'); // compensating for coinselect inability to deal with segwit inputs, and overriding script length for proper vbytes calculation for (const u of utxos) { u.script = { length: 50 }; } - const { inputs, outputs, fee } = this.coinselect(utxos, targets, feeRate, changeAddress); + const { inputs, outputs, fee } = this.coinselect(utxos, targets, feeRate); sequence = sequence || 0xffffffff; // disable RBF by default const psbt = new bitcoin.Psbt(); let c = 0; - const values = {}; - let keyPair; + const values: Record = {}; + const keyPair = ECPair.fromWIF(this.secret); inputs.forEach(input => { - if (!skipSigning) { - // skiping signing related stuff - keyPair = ECPair.fromWIF(this.secret); // secret is WIF - } values[c] = input.value; c++; const pubkey = keyPair.publicKey; const p2wpkh = bitcoin.payments.p2wpkh({ pubkey }); const p2sh = bitcoin.payments.p2sh({ redeem: p2wpkh }); + if (!p2sh.output) { + throw new Error('Internal error: no p2sh.output during createTransaction()'); + } psbt.addInput({ hash: input.txid, diff --git a/class/wallets/slip39-wallets.js b/class/wallets/slip39-wallets.ts similarity index 56% rename from class/wallets/slip39-wallets.js rename to class/wallets/slip39-wallets.ts index a8672ec1a0..0ee4e098dc 100644 --- a/class/wallets/slip39-wallets.js +++ b/class/wallets/slip39-wallets.ts @@ -1,30 +1,37 @@ -import slip39 from 'slip39'; -import { WORD_LIST } from 'slip39/dist/slip39_helper'; import createHash from 'create-hash'; +import slip39 from 'slip39'; +import { WORD_LIST } from 'slip39/src/slip39_helper'; import { HDLegacyP2PKHWallet } from './hd-legacy-p2pkh-wallet'; -import { HDSegwitP2SHWallet } from './hd-segwit-p2sh-wallet'; import { HDSegwitBech32Wallet } from './hd-segwit-bech32-wallet'; +import { HDSegwitP2SHWallet } from './hd-segwit-p2sh-wallet'; + +type TWalletThis = Omit & { + secret: string[]; +}; // collection of SLIP39 functions const SLIP39Mixin = { _getSeed() { - const master = slip39.recoverSecret(this.secret, this.passphrase); + const self = this as unknown as TWalletThis; + const master = slip39.recoverSecret(self.secret, self.passphrase); return Buffer.from(master); }, validateMnemonic() { - if (!this.secret.every(m => slip39.validateMnemonic(m))) return false; + const self = this as unknown as TWalletThis; + if (!self.secret.every(m => slip39.validateMnemonic(m))) return false; try { - slip39.recoverSecret(this.secret); + slip39.recoverSecret(self.secret); } catch (e) { return false; } return true; }, - setSecret(newSecret) { + setSecret(newSecret: string) { + const self = this as unknown as TWalletThis; // Try to match words to the default slip39 wordlist and complete partial words const lookupMap = WORD_LIST.reduce((map, word) => { const prefix3 = word.substr(0, 3); @@ -36,7 +43,7 @@ const SLIP39Mixin = { return map; }, new Map()); - this.secret = newSecret + self.secret = newSecret .trim() .split('\n') .filter(s => s) @@ -54,18 +61,23 @@ const SLIP39Mixin = { return secret; }); - return this; + return self; }, getID() { - const string2hash = this.secret.sort().join(',') + (this.getPassphrase() || ''); + const self = this as unknown as TWalletThis; + const string2hash = self.secret.sort().join(',') + (self.getPassphrase() || ''); return createHash('sha256').update(string2hash).digest().toString('hex'); }, }; export class SLIP39LegacyP2PKHWallet extends HDLegacyP2PKHWallet { - static type = 'SLIP39legacyP2PKH'; - static typeReadable = 'SLIP39 Legacy (P2PKH)'; + static readonly type = 'SLIP39legacyP2PKH'; + static readonly typeReadable = 'SLIP39 Legacy (P2PKH)'; + // @ts-ignore: override + public readonly type = SLIP39LegacyP2PKHWallet.type; + // @ts-ignore: override + public readonly typeReadable = SLIP39LegacyP2PKHWallet.typeReadable; allowBIP47() { return false; @@ -73,23 +85,33 @@ export class SLIP39LegacyP2PKHWallet extends HDLegacyP2PKHWallet { _getSeed = SLIP39Mixin._getSeed; validateMnemonic = SLIP39Mixin.validateMnemonic; + // @ts-ignore: this type mismatch setSecret = SLIP39Mixin.setSecret; getID = SLIP39Mixin.getID; } export class SLIP39SegwitP2SHWallet extends HDSegwitP2SHWallet { - static type = 'SLIP39segwitP2SH'; - static typeReadable = 'SLIP39 SegWit (P2SH)'; + static readonly type = 'SLIP39segwitP2SH'; + static readonly typeReadable = 'SLIP39 SegWit (P2SH)'; + // @ts-ignore: override + public readonly type = SLIP39SegwitP2SHWallet.type; + // @ts-ignore: override + public readonly typeReadable = SLIP39SegwitP2SHWallet.typeReadable; _getSeed = SLIP39Mixin._getSeed; validateMnemonic = SLIP39Mixin.validateMnemonic; + // @ts-ignore: this type mismatch setSecret = SLIP39Mixin.setSecret; getID = SLIP39Mixin.getID; } export class SLIP39SegwitBech32Wallet extends HDSegwitBech32Wallet { - static type = 'SLIP39segwitBech32'; - static typeReadable = 'SLIP39 SegWit (Bech32)'; + static readonly type = 'SLIP39segwitBech32'; + static readonly typeReadable = 'SLIP39 SegWit (Bech32)'; + // @ts-ignore: override + public readonly type = SLIP39SegwitBech32Wallet.type; + // @ts-ignore: override + public readonly typeReadable = SLIP39SegwitBech32Wallet.typeReadable; allowBIP47() { return false; @@ -97,6 +119,7 @@ export class SLIP39SegwitBech32Wallet extends HDSegwitBech32Wallet { _getSeed = SLIP39Mixin._getSeed; validateMnemonic = SLIP39Mixin.validateMnemonic; + // @ts-ignore: this type mismatch setSecret = SLIP39Mixin.setSecret; getID = SLIP39Mixin.getID; } diff --git a/class/wallets/taproot-wallet.js b/class/wallets/taproot-wallet.ts similarity index 56% rename from class/wallets/taproot-wallet.js rename to class/wallets/taproot-wallet.ts index 67e47d4989..9d8a805f00 100644 --- a/class/wallets/taproot-wallet.js +++ b/class/wallets/taproot-wallet.ts @@ -1,10 +1,15 @@ +import * as bitcoin from 'bitcoinjs-lib'; + import { SegwitBech32Wallet } from './segwit-bech32-wallet'; -const bitcoin = require('bitcoinjs-lib'); export class TaprootWallet extends SegwitBech32Wallet { - static type = 'taproot'; - static typeReadable = 'P2 TR'; - static segwitType = 'p2wpkh'; + static readonly type = 'taproot'; + static readonly typeReadable = 'P2 TR'; + // @ts-ignore: override + public readonly type = TaprootWallet.type; + // @ts-ignore: override + public readonly typeReadable = TaprootWallet.typeReadable; + public readonly segwitType = 'p2wpkh'; /** * Converts script pub key to a Taproot address if it can. Returns FALSE if it cant. @@ -12,7 +17,7 @@ export class TaprootWallet extends SegwitBech32Wallet { * @param scriptPubKey * @returns {boolean|string} Either bech32 address or false */ - static scriptPubKeyToAddress(scriptPubKey) { + static scriptPubKeyToAddress(scriptPubKey: string): string | false { try { const publicKey = Buffer.from(scriptPubKey, 'hex'); return bitcoin.address.fromOutputScript(publicKey, bitcoin.networks.bitcoin); diff --git a/class/wallets/types.ts b/class/wallets/types.ts index e4d9b10a42..870dba7364 100644 --- a/class/wallets/types.ts +++ b/class/wallets/types.ts @@ -1,34 +1,50 @@ import bitcoin from 'bitcoinjs-lib'; -import { CoinSelectOutput, CoinSelectReturnInput } from 'coinselect'; +import { CoinSelectOutput, CoinSelectReturnInput, CoinSelectUtxo } from 'coinselect'; + +import { BitcoinUnit } from '../../models/bitcoinUnits'; +import { HDAezeedWallet } from './hd-aezeed-wallet'; +import { HDLegacyBreadwalletWallet } from './hd-legacy-breadwallet-wallet'; +import { HDLegacyElectrumSeedP2PKHWallet } from './hd-legacy-electrum-seed-p2pkh-wallet'; +import { HDLegacyP2PKHWallet } from './hd-legacy-p2pkh-wallet'; +import { HDSegwitBech32Wallet } from './hd-segwit-bech32-wallet'; +import { HDSegwitElectrumSeedP2WPKHWallet } from './hd-segwit-electrum-seed-p2wpkh-wallet'; +import { HDSegwitP2SHWallet } from './hd-segwit-p2sh-wallet'; +import { LegacyWallet } from './legacy-wallet'; +import { LightningCustodianWallet } from './lightning-custodian-wallet'; +import { MultisigHDWallet } from './multisig-hd-wallet'; +import { SegwitBech32Wallet } from './segwit-bech32-wallet'; +import { SegwitP2SHWallet } from './segwit-p2sh-wallet'; +import { SLIP39LegacyP2PKHWallet, SLIP39SegwitBech32Wallet, SLIP39SegwitP2SHWallet } from './slip39-wallets'; +import { WatchOnlyWallet } from './watch-only-wallet'; export type Utxo = { // Returned by BlueElectrum height: number; address: string; - txId: string; + txid: string; vout: number; value: number; // Others txhex?: string; - txid?: string; // TODO: same as txId, do we really need it? confirmations?: number; - amount?: number; // TODO: same as value, do we really need it? wif?: string | false; }; /** - * basically the same as coinselect.d.ts/CoinselectUtxo - * and should be unified as soon as bullshit with txid/txId is sorted + * same as coinselect.d.ts/CoinSelectUtxo */ -export type CreateTransactionUtxo = { - txId: string; - txid: string; // TODO: same as txId, do we really need it? - txhex: string; - vout: number; - value: number; +export interface CreateTransactionUtxo extends CoinSelectUtxo {} + +/** + * if address is missing and `script.hex` is set - this is a custom script (like OP_RETURN) + */ +export type CreateTransactionTarget = { + address?: string; + value?: number; script?: { - length: number; + length?: number; // either length or hex should be present + hex?: string; }; }; @@ -63,6 +79,17 @@ export type TransactionOutput = { }; }; +export type LightningTransaction = { + memo?: string; + type?: 'user_invoice' | 'payment_request' | 'bitcoind_tx' | 'paid_invoice'; + payment_hash?: string | { data: string }; + category?: 'receive'; + timestamp?: number; + expire_time?: number; + ispaid?: boolean; + walletID?: string; +}; + export type Transaction = { txid: string; hash: string; @@ -74,9 +101,43 @@ export type Transaction = { inputs: TransactionInput[]; outputs: TransactionOutput[]; blockhash: string; - confirmations?: number; + confirmations: number; time: number; blocktime: number; received?: number; value?: number; + + /** + * if known, who is on the other end of the transaction (BIP47 payment code) + */ + counterparty?: string; }; + +/** + * in some cases we add additional data to each tx object so the code that works with that transaction can find the + * wallet that owns it etc + */ +export type ExtendedTransaction = Transaction & { + walletID: string; + walletPreferredBalanceUnit: BitcoinUnit; +}; + +export type TWallet = + | HDAezeedWallet + | HDLegacyBreadwalletWallet + | HDLegacyElectrumSeedP2PKHWallet + | HDLegacyP2PKHWallet + | HDSegwitBech32Wallet + | HDSegwitElectrumSeedP2WPKHWallet + | HDSegwitP2SHWallet + | LegacyWallet + | LightningCustodianWallet + | MultisigHDWallet + | SLIP39LegacyP2PKHWallet + | SLIP39SegwitBech32Wallet + | SLIP39SegwitP2SHWallet + | SegwitBech32Wallet + | SegwitP2SHWallet + | WatchOnlyWallet; + +export type THDWalletForWatchOnly = HDSegwitBech32Wallet | HDSegwitP2SHWallet | HDLegacyP2PKHWallet; diff --git a/class/wallets/watch-only-wallet.js b/class/wallets/watch-only-wallet.ts similarity index 80% rename from class/wallets/watch-only-wallet.js rename to class/wallets/watch-only-wallet.ts index 60f8955ec9..ca889ed3bb 100644 --- a/class/wallets/watch-only-wallet.js +++ b/class/wallets/watch-only-wallet.ts @@ -1,22 +1,28 @@ -import { LegacyWallet } from './legacy-wallet'; -import { HDSegwitP2SHWallet } from './hd-segwit-p2sh-wallet'; -import { HDLegacyP2PKHWallet } from './hd-legacy-p2pkh-wallet'; -import { HDSegwitBech32Wallet } from './hd-segwit-bech32-wallet'; import BIP32Factory from 'bip32'; +import * as bitcoin from 'bitcoinjs-lib'; + import ecc from '../../blue_modules/noble_ecc'; +import { AbstractWallet } from './abstract-wallet'; +import { HDLegacyP2PKHWallet } from './hd-legacy-p2pkh-wallet'; +import { HDSegwitBech32Wallet } from './hd-segwit-bech32-wallet'; +import { HDSegwitP2SHWallet } from './hd-segwit-p2sh-wallet'; +import { LegacyWallet } from './legacy-wallet'; +import { THDWalletForWatchOnly } from './types'; -const bitcoin = require('bitcoinjs-lib'); const bip32 = BIP32Factory(ecc); export class WatchOnlyWallet extends LegacyWallet { - static type = 'watchOnly'; - static typeReadable = 'Watch-only'; - - constructor() { - super(); - this.use_with_hardware_wallet = false; - this.masterFingerprint = false; - } + static readonly type = 'watchOnly'; + static readonly typeReadable = 'Watch-only'; + // @ts-ignore: override + public readonly type = WatchOnlyWallet.type; + // @ts-ignore: override + public readonly typeReadable = WatchOnlyWallet.typeReadable; + public isWatchOnlyWarningVisible = true; + + public _hdWalletInstance?: THDWalletForWatchOnly; + use_with_hardware_wallet = false; + masterFingerprint: number = 0; /** * @inheritDoc @@ -37,7 +43,7 @@ export class WatchOnlyWallet extends LegacyWallet { } allowSend() { - return this.useWithHardwareWalletEnabled() && this.isHd() && this._hdWalletInstance.allowSend(); + return this.useWithHardwareWalletEnabled() && this.isHd() && this._hdWalletInstance!.allowSend(); } allowSignVerifyMessage() { @@ -65,11 +71,9 @@ export class WatchOnlyWallet extends LegacyWallet { * this method creates appropriate HD wallet class, depending on whether we have xpub, ypub or zpub * as a property of `this`, and in case such property exists - it recreates it and copies data from old one. * this is needed after serialization/save/load/deserialization procedure. - * - * @return {WatchOnlyWallet} this */ init() { - let hdWalletInstance; + let hdWalletInstance: THDWalletForWatchOnly; if (this.secret.startsWith('xpub')) hdWalletInstance = new HDLegacyP2PKHWallet(); else if (this.secret.startsWith('ypub')) hdWalletInstance = new HDSegwitP2SHWallet(); else if (this.secret.startsWith('zpub')) hdWalletInstance = new HDSegwitBech32Wallet(); @@ -84,6 +88,7 @@ export class WatchOnlyWallet extends LegacyWallet { if (this._hdWalletInstance) { // now, porting all properties from old object to new one for (const k of Object.keys(this._hdWalletInstance)) { + // @ts-ignore: JS magic here hdWalletInstance[k] = this._hdWalletInstance[k]; } @@ -117,6 +122,7 @@ export class WatchOnlyWallet extends LegacyWallet { async fetchBalance() { if (this.secret.startsWith('xpub') || this.secret.startsWith('ypub') || this.secret.startsWith('zpub')) { if (!this._hdWalletInstance) this.init(); + if (!this._hdWalletInstance) throw new Error('Internal error: _hdWalletInstance is not initialized'); return this._hdWalletInstance.fetchBalance(); } else { // return LegacyWallet.prototype.fetchBalance.call(this); @@ -127,6 +133,7 @@ export class WatchOnlyWallet extends LegacyWallet { async fetchTransactions() { if (this.secret.startsWith('xpub') || this.secret.startsWith('ypub') || this.secret.startsWith('zpub')) { if (!this._hdWalletInstance) this.init(); + if (!this._hdWalletInstance) throw new Error('Internal error: _hdWalletInstance is not initialized'); return this._hdWalletInstance.fetchTransactions(); } else { // return LegacyWallet.prototype.fetchBalance.call(this); @@ -134,18 +141,18 @@ export class WatchOnlyWallet extends LegacyWallet { } } - async getAddressAsync() { + async getAddressAsync(): Promise { if (this.isAddressValid(this.secret)) return new Promise(resolve => resolve(this.secret)); if (this._hdWalletInstance) return this._hdWalletInstance.getAddressAsync(); throw new Error('Not initialized'); } - _getExternalAddressByIndex(index) { + _getExternalAddressByIndex(index: number) { if (this._hdWalletInstance) return this._hdWalletInstance._getExternalAddressByIndex(index); throw new Error('Not initialized'); } - _getInternalAddressByIndex(index) { + _getInternalAddressByIndex(index: number) { if (this._hdWalletInstance) return this._hdWalletInstance._getInternalAddressByIndex(index); throw new Error('Not initialized'); } @@ -170,29 +177,30 @@ export class WatchOnlyWallet extends LegacyWallet { throw new Error('Not initialized'); } - getUtxo(...args) { + getUtxo(...args: Parameters) { if (this._hdWalletInstance) return this._hdWalletInstance.getUtxo(...args); throw new Error('Not initialized'); } - combinePsbt(base64one, base64two) { - if (this._hdWalletInstance) return this._hdWalletInstance.combinePsbt(base64one, base64two); + combinePsbt(...args: Parameters) { + if (this._hdWalletInstance) return this._hdWalletInstance.combinePsbt(...args); throw new Error('Not initialized'); } - broadcastTx(hex) { - if (this._hdWalletInstance) return this._hdWalletInstance.broadcastTx(hex); + broadcastTx(...args: Parameters) { + if (this._hdWalletInstance) return this._hdWalletInstance.broadcastTx(...args); throw new Error('Not initialized'); } /** * signature of this method is the same ad BIP84 createTransaction, BUT this method should be used to create * unsinged PSBT to be used with HW wallet (or other external signer) - * @see HDSegwitBech32Wallet.createTransaction */ - createTransaction(utxos, targets, feeRate, changeAddress, sequence) { + createTransaction(...args: Parameters) { + const [utxos, targets, feeRate, changeAddress, sequence] = args; if (this._hdWalletInstance && this.isHd()) { - return this._hdWalletInstance.createTransaction(utxos, targets, feeRate, changeAddress, sequence, true, this.getMasterFingerprint()); + const masterFingerprint = this.getMasterFingerprint(); + return this._hdWalletInstance.createTransaction(utxos, targets, feeRate, changeAddress, sequence, true, masterFingerprint); } else { throw new Error('Not a HD watch-only wallet, cant create PSBT (or just not initialized)'); } @@ -224,7 +232,7 @@ export class WatchOnlyWallet extends LegacyWallet { return this.secret.startsWith('xpub') || this.secret.startsWith('ypub') || this.secret.startsWith('zpub'); } - weOwnAddress(address) { + weOwnAddress(address: string) { if (this.isHd()) { if (this._hdWalletInstance) return this._hdWalletInstance.weOwnAddress(address); throw new Error('Not initialized'); @@ -235,10 +243,6 @@ export class WatchOnlyWallet extends LegacyWallet { return this.getAddress() === address; } - allowHodlHodlTrading() { - return this.isHd(); - } - allowMasterFingerprint() { return this.getSecret().startsWith('zpub'); } @@ -247,7 +251,7 @@ export class WatchOnlyWallet extends LegacyWallet { return !!this.use_with_hardware_wallet; } - setUseWithHardwareWalletEnabled(enabled) { + setUseWithHardwareWalletEnabled(enabled: boolean) { this.use_with_hardware_wallet = !!enabled; } @@ -266,7 +270,7 @@ export class WatchOnlyWallet extends LegacyWallet { if (this.secret.startsWith('zpub')) { xpub = this._zpubToXpub(this.secret); } else if (this.secret.startsWith('ypub')) { - xpub = this.constructor._ypubToXpub(this.secret); + xpub = AbstractWallet._ypubToXpub(this.secret); } else { xpub = this.secret; } @@ -279,32 +283,32 @@ export class WatchOnlyWallet extends LegacyWallet { return false; } - addressIsChange(...args) { + addressIsChange(...args: Parameters) { if (this._hdWalletInstance) return this._hdWalletInstance.addressIsChange(...args); return super.addressIsChange(...args); } - getUTXOMetadata(...args) { + getUTXOMetadata(...args: Parameters) { if (this._hdWalletInstance) return this._hdWalletInstance.getUTXOMetadata(...args); return super.getUTXOMetadata(...args); } - setUTXOMetadata(...args) { + setUTXOMetadata(...args: Parameters) { if (this._hdWalletInstance) return this._hdWalletInstance.setUTXOMetadata(...args); return super.setUTXOMetadata(...args); } - getDerivationPath(...args) { + getDerivationPath(...args: Parameters) { if (this._hdWalletInstance) return this._hdWalletInstance.getDerivationPath(...args); throw new Error("Not a HD watch-only wallet, can't use derivation path"); } - setDerivationPath(...args) { + setDerivationPath(...args: Parameters) { if (this._hdWalletInstance) return this._hdWalletInstance.setDerivationPath(...args); throw new Error("Not a HD watch-only wallet, can't use derivation path"); } - isSegwit() { + isSegwit(): boolean { if (this._hdWalletInstance) return this._hdWalletInstance.isSegwit(); return super.isSegwit(); } diff --git a/components/AddWalletButton.tsx b/components/AddWalletButton.tsx new file mode 100644 index 0000000000..5ebdd178d6 --- /dev/null +++ b/components/AddWalletButton.tsx @@ -0,0 +1,53 @@ +import React, { useCallback, useMemo } from 'react'; +import { StyleSheet, TouchableOpacity, GestureResponderEvent } from 'react-native'; +import { Icon } from '@rneui/themed'; +import { useTheme } from './themes'; +import ToolTipMenu from './TooltipMenu'; +import { CommonToolTipActions } from '../typings/CommonToolTipActions'; +import loc from '../loc'; +import { navigationRef } from '../NavigationService'; + +type AddWalletButtonProps = { + onPress?: (event: GestureResponderEvent) => void; +}; + +const styles = StyleSheet.create({ + ball: { + width: 30, + height: 30, + borderRadius: 15, + justifyContent: 'center', + alignContent: 'center', + }, +}); + +const AddWalletButton: React.FC = ({ onPress }) => { + const { colors } = useTheme(); + const stylesHook = StyleSheet.create({ + ball: { + backgroundColor: colors.buttonBackgroundColor, + }, + }); + + const onPressMenuItem = useCallback((action: string) => { + switch (action) { + case CommonToolTipActions.ImportWallet.id: + navigationRef.current?.navigate('AddWalletRoot', { screen: 'ImportWallet' }); + break; + default: + break; + } + }, []); + + const actions = useMemo(() => [CommonToolTipActions.ImportWallet], []); + + return ( + + + + + + ); +}; + +export default AddWalletButton; diff --git a/components/AddressInput.js b/components/AddressInput.js deleted file mode 100644 index 2a674534df..0000000000 --- a/components/AddressInput.js +++ /dev/null @@ -1,151 +0,0 @@ -import React, { useRef } from 'react'; -import PropTypes from 'prop-types'; -import { Text } from 'react-native-elements'; -import { findNodeHandle, Image, Keyboard, StyleSheet, TextInput, TouchableOpacity, View } from 'react-native'; -import { getSystemName } from 'react-native-device-info'; -import { useTheme } from '@react-navigation/native'; - -import loc from '../loc'; -import * as NavigationService from '../NavigationService'; -const fs = require('../blue_modules/fs'); - -const isDesktop = getSystemName() === 'Mac OS X'; - -const AddressInput = ({ - isLoading = false, - address = '', - placeholder = loc.send.details_address, - onChangeText, - onBarScanned, - onBarScannerDismissWithoutData = () => {}, - scanButtonTapped = () => {}, - launchedBy, - editable = true, - inputAccessoryViewID, - onBlur = () => {}, - keyboardType = 'default', -}) => { - const { colors } = useTheme(); - const scanButtonRef = useRef(); - - const stylesHook = StyleSheet.create({ - root: { - borderColor: colors.formBorder, - borderBottomColor: colors.formBorder, - backgroundColor: colors.inputBackgroundColor, - }, - scan: { - backgroundColor: colors.scanLabel, - }, - scanText: { - color: colors.inverseForegroundColor, - }, - }); - - const onBlurEditing = () => { - onBlur(); - Keyboard.dismiss(); - }; - - return ( - - - {editable ? ( - { - await scanButtonTapped(); - Keyboard.dismiss(); - if (isDesktop) { - fs.showActionSheet({ anchor: findNodeHandle(scanButtonRef.current) }).then(onBarScanned); - } else { - NavigationService.navigate('ScanQRCodeRoot', { - screen: 'ScanQRCode', - params: { - launchedBy, - onBarScanned, - onBarScannerDismissWithoutData, - }, - }); - } - }} - accessibilityRole="button" - style={[styles.scan, stylesHook.scan]} - accessibilityLabel={loc.send.details_scan} - accessibilityHint={loc.send.details_scan_hint} - > - - - {loc.send.details_scan} - - - ) : null} - - ); -}; - -const styles = StyleSheet.create({ - root: { - flexDirection: 'row', - borderWidth: 1.0, - borderBottomWidth: 0.5, - minHeight: 44, - height: 44, - marginHorizontal: 20, - alignItems: 'center', - marginVertical: 8, - borderRadius: 4, - }, - input: { - flex: 1, - marginHorizontal: 8, - minHeight: 33, - color: '#81868e', - }, - scan: { - height: 36, - flexDirection: 'row', - alignItems: 'center', - justifyContent: 'space-between', - borderRadius: 4, - paddingVertical: 4, - paddingHorizontal: 8, - marginHorizontal: 4, - }, - scanText: { - marginLeft: 4, - }, -}); - -AddressInput.propTypes = { - isLoading: PropTypes.bool, - onChangeText: PropTypes.func, - onBarScanned: PropTypes.func.isRequired, - launchedBy: PropTypes.string, - address: PropTypes.string, - placeholder: PropTypes.string, - editable: PropTypes.bool, - scanButtonTapped: PropTypes.func, - inputAccessoryViewID: PropTypes.string, - onBarScannerDismissWithoutData: PropTypes.func, - onBlur: PropTypes.func, - keyboardType: PropTypes.string, -}; - -export default AddressInput; diff --git a/components/AddressInput.tsx b/components/AddressInput.tsx new file mode 100644 index 0000000000..ae8b3b43d4 --- /dev/null +++ b/components/AddressInput.tsx @@ -0,0 +1,134 @@ +import React, { useCallback } from 'react'; +import { Keyboard, StyleProp, StyleSheet, TextInput, View, ViewStyle } from 'react-native'; +import loc from '../loc'; +import { AddressInputScanButton } from './AddressInputScanButton'; +import { useTheme } from './themes'; +import DeeplinkSchemaMatch from '../class/deeplink-schema-match'; +import triggerHapticFeedback, { HapticFeedbackTypes } from '../blue_modules/hapticFeedback'; + +interface AddressInputProps { + isLoading?: boolean; + address?: string; + placeholder?: string; + onChangeText: (text: string) => void; + onBarScanned: (ret: { data?: any }) => void; + scanButtonTapped?: () => void; + launchedBy?: string; + editable?: boolean; + inputAccessoryViewID?: string; + onBlur?: () => void; + onFocus?: () => void; + testID?: string; + style?: StyleProp; + keyboardType?: + | 'default' + | 'numeric' + | 'email-address' + | 'ascii-capable' + | 'numbers-and-punctuation' + | 'url' + | 'number-pad' + | 'phone-pad' + | 'name-phone-pad' + | 'decimal-pad' + | 'twitter' + | 'web-search' + | 'visible-password'; +} + +const AddressInput = ({ + isLoading = false, + address = '', + testID = 'AddressInput', + placeholder = loc.send.details_address, + onChangeText, + onBarScanned, + scanButtonTapped = () => {}, + launchedBy, + editable = true, + inputAccessoryViewID, + onBlur = () => {}, + onFocus = () => {}, + keyboardType = 'default', + style, +}: AddressInputProps) => { + const { colors } = useTheme(); + const stylesHook = StyleSheet.create({ + root: { + borderColor: colors.formBorder, + borderBottomColor: colors.formBorder, + backgroundColor: colors.inputBackgroundColor, + }, + input: { + color: colors.foregroundColor, + }, + }); + + const validateAddressWithFeedback = useCallback((value: string) => { + const isBitcoinAddress = DeeplinkSchemaMatch.isBitcoinAddress(value); + const isLightningInvoice = DeeplinkSchemaMatch.isLightningInvoice(value); + const isValid = isBitcoinAddress || isLightningInvoice; + + triggerHapticFeedback(isValid ? HapticFeedbackTypes.NotificationSuccess : HapticFeedbackTypes.NotificationError); + return { + isValid, + type: isBitcoinAddress ? 'bitcoin' : isLightningInvoice ? 'lightning' : 'invalid', + }; + }, []); + + const onBlurEditing = () => { + validateAddressWithFeedback(address); + onBlur(); + Keyboard.dismiss(); + }; + + return ( + + + {editable ? ( + + ) : null} + + ); +}; + +const styles = StyleSheet.create({ + root: { + flexDirection: 'row', + borderWidth: 1.0, + borderBottomWidth: 0.5, + minHeight: 44, + height: 44, + alignItems: 'center', + borderRadius: 4, + }, + input: { + flex: 1, + paddingHorizontal: 8, + minHeight: 33, + }, +}); + +export default AddressInput; diff --git a/components/AddressInputScanButton.tsx b/components/AddressInputScanButton.tsx new file mode 100644 index 0000000000..ac2001e336 --- /dev/null +++ b/components/AddressInputScanButton.tsx @@ -0,0 +1,176 @@ +import React, { useCallback, useMemo } from 'react'; +import { Image, Keyboard, Platform, StyleSheet, Text } from 'react-native'; +import Clipboard from '@react-native-clipboard/clipboard'; +import ToolTipMenu from './TooltipMenu'; +import loc from '../loc'; +import { scanQrHelper } from '../helpers/scan-qr'; +import { showFilePickerAndReadFile, showImagePickerAndReadImage } from '../blue_modules/fs'; +import presentAlert from './Alert'; +import { useTheme } from './themes'; +import RNQRGenerator from 'rn-qr-generator'; +import { CommonToolTipActions } from '../typings/CommonToolTipActions'; +import { useSettings } from '../hooks/context/useSettings'; + +interface AddressInputScanButtonProps { + isLoading: boolean; + launchedBy?: string; + scanButtonTapped: () => void; + onBarScanned: (ret: { data?: any }) => void; + onChangeText: (text: string) => void; +} + +export const AddressInputScanButton = ({ + isLoading, + launchedBy, + scanButtonTapped, + onBarScanned, + onChangeText, +}: AddressInputScanButtonProps) => { + const { colors } = useTheme(); + const { isClipboardGetContentEnabled } = useSettings(); + const stylesHook = StyleSheet.create({ + scan: { + backgroundColor: colors.scanLabel, + }, + scanText: { + color: colors.inverseForegroundColor, + }, + }); + + const toolTipOnPress = useCallback(async () => { + await scanButtonTapped(); + Keyboard.dismiss(); + if (launchedBy) scanQrHelper(launchedBy, true).then(value => onBarScanned({ data: value })); + }, [launchedBy, onBarScanned, scanButtonTapped]); + + const actions = useMemo(() => { + const availableActions = [ + CommonToolTipActions.ScanQR, + CommonToolTipActions.ChoosePhoto, + CommonToolTipActions.ImportFile, + { + ...CommonToolTipActions.PasteFromClipboard, + hidden: !isClipboardGetContentEnabled, + }, + ]; + + return availableActions; + }, [isClipboardGetContentEnabled]); + + const onMenuItemPressed = useCallback( + async (action: string) => { + if (onBarScanned === undefined) throw new Error('onBarScanned is required'); + switch (action) { + case CommonToolTipActions.ScanQR.id: + scanButtonTapped(); + if (launchedBy) { + scanQrHelper(launchedBy) + .then(value => onBarScanned({ data: value })) + .catch(error => { + presentAlert({ message: error.message }); + }); + } + + break; + case CommonToolTipActions.PasteFromClipboard.id: + try { + let getImage: string | null = null; + let hasImage = false; + if (Platform.OS === 'android') { + hasImage = true; + } else { + hasImage = await Clipboard.hasImage(); + } + + if (hasImage) { + getImage = await Clipboard.getImage(); + } + + if (getImage) { + try { + const base64Data = getImage.replace(/^data:image\/(png|jpeg|jpg);base64,/, ''); + const values = await RNQRGenerator.detect({ + base64: base64Data, + }); + + if (values && values.values.length > 0) { + onChangeText(values.values[0]); + } else { + presentAlert({ message: loc.send.qr_error_no_qrcode }); + } + } catch (error) { + presentAlert({ message: (error as Error).message }); + } + } else { + const clipboardText = await Clipboard.getString(); + onChangeText(clipboardText); + } + } catch (error) { + presentAlert({ message: (error as Error).message }); + } + break; + case CommonToolTipActions.ChoosePhoto.id: + showImagePickerAndReadImage() + .then(value => { + if (value) { + onChangeText(value); + } + }) + .catch(error => { + presentAlert({ message: error.message }); + }); + break; + case CommonToolTipActions.ImportFile.id: + showFilePickerAndReadFile() + .then(value => { + if (value.data) { + onChangeText(value.data); + } + }) + .catch(error => { + presentAlert({ message: error.message }); + }); + break; + } + Keyboard.dismiss(); + }, + [launchedBy, onBarScanned, onChangeText, scanButtonTapped], + ); + + const buttonStyle = useMemo(() => [styles.scan, stylesHook.scan], [stylesHook.scan]); + + return ( + + + + {loc.send.details_scan} + + + ); +}; + +const styles = StyleSheet.create({ + scan: { + height: 36, + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'space-between', + borderRadius: 4, + paddingVertical: 4, + paddingHorizontal: 8, + marginHorizontal: 4, + }, + scanText: { + marginLeft: 4, + }, +}); diff --git a/components/Alert.js b/components/Alert.js deleted file mode 100644 index 44fcc6a2f2..0000000000 --- a/components/Alert.js +++ /dev/null @@ -1,6 +0,0 @@ -import { Alert } from 'react-native'; -import loc from '../loc'; -const alert = string => { - Alert.alert(loc.alert.default, string); -}; -export default alert; diff --git a/components/Alert.ts b/components/Alert.ts new file mode 100644 index 0000000000..c9313d84d1 --- /dev/null +++ b/components/Alert.ts @@ -0,0 +1,84 @@ +import { Alert as RNAlert, Platform, ToastAndroid, AlertButton, AlertOptions } from 'react-native'; +import triggerHapticFeedback, { HapticFeedbackTypes } from '../blue_modules/hapticFeedback'; +import loc from '../loc'; + +export enum AlertType { + Alert, + Toast, +} + +const presentAlert = (() => { + let lastAlertParams: { + title?: string; + message: string; + type?: AlertType; + hapticFeedback?: HapticFeedbackTypes; + buttons?: AlertButton[]; + options?: AlertOptions; + } | null = null; + + const clearCache = () => { + lastAlertParams = null; + }; + + const showAlert = (title: string | undefined, message: string, buttons: AlertButton[], options: AlertOptions) => { + if (Platform.OS === 'ios') { + RNAlert.alert(title ?? message, title && message ? message : undefined, buttons, options); + } else { + RNAlert.alert(title ?? '', message, buttons, options); + } + }; + + return ({ + title, + message, + type = AlertType.Alert, + hapticFeedback, + buttons = [], + options = { cancelable: false }, + allowRepeat = true, + }: { + title?: string; + message: string; + type?: AlertType; + hapticFeedback?: HapticFeedbackTypes; + buttons?: AlertButton[]; + options?: AlertOptions; + allowRepeat?: boolean; + }) => { + const currentAlertParams = { title, message, type, hapticFeedback, buttons, options }; + + if (!allowRepeat && lastAlertParams && JSON.stringify(lastAlertParams) === JSON.stringify(currentAlertParams)) { + return; + } + + if (JSON.stringify(lastAlertParams) !== JSON.stringify(currentAlertParams)) { + clearCache(); + } + + lastAlertParams = currentAlertParams; + + if (hapticFeedback) { + triggerHapticFeedback(hapticFeedback); + } + + const wrappedButtons: AlertButton[] = buttons.length > 0 ? buttons : [{ text: loc._.ok, onPress: () => {}, style: 'default' }]; + + switch (type) { + case AlertType.Toast: + if (Platform.OS === 'android') { + ToastAndroid.show(message, ToastAndroid.LONG); + clearCache(); + } else { + // For iOS, treat Toast as a normal alert + showAlert(title, message, wrappedButtons, options); + } + break; + default: + showAlert(title, message, wrappedButtons, options); + break; + } + }; +})(); + +export default presentAlert; diff --git a/components/AmountInput.js b/components/AmountInput.js index 2c67edc528..b8a8c344ce 100644 --- a/components/AmountInput.js +++ b/components/AmountInput.js @@ -1,16 +1,26 @@ -import React, { Component } from 'react'; -import PropTypes from 'prop-types'; import BigNumber from 'bignumber.js'; -import { Badge, Icon, Text } from 'react-native-elements'; +import dayjs from 'dayjs'; +import localizedFormat from 'dayjs/plugin/localizedFormat'; +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; import { Image, LayoutAnimation, Pressable, StyleSheet, TextInput, TouchableOpacity, TouchableWithoutFeedback, View } from 'react-native'; -import { useTheme } from '@react-navigation/native'; +import { Badge, Icon, Text } from '@rneui/themed'; + +import { + fiatToBTC, + getCurrencySymbol, + isRateOutdated, + mostRecentFetchedRate, + satoshiToBTC, + updateExchangeRate, +} from '../blue_modules/currency'; +import { BlueText } from '../BlueComponents'; import confirm from '../helpers/confirm'; +import loc, { formatBalancePlain, formatBalanceWithoutSuffix, removeTrailingZeros } from '../loc'; import { BitcoinUnit } from '../models/bitcoinUnits'; -import loc, { formatBalanceWithoutSuffix, formatBalancePlain, removeTrailingZeros } from '../loc'; -import { BlueText } from '../BlueComponents'; -import dayjs from 'dayjs'; -const currency = require('../blue_modules/currency'); -dayjs.extend(require('dayjs/plugin/localizedFormat')); +import { useTheme } from './themes'; + +dayjs.extend(localizedFormat); class AmountInput extends Component { static propTypes = { @@ -56,13 +66,12 @@ class AmountInput extends Component { } componentDidMount() { - currency - .mostRecentFetchedRate() - .then(mostRecentFetchedRate => { - this.setState({ mostRecentFetchedRate }); + mostRecentFetchedRate() + .then(mostRecentFetchedRateValue => { + this.setState({ mostRecentFetchedRate: mostRecentFetchedRateValue }); }) .finally(() => { - currency.isRateOutdated().then(isRateOutdated => this.setState({ isRateOutdated })); + isRateOutdated().then(isRateOutdatedValue => this.setState({ isRateOutdated: isRateOutdatedValue })); }); } @@ -85,7 +94,7 @@ class AmountInput extends Component { sats = amount; break; case BitcoinUnit.LOCAL_CURRENCY: - sats = new BigNumber(currency.fiatToBTC(amount)).multipliedBy(100000000).toString(); + sats = new BigNumber(fiatToBTC(amount)).multipliedBy(100000000).toString(); break; } if (previousUnit === BitcoinUnit.LOCAL_CURRENCY && AmountInput.conversionCache[amount + previousUnit]) { @@ -190,18 +199,25 @@ class AmountInput extends Component { LayoutAnimation.configureNext(LayoutAnimation.Presets.easeInEaseOut); this.setState({ isRateBeingUpdated: true }, async () => { try { - await currency.updateExchangeRate(); - currency.mostRecentFetchedRate().then(mostRecentFetchedRate => { + await updateExchangeRate(); + mostRecentFetchedRate().then(mostRecentFetchedRateValue => { LayoutAnimation.configureNext(LayoutAnimation.Presets.easeInEaseOut); - this.setState({ mostRecentFetchedRate }); + this.setState({ mostRecentFetchedRate: mostRecentFetchedRateValue }); }); } finally { LayoutAnimation.configureNext(LayoutAnimation.Presets.easeInEaseOut); - this.setState({ isRateBeingUpdated: false, isRateOutdated: await currency.isRateOutdated() }); + this.setState({ isRateBeingUpdated: false, isRateOutdated: await isRateOutdated() }); } }); }; + handleSelectionChange = event => { + const { selection } = event.nativeEvent; + if (selection.start !== selection.end || selection.start !== this.props.amount?.length) { + this.textInput?.setNativeProps({ selection: { start: this.props.amount?.length, end: this.props.amount?.length } }); + } + }; + render() { const { colors, disabled, unit } = this.props; const amount = this.props.amount || 0; @@ -219,11 +235,11 @@ class AmountInput extends Component { secondaryDisplayCurrency = formatBalanceWithoutSuffix((isNaN(amount) ? 0 : amount).toString(), BitcoinUnit.LOCAL_CURRENCY, false); break; case BitcoinUnit.LOCAL_CURRENCY: - secondaryDisplayCurrency = currency.fiatToBTC(parseFloat(isNaN(amount) ? 0 : amount)); + secondaryDisplayCurrency = fiatToBTC(parseFloat(isNaN(amount) ? 0 : amount)); if (AmountInput.conversionCache[isNaN(amount) ? 0 : amount + BitcoinUnit.LOCAL_CURRENCY]) { // cache hit! we reuse old value that supposedly doesn't have rounding errors const sats = AmountInput.conversionCache[isNaN(amount) ? 0 : amount + BitcoinUnit.LOCAL_CURRENCY]; - secondaryDisplayCurrency = currency.satoshiToBTC(sats); + secondaryDisplayCurrency = satoshiToBTC(sats); } break; } @@ -250,11 +266,12 @@ class AmountInput extends Component { {unit === BitcoinUnit.LOCAL_CURRENCY && amount !== BitcoinUnit.MAX && ( - {currency.getCurrencySymbol() + ' '} + {getCurrencySymbol() + ' '} )} {amount !== BitcoinUnit.MAX ? ( { const stylesHook = { text: { - // @ts-ignore: Ignore theme typescript error color: colors.foregroundColor, }, }; diff --git a/components/BlurredBalanceView.tsx b/components/BlurredBalanceView.tsx new file mode 100644 index 0000000000..82cd75b45a --- /dev/null +++ b/components/BlurredBalanceView.tsx @@ -0,0 +1,27 @@ +import React from 'react'; +import { StyleSheet, View } from 'react-native'; +import { Icon } from '@rneui/themed'; + +export const BlurredBalanceView = () => { + return ( + + + + + ); +}; + +const styles = StyleSheet.create({ + container: { + flexDirection: 'row', + alignItems: 'center', + borderRadius: 9, + }, + background: { + backgroundColor: 'rgba(255, 255, 255, 0.5)', + height: 30, + width: 110, + marginRight: 8, + borderRadius: 9, + }, +}); diff --git a/components/BottomModal.js b/components/BottomModal.js deleted file mode 100644 index 40a42979e6..0000000000 --- a/components/BottomModal.js +++ /dev/null @@ -1,76 +0,0 @@ -import React from 'react'; -import PropTypes from 'prop-types'; -import { StyleSheet, Platform, useWindowDimensions, View } from 'react-native'; -import Modal from 'react-native-modal'; -import { BlueButton, BlueSpacing10 } from '../BlueComponents'; -import loc from '../loc'; -import { useTheme } from '@react-navigation/native'; - -const styles = StyleSheet.create({ - root: { - justifyContent: 'flex-end', - margin: 0, - }, - hasDoneButton: { - padding: 16, - paddingBottom: 24, - }, -}); - -const BottomModal = ({ - onBackButtonPress = undefined, - onBackdropPress = undefined, - onClose, - windowHeight = undefined, - windowWidth = undefined, - doneButton = undefined, - avoidKeyboard = false, - allowBackdropPress = true, - ...props -}) => { - const valueWindowHeight = useWindowDimensions().height; - const valueWindowWidth = useWindowDimensions().width; - const handleBackButtonPress = onBackButtonPress ?? onClose; - const handleBackdropPress = allowBackdropPress ? onBackdropPress ?? onClose : undefined; - const { colors } = useTheme(); - const stylesHook = StyleSheet.create({ - hasDoneButton: { - backgroundColor: colors.elevated, - }, - }); - return ( - - {props.children} - {doneButton && ( - - - - - )} - - ); -}; - -BottomModal.propTypes = { - children: PropTypes.oneOfType([PropTypes.arrayOf(PropTypes.element), PropTypes.element]), - onBackButtonPress: PropTypes.func, - onBackdropPress: PropTypes.func, - onClose: PropTypes.func, - doneButton: PropTypes.bool, - windowHeight: PropTypes.number, - windowWidth: PropTypes.number, - avoidKeyboard: PropTypes.bool, - allowBackdropPress: PropTypes.bool, -}; - -export default BottomModal; diff --git a/components/BottomModal.tsx b/components/BottomModal.tsx new file mode 100644 index 0000000000..e765e74e79 --- /dev/null +++ b/components/BottomModal.tsx @@ -0,0 +1,269 @@ +import React, { forwardRef, useImperativeHandle, useRef, ReactElement, ComponentType } from 'react'; +import { SheetSize, SizeInfo, TrueSheet, TrueSheetProps } from '@lodev09/react-native-true-sheet'; +import { Keyboard, Image, StyleSheet, View, TouchableOpacity, Platform, GestureResponderEvent, Text } from 'react-native'; +import SaveFileButton from './SaveFileButton'; +import { useTheme } from './themes'; +import { Icon } from '@rneui/base'; + +interface BottomModalProps extends TrueSheetProps { + children?: React.ReactNode; + onClose?: () => void; + onCloseModalPressed?: () => Promise; + isGrabberVisible?: boolean; + sizes?: SheetSize[] | undefined; + footer?: ReactElement | ComponentType | null; + footerDefaultMargins?: boolean | number; + onPresent?: () => void; + onSizeChange?: (size: SizeInfo) => void; + showCloseButton?: boolean; + shareContent?: BottomModalShareContent; + shareButtonOnPress?: (event: GestureResponderEvent) => void; + header?: ReactElement | ComponentType | null; + headerTitle?: string; + headerSubtitle?: string; +} + +type BottomModalShareContent = { + fileName: string; + fileContent: string; +}; + +export interface BottomModalHandle { + present: () => Promise; + dismiss: () => Promise; +} + +const BottomModal = forwardRef( + ( + { + onClose, + onCloseModalPressed, + onPresent, + onSizeChange, + showCloseButton = true, + isGrabberVisible = true, + shareContent, + shareButtonOnPress, + sizes = ['auto'], + footer, + footerDefaultMargins, + header, + headerTitle, + headerSubtitle, + children, + ...props + }, + ref, + ) => { + const trueSheetRef = useRef(null); + const { colors, closeImage } = useTheme(); + const stylesHook = StyleSheet.create({ + barButton: { + backgroundColor: colors.lightButton, + }, + headerTitle: { + color: colors.foregroundColor, + }, + }); + + useImperativeHandle(ref, () => ({ + present: async () => { + Keyboard.dismiss(); + if (trueSheetRef.current?.present) { + await trueSheetRef.current.present(); + } else { + return Promise.resolve(); + } + }, + dismiss: async () => { + Keyboard.dismiss(); + if (trueSheetRef.current?.dismiss) { + await trueSheetRef.current.dismiss(); + } else { + return Promise.resolve(); + } + }, + })); + + const dismiss = async () => { + try { + await onCloseModalPressed?.(); + await trueSheetRef.current?.dismiss(); + } catch (error) { + console.error('Error during dismiss:', error); + } + }; + + const renderTopRightButton = () => { + const buttons = []; + if (shareContent || shareButtonOnPress) { + if (shareContent) { + buttons.push( + + + , + ); + } else if (shareButtonOnPress) { + buttons.push( + + + , + ); + } + } + if (showCloseButton) { + buttons.push( + + + , + ); + } + return {buttons}; + }; + + const renderHeader = () => { + if (headerTitle || headerSubtitle) { + return ( + + + {headerTitle && {headerTitle}} + {headerSubtitle && {headerSubtitle}} + + {renderTopRightButton()} + + ); + } + + if (showCloseButton || shareContent) + return ( + + {typeof header === 'function' ?
: header} + {renderTopRightButton()} + + ); + + if (React.isValidElement(header)) { + return ( + + {header} + {renderTopRightButton()} + + ); + } + return null; + }; + + const renderFooter = (): ReactElement | undefined => { + if (React.isValidElement(footer)) { + return footerDefaultMargins ? {footer} : footer; + } else if (typeof footer === 'function') { + const ModalFooterComponent = footer as ComponentType; + return ; + } + + return undefined; + }; + + const FooterComponent = renderFooter(); + + return ( + + {children} + {renderHeader()} + + ); + }, +); + +export default BottomModal; + +const styles = StyleSheet.create({ + footerContainer: { + alignItems: 'center', + justifyContent: 'center', + }, + headerContainer: { + position: 'absolute', + flexDirection: 'row', + alignItems: 'center', + paddingVertical: 8, + minHeight: 22, + right: 16, + top: 16, + }, + headerContainerWithCloseButton: { + position: 'absolute', + flexDirection: 'row', + alignItems: 'center', + paddingVertical: 8, + minHeight: 22, + width: '100%', + top: 16, + left: 0, + justifyContent: 'space-between', + }, + headerContent: { + flex: 1, + justifyContent: 'center', + minHeight: 22, + }, + headerTitle: { + fontSize: 18, + fontWeight: 'bold', + }, + headerSubtitle: { + fontSize: 14, + }, + topRightButton: { + justifyContent: 'center', + alignItems: 'center', + width: 30, + height: 30, + borderRadius: 15, + marginLeft: 22, + }, + topRightButtonContainer: { + flexDirection: 'row', + alignItems: 'center', + }, + childrenContainer: { + paddingTop: 66, + paddingHorizontal: 16, + width: '100%', + }, +}); diff --git a/components/Button.tsx b/components/Button.tsx new file mode 100644 index 0000000000..eb78dac544 --- /dev/null +++ b/components/Button.tsx @@ -0,0 +1,94 @@ +import React, { forwardRef } from 'react'; +import { ActivityIndicator, StyleProp, StyleSheet, Text, TouchableOpacity, TouchableOpacityProps, View, ViewStyle } from 'react-native'; +import { Icon } from '@rneui/themed'; + +import { useTheme } from './themes'; + +interface ButtonProps extends TouchableOpacityProps { + backgroundColor?: string; + buttonTextColor?: string; + disabled?: boolean; + testID?: string; + icon?: { + name: string; + type: string; + color: string; + }; + title?: string; + style?: StyleProp; + onPress?: () => void; + showActivityIndicator?: boolean; +} + +export const Button = forwardRef, ButtonProps>((props, ref) => { + const { colors } = useTheme(); + + let backgroundColor = props.backgroundColor ?? colors.mainColor; + let fontColor = props.buttonTextColor ?? colors.buttonTextColor; + if (props.disabled) { + backgroundColor = colors.buttonDisabledBackgroundColor; + fontColor = colors.buttonDisabledTextColor; + } + + const buttonStyle = { + ...styles.button, + backgroundColor, + borderColor: props.disabled ? colors.buttonDisabledBackgroundColor : 'transparent', + }; + + const textStyle = { + ...styles.text, + color: fontColor, + }; + + const buttonView = props.showActivityIndicator ? ( + + ) : ( + <> + {props.icon && } + {props.title && {props.title}} + + ); + + return props.onPress ? ( + + {buttonView} + + ) : ( + {buttonView} + ); +}); + +const styles = StyleSheet.create({ + button: { + borderWidth: 0.7, + minHeight: 45, + height: 48, + maxHeight: 48, + borderRadius: 25, + justifyContent: 'center', + alignItems: 'center', + paddingHorizontal: 16, + flexGrow: 1, + }, + content: { + flexDirection: 'row', + justifyContent: 'center', + alignItems: 'center', + }, + text: { + marginHorizontal: 8, + fontSize: 16, + fontWeight: '600', + }, +}); + +export default Button; diff --git a/components/CoinsSelected.js b/components/CoinsSelected.tsx similarity index 75% rename from components/CoinsSelected.js rename to components/CoinsSelected.tsx index 2c7c12550a..e8b8c17132 100644 --- a/components/CoinsSelected.js +++ b/components/CoinsSelected.tsx @@ -1,7 +1,6 @@ import React from 'react'; -import PropTypes from 'prop-types'; -import { View, Text, TouchableOpacity, StyleSheet } from 'react-native'; -import { Avatar } from 'react-native-elements'; +import { StyleSheet, Text, TouchableOpacity, View } from 'react-native'; +import { Avatar } from '@rneui/themed'; import loc from '../loc'; @@ -34,7 +33,13 @@ const styles = StyleSheet.create({ }, }); -const CoinsSelected = ({ number, onContainerPress, onClose }) => ( +interface CoinsSelectedProps { + number: number; + onContainerPress: () => void; + onClose: () => void; +} + +const CoinsSelected: React.FC = ({ number, onContainerPress, onClose }) => ( {loc.formatString(loc.cc.coins_selected, { number })} @@ -45,10 +50,4 @@ const CoinsSelected = ({ number, onContainerPress, onClose }) => ( ); -CoinsSelected.propTypes = { - number: PropTypes.number.isRequired, - onContainerPress: PropTypes.func.isRequired, - onClose: PropTypes.func.isRequired, -}; - export default CoinsSelected; diff --git a/components/CompanionDelegates.tsx b/components/CompanionDelegates.tsx new file mode 100644 index 0000000000..03f3007f19 --- /dev/null +++ b/components/CompanionDelegates.tsx @@ -0,0 +1,315 @@ +import 'react-native-gesture-handler'; // should be on top + +import { CommonActions } from '@react-navigation/native'; +import { useCallback, useEffect, useRef } from 'react'; +import { AppState, AppStateStatus, Linking } from 'react-native'; +import A from '../blue_modules/analytics'; +import { getClipboardContent } from '../blue_modules/clipboard'; +import { updateExchangeRate } from '../blue_modules/currency'; +import triggerHapticFeedback, { HapticFeedbackTypes } from '../blue_modules/hapticFeedback'; +import { + clearStoredNotifications, + getDeliveredNotifications, + getStoredNotifications, + initializeNotifications, + removeAllDeliveredNotifications, + setApplicationIconBadgeNumber, +} from '../blue_modules/notifications'; +import { LightningCustodianWallet } from '../class'; +import DeeplinkSchemaMatch from '../class/deeplink-schema-match'; +import loc from '../loc'; +import { Chain } from '../models/bitcoinUnits'; +import { navigationRef } from '../NavigationService'; +import ActionSheet from '../screen/ActionSheet'; +import { useStorage } from '../hooks/context/useStorage'; +import RNQRGenerator from 'rn-qr-generator'; +import presentAlert from './Alert'; +import useMenuElements from '../hooks/useMenuElements'; +import useWidgetCommunication from '../hooks/useWidgetCommunication'; +import useWatchConnectivity from '../hooks/useWatchConnectivity'; +import useDeviceQuickActions from '../hooks/useDeviceQuickActions'; +import useHandoffListener from '../hooks/useHandoffListener'; + +const ClipboardContentType = Object.freeze({ + BITCOIN: 'BITCOIN', + LIGHTNING: 'LIGHTNING', +}); + +const CompanionDelegates = () => { + const { wallets, addWallet, saveToDisk, fetchAndSaveWalletTransactions, refreshAllWalletTransactions, setSharedCosigner } = useStorage(); + const appState = useRef(AppState.currentState); + const clipboardContent = useRef(); + + useWatchConnectivity(); + useWidgetCommunication(); + useMenuElements(); + useDeviceQuickActions(); + useHandoffListener(); + + const processPushNotifications = useCallback(async () => { + await new Promise(resolve => setTimeout(resolve, 200)); + try { + const notifications2process = await getStoredNotifications(); + await clearStoredNotifications(); + setApplicationIconBadgeNumber(0); + + const deliveredNotifications = await getDeliveredNotifications(); + setTimeout(async () => { + try { + removeAllDeliveredNotifications(); + } catch (error) { + console.error('Failed to remove delivered notifications:', error); + } + }, 5000); + + // Process notifications + for (const payload of notifications2process) { + const wasTapped = payload.foreground === false || (payload.foreground === true && payload.userInteraction); + + console.log('processing push notification:', payload); + let wallet; + switch (+payload.type) { + case 2: + case 3: + wallet = wallets.find(w => w.weOwnAddress(payload.address)); + break; + case 1: + case 4: + wallet = wallets.find(w => w.weOwnTransaction(payload.txid || payload.hash)); + break; + } + + if (wallet) { + const walletID = wallet.getID(); + fetchAndSaveWalletTransactions(walletID); + if (wasTapped) { + if (payload.type !== 3 || wallet.chain === Chain.OFFCHAIN) { + navigationRef.dispatch( + CommonActions.navigate({ + name: 'WalletTransactions', + params: { + walletID, + walletType: wallet.type, + }, + }), + ); + } else { + navigationRef.navigate('ReceiveDetailsRoot', { + screen: 'ReceiveDetails', + params: { + walletID, + address: payload.address, + }, + }); + } + + return true; + } + } else { + console.log('could not find wallet while processing push notification, NOP'); + } + } + + if (deliveredNotifications.length > 0) { + for (const payload of deliveredNotifications) { + const wasTapped = payload.foreground === false || (payload.foreground === true && payload.userInteraction); + + console.log('processing push notification:', payload); + let wallet; + switch (+payload.type) { + case 2: + case 3: + wallet = wallets.find(w => w.weOwnAddress(payload.address)); + break; + case 1: + case 4: + wallet = wallets.find(w => w.weOwnTransaction(payload.txid || payload.hash)); + break; + } + + if (wallet) { + const walletID = wallet.getID(); + fetchAndSaveWalletTransactions(walletID); + if (wasTapped) { + if (payload.type !== 3 || wallet.chain === Chain.OFFCHAIN) { + navigationRef.dispatch( + CommonActions.navigate({ + name: 'WalletTransactions', + params: { + walletID, + walletType: wallet.type, + }, + }), + ); + } else { + navigationRef.navigate('ReceiveDetailsRoot', { + screen: 'ReceiveDetails', + params: { + walletID, + address: payload.address, + }, + }); + } + + return true; + } + } else { + console.log('could not find wallet while processing push notification, NOP'); + } + } + } + + if (deliveredNotifications.length > 0) { + refreshAllWalletTransactions(); + } + } catch (error) { + console.error('Failed to process push notifications:', error); + } + return false; + }, [fetchAndSaveWalletTransactions, refreshAllWalletTransactions, wallets]); + + useEffect(() => { + initializeNotifications(processPushNotifications); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + const handleOpenURL = useCallback( + async (event: { url: string }): Promise => { + const { url } = event; + + if (url) { + const decodedUrl = decodeURIComponent(url); + const fileName = decodedUrl.split('/').pop()?.toLowerCase(); + + if (fileName && /\.(jpe?g|png)$/i.test(fileName)) { + try { + const values = await RNQRGenerator.detect({ + uri: decodedUrl, + }); + + if (values && values.values.length > 0) { + triggerHapticFeedback(HapticFeedbackTypes.NotificationSuccess); + DeeplinkSchemaMatch.navigationRouteFor( + { url: values.values[0] }, + (value: [string, any]) => navigationRef.navigate(...value), + { + wallets, + addWallet, + saveToDisk, + setSharedCosigner, + }, + ); + } else { + triggerHapticFeedback(HapticFeedbackTypes.NotificationError); + presentAlert({ message: loc.send.qr_error_no_qrcode }); + } + } catch (error) { + console.error('Error detecting QR code:', error); + } + } else { + DeeplinkSchemaMatch.navigationRouteFor(event, (value: [string, any]) => navigationRef.navigate(...value), { + wallets, + addWallet, + saveToDisk, + setSharedCosigner, + }); + } + } + }, + [wallets, addWallet, saveToDisk, setSharedCosigner], + ); + const showClipboardAlert = useCallback( + ({ contentType }: { contentType: undefined | string }) => { + triggerHapticFeedback(HapticFeedbackTypes.ImpactLight); + getClipboardContent().then(clipboard => { + if (!clipboard) return; + ActionSheet.showActionSheetWithOptions( + { + title: loc._.clipboard, + message: contentType === ClipboardContentType.BITCOIN ? loc.wallets.clipboard_bitcoin : loc.wallets.clipboard_lightning, + options: [loc._.cancel, loc._.continue], + cancelButtonIndex: 0, + }, + buttonIndex => { + switch (buttonIndex) { + case 0: + break; + case 1: + handleOpenURL({ url: clipboard }); + break; + } + }, + ); + }); + }, + [handleOpenURL], + ); + + const handleAppStateChange = useCallback( + async (nextAppState: AppStateStatus | undefined) => { + if (wallets.length === 0) return; + if ((appState.current.match(/background/) && nextAppState === 'active') || nextAppState === undefined) { + setTimeout(() => A(A.ENUM.APP_UNSUSPENDED), 2000); + updateExchangeRate(); + const processed = await processPushNotifications(); + if (processed) return; + const clipboard = await getClipboardContent(); + if (!clipboard) return; + const isAddressFromStoredWallet = wallets.some(wallet => { + if (wallet.chain === Chain.ONCHAIN) { + return wallet.isAddressValid && wallet.isAddressValid(clipboard) && wallet.weOwnAddress(clipboard); + } else { + return (wallet as LightningCustodianWallet).isInvoiceGeneratedByWallet(clipboard) || wallet.weOwnAddress(clipboard); + } + }); + const isBitcoinAddress = DeeplinkSchemaMatch.isBitcoinAddress(clipboard); + const isLightningInvoice = DeeplinkSchemaMatch.isLightningInvoice(clipboard); + const isLNURL = DeeplinkSchemaMatch.isLnUrl(clipboard); + const isBothBitcoinAndLightning = DeeplinkSchemaMatch.isBothBitcoinAndLightning(clipboard); + if ( + !isAddressFromStoredWallet && + clipboardContent.current !== clipboard && + (isBitcoinAddress || isLightningInvoice || isLNURL || isBothBitcoinAndLightning) + ) { + let contentType; + if (isBitcoinAddress) { + contentType = ClipboardContentType.BITCOIN; + } else if (isLightningInvoice || isLNURL) { + contentType = ClipboardContentType.LIGHTNING; + } else if (isBothBitcoinAndLightning) { + contentType = ClipboardContentType.BITCOIN; + } + showClipboardAlert({ contentType }); + } + clipboardContent.current = clipboard; + } + if (nextAppState) { + appState.current = nextAppState; + } + }, + [processPushNotifications, showClipboardAlert, wallets], + ); + + const addListeners = useCallback(() => { + const urlSubscription = Linking.addEventListener('url', handleOpenURL); + const appStateSubscription = AppState.addEventListener('change', handleAppStateChange); + + return { + urlSubscription, + appStateSubscription, + }; + }, [handleOpenURL, handleAppStateChange]); + + useEffect(() => { + const subscriptions = addListeners(); + + return () => { + subscriptions.urlSubscription?.remove(); + subscriptions.appStateSubscription?.remove(); + }; + }, [addListeners]); + + return null; +}; + +export default CompanionDelegates; diff --git a/components/Context/LargeScreenProvider.tsx b/components/Context/LargeScreenProvider.tsx new file mode 100644 index 0000000000..be03fba0c5 --- /dev/null +++ b/components/Context/LargeScreenProvider.tsx @@ -0,0 +1,55 @@ +import React, { createContext, ReactNode, useEffect, useMemo, useState } from 'react'; +import { Dimensions } from 'react-native'; + +import { isDesktop, isTablet } from '../../blue_modules/environment'; + +type ScreenSize = 'Handheld' | 'LargeScreen' | undefined; + +interface ILargeScreenContext { + isLargeScreen: boolean; + setLargeScreenValue: (value: ScreenSize) => void; +} + +export const LargeScreenContext = createContext(undefined); + +interface LargeScreenProviderProps { + children: ReactNode; +} + +export const LargeScreenProvider: React.FC = ({ children }) => { + const [windowWidth, setWindowWidth] = useState(Dimensions.get('window').width); + const [largeScreenValue, setLargeScreenValue] = useState(undefined); + + useEffect(() => { + const updateScreenUsage = (): void => { + const newWindowWidth = Dimensions.get('window').width; + if (newWindowWidth !== windowWidth) { + setWindowWidth(newWindowWidth); + } + }; + + const subscription = Dimensions.addEventListener('change', updateScreenUsage); + return () => subscription.remove(); + }, [windowWidth]); + + const isLargeScreen: boolean = useMemo(() => { + if (largeScreenValue === 'LargeScreen') { + return true; + } else if (largeScreenValue === 'Handheld') { + return false; + } + const screenWidth: number = Dimensions.get('screen').width; + const halfScreenWidth = windowWidth >= screenWidth / 2; + return (isTablet && halfScreenWidth) || isDesktop; + }, [windowWidth, largeScreenValue]); + + const contextValue = useMemo( + () => ({ + isLargeScreen, + setLargeScreenValue, + }), + [isLargeScreen, setLargeScreenValue], + ); + + return {children}; +}; diff --git a/components/Context/SettingsProvider.tsx b/components/Context/SettingsProvider.tsx new file mode 100644 index 0000000000..15a4cdaa3c --- /dev/null +++ b/components/Context/SettingsProvider.tsx @@ -0,0 +1,407 @@ +import React, { createContext, useCallback, useEffect, useMemo, useState } from 'react'; +import DefaultPreference from 'react-native-default-preference'; +import { isReadClipboardAllowed, setReadClipboardAllowed } from '../../blue_modules/clipboard'; +import { getPreferredCurrency, GROUP_IO_BLUEWALLET, initCurrencyDaemon, setPreferredCurrency } from '../../blue_modules/currency'; +import { clearUseURv1, isURv1Enabled, setUseURv1 } from '../../blue_modules/ur'; +import { BlueApp } from '../../class'; +import { saveLanguage, STORAGE_KEY } from '../../loc'; +import { FiatUnit, TFiatUnit } from '../../models/fiatUnit'; +import { + getEnabled as getIsDeviceQuickActionsEnabled, + setEnabled as setIsDeviceQuickActionsEnabled, +} from '../../hooks/useDeviceQuickActions'; +import { getIsHandOffUseEnabled, setIsHandOffUseEnabled } from '../HandOffComponent'; +import { useStorage } from '../../hooks/context/useStorage'; +import { BitcoinUnit } from '../../models/bitcoinUnits'; +import { TotalWalletsBalanceKey, TotalWalletsBalancePreferredUnit } from '../TotalWalletsBalance'; +import { BLOCK_EXPLORERS, getBlockExplorerUrl, saveBlockExplorer, BlockExplorer, normalizeUrl } from '../../models/blockExplorer'; +import * as BlueElectrum from '../../blue_modules/BlueElectrum'; +import { isBalanceDisplayAllowed, setBalanceDisplayAllowed } from '../../hooks/useWidgetCommunication'; +import AsyncStorage from '@react-native-async-storage/async-storage'; + +const getDoNotTrackStorage = async (): Promise => { + try { + await DefaultPreference.setName(GROUP_IO_BLUEWALLET); + const doNotTrack = await DefaultPreference.get(BlueApp.DO_NOT_TRACK); + return doNotTrack === '1'; + } catch { + console.error('Error getting DoNotTrack'); + return false; + } +}; + +export const setTotalBalanceViewEnabledStorage = async (value: boolean): Promise => { + try { + await DefaultPreference.setName(GROUP_IO_BLUEWALLET); + await DefaultPreference.set(TotalWalletsBalanceKey, value ? 'true' : 'false'); + console.debug('setTotalBalanceViewEnabledStorage value:', value); + } catch (e) { + console.error('Error setting TotalBalanceViewEnabled:', e); + } +}; + +export const getIsTotalBalanceViewEnabled = async (): Promise => { + try { + await DefaultPreference.setName(GROUP_IO_BLUEWALLET); + const isEnabledValue = (await DefaultPreference.get(TotalWalletsBalanceKey)) ?? 'true'; + console.debug('getIsTotalBalanceViewEnabled', isEnabledValue); + return isEnabledValue === 'true'; + } catch (e) { + console.error('Error getting TotalBalanceViewEnabled:', e); + return true; + } +}; + +export const setTotalBalancePreferredUnitStorageFunc = async (unit: BitcoinUnit): Promise => { + try { + await DefaultPreference.setName(GROUP_IO_BLUEWALLET); + await DefaultPreference.set(TotalWalletsBalancePreferredUnit, unit); + } catch (e) { + console.error('Error setting TotalBalancePreferredUnit:', e); + } +}; + +export const getTotalBalancePreferredUnit = async (): Promise => { + try { + await DefaultPreference.setName(GROUP_IO_BLUEWALLET); + const unit = (await DefaultPreference.get(TotalWalletsBalancePreferredUnit)) as BitcoinUnit | null; + return unit ?? BitcoinUnit.BTC; + } catch (e) { + console.error('Error getting TotalBalancePreferredUnit:', e); + return BitcoinUnit.BTC; + } +}; + +interface SettingsContextType { + preferredFiatCurrency: TFiatUnit; + setPreferredFiatCurrencyStorage: (currency: TFiatUnit) => Promise; + language: string; + setLanguageStorage: (language: string) => Promise; + isHandOffUseEnabled: boolean; + setIsHandOffUseEnabledAsyncStorage: (value: boolean) => Promise; + isPrivacyBlurEnabled: boolean; + setIsPrivacyBlurEnabled: (value: boolean) => void; + isDoNotTrackEnabled: boolean; + setDoNotTrackStorage: (value: boolean) => Promise; + isWidgetBalanceDisplayAllowed: boolean; + setIsWidgetBalanceDisplayAllowedStorage: (value: boolean) => Promise; + isLegacyURv1Enabled: boolean; + setIsLegacyURv1EnabledStorage: (value: boolean) => Promise; + isClipboardGetContentEnabled: boolean; + setIsClipboardGetContentEnabledStorage: (value: boolean) => Promise; + isQuickActionsEnabled: boolean; + setIsQuickActionsEnabledStorage: (value: boolean) => Promise; + isTotalBalanceEnabled: boolean; + setIsTotalBalanceEnabledStorage: (value: boolean) => Promise; + totalBalancePreferredUnit: BitcoinUnit; + setTotalBalancePreferredUnitStorage: (unit: BitcoinUnit) => Promise; + isDrawerShouldHide: boolean; + setIsDrawerShouldHide: (value: boolean) => void; + selectedBlockExplorer: BlockExplorer; + setBlockExplorerStorage: (explorer: BlockExplorer) => Promise; + isElectrumDisabled: boolean; + setIsElectrumDisabled: (value: boolean) => void; +} + +const defaultSettingsContext: SettingsContextType = { + preferredFiatCurrency: FiatUnit.USD, + setPreferredFiatCurrencyStorage: async () => {}, + language: 'en', + setLanguageStorage: async () => {}, + isHandOffUseEnabled: false, + setIsHandOffUseEnabledAsyncStorage: async () => {}, + isPrivacyBlurEnabled: true, + setIsPrivacyBlurEnabled: () => {}, + isDoNotTrackEnabled: false, + setDoNotTrackStorage: async () => {}, + isWidgetBalanceDisplayAllowed: true, + setIsWidgetBalanceDisplayAllowedStorage: async () => {}, + isLegacyURv1Enabled: false, + setIsLegacyURv1EnabledStorage: async () => {}, + isClipboardGetContentEnabled: true, + setIsClipboardGetContentEnabledStorage: async () => {}, + isQuickActionsEnabled: true, + setIsQuickActionsEnabledStorage: async () => {}, + isTotalBalanceEnabled: true, + setIsTotalBalanceEnabledStorage: async () => {}, + totalBalancePreferredUnit: BitcoinUnit.BTC, + setTotalBalancePreferredUnitStorage: async () => {}, + isDrawerShouldHide: false, + setIsDrawerShouldHide: () => {}, + selectedBlockExplorer: BLOCK_EXPLORERS.default, + setBlockExplorerStorage: async () => false, + isElectrumDisabled: false, + setIsElectrumDisabled: () => {}, +}; + +export const SettingsContext = createContext(defaultSettingsContext); + +export const SettingsProvider: React.FC<{ children: React.ReactNode }> = React.memo(({ children }) => { + const [preferredFiatCurrency, setPreferredFiatCurrencyState] = useState(FiatUnit.USD); + const [language, setLanguage] = useState('en'); + const [isHandOffUseEnabled, setIsHandOffUseEnabledState] = useState(false); + const [isPrivacyBlurEnabled, setIsPrivacyBlurEnabled] = useState(true); + const [isDoNotTrackEnabled, setIsDoNotTrackEnabled] = useState(false); + const [isWidgetBalanceDisplayAllowed, setIsWidgetBalanceDisplayAllowed] = useState(true); + const [isLegacyURv1Enabled, setIsLegacyURv1Enabled] = useState(false); + const [isClipboardGetContentEnabled, setIsClipboardGetContentEnabled] = useState(true); + const [isQuickActionsEnabled, setIsQuickActionsEnabled] = useState(true); + const [isTotalBalanceEnabled, setIsTotalBalanceEnabled] = useState(true); + const [totalBalancePreferredUnit, setTotalBalancePreferredUnit] = useState(BitcoinUnit.BTC); + const [isDrawerShouldHide, setIsDrawerShouldHide] = useState(false); + const [selectedBlockExplorer, setSelectedBlockExplorer] = useState(BLOCK_EXPLORERS.default); + const [isElectrumDisabled, setIsElectrumDisabled] = useState(true); + + const { walletsInitialized } = useStorage(); + + useEffect(() => { + const loadSettings = async () => { + try { + await DefaultPreference.setName(GROUP_IO_BLUEWALLET); + } catch (e) { + console.error('Error setting preference name:', e); + } + + const promises: Promise[] = [ + BlueElectrum.isDisabled().then(disabled => { + setIsElectrumDisabled(disabled); + }), + getIsHandOffUseEnabled().then(handOff => { + setIsHandOffUseEnabledState(handOff); + }), + AsyncStorage.getItem(STORAGE_KEY).then(lang => { + setLanguage(lang ?? 'en'); + }), + isBalanceDisplayAllowed().then(balanceDisplayAllowed => { + setIsWidgetBalanceDisplayAllowed(balanceDisplayAllowed); + }), + isURv1Enabled().then(urv1Enabled => { + setIsLegacyURv1Enabled(urv1Enabled); + }), + isReadClipboardAllowed().then(clipboardEnabled => { + setIsClipboardGetContentEnabled(clipboardEnabled); + }), + getIsDeviceQuickActionsEnabled().then(quickActionsEnabled => { + setIsQuickActionsEnabled(quickActionsEnabled); + }), + getDoNotTrackStorage().then(doNotTrack => { + setIsDoNotTrackEnabled(doNotTrack); + }), + getIsTotalBalanceViewEnabled().then(totalBalanceEnabled => { + setIsTotalBalanceEnabled(totalBalanceEnabled); + }), + getTotalBalancePreferredUnit().then(preferredUnit => { + setTotalBalancePreferredUnit(preferredUnit); + }), + getBlockExplorerUrl().then(url => { + const predefinedExplorer = Object.values(BLOCK_EXPLORERS).find(explorer => normalizeUrl(explorer.url) === normalizeUrl(url)); + setSelectedBlockExplorer(predefinedExplorer ?? ({ key: 'custom', name: 'Custom', url } as BlockExplorer)); + }), + ]; + + const results = await Promise.allSettled(promises); + + results.forEach((result, index) => { + if (result.status === 'rejected') { + console.error(`Error loading setting ${index}:`, result.reason); + } + }); + }; + + loadSettings(); + }, []); + + useEffect(() => { + initCurrencyDaemon() + .then(getPreferredCurrency) + .then(currency => { + console.debug('SettingsContext currency:', currency); + setPreferredFiatCurrencyState(currency as TFiatUnit); + }) + .catch(e => { + console.error('Error initializing currency daemon or getting preferred currency:', e); + }); + }, []); + + useEffect(() => { + if (walletsInitialized) { + isElectrumDisabled ? BlueElectrum.forceDisconnect() : BlueElectrum.connectMain(); + } + }, [isElectrumDisabled, walletsInitialized]); + + const setPreferredFiatCurrencyStorage = useCallback(async (currency: TFiatUnit): Promise => { + try { + await setPreferredCurrency(currency); + setPreferredFiatCurrencyState(currency); + } catch (e) { + console.error('Error setting preferredFiatCurrency:', e); + } + }, []); + + const setLanguageStorage = useCallback(async (newLanguage: string): Promise => { + try { + await saveLanguage(newLanguage); + setLanguage(newLanguage); + } catch (e) { + console.error('Error setting language:', e); + } + }, []); + + const setDoNotTrackStorage = useCallback(async (value: boolean): Promise => { + try { + await DefaultPreference.setName(GROUP_IO_BLUEWALLET); + if (value) { + await DefaultPreference.set(BlueApp.DO_NOT_TRACK, '1'); + } else { + await DefaultPreference.clear(BlueApp.DO_NOT_TRACK); + } + setIsDoNotTrackEnabled(value); + } catch (e) { + console.error('Error setting DoNotTrack:', e); + } + }, []); + + const setIsHandOffUseEnabledAsyncStorage = useCallback(async (value: boolean): Promise => { + try { + console.debug('setIsHandOffUseEnabledAsyncStorage', value); + await setIsHandOffUseEnabled(value); + setIsHandOffUseEnabledState(value); + } catch (e) { + console.error('Error setting isHandOffUseEnabled:', e); + } + }, []); + + const setIsWidgetBalanceDisplayAllowedStorage = useCallback(async (value: boolean): Promise => { + try { + await setBalanceDisplayAllowed(value); + setIsWidgetBalanceDisplayAllowed(value); + } catch (e) { + console.error('Error setting isWidgetBalanceDisplayAllowed:', e); + } + }, []); + + const setIsLegacyURv1EnabledStorage = useCallback(async (value: boolean): Promise => { + try { + if (value) { + await setUseURv1(); + } else { + await clearUseURv1(); + } + setIsLegacyURv1Enabled(value); + } catch (e) { + console.error('Error setting isLegacyURv1Enabled:', e); + } + }, []); + + const setIsClipboardGetContentEnabledStorage = useCallback(async (value: boolean): Promise => { + try { + await setReadClipboardAllowed(value); + setIsClipboardGetContentEnabled(value); + } catch (e) { + console.error('Error setting isClipboardGetContentEnabled:', e); + } + }, []); + + const setIsQuickActionsEnabledStorage = useCallback(async (value: boolean): Promise => { + try { + await setIsDeviceQuickActionsEnabled(value); + setIsQuickActionsEnabled(value); + } catch (e) { + console.error('Error setting isQuickActionsEnabled:', e); + } + }, []); + const setIsTotalBalanceEnabledStorage = useCallback(async (value: boolean): Promise => { + try { + await setTotalBalanceViewEnabledStorage(value); + setIsTotalBalanceEnabled(value); + } catch (e) { + console.error('Error setting isTotalBalanceEnabled:', e); + } + }, []); + + const setTotalBalancePreferredUnitStorage = useCallback(async (unit: BitcoinUnit): Promise => { + try { + await setTotalBalancePreferredUnitStorageFunc(unit); + setTotalBalancePreferredUnit(unit); + } catch (e) { + console.error('Error setting totalBalancePreferredUnit:', e); + } + }, []); + + const setBlockExplorerStorage = useCallback(async (explorer: BlockExplorer): Promise => { + try { + const success = await saveBlockExplorer(explorer.url); + if (success) { + setSelectedBlockExplorer(explorer); + } + return success; + } catch (e) { + console.error('Error setting BlockExplorer:', e); + return false; + } + }, []); + + const value = useMemo( + () => ({ + preferredFiatCurrency, + setPreferredFiatCurrencyStorage, + language, + setLanguageStorage, + isHandOffUseEnabled, + setIsHandOffUseEnabledAsyncStorage, + isPrivacyBlurEnabled, + setIsPrivacyBlurEnabled, + isDoNotTrackEnabled, + setDoNotTrackStorage, + isWidgetBalanceDisplayAllowed, + setIsWidgetBalanceDisplayAllowedStorage, + isLegacyURv1Enabled, + setIsLegacyURv1EnabledStorage, + isClipboardGetContentEnabled, + setIsClipboardGetContentEnabledStorage, + isQuickActionsEnabled, + setIsQuickActionsEnabledStorage, + isTotalBalanceEnabled, + setIsTotalBalanceEnabledStorage, + totalBalancePreferredUnit, + setTotalBalancePreferredUnitStorage, + isDrawerShouldHide, + setIsDrawerShouldHide, + selectedBlockExplorer, + setBlockExplorerStorage, + isElectrumDisabled, + setIsElectrumDisabled, + }), + [ + preferredFiatCurrency, + setPreferredFiatCurrencyStorage, + language, + setLanguageStorage, + isHandOffUseEnabled, + setIsHandOffUseEnabledAsyncStorage, + isPrivacyBlurEnabled, + setIsPrivacyBlurEnabled, + isDoNotTrackEnabled, + setDoNotTrackStorage, + isWidgetBalanceDisplayAllowed, + setIsWidgetBalanceDisplayAllowedStorage, + isLegacyURv1Enabled, + setIsLegacyURv1EnabledStorage, + isClipboardGetContentEnabled, + setIsClipboardGetContentEnabledStorage, + isQuickActionsEnabled, + setIsQuickActionsEnabledStorage, + isTotalBalanceEnabled, + setIsTotalBalanceEnabledStorage, + totalBalancePreferredUnit, + setTotalBalancePreferredUnitStorage, + isDrawerShouldHide, + setIsDrawerShouldHide, + selectedBlockExplorer, + setBlockExplorerStorage, + isElectrumDisabled, + ], + ); + + return {children}; +}); diff --git a/components/Context/StorageProvider.tsx b/components/Context/StorageProvider.tsx new file mode 100644 index 0000000000..f96587bc60 --- /dev/null +++ b/components/Context/StorageProvider.tsx @@ -0,0 +1,299 @@ +import React, { createContext, useCallback, useEffect, useMemo, useRef, useState } from 'react'; +import { InteractionManager } from 'react-native'; +import A from '../../blue_modules/analytics'; +import { BlueApp as BlueAppClass, LegacyWallet, TCounterpartyMetadata, TTXMetadata, WatchOnlyWallet } from '../../class'; +import type { TWallet } from '../../class/wallets/types'; +import presentAlert from '../../components/Alert'; +import loc from '../../loc'; +import * as BlueElectrum from '../../blue_modules/BlueElectrum'; +import triggerHapticFeedback, { HapticFeedbackTypes } from '../../blue_modules/hapticFeedback'; +import { startAndDecrypt } from '../../blue_modules/start-and-decrypt'; +import { majorTomToGroundControl } from '../../blue_modules/notifications'; + +const BlueApp = BlueAppClass.getInstance(); + +// hashmap of timestamps we _started_ refetching some wallet +const _lastTimeTriedToRefetchWallet: { [walletID: string]: number } = {}; + +interface StorageContextType { + wallets: TWallet[]; + setWalletsWithNewOrder: (wallets: TWallet[]) => void; + txMetadata: TTXMetadata; + counterpartyMetadata: TCounterpartyMetadata; + saveToDisk: (force?: boolean) => Promise; + selectedWalletID: string | undefined; + setSelectedWalletID: (walletID: string | undefined) => void; + addWallet: (wallet: TWallet) => void; + deleteWallet: (wallet: TWallet) => void; + currentSharedCosigner: string; + setSharedCosigner: (cosigner: string) => void; + addAndSaveWallet: (wallet: TWallet) => Promise; + fetchAndSaveWalletTransactions: (walletID: string) => Promise; + walletsInitialized: boolean; + setWalletsInitialized: (initialized: boolean) => void; + refreshAllWalletTransactions: (lastSnappedTo?: number, showUpdateStatusIndicator?: boolean) => Promise; + resetWallets: () => void; + walletTransactionUpdateStatus: WalletTransactionsStatus | string; + setWalletTransactionUpdateStatus: (status: WalletTransactionsStatus | string) => void; + getTransactions: typeof BlueApp.getTransactions; + fetchWalletBalances: typeof BlueApp.fetchWalletBalances; + fetchWalletTransactions: typeof BlueApp.fetchWalletTransactions; + getBalance: typeof BlueApp.getBalance; + isStorageEncrypted: typeof BlueApp.storageIsEncrypted; + startAndDecrypt: typeof startAndDecrypt; + encryptStorage: typeof BlueApp.encryptStorage; + sleep: typeof BlueApp.sleep; + createFakeStorage: typeof BlueApp.createFakeStorage; + decryptStorage: typeof BlueApp.decryptStorage; + isPasswordInUse: typeof BlueApp.isPasswordInUse; + cachedPassword: typeof BlueApp.cachedPassword; + getItem: typeof BlueApp.getItem; + setItem: typeof BlueApp.setItem; +} + +export enum WalletTransactionsStatus { + NONE = 'NONE', + ALL = 'ALL', +} + +// @ts-ignore default value does not match the type +export const StorageContext = createContext(undefined); + +export const StorageProvider = ({ children }: { children: React.ReactNode }) => { + const txMetadata = useRef(BlueApp.tx_metadata); + const counterpartyMetadata = useRef(BlueApp.counterparty_metadata || {}); // init + + const [wallets, setWallets] = useState([]); + const [selectedWalletID, setSelectedWalletID] = useState(); + const [walletTransactionUpdateStatus, setWalletTransactionUpdateStatus] = useState( + WalletTransactionsStatus.NONE, + ); + const [walletsInitialized, setWalletsInitialized] = useState(false); + const [currentSharedCosigner, setCurrentSharedCosigner] = useState(''); + + const saveToDisk = useCallback( + async (force: boolean = false) => { + if (!force && BlueApp.getWallets().length === 0) { + console.debug('Not saving empty wallets array'); + return; + } + await InteractionManager.runAfterInteractions(async () => { + BlueApp.tx_metadata = txMetadata.current; + BlueApp.counterparty_metadata = counterpartyMetadata.current; + await BlueApp.saveToDisk(); + const w: TWallet[] = [...BlueApp.getWallets()]; + setWallets(w); + }); + }, + // eslint-disable-next-line react-hooks/exhaustive-deps + [txMetadata.current, counterpartyMetadata.current], + ); + + const addWallet = useCallback((wallet: TWallet) => { + BlueApp.wallets.push(wallet); + setWallets([...BlueApp.getWallets()]); + }, []); + + const deleteWallet = useCallback((wallet: TWallet) => { + BlueApp.deleteWallet(wallet); + setWallets([...BlueApp.getWallets()]); + }, []); + + const resetWallets = useCallback(() => { + setWallets(BlueApp.getWallets()); + }, []); + + const setWalletsWithNewOrder = useCallback( + (wlts: TWallet[]) => { + BlueApp.wallets = wlts; + saveToDisk(); + }, + [saveToDisk], + ); + + // Initialize wallets and connect to Electrum + useEffect(() => { + if (walletsInitialized) { + txMetadata.current = BlueApp.tx_metadata; + counterpartyMetadata.current = BlueApp.counterparty_metadata; + setWallets(BlueApp.getWallets()); + } + }, [walletsInitialized]); + + const refreshAllWalletTransactions = useCallback( + async (lastSnappedTo?: number, showUpdateStatusIndicator: boolean = true) => { + const TIMEOUT_DURATION = 30000; + + const timeoutPromise = new Promise((_resolve, reject) => + setTimeout(() => { + reject(new Error('refreshAllWalletTransactions: Timeout reached')); + }, TIMEOUT_DURATION), + ); + + const mainLogicPromise = new Promise((resolve, reject) => { + InteractionManager.runAfterInteractions(async () => { + let noErr = true; + try { + await BlueElectrum.waitTillConnected(); + if (showUpdateStatusIndicator) { + setWalletTransactionUpdateStatus(WalletTransactionsStatus.ALL); + } + const paymentCodesStart = Date.now(); + await BlueApp.fetchSenderPaymentCodes(lastSnappedTo); + const paymentCodesEnd = Date.now(); + console.debug('fetch payment codes took', (paymentCodesEnd - paymentCodesStart) / 1000, 'sec'); + + const balanceStart = Date.now(); + await BlueApp.fetchWalletBalances(lastSnappedTo); + const balanceEnd = Date.now(); + console.debug('fetch balance took', (balanceEnd - balanceStart) / 1000, 'sec'); + + const start = Date.now(); + await BlueApp.fetchWalletTransactions(lastSnappedTo); + const end = Date.now(); + console.debug('fetch tx took', (end - start) / 1000, 'sec'); + } catch (err) { + noErr = false; + console.error(err); + reject(err); + } finally { + setWalletTransactionUpdateStatus(WalletTransactionsStatus.NONE); + } + if (noErr) await saveToDisk(); + resolve(); + }); + }); + + try { + await Promise.race([mainLogicPromise, timeoutPromise]); + } catch (err) { + console.error('Error in refreshAllWalletTransactions:', err); + } finally { + setWalletTransactionUpdateStatus(WalletTransactionsStatus.NONE); + } + }, + [saveToDisk], + ); + + const fetchAndSaveWalletTransactions = useCallback( + async (walletID: string) => { + await InteractionManager.runAfterInteractions(async () => { + const index = wallets.findIndex(wallet => wallet.getID() === walletID); + let noErr = true; + try { + if (Date.now() - (_lastTimeTriedToRefetchWallet[walletID] || 0) < 5000) { + console.debug('Re-fetch wallet happens too fast; NOP'); + return; + } + _lastTimeTriedToRefetchWallet[walletID] = Date.now(); + + await BlueElectrum.waitTillConnected(); + setWalletTransactionUpdateStatus(walletID); + const balanceStart = Date.now(); + await BlueApp.fetchWalletBalances(index); + const balanceEnd = Date.now(); + console.debug('fetch balance took', (balanceEnd - balanceStart) / 1000, 'sec'); + const start = Date.now(); + await BlueApp.fetchWalletTransactions(index); + const end = Date.now(); + console.debug('fetch tx took', (end - start) / 1000, 'sec'); + } catch (err) { + noErr = false; + console.error(err); + } finally { + setWalletTransactionUpdateStatus(WalletTransactionsStatus.NONE); + } + if (noErr) await saveToDisk(); + }); + }, + [saveToDisk, wallets], + ); + + const addAndSaveWallet = useCallback( + async (w: TWallet) => { + if (wallets.some(i => i.getID() === w.getID())) { + triggerHapticFeedback(HapticFeedbackTypes.NotificationError); + presentAlert({ message: 'This wallet has been previously imported.' }); + return; + } + const emptyWalletLabel = new LegacyWallet().getLabel(); + triggerHapticFeedback(HapticFeedbackTypes.NotificationSuccess); + if (w.getLabel() === emptyWalletLabel) w.setLabel(loc.wallets.import_imported + ' ' + w.typeReadable); + w.setUserHasSavedExport(true); + addWallet(w); + await saveToDisk(); + A(A.ENUM.CREATED_WALLET); + presentAlert({ + hapticFeedback: HapticFeedbackTypes.ImpactHeavy, + message: w.type === WatchOnlyWallet.type ? loc.wallets.import_success_watchonly : loc.wallets.import_success, + }); + + await w.fetchBalance(); + try { + await majorTomToGroundControl(w.getAllExternalAddresses(), [], []); + } catch (error) { + console.warn('Failed to setup notifications:', error); + // Consider if user should be notified of notification setup failure + } + }, + [wallets, addWallet, saveToDisk], + ); + + const value: StorageContextType = useMemo( + () => ({ + wallets, + setWalletsWithNewOrder, + txMetadata: txMetadata.current, + counterpartyMetadata: counterpartyMetadata.current, + saveToDisk, + getTransactions: BlueApp.getTransactions, + selectedWalletID, + setSelectedWalletID, + addWallet, + deleteWallet, + currentSharedCosigner, + setSharedCosigner: setCurrentSharedCosigner, + addAndSaveWallet, + setItem: BlueApp.setItem, + getItem: BlueApp.getItem, + fetchWalletBalances: BlueApp.fetchWalletBalances, + fetchWalletTransactions: BlueApp.fetchWalletTransactions, + fetchAndSaveWalletTransactions, + isStorageEncrypted: BlueApp.storageIsEncrypted, + encryptStorage: BlueApp.encryptStorage, + startAndDecrypt, + cachedPassword: BlueApp.cachedPassword, + getBalance: BlueApp.getBalance, + walletsInitialized, + setWalletsInitialized, + refreshAllWalletTransactions, + sleep: BlueApp.sleep, + createFakeStorage: BlueApp.createFakeStorage, + resetWallets, + decryptStorage: BlueApp.decryptStorage, + isPasswordInUse: BlueApp.isPasswordInUse, + walletTransactionUpdateStatus, + setWalletTransactionUpdateStatus, + }), + [ + wallets, + setWalletsWithNewOrder, + saveToDisk, + selectedWalletID, + setSelectedWalletID, + addWallet, + deleteWallet, + currentSharedCosigner, + addAndSaveWallet, + fetchAndSaveWalletTransactions, + walletsInitialized, + setWalletsInitialized, + refreshAllWalletTransactions, + resetWallets, + walletTransactionUpdateStatus, + setWalletTransactionUpdateStatus, + ], + ); + + return {children}; +}; diff --git a/components/CopyTextToClipboard.tsx b/components/CopyTextToClipboard.tsx new file mode 100644 index 0000000000..db49653d6e --- /dev/null +++ b/components/CopyTextToClipboard.tsx @@ -0,0 +1,68 @@ +import Clipboard from '@react-native-clipboard/clipboard'; +import React, { forwardRef, useEffect, useState } from 'react'; +import { Animated, StyleSheet, TouchableOpacity, View } from 'react-native'; + +import triggerHapticFeedback, { HapticFeedbackTypes } from '../blue_modules/hapticFeedback'; +import loc from '../loc'; + +type CopyTextToClipboardProps = { + text: string; + truncated?: boolean; +}; + +const styleCopyTextToClipboard = StyleSheet.create({ + address: { + marginVertical: 32, + fontSize: 15, + color: '#9aa0aa', + textAlign: 'center', + }, +}); + +const CopyTextToClipboard = forwardRef, CopyTextToClipboardProps>(({ text, truncated }, ref) => { + const [hasTappedText, setHasTappedText] = useState(false); + const [address, setAddress] = useState(text); + + useEffect(() => { + if (!hasTappedText) { + setAddress(text); + } + }, [text, hasTappedText]); + + const copyToClipboard = () => { + setHasTappedText(true); + Clipboard.setString(text); + triggerHapticFeedback(HapticFeedbackTypes.Selection); + setAddress(loc.wallets.xpub_copiedToClipboard); // Adjust according to your localization logic + setTimeout(() => { + setHasTappedText(false); + setAddress(text); + }, 1000); + }; + + return ( + + + + {address} + + + + ); +}); + +export default CopyTextToClipboard; + +const styles = StyleSheet.create({ + container: { justifyContent: 'center', alignItems: 'center', paddingHorizontal: 16 }, +}); diff --git a/components/CopyToClipboardButton.tsx b/components/CopyToClipboardButton.tsx new file mode 100644 index 0000000000..41e8cb6917 --- /dev/null +++ b/components/CopyToClipboardButton.tsx @@ -0,0 +1,30 @@ +import Clipboard from '@react-native-clipboard/clipboard'; +import React from 'react'; +import { StyleSheet, Text, TouchableOpacity } from 'react-native'; + +import triggerHapticFeedback, { HapticFeedbackTypes } from '../blue_modules/hapticFeedback'; +import loc from '../loc'; + +type CopyToClipboardButtonProps = { + stringToCopy: string; + displayText?: string; +}; + +export const CopyToClipboardButton: React.FC = ({ stringToCopy, displayText }) => { + const onPress = () => { + Clipboard.setString(stringToCopy); + triggerHapticFeedback(HapticFeedbackTypes.Selection); + }; + + return ( + + {displayText && displayText.length > 0 ? displayText : loc.transactions.details_copy} + + ); +}; + +const styles = StyleSheet.create({ + text: { fontSize: 16, fontWeight: '400', color: '#68bbe1' }, +}); + +export default CopyToClipboardButton; diff --git a/components/DevMenu.tsx b/components/DevMenu.tsx new file mode 100644 index 0000000000..4b1490f84e --- /dev/null +++ b/components/DevMenu.tsx @@ -0,0 +1,187 @@ +import React, { useEffect } from 'react'; +import { DevSettings, Alert, Platform, AlertButton } from 'react-native'; +import { useStorage } from '../hooks/context/useStorage'; +import { HDSegwitBech32Wallet } from '../class'; +import Clipboard from '@react-native-clipboard/clipboard'; +import { useIsLargeScreen } from '../hooks/useIsLargeScreen'; +import { TWallet } from '../class/wallets/types'; + +const getRandomLabelFromSecret = (secret: string): string => { + const words = secret.split(' '); + const firstWord = words[0]; + const lastWord = words[words.length - 1]; + return `[Developer] ${firstWord} ${lastWord}`; +}; + +const showAlertWithWalletOptions = ( + wallets: TWallet[], + title: string, + message: string, + onWalletSelected: (wallet: TWallet) => void, + filterFn?: (wallet: TWallet) => boolean, +) => { + const filteredWallets = filterFn ? wallets.filter(filterFn) : wallets; + + const showWallet = (index: number) => { + if (index >= filteredWallets.length) return; + const wallet = filteredWallets[index]; + + if (Platform.OS === 'android') { + // Android: Use a limited number of buttons since the alert dialog has a limit + Alert.alert( + `${title}: ${wallet.getLabel()}`, + `${message}\n\nSelected Wallet: ${wallet.getLabel()}\n\nWould you like to select this wallet or see the next one?`, + [ + { + text: 'Select This Wallet', + onPress: () => onWalletSelected(wallet), + }, + { + text: 'Show Next Wallet', + onPress: () => showWallet(index + 1), + }, + { + text: 'Cancel', + style: 'cancel', + }, + ], + { cancelable: true }, + ); + } else { + const options: AlertButton[] = filteredWallets.map(w => ({ + text: w.getLabel(), + onPress: () => onWalletSelected(w), + })); + + options.push({ + text: 'Cancel', + style: 'cancel', + }); + + Alert.alert(title, message, options, { cancelable: true }); + } + }; + + if (filteredWallets.length > 0) { + showWallet(0); + } else { + Alert.alert('No wallets available'); + } +}; + +const DevMenu: React.FC = () => { + const { wallets, addWallet } = useStorage(); + const { setLargeScreenValue } = useIsLargeScreen(); + + useEffect(() => { + if (__DEV__) { + // Clear existing Dev Menu items to prevent duplication + DevSettings.addMenuItem('Reset Dev Menu', () => { + DevSettings.reload(); + }); + + DevSettings.addMenuItem('Add New Wallet', async () => { + const wallet = new HDSegwitBech32Wallet(); + await wallet.generate(); + const label = getRandomLabelFromSecret(wallet.getSecret()); + wallet.setLabel(label); + addWallet(wallet); + + Clipboard.setString(wallet.getSecret()); + Alert.alert('New Wallet created!', `Wallet secret copied to clipboard.\nLabel: ${label}`); + }); + + DevSettings.addMenuItem('Copy Wallet Secret', () => { + if (wallets.length === 0) { + Alert.alert('No wallets available'); + return; + } + + showAlertWithWalletOptions(wallets, 'Copy Wallet Secret', 'Select the wallet to copy the secret', wallet => { + Clipboard.setString(wallet.getSecret()); + Alert.alert('Wallet Secret copied to clipboard!'); + }); + }); + + DevSettings.addMenuItem('Copy Wallet ID', () => { + if (wallets.length === 0) { + Alert.alert('No wallets available'); + return; + } + + showAlertWithWalletOptions(wallets, 'Copy Wallet ID', 'Select the wallet to copy the ID', wallet => { + Clipboard.setString(wallet.getID()); + Alert.alert('Wallet ID copied to clipboard!'); + }); + }); + + DevSettings.addMenuItem('Copy Wallet Xpub', () => { + if (wallets.length === 0) { + Alert.alert('No wallets available'); + return; + } + + showAlertWithWalletOptions( + wallets, + 'Copy Wallet Xpub', + 'Select the wallet to copy the Xpub', + wallet => { + const xpub = wallet.getXpub(); + if (xpub) { + Clipboard.setString(xpub); + Alert.alert('Wallet Xpub copied to clipboard!'); + } else { + Alert.alert('This wallet does not have an Xpub.'); + } + }, + wallet => typeof wallet.getXpub === 'function', + ); + }); + + DevSettings.addMenuItem('Purge Wallet Transactions', () => { + if (wallets.length === 0) { + Alert.alert('No wallets available'); + return; + } + + showAlertWithWalletOptions(wallets, 'Purge Wallet Transactions', 'Select the wallet to purge transactions', wallet => { + const msg = 'Transactions purged successfully!'; + + if (wallet.type === HDSegwitBech32Wallet.type) { + wallet._txs_by_external_index = {}; + wallet._txs_by_internal_index = {}; + } + + // @ts-ignore: Property '_hdWalletInstance' does not exist on type 'Wallet'. Pls help + if (wallet._hdWalletInstance) { + // @ts-ignore: Property '_hdWalletInstance' does not exist on type 'Wallet'. Pls help + wallet._hdWalletInstance._txs_by_external_index = {}; + // @ts-ignore: Property '_hdWalletInstance' does not exist on type 'Wallet'. Pls help + wallet._hdWalletInstance._txs_by_internal_index = {}; + } + + Alert.alert(msg); + }); + }); + + DevSettings.addMenuItem('Force Large Screen Interface', () => { + setLargeScreenValue('LargeScreen'); + Alert.alert('Large Screen Interface forced.'); + }); + + DevSettings.addMenuItem('Force Handheld Interface', () => { + setLargeScreenValue('Handheld'); + Alert.alert('Handheld Interface forced.'); + }); + + DevSettings.addMenuItem('Reset Screen Interface', () => { + setLargeScreenValue(undefined); + Alert.alert('Screen Interface reset to default.'); + }); + } + }, [wallets, addWallet, setLargeScreenValue]); + + return null; +}; + +export default DevMenu; diff --git a/components/DismissKeyboardInputAccessory.tsx b/components/DismissKeyboardInputAccessory.tsx new file mode 100644 index 0000000000..4e18037b74 --- /dev/null +++ b/components/DismissKeyboardInputAccessory.tsx @@ -0,0 +1,36 @@ +import React from 'react'; +import { InputAccessoryView, Keyboard, Platform, StyleSheet, View } from 'react-native'; +import { useTheme } from './themes'; +import { BlueButtonLink } from '../BlueComponents'; +import loc from '../loc'; + +export const DismissKeyboardInputAccessoryViewID = 'DismissKeyboardInputAccessory'; +export const DismissKeyboardInputAccessory: React.FC = () => { + const { colors } = useTheme(); + const styleHooks = StyleSheet.create({ + container: { + backgroundColor: colors.inputBackgroundColor, + }, + }); + + if (Platform.OS !== 'ios') { + return null; + } + + return ( + + + + + + ); +}; + +const styles = StyleSheet.create({ + container: { + flexDirection: 'row', + justifyContent: 'flex-end', + alignItems: 'center', + maxHeight: 44, + }, +}); diff --git a/components/DoneAndDismissKeyboardInputAccessory.tsx b/components/DoneAndDismissKeyboardInputAccessory.tsx new file mode 100644 index 0000000000..1bbad02e38 --- /dev/null +++ b/components/DoneAndDismissKeyboardInputAccessory.tsx @@ -0,0 +1,49 @@ +import React from 'react'; +import { InputAccessoryView, Keyboard, Platform, StyleSheet, View } from 'react-native'; +import { BlueButtonLink } from '../BlueComponents'; +import loc from '../loc'; +import { useTheme } from './themes'; +import Clipboard from '@react-native-clipboard/clipboard'; + +interface DoneAndDismissKeyboardInputAccessoryProps { + onPasteTapped: (clipboard: string) => void; + onClearTapped: () => void; +} +export const DoneAndDismissKeyboardInputAccessoryViewID = 'DoneAndDismissKeyboardInputAccessory'; +export const DoneAndDismissKeyboardInputAccessory: React.FC = props => { + const { colors } = useTheme(); + + const styleHooks = StyleSheet.create({ + container: { + backgroundColor: colors.inputBackgroundColor, + }, + }); + + const onPasteTapped = async () => { + const clipboard = await Clipboard.getString(); + props.onPasteTapped(clipboard); + }; + + const inputView = ( + + + + + + ); + + if (Platform.OS === 'ios') { + return {inputView}; + } else { + return inputView; + } +}; + +const styles = StyleSheet.create({ + container: { + flexDirection: 'row', + justifyContent: 'flex-end', + alignItems: 'center', + maxHeight: 44, + }, +}); diff --git a/components/DynamicQRCode.js b/components/DynamicQRCode.tsx similarity index 82% rename from components/DynamicQRCode.js rename to components/DynamicQRCode.tsx index 67c7790574..5f90fc05e3 100644 --- a/components/DynamicQRCode.js +++ b/components/DynamicQRCode.tsx @@ -1,18 +1,33 @@ -/* eslint react/prop-types: "off", react-native/no-inline-styles: "off" */ import React, { Component } from 'react'; -import { Text } from 'react-native-elements'; import { Dimensions, LayoutAnimation, StyleSheet, TouchableOpacity, View } from 'react-native'; +import { Text } from '@rneui/themed'; + import { encodeUR } from '../blue_modules/ur'; -import QRCodeComponent from './QRCodeComponent'; -import { BlueCurrentTheme } from '../components/themes'; import { BlueSpacing20 } from '../BlueComponents'; +import { BlueCurrentTheme } from '../components/themes'; import loc from '../loc'; +import QRCodeComponent from './QRCodeComponent'; const { height, width } = Dimensions.get('window'); -export class DynamicQRCode extends Component { - constructor() { - super(); +interface DynamicQRCodeProps { + value: string; + capacity?: number; + hideControls?: boolean; +} + +interface DynamicQRCodeState { + index: number; + total: number; + qrCodeHeight: number; + intervalHandler: ReturnType | number | null; + displayQRCode: boolean; + hideControls?: boolean; +} + +export class DynamicQRCode extends Component { + constructor(props: DynamicQRCodeProps) { + super(props); const qrCodeHeight = height > width ? width - 40 : width / 3; const qrCodeMaxHeight = 370; this.state = { @@ -24,10 +39,10 @@ export class DynamicQRCode extends Component { }; } - fragments = []; + fragments: string[] = []; componentDidMount() { - const { value, capacity = 200, hideControls = true } = this.props; + const { value, capacity = 175, hideControls = true } = this.props; try { this.fragments = encodeUR(value, capacity); this.setState( @@ -67,7 +82,7 @@ export class DynamicQRCode extends Component { }; stopAutoMove = () => { - clearInterval(this.state.intervalHandler); + clearInterval(this.state.intervalHandler as number); this.setState(() => ({ intervalHandler: null, })); @@ -138,21 +153,21 @@ export class DynamicQRCode extends Component { {loc.send.dynamic_prev} {this.state.intervalHandler ? loc.send.dynamic_stop : loc.send.dynamic_start} {loc.send.dynamic_next} @@ -189,6 +204,17 @@ const animatedQRCodeStyle = StyleSheet.create({ height: 45, justifyContent: 'center', }, + buttonPrev: { + width: '25%', + alignItems: 'flex-start', + }, + buttonStopStart: { + width: '50%', + }, + buttonNext: { + width: '25%', + alignItems: 'flex-end', + }, text: { fontSize: 14, color: BlueCurrentTheme.colors.foregroundColor, diff --git a/components/FloatButtons.js b/components/FloatButtons.js deleted file mode 100644 index 32ff1060e3..0000000000 --- a/components/FloatButtons.js +++ /dev/null @@ -1,138 +0,0 @@ -import React, { useState, useRef, forwardRef } from 'react'; -import PropTypes from 'prop-types'; -import { View, Text, TouchableOpacity, StyleSheet, Dimensions, PixelRatio } from 'react-native'; -import { useTheme } from '@react-navigation/native'; - -const BORDER_RADIUS = 30; -const PADDINGS = 8; -const ICON_MARGIN = 7; - -const cStyles = StyleSheet.create({ - root: { - alignSelf: 'center', - height: '6.3%', - minHeight: 44, - }, - rootAbsolute: { - position: 'absolute', - bottom: 30, - }, - rootInline: {}, - rootPre: { - position: 'absolute', - bottom: -1000, - }, - rootPost: { - borderRadius: BORDER_RADIUS, - flexDirection: 'row', - overflow: 'hidden', - }, -}); - -export const FContainer = forwardRef((props, ref) => { - const [newWidth, setNewWidth] = useState(); - const layoutCalculated = useRef(false); - - const onLayout = event => { - if (layoutCalculated.current) return; - const maxWidth = Dimensions.get('window').width - BORDER_RADIUS - 20; - const { width } = event.nativeEvent.layout; - const withPaddings = Math.ceil(width + PADDINGS * 2); - const len = React.Children.toArray(props.children).filter(Boolean).length; - let newW = withPaddings * len > maxWidth ? Math.floor(maxWidth / len) : withPaddings; - if (len === 1 && newW < 90) newW = 90; // to add Paddings for lonely small button, like Scan on main screen - setNewWidth(newW); - layoutCalculated.current = true; - }; - - return ( - - {newWidth - ? React.Children.toArray(props.children) - .filter(Boolean) - .map((c, index, array) => - React.cloneElement(c, { - width: newWidth, - key: index, - first: index === 0, - last: index === array.length - 1, - }), - ) - : props.children} - - ); -}); - -FContainer.propTypes = { - children: PropTypes.oneOfType([PropTypes.arrayOf(PropTypes.element), PropTypes.element]), - inline: PropTypes.bool, -}; - -const buttonFontSize = - PixelRatio.roundToNearestPixel(Dimensions.get('window').width / 26) > 22 - ? 22 - : PixelRatio.roundToNearestPixel(Dimensions.get('window').width / 26); - -const bStyles = StyleSheet.create({ - root: { - flexDirection: 'row', - alignItems: 'center', - justifyContent: 'center', - overflow: 'hidden', - }, - icon: { - alignItems: 'center', - }, - text: { - fontSize: buttonFontSize, - fontWeight: '600', - marginLeft: ICON_MARGIN, - backgroundColor: 'transparent', - }, -}); - -export const FButton = ({ text, icon, width, first, last, ...props }) => { - const { colors } = useTheme(); - const bStylesHook = StyleSheet.create({ - root: { - backgroundColor: colors.buttonBackgroundColor, - }, - text: { - color: colors.buttonAlternativeTextColor, - }, - textDisabled: { - color: colors.formBorder, - }, - }); - const style = {}; - - if (width) { - const paddingLeft = first ? BORDER_RADIUS / 2 : PADDINGS; - const paddingRight = last ? BORDER_RADIUS / 2 : PADDINGS; - style.paddingRight = paddingRight; - style.paddingLeft = paddingLeft; - style.width = width + paddingRight + paddingLeft; - } - - return ( - - {icon} - - {text} - - - ); -}; - -FButton.propTypes = { - text: PropTypes.string, - icon: PropTypes.element, - width: PropTypes.number, - first: PropTypes.bool, - last: PropTypes.bool, - disabled: PropTypes.bool, -}; diff --git a/components/FloatButtons.tsx b/components/FloatButtons.tsx new file mode 100644 index 0000000000..8a02a0ef83 --- /dev/null +++ b/components/FloatButtons.tsx @@ -0,0 +1,177 @@ +import React, { forwardRef, ReactNode, useEffect, useRef, useState } from 'react'; +import { Animated, Dimensions, PixelRatio, StyleSheet, Text, TouchableOpacity, useWindowDimensions, View } from 'react-native'; +import { useSafeAreaInsets } from 'react-native-safe-area-context'; + +import { useTheme } from './themes'; + +const BORDER_RADIUS = 8; +const PADDINGS = 24; +const ICON_MARGIN = 7; + +const cStyles = StyleSheet.create({ + root: { + alignSelf: 'center', + height: '6.9%', + minHeight: 44, + }, + rootAbsolute: { + position: 'absolute', + }, + rootInline: {}, + rootPre: { + position: 'absolute', + bottom: -1000, + }, + rootPost: { + flexDirection: 'row', + overflow: 'hidden', + }, +}); + +interface FContainerProps { + children: ReactNode | ReactNode[]; + inline?: boolean; +} + +export const FContainer = forwardRef((props, ref) => { + const insets = useSafeAreaInsets(); + const [newWidth, setNewWidth] = useState(undefined); + const layoutCalculated = useRef(false); + const bottomInsets = { bottom: insets.bottom ? insets.bottom + 10 : 30 }; + const { height, width } = useWindowDimensions(); + const slideAnimation = useRef(new Animated.Value(height)).current; + + useEffect(() => { + slideAnimation.setValue(height); + Animated.spring(slideAnimation, { + toValue: 0, + useNativeDriver: true, + speed: 100, + bounciness: 3, + }).start(); + }, [height, slideAnimation]); + + const onLayout = (event: { nativeEvent: { layout: { width: number } } }) => { + if (layoutCalculated.current) return; + const maxWidth = width - BORDER_RADIUS - 140; + const layoutWidth = event.nativeEvent.layout.width; + const withPaddings = Math.ceil(layoutWidth + PADDINGS * 2); + const len = React.Children.toArray(props.children).filter(Boolean).length; + let newW = withPaddings * len > maxWidth ? Math.floor(maxWidth / len) : withPaddings; + if (len === 1 && newW < 90) newW = 90; + setNewWidth(newW); + layoutCalculated.current = true; + }; + + return ( + + {newWidth + ? React.Children.toArray(props.children) + .filter(Boolean) + .map((child, index, array) => { + if (typeof child === 'string') { + return ( + + + {child} + + + ); + } + return React.cloneElement(child as React.ReactElement, { + width: newWidth, + key: index, + first: index === 0, + last: index === array.length - 1, + }); + }) + : props.children} + + ); +}); + +const buttonFontSize = + PixelRatio.roundToNearestPixel(Dimensions.get('window').width / 26) > 22 + ? 22 + : PixelRatio.roundToNearestPixel(Dimensions.get('window').width / 26); + +const bStyles = StyleSheet.create({ + root: { + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'center', + overflow: 'hidden', + }, + icon: { + alignItems: 'center', + }, + text: { + fontSize: buttonFontSize, + fontWeight: '600', + marginLeft: ICON_MARGIN, + backgroundColor: 'transparent', + }, +}); + +interface FButtonProps { + text: string; + icon: ReactNode; + width?: number; + first?: boolean; + last?: boolean; + disabled?: boolean; + testID?: string; + onPress: () => void; + onLongPress?: () => void; +} + +export const FButton = ({ text, icon, width, first, last, testID, ...props }: FButtonProps) => { + const { colors } = useTheme(); + const bStylesHook = StyleSheet.create({ + root: { + backgroundColor: colors.buttonBackgroundColor, + borderRadius: BORDER_RADIUS, + }, + text: { + color: colors.buttonAlternativeTextColor, + }, + textDisabled: { + color: colors.formBorder, + }, + marginRight: { + marginRight: 10, + }, + }); + const style: Record = {}; + const additionalStyles = !last ? bStylesHook.marginRight : {}; + + if (width) { + style.paddingHorizontal = PADDINGS; + style.width = width + PADDINGS * 2; + } + + return ( + + {icon} + + {text} + + + ); +}; diff --git a/components/HandOffComponent.ios.tsx b/components/HandOffComponent.ios.tsx new file mode 100644 index 0000000000..0a262f3fe3 --- /dev/null +++ b/components/HandOffComponent.ios.tsx @@ -0,0 +1,42 @@ +import React from 'react'; +import DefaultPreference from 'react-native-default-preference'; +// @ts-ignore: Handoff is not typed +import Handoff from 'react-native-handoff'; +import { useSettings } from '../hooks/context/useSettings'; +import { GROUP_IO_BLUEWALLET } from '../blue_modules/currency'; +import { BlueApp } from '../class'; +import { HandOffComponentProps } from './types'; + +const HandOffComponent: React.FC = props => { + const { isHandOffUseEnabled } = useSettings(); + console.debug('HandOffComponent is rendering.'); + return isHandOffUseEnabled ? : null; +}; + +const MemoizedHandOffComponent = React.memo(HandOffComponent); + +export const setIsHandOffUseEnabled = async (value: boolean) => { + try { + await DefaultPreference.setName(GROUP_IO_BLUEWALLET); + await DefaultPreference.set(BlueApp.HANDOFF_STORAGE_KEY, value.toString()); + console.debug('setIsHandOffUseEnabled', value); + } catch (error) { + console.error('Error setting handoff enabled status:', error); + throw error; // Propagate error to caller + } +}; + +export const getIsHandOffUseEnabled = async (): Promise => { + try { + await DefaultPreference.setName(GROUP_IO_BLUEWALLET); + const isEnabledValue = await DefaultPreference.get(BlueApp.HANDOFF_STORAGE_KEY); + const result = isEnabledValue === 'true'; + console.debug('getIsHandOffUseEnabled', result); + return result; + } catch (error) { + console.error('Error getting handoff enabled status:', error); + return false; + } +}; + +export default MemoizedHandOffComponent; diff --git a/components/HandOffComponent.tsx b/components/HandOffComponent.tsx new file mode 100644 index 0000000000..12787f7df9 --- /dev/null +++ b/components/HandOffComponent.tsx @@ -0,0 +1,15 @@ +import React from 'react'; +import { HandOffComponentProps } from './types'; + +const HandOffComponent: React.FC = props => { + console.debug('HandOffComponent render.'); + return null; +}; + +export const setIsHandOffUseEnabled = async (value: boolean) => {}; + +export const getIsHandOffUseEnabled = async (): Promise => { + return false; +}; + +export default HandOffComponent; diff --git a/components/Header.tsx b/components/Header.tsx new file mode 100644 index 0000000000..eef8335e32 --- /dev/null +++ b/components/Header.tsx @@ -0,0 +1,46 @@ +import React from 'react'; +import { StyleSheet, Text, View } from 'react-native'; +import { useTheme } from './themes'; +import AddWalletButton from './AddWalletButton'; + +interface HeaderProps { + leftText: string; + isDrawerList?: boolean; + onNewWalletPress?: () => void; +} + +export const Header: React.FC = ({ leftText, isDrawerList, onNewWalletPress }) => { + const { colors } = useTheme(); + const styleWithProps = StyleSheet.create({ + root: { + backgroundColor: isDrawerList ? colors.elevated : colors.background, + borderTopColor: isDrawerList ? colors.elevated : colors.background, + borderBottomColor: isDrawerList ? colors.elevated : colors.background, + }, + text: { + color: colors.foregroundColor, + }, + }); + + return ( + + {leftText} + {onNewWalletPress && } + + ); +}; + +const styles = StyleSheet.create({ + root: { + flexDirection: 'row', + justifyContent: 'space-between', + alignItems: 'center', + paddingHorizontal: 16, + marginBottom: 8, + }, + text: { + textAlign: 'left', + fontWeight: 'bold', + fontSize: 34, + }, +}); diff --git a/components/HeaderMenuButton.tsx b/components/HeaderMenuButton.tsx new file mode 100644 index 0000000000..6e3d8d1a40 --- /dev/null +++ b/components/HeaderMenuButton.tsx @@ -0,0 +1,49 @@ +import React from 'react'; +import { Pressable, Platform } from 'react-native'; +import ToolTipMenu from './TooltipMenu'; +import { useTheme } from './themes'; +import { Icon } from '@rneui/themed'; +import { Action } from './types'; + +interface HeaderMenuButtonProps { + onPressMenuItem: (id: string) => void; + actions?: Action[] | Action[][]; + disabled?: boolean; + title?: string; +} + +const HeaderMenuButton: React.FC = ({ onPressMenuItem, actions, disabled, title }) => { + const { colors } = useTheme(); + const styleProps = Platform.OS === 'android' ? { iconStyle: { transform: [{ rotate: '90deg' }] } } : {}; + + if (!actions || actions.length === 0) { + return ( + [{ opacity: pressed ? 0.5 : 1 }]} + > + + + ); + } + + const menuActions = Array.isArray(actions[0]) ? (actions as Action[][]) : (actions as Action[]); + + return ( + + + + ); +}; + +export default HeaderMenuButton; diff --git a/components/HeaderRightButton.tsx b/components/HeaderRightButton.tsx new file mode 100644 index 0000000000..f02f5fcdb1 --- /dev/null +++ b/components/HeaderRightButton.tsx @@ -0,0 +1,43 @@ +import React from 'react'; +import { StyleSheet, Text, TouchableOpacity } from 'react-native'; + +import { useTheme } from './themes'; + +interface HeaderRightButtonProps { + disabled?: boolean; + onPress?: () => void; + title: string; + testID?: string; +} + +const HeaderRightButton: React.FC = ({ disabled = true, onPress, title, testID }) => { + const { colors } = useTheme(); + const opacity = disabled ? 0.5 : 1; + return ( + + {title} + + ); +}; + +const styles = StyleSheet.create({ + save: { + alignItems: 'center', + justifyContent: 'center', + width: 80, + borderRadius: 8, + height: 34, + }, + saveText: { + fontSize: 15, + fontWeight: '600', + }, +}); + +export default HeaderRightButton; diff --git a/components/InputAccessoryAllFunds.js b/components/InputAccessoryAllFunds.tsx similarity index 80% rename from components/InputAccessoryAllFunds.js rename to components/InputAccessoryAllFunds.tsx index 4f7ec698fc..6a50a05510 100644 --- a/components/InputAccessoryAllFunds.js +++ b/components/InputAccessoryAllFunds.tsx @@ -1,14 +1,18 @@ import React from 'react'; -import PropTypes from 'prop-types'; -import { Text } from 'react-native-elements'; -import { InputAccessoryView, StyleSheet, Keyboard, Platform, View } from 'react-native'; -import { useTheme } from '@react-navigation/native'; - +import { InputAccessoryView, Keyboard, Platform, StyleSheet, View } from 'react-native'; +import { Text } from '@rneui/themed'; +import { BlueButtonLink } from '../BlueComponents'; import loc from '../loc'; import { BitcoinUnit } from '../models/bitcoinUnits'; -import { BlueButtonLink } from '../BlueComponents'; +import { useTheme } from './themes'; -const InputAccessoryAllFunds = ({ balance, canUseAll, onUseAllPressed }) => { +interface InputAccessoryAllFundsProps { + balance: string; + canUseAll: boolean; + onUseAllPressed: () => void; +} + +const InputAccessoryAllFunds: React.FC = ({ balance, canUseAll, onUseAllPressed }) => { const { colors } = useTheme(); const stylesHook = StyleSheet.create({ @@ -42,7 +46,7 @@ const InputAccessoryAllFunds = ({ balance, canUseAll, onUseAllPressed }) => { ); if (Platform.OS === 'ios') { - return {inputView}; + return {inputView}; } // androidPlaceholder View is needed to force shrink screen (KeyboardAvoidingView) where this component is used @@ -54,13 +58,7 @@ const InputAccessoryAllFunds = ({ balance, canUseAll, onUseAllPressed }) => { ); }; -InputAccessoryAllFunds.InputAccessoryViewID = 'useMaxInputAccessoryViewID'; - -InputAccessoryAllFunds.propTypes = { - balance: PropTypes.string.isRequired, - canUseAll: PropTypes.bool.isRequired, - onUseAllPressed: PropTypes.func.isRequired, -}; +export const InputAccessoryAllFundsAccessoryViewID = 'useMaxInputAccessoryViewID'; const styles = StyleSheet.create({ root: { diff --git a/components/LNNodeBar.js b/components/LNNodeBar.js deleted file mode 100644 index 9fb7b97a82..0000000000 --- a/components/LNNodeBar.js +++ /dev/null @@ -1,88 +0,0 @@ -import React from 'react'; -import { View, Text, StyleSheet } from 'react-native'; -import loc, { formatBalanceWithoutSuffix } from '../loc'; -import PropTypes from 'prop-types'; -import { BitcoinUnit } from '../models/bitcoinUnits'; -import { useTheme } from '@react-navigation/native'; - -export const LNNodeBar = props => { - const { canReceive = 0, canSend = 0, nodeAlias = '', disabled = false, itemPriceUnit = BitcoinUnit.SATS } = props; - const { colors } = useTheme(); - const opacity = { opacity: disabled ? 0.5 : 1.0 }; - const canSendBarFlex = { - flex: canReceive > 0 && canSend > 0 ? Math.abs(canSend / (canReceive + canSend)) * 1.0 : 1.0, - }; - const stylesHook = StyleSheet.create({ - nodeAlias: { - color: colors.alternativeTextColor2, - }, - }); - return ( - - {nodeAlias.trim().length > 0 && {nodeAlias}} - - - - - - - - - {loc.lnd.can_send.toUpperCase()} - {formatBalanceWithoutSuffix(canSend, itemPriceUnit, true).toString()} - - - {loc.lnd.can_receive.toUpperCase()} - {formatBalanceWithoutSuffix(canReceive, itemPriceUnit, true).toString()} - - - - ); -}; - -export default LNNodeBar; - -LNNodeBar.propTypes = { - canReceive: PropTypes.number.isRequired, - canSend: PropTypes.number.isRequired, - nodeAlias: PropTypes.string, - disabled: PropTypes.bool, - itemPriceUnit: PropTypes.string, -}; -const styles = StyleSheet.create({ - root: { - flex: 1, - }, - containerBottomText: { - flexDirection: 'row', - justifyContent: 'space-between', - marginTop: 16, - }, - nodeAlias: { - marginVertical: 16, - }, - canSendBar: { - height: 14, - maxHeight: 14, - backgroundColor: '#4E6CF5', - borderRadius: 6, - }, - canReceiveBar: { backgroundColor: '#57B996', borderRadius: 6, height: 14, maxHeight: 14 }, - fullFlexDirectionRow: { - flexDirection: 'row', - flex: 1, - }, - containerBottomLeftText: {}, - containerBottomRightText: {}, - titleText: { - color: '#9AA0AA', - }, - canReceive: { - color: '#57B996', - textAlign: 'right', - }, - canSend: { - color: '#4E6CF5', - textAlign: 'left', - }, -}); diff --git a/components/LdkButton.js b/components/LdkButton.js deleted file mode 100644 index f040a227cf..0000000000 --- a/components/LdkButton.js +++ /dev/null @@ -1,36 +0,0 @@ -/* eslint react/prop-types: "off", react-native/no-inline-styles: "off" */ -import { useTheme } from '@react-navigation/native'; -import { Image, TouchableOpacity, View } from 'react-native'; -import { Text } from 'react-native-elements'; -import React from 'react'; - -export const LdkButton = props => { - const { colors } = useTheme(); - return ( - - - - - - - - {props.text || '?'} - {props.subtext || '?'} - - - - - ); -}; diff --git a/components/ListItem.tsx b/components/ListItem.tsx new file mode 100644 index 0000000000..ea7e2ae52c --- /dev/null +++ b/components/ListItem.tsx @@ -0,0 +1,227 @@ +import React, { useMemo } from 'react'; +import { ActivityIndicator, I18nManager, Pressable, PressableProps, StyleSheet, Switch, TouchableOpacity, View } from 'react-native'; +import { Avatar, ListItem as RNElementsListItem, Button } from '@rneui/themed'; // Replace with actual import paths + +import { useTheme } from './themes'; + +// Update the type for the props +interface ListItemProps { + swipeable?: boolean; + rightIcon?: any; + leftAvatar?: React.JSX.Element; + containerStyle?: object; + Component?: typeof React.Component | typeof PressableWrapper; + bottomDivider?: boolean; + topDivider?: boolean; + testID?: string; + onPress?: () => void; + onLongPress?: () => void; + onDeletePressed?: () => void; + disabled?: boolean; + switch?: object; // Define more specific type if needed + leftIcon?: any; // Define more specific type if needed + title: string; + subtitle?: string | React.ReactNode; + subtitleNumberOfLines?: number; + rightTitle?: string; + rightTitleStyle?: object; + isLoading?: boolean; + chevron?: boolean; + checkmark?: boolean; + subtitleProps?: object; + swipeableLeftContent?: React.ReactNode; + swipeableRightContent?: React.ReactNode; +} + +export class PressableWrapper extends React.Component { + render() { + return ; + } +} + +export class TouchableOpacityWrapper extends React.Component { + render() { + return ; + } +} + +// Define Swipeable Button Components +const DefaultRightContent: React.FC<{ reset: () => void; onDeletePressed?: () => void }> = ({ reset, onDeletePressed }) => ( +