diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 000000000..f59ec20aa --- /dev/null +++ b/.dockerignore @@ -0,0 +1 @@ +* \ No newline at end of file diff --git a/.github/ISSUE_TEMPLATE/bug-report-🪲-.md b/.github/ISSUE_TEMPLATE/bug-report.md similarity index 100% rename from .github/ISSUE_TEMPLATE/bug-report-🪲-.md rename to .github/ISSUE_TEMPLATE/bug-report.md diff --git a/.github/ISSUE_TEMPLATE/config.yml b/.github/ISSUE_TEMPLATE/config.yml index d7a1a3ed9..f8cc8f9ca 100644 --- a/.github/ISSUE_TEMPLATE/config.yml +++ b/.github/ISSUE_TEMPLATE/config.yml @@ -1,8 +1,11 @@ blank_issues_enabled: false contact_links: + - name: Feature or Enhancement Request ✨ + url: https://github.com/cake-tech/cake_wallet/discussions/new?category=feature-requests + about: Suggest an idea for Cake Wallet - name: Not sure where to start? - url: https://guides.cakewallet.com + url: https://docs.cakewallet.com about: Start by reading checking out the guides! - name: Need help? url: https://cakewallet.com/#contact - about: Use our live chat or send a support email! \ No newline at end of file + about: Use our live chat or send a support email! diff --git a/.github/ISSUE_TEMPLATE/feature-or-enhancement-request-✨.md b/.github/ISSUE_TEMPLATE/feature-or-enhancement-request-✨.md deleted file mode 100644 index 20bf2d53f..000000000 --- a/.github/ISSUE_TEMPLATE/feature-or-enhancement-request-✨.md +++ /dev/null @@ -1,20 +0,0 @@ ---- -name: Feature or Enhancement Request ✨ -about: Suggest an idea for Cake Wallet -title: '' -labels: Enhancement -assignees: '' - ---- - -**Is your feature request related to a problem? Please describe.** -A clear and concise description of what the problem is. Ex. I'm always frustrated when [...] - -**Describe the solution you'd like** -A clear and concise description of what you want to happen. - -**Describe alternatives you've considered** -A clear and concise description of any alternative solutions or features you've considered. - -**Additional context** -Add any other context or screenshots about the feature request here. diff --git a/.github/assets/Logo_CakeWallet.png b/.github/assets/Logo_CakeWallet.png new file mode 100644 index 000000000..459a6b37c Binary files /dev/null and b/.github/assets/Logo_CakeWallet.png differ diff --git a/.github/assets/NOTICE.txt b/.github/assets/NOTICE.txt new file mode 100644 index 000000000..9719639a1 --- /dev/null +++ b/.github/assets/NOTICE.txt @@ -0,0 +1,48 @@ +Notice for linux-badge.svg: + +1: +This is the Linux-penguin again... + +Originally drewn by Larry Ewing (http://www.isc.tamu.edu/~lewing/) +(with the GIMP) the Linux Logo has been vectorized by me (Simon Budig, +http://www.home.unix-ag.org/simon/). + +This happened quite some time ago with Corel Draw 4. But luckily +meanwhile there are tools available to handle vector graphics with +Linux. Bernhard Herzog (bernhard@users.sourceforge.net) deserves kudos +for creating Sketch (http://sketch.sourceforge.net), a powerful free +tool for creating vector graphics. He converted the Corel Draw file to +the Sketch native format. Since I am unable to maintain the Corel Draw +file any longer, the Sketch version now is the "official" one. + +Anja Gerwinski (anja@gerwinski.de) has created an alternate version of +the penguin (penguin-variant.sk) with a thinner mouth line and slightly +altered gradients. It also features a nifty drop shadow. + +The third bird (penguin-flat.sk) is a version reduced to three colors +(black/white/yellow) for e.g. silk screen printing. I made this version +for a mug, available at the friendly folks at +http://www.kernelconcepts.de/ - they do good stuff, mail Petra +(pinguin@kernelconcepts.de) if you need something special or don't +understand the german :-) + +These drawings are copyrighted by Larry Ewing and Simon Budig +(penguin-variant.sk also by Anja Gerwinski), redistribution is free but +has to include this README/Copyright notice. + +The use of these drawings is free. However I am happy about a sample of +your mug/t-shirt/whatever with this penguin on it... + +Have fun + Simon Budig + + +Simon.Budig@unix-ag.org +http://www.home.unix-ag.org/simon/ + +Simon Budig +Am Hardtkoeppel 2 +D-61279 Graevenwiesbach + +2: +Attribution: lewing@isc.tamu.edu Larry Ewing and The GIMP \ No newline at end of file diff --git a/.github/assets/app-store-badge.svg b/.github/assets/app-store-badge.svg new file mode 100755 index 000000000..072b425a1 --- /dev/null +++ b/.github/assets/app-store-badge.svg @@ -0,0 +1,46 @@ + + Download_on_the_App_Store_Badge_US-UK_RGB_blk_4SVG_092917 + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/.github/assets/devices.png b/.github/assets/devices.png new file mode 100644 index 000000000..7bdccc5b5 Binary files /dev/null and b/.github/assets/devices.png differ diff --git a/.github/assets/f-droid-badge.png b/.github/assets/f-droid-badge.png new file mode 100644 index 000000000..2c9521de1 Binary files /dev/null and b/.github/assets/f-droid-badge.png differ diff --git a/.github/assets/google-play-badge.png b/.github/assets/google-play-badge.png new file mode 100644 index 000000000..9667c568d Binary files /dev/null and b/.github/assets/google-play-badge.png differ diff --git a/.github/assets/linux-badge.svg b/.github/assets/linux-badge.svg new file mode 100755 index 000000000..8416e1bb1 --- /dev/null +++ b/.github/assets/linux-badge.svg @@ -0,0 +1,1071 @@ + +linux-badgeGET IT ONLinuxlinux-badge diff --git a/.github/assets/mac-store-badge.svg b/.github/assets/mac-store-badge.svg new file mode 100755 index 000000000..c36a76a5a --- /dev/null +++ b/.github/assets/mac-store-badge.svg @@ -0,0 +1,51 @@ + + Download_on_the_Mac_App_Store_Badge_US-UK_RGB_blk_092917 + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md index 18ad16e4b..272f7bbee 100644 --- a/.github/pull_request_template.md +++ b/.github/pull_request_template.md @@ -11,3 +11,4 @@ Please include a summary of the changes and which issue is fixed / feature is ad - [ ] Format code - [ ] Look for code duplication - [ ] Clear naming for variables and methods +- [ ] Manual tests in accessibility mode (TalkBack on Android) passed diff --git a/.github/workflows/automated_integration_test.yml b/.github/workflows/automated_integration_test.yml new file mode 100644 index 000000000..47b08c44d --- /dev/null +++ b/.github/workflows/automated_integration_test.yml @@ -0,0 +1,305 @@ +name: Automated Integration Tests + +on: + # pull_request: + # branches: [main, CW-659-Transaction-History-Automated-Tests] + workflow_dispatch: + inputs: + branch: + description: "Branch name to build" + required: true + default: "main" + +jobs: + Automated_integration_test: + runs-on: ubuntu-24.04 + strategy: + fail-fast: false + matrix: + api-level: [29] + # arch: [x86, x86_64] + env: + STORE_PASS: test@cake_wallet + KEY_PASS: test@cake_wallet + PR_NUMBER: ${{ github.event.number }} + + steps: + - name: is pr + if: github.event_name == 'pull_request' + run: echo "BRANCH_NAME=${GITHUB_HEAD_REF}" >> $GITHUB_ENV + + - name: is not pr + if: github.event_name != 'pull_request' + run: echo "BRANCH_NAME=${{ github.event.inputs.branch }}" >> $GITHUB_ENV + + - name: Free Disk Space (Ubuntu) + uses: insightsengineering/disk-space-reclaimer@v1 + with: + tools-cache: true + android: false + dotnet: true + haskell: true + large-packages: true + swap-storage: true + docker-images: true + + - uses: actions/checkout@v2 + - uses: actions/setup-java@v2 + with: + distribution: "temurin" + java-version: "17" + - name: Configure placeholder git details + run: | + git config --global user.email "CI@cakewallet.com" + git config --global user.name "Cake Github Actions" + - name: Flutter action + uses: subosito/flutter-action@v1 + with: + flutter-version: "3.27.0" + channel: stable + + - name: Install package dependencies + run: | + sudo apt update + sudo apt-get install -y curl unzip automake build-essential file pkg-config git python libtool libtinfo5 cmake clang + + - name: Execute Build and Setup Commands + run: | + sudo mkdir -p /opt/android + sudo chown $USER /opt/android + cd /opt/android + -y curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh + cargo install cargo-ndk + git clone https://github.com/cake-tech/cake_wallet.git --branch ${{ env.BRANCH_NAME }} + cd cake_wallet/scripts/android/ + ./install_ndk.sh + source ./app_env.sh cakewallet + chmod +x pubspec_gen.sh + ./app_config.sh + + - name: Cache Externals + id: cache-externals + uses: actions/cache@v3 + with: + path: | + /opt/android/cake_wallet/cw_haven/android/.cxx + /opt/android/cake_wallet/scripts/monero_c/release + key: ${{ hashFiles('**/prepare_moneroc.sh' ,'**/build_monero_all.sh' ,'**/cache_dependencies.yml') }} + + - if: ${{ steps.cache-externals.outputs.cache-hit != 'true' }} + name: Generate Externals + run: | + cd /opt/android/cake_wallet/scripts/android/ + source ./app_env.sh cakewallet + ./build_monero_all.sh + + - name: Install Flutter dependencies + run: | + cd /opt/android/cake_wallet + flutter pub get + + + - name: Install go and gomobile + run: | + # install go > 1.23: + wget https://go.dev/dl/go1.23.1.linux-amd64.tar.gz + sudo rm -rf /usr/local/go && sudo tar -C /usr/local -xzf go1.23.1.linux-amd64.tar.gz + export PATH=$PATH:/usr/local/go/bin + export PATH=$PATH:~/go/bin + go install golang.org/x/mobile/cmd/gomobile@latest + gomobile init + + - name: Build mwebd + run: | + # paths are reset after each step, so we need to set them again: + export PATH=$PATH:/usr/local/go/bin + export PATH=$PATH:~/go/bin + cd /opt/android/cake_wallet/scripts/android/ + ./build_mwebd.sh --dont-install + + - name: Generate KeyStore + run: | + cd /opt/android/cake_wallet/android/app + keytool -genkey -v -keystore key.jks -keyalg RSA -keysize 2048 -validity 10000 -alias testKey -noprompt -dname "CN=CakeWallet, OU=CakeWallet, O=CakeWallet, L=Florida, S=America, C=USA" -storepass $STORE_PASS -keypass $KEY_PASS + + - name: Generate key properties + run: | + cd /opt/android/cake_wallet + flutter packages pub run tool/generate_android_key_properties.dart keyAlias=testKey storeFile=key.jks storePassword=$STORE_PASS keyPassword=$KEY_PASS + + - name: Generate localization + run: | + cd /opt/android/cake_wallet + flutter packages pub run tool/generate_localization.dart + + - name: Build generated code + run: | + cd /opt/android/cake_wallet + ./model_generator.sh + + - name: Add secrets + run: | + cd /opt/android/cake_wallet + touch lib/.secrets.g.dart + touch cw_evm/lib/.secrets.g.dart + touch cw_solana/lib/.secrets.g.dart + touch cw_core/lib/.secrets.g.dart + touch cw_nano/lib/.secrets.g.dart + touch cw_tron/lib/.secrets.g.dart + echo "const salt = '${{ secrets.SALT }}';" > lib/.secrets.g.dart + echo "const keychainSalt = '${{ secrets.KEY_CHAIN_SALT }}';" >> lib/.secrets.g.dart + echo "const key = '${{ secrets.KEY }}';" >> lib/.secrets.g.dart + echo "const walletSalt = '${{ secrets.WALLET_SALT }}';" >> lib/.secrets.g.dart + echo "const shortKey = '${{ secrets.SHORT_KEY }}';" >> lib/.secrets.g.dart + echo "const backupSalt = '${{ secrets.BACKUP_SALT }}';" >> lib/.secrets.g.dart + echo "const backupKeychainSalt = '${{ secrets.BACKUP_KEY_CHAIN_SALT }}';" >> lib/.secrets.g.dart + echo "const changeNowCakeWalletApiKey = '${{ secrets.CHANGE_NOW_API_KEY }}';" >> lib/.secrets.g.dart + echo "const changeNowMoneroApiKey = '${{ secrets.CHANGE_NOW_API_KEY }}';" >> lib/.secrets.g.dart + echo "const wyreSecretKey = '${{ secrets.WYRE_SECRET_KEY }}';" >> lib/.secrets.g.dart + echo "const wyreApiKey = '${{ secrets.WYRE_API_KEY }}';" >> lib/.secrets.g.dart + echo "const wyreAccountId = '${{ secrets.WYRE_ACCOUNT_ID }}';" >> lib/.secrets.g.dart + echo "const moonPayApiKey = '${{ secrets.MOON_PAY_API_KEY }}';" >> lib/.secrets.g.dart + echo "const moonPaySecretKey = '${{ secrets.MOON_PAY_SECRET_KEY }}';" >> lib/.secrets.g.dart + echo "const sideShiftAffiliateId = '${{ secrets.SIDE_SHIFT_AFFILIATE_ID }}';" >> lib/.secrets.g.dart + echo "const simpleSwapApiKey = '${{ secrets.SIMPLE_SWAP_API_KEY }}';" >> lib/.secrets.g.dart + echo "const simpleSwapApiKeyDesktop = '${{ secrets.SIMPLE_SWAP_API_KEY_DESKTOP }}';" >> lib/.secrets.g.dart + echo "const onramperApiKey = '${{ secrets.ONRAMPER_API_KEY }}';" >> lib/.secrets.g.dart + echo "const anypayToken = '${{ secrets.ANY_PAY_TOKEN }}';" >> lib/.secrets.g.dart + echo "const ioniaClientId = '${{ secrets.IONIA_CLIENT_ID }}';" >> lib/.secrets.g.dart + echo "const twitterBearerToken = '${{ secrets.TWITTER_BEARER_TOKEN }}';" >> lib/.secrets.g.dart + echo "const trocadorApiKey = '${{ secrets.TROCADOR_API_KEY }}';" >> lib/.secrets.g.dart + echo "const trocadorMoneroApiKey = '${{ secrets.TROCADOR_API_KEY }}';" >> lib/.secrets.g.dart + echo "const trocadorExchangeMarkup = '${{ secrets.TROCADOR_EXCHANGE_MARKUP }}';" >> lib/.secrets.g.dart + echo "const anonPayReferralCode = '${{ secrets.ANON_PAY_REFERRAL_CODE }}';" >> lib/.secrets.g.dart + echo "const fiatApiKey = '${{ secrets.FIAT_API_KEY }}';" >> lib/.secrets.g.dart + echo "const ankrApiKey = '${{ secrets.ANKR_API_KEY }}';" >> lib/.secrets.g.dart + echo "const chainStackApiKey = '${{ secrets.CHAIN_STACK_API_KEY }}';" >> lib/.secrets.g.dart + echo "const etherScanApiKey = '${{ secrets.ETHER_SCAN_API_KEY }}';" >> lib/.secrets.g.dart + echo "const polygonScanApiKey = '${{ secrets.POLYGON_SCAN_API_KEY }}';" >> lib/.secrets.g.dart + echo "const etherScanApiKey = '${{ secrets.ETHER_SCAN_API_KEY }}';" >> cw_evm/lib/.secrets.g.dart + echo "const moralisApiKey = '${{ secrets.MORALIS_API_KEY }}';" >> cw_evm/lib/.secrets.g.dart + echo "const chatwootWebsiteToken = '${{ secrets.CHATWOOT_WEBSITE_TOKEN }}';" >> lib/.secrets.g.dart + echo "const exolixCakeWalletApiKey = '${{ secrets.EXOLIX_API_KEY }}';" >> lib/.secrets.g.dart + echo "const exolixMoneroApiKey = '${{ secrets.EXOLIX_API_KEY }}';" >> lib/.secrets.g.dart + echo "const robinhoodApplicationId = '${{ secrets.ROBINHOOD_APPLICATION_ID }}';" >> lib/.secrets.g.dart + echo "const exchangeHelperApiKey = '${{ secrets.ROBINHOOD_CID_CLIENT_SECRET }}';" >> lib/.secrets.g.dart + echo "const walletConnectProjectId = '${{ secrets.WALLET_CONNECT_PROJECT_ID }}';" >> lib/.secrets.g.dart + echo "const moralisApiKey = '${{ secrets.MORALIS_API_KEY }}';" >> lib/.secrets.g.dart + echo "const polygonScanApiKey = '${{ secrets.POLYGON_SCAN_API_KEY }}';" >> cw_evm/lib/.secrets.g.dart + echo "const ankrApiKey = '${{ secrets.ANKR_API_KEY }}';" >> cw_solana/lib/.secrets.g.dart + echo "const chainStackApiKey = '${{ secrets.CHAIN_STACK_API_KEY }}';" >> cw_solana/lib/.secrets.g.dart + echo "const testCakePayApiKey = '${{ secrets.TEST_CAKE_PAY_API_KEY }}';" >> lib/.secrets.g.dart + echo "const cakePayApiKey = '${{ secrets.CAKE_PAY_API_KEY }}';" >> lib/.secrets.g.dart + echo "const authorization = '${{ secrets.CAKE_PAY_AUTHORIZATION }}';" >> lib/.secrets.g.dart + echo "const CSRFToken = '${{ secrets.CSRF_TOKEN }}';" >> lib/.secrets.g.dart + echo "const swapTradeExchangeMarkup = '${{ secrets.SWAPTRADE_EXCHANGE_MARKUP }}';" >> lib/.secrets.g.dart + echo "const nano2ApiKey = '${{ secrets.NANO2_API_KEY }}';" >> cw_nano/lib/.secrets.g.dart + echo "const nanoNowNodesApiKey = '${{ secrets.NANO_NOW_NODES_API_KEY }}';" >> cw_nano/lib/.secrets.g.dart + echo "const tronGridApiKey = '${{ secrets.TRON_GRID_API_KEY }}';" >> cw_tron/lib/.secrets.g.dart + echo "const tronNowNodesApiKey = '${{ secrets.TRON_NOW_NODES_API_KEY }}';" >> cw_tron/lib/.secrets.g.dart + echo "const meldTestApiKey = '${{ secrets.MELD_TEST_API_KEY }}';" >> lib/.secrets.g.dart + echo "const meldTestPublicKey = '${{ secrets.MELD_TEST_PUBLIC_KEY}}';" >> lib/.secrets.g.dart + echo "const letsExchangeBearerToken = '${{ secrets.LETS_EXCHANGE_TOKEN }}';" >> lib/.secrets.g.dart + echo "const letsExchangeAffiliateId = '${{ secrets.LETS_EXCHANGE_AFFILIATE_ID }}';" >> lib/.secrets.g.dart + echo "const stealthExBearerToken = '${{ secrets.STEALTH_EX_BEARER_TOKEN }}';" >> lib/.secrets.g.dart + echo "const stealthExAdditionalFeePercent = '${{ secrets.STEALTH_EX_ADDITIONAL_FEE_PERCENT }}';" >> lib/.secrets.g.dart + echo "const moneroTestWalletSeeds ='${{ secrets.MONERO_TEST_WALLET_SEEDS }}';" >> lib/.secrets.g.dart + echo "const moneroLegacyTestWalletSeeds = '${{ secrets.MONERO_LEGACY_TEST_WALLET_SEEDS }}';" >> lib/.secrets.g.dart + echo "const bitcoinTestWalletSeeds = '${{ secrets.BITCOIN_TEST_WALLET_SEEDS }}';" >> lib/.secrets.g.dart + echo "const ethereumTestWalletSeeds = '${{ secrets.ETHEREUM_TEST_WALLET_SEEDS }}';" >> lib/.secrets.g.dart + echo "const litecoinTestWalletSeeds = '${{ secrets.LITECOIN_TEST_WALLET_SEEDS }}';" >> lib/.secrets.g.dart + echo "const bitcoinCashTestWalletSeeds = '${{ secrets.BITCOIN_CASH_TEST_WALLET_SEEDS }}';" >> lib/.secrets.g.dart + echo "const polygonTestWalletSeeds = '${{ secrets.POLYGON_TEST_WALLET_SEEDS }}';" >> lib/.secrets.g.dart + echo "const solanaTestWalletSeeds = '${{ secrets.SOLANA_TEST_WALLET_SEEDS }}';" >> lib/.secrets.g.dart + echo "const tronTestWalletSeeds = '${{ secrets.TRON_TEST_WALLET_SEEDS }}';" >> lib/.secrets.g.dart + echo "const nanoTestWalletSeeds = '${{ secrets.NANO_TEST_WALLET_SEEDS }}';" >> lib/.secrets.g.dart + echo "const wowneroTestWalletSeeds = '${{ secrets.WOWNERO_TEST_WALLET_SEEDS }}';" >> lib/.secrets.g.dart + echo "const moneroTestWalletReceiveAddress = '${{ secrets.MONERO_TEST_WALLET_RECEIVE_ADDRESS }}';" >> lib/.secrets.g.dart + echo "const bitcoinTestWalletReceiveAddress = '${{ secrets.BITCOIN_TEST_WALLET_RECEIVE_ADDRESS }}';" >> lib/.secrets.g.dart + echo "const ethereumTestWalletReceiveAddress = '${{ secrets.ETHEREUM_TEST_WALLET_RECEIVE_ADDRESS }}';" >> lib/.secrets.g.dart + echo "const litecoinTestWalletReceiveAddress = '${{ secrets.LITECOIN_TEST_WALLET_RECEIVE_ADDRESS }}';" >> lib/.secrets.g.dart + echo "const bitcoinCashTestWalletReceiveAddress = '${{ secrets.BITCOIN_CASH_TEST_WALLET_RECEIVE_ADDRESS }}';" >> lib/.secrets.g.dart + echo "const polygonTestWalletReceiveAddress = '${{ secrets.POLYGON_TEST_WALLET_RECEIVE_ADDRESS }}';" >> lib/.secrets.g.dart + echo "const solanaTestWalletReceiveAddress = '${{ secrets.SOLANA_TEST_WALLET_RECEIVE_ADDRESS }}';" >> lib/.secrets.g.dart + echo "const tronTestWalletReceiveAddress = '${{ secrets.TRON_TEST_WALLET_RECEIVE_ADDRESS }}';" >> lib/.secrets.g.dart + echo "const nanoTestWalletReceiveAddress = '${{ secrets.NANO_TEST_WALLET_RECEIVE_ADDRESS }}';" >> lib/.secrets.g.dart + echo "const wowneroTestWalletReceiveAddress = '${{ secrets.WOWNERO_TEST_WALLET_RECEIVE_ADDRESS }}';" >> lib/.secrets.g.dart + echo "const moneroTestWalletBlockHeight = '${{ secrets.MONERO_TEST_WALLET_BLOCK_HEIGHT }}';" >> lib/.secrets.g.dart + # end of test secrets + echo "const chainflipApiKey = '${{ secrets.CHAINFLIP_API_KEY }}';" >> lib/.secrets.g.dart + echo "const chainflipAffiliateFee = '${{ secrets.CHAINFLIP_AFFILIATE_FEE }}';" >> lib/.secrets.g.dart + echo "const walletGroupSalt = '${{ secrets.WALLET_GROUP_SALT }}';" >> lib/.secrets.g.dart + + - name: Rename app + run: | + echo -e "id=com.cakewallet.test_${{ env.PR_NUMBER }}\nname=${{ env.BRANCH_NAME }}" > /opt/android/cake_wallet/android/app.properties + + - name: Build + run: | + cd /opt/android/cake_wallet + flutter build apk --release --split-per-abi + + # - name: Rename apk file + # run: | + # cd /opt/android/cake_wallet/build/app/outputs/flutter-apk + # mkdir test-apk + # cp app-arm64-v8a-release.apk test-apk/${{env.BRANCH_NAME}}.apk + # cp app-x86_64-release.apk test-apk/${{env.BRANCH_NAME}}_x86.apk + + # - name: Upload Artifact + # uses: kittaakos/upload-artifact-as-is@v0 + # with: + # path: /opt/android/cake_wallet/build/app/outputs/flutter-apk/test-apk/ + + # - name: Send Test APK + # continue-on-error: true + # uses: adrey/slack-file-upload-action@1.0.5 + # with: + # token: ${{ secrets.SLACK_APP_TOKEN }} + # path: /opt/android/cake_wallet/build/app/outputs/flutter-apk/test-apk/${{env.BRANCH_NAME}}.apk + # channel: ${{ secrets.SLACK_APK_CHANNEL }} + # title: "${{ env.BRANCH_NAME }}.apk" + # filename: ${{ env.BRANCH_NAME }}.apk + # initial_comment: ${{ github.event.head_commit.message }} + + - name: 🦾 Enable KVM + run: | + echo 'KERNEL=="kvm", GROUP="kvm", MODE="0666", OPTIONS+="static_node=kvm"' | sudo tee /etc/udev/rules.d/99-kvm4all.rules + sudo udevadm control --reload-rules + sudo udevadm trigger --name-match=kvm + + - name: 🦾 Cache gradle + uses: gradle/actions/setup-gradle@v3 + + - name: 🦾 Cache AVD + uses: actions/cache@v4 + id: avd-cache + with: + path: | + ~/.android/avd/* + ~/.android/adb* + key: avd-${{ matrix.api-level }} + + - name: 🦾 Create AVD and generate snapshot for caching + if: steps.avd-cache.outputs.cache-hit != 'true' + uses: reactivecircus/android-emulator-runner@v2 + with: + api-level: ${{ matrix.api-level }} + force-avd-creation: false + # arch: ${{ matrix.arch }} + emulator-options: -no-window -gpu swiftshader_indirect -noaudio -no-boot-anim -camera-back none + working-directory: /opt/android/cake_wallet + disable-animations: false + script: echo "Generated AVD snapshot for caching." + + - name: 🚀 Integration tests on Android Emulator + uses: reactivecircus/android-emulator-runner@v2 + with: + api-level: ${{ matrix.api-level }} + force-avd-creation: false + emulator-options: -no-snapshot-save -no-window -gpu swiftshader_indirect -noaudio -no-boot-anim -camera-back none + disable-animations: true + working-directory: /opt/android/cake_wallet + script: | + chmod a+rx integration_test_runner.sh + ./integration_test_runner.sh diff --git a/.github/workflows/cache_dependencies.yml b/.github/workflows/cache_dependencies.yml deleted file mode 100644 index bf0d0f7bc..000000000 --- a/.github/workflows/cache_dependencies.yml +++ /dev/null @@ -1,57 +0,0 @@ -name: Cache Dependencies - -on: - workflow_dispatch: - push: - branches: [ main ] - -jobs: - test: - - runs-on: ubuntu-20.04 - - steps: - - uses: actions/checkout@v2 - - uses: actions/setup-java@v1 - with: - java-version: '11.x' - - - name: Flutter action - uses: subosito/flutter-action@v1 - with: - flutter-version: '3.10.x' - channel: stable - - - name: Install package dependencies - run: sudo apt-get install -y curl unzip automake build-essential file pkg-config git python libtool libtinfo5 cmake clang - - - name: Execute Build and Setup Commands - run: | - sudo mkdir -p /opt/android - sudo chown $USER /opt/android - cd /opt/android - git clone https://github.com/cake-tech/cake_wallet.git --branch main - cd cake_wallet/scripts/android/ - ./install_ndk.sh - source ./app_env.sh cakewallet - ./app_config.sh - - - name: Cache Externals - id: cache-externals - uses: actions/cache@v3 - with: - path: | - /opt/android/cake_wallet/cw_haven/android/.cxx - /opt/android/cake_wallet/cw_haven/ios/External - /opt/android/cake_wallet/cw_monero/android/.cxx - /opt/android/cake_wallet/cw_monero/ios/External - /opt/android/cake_wallet/cw_shared_external/ios/External - key: ${{ hashFiles('**/build_monero.sh', '**/build_haven.sh', '**/monero_api.cpp') }} - - - if: ${{ steps.cache-externals.outputs.cache-hit != 'true' }} - name: Generate Externals - run: | - cd /opt/android/cake_wallet/scripts/android/ - source ./app_env.sh cakewallet - ./build_all.sh - ./copy_monero_deps.sh diff --git a/.github/workflows/no_http_imports.yaml b/.github/workflows/no_http_imports.yaml new file mode 100644 index 000000000..dad6821ac --- /dev/null +++ b/.github/workflows/no_http_imports.yaml @@ -0,0 +1,21 @@ +name: No http imports + +on: [pull_request] + +jobs: + PR_test_build: + runs-on: ubuntu-24.04 + + steps: + - uses: actions/checkout@v4 + - name: Check for http package usage + if: github.event_name == 'pull_request' + run: | + GIT_GREP_OUT="$(git grep package:http | (grep .dart: || test $? = 1) | (grep -v proxy_wrapper.dart || test $? = 1) | (grep -v very_insecure_http_do_not_use || test $? = 1) || true)" + [[ "x$GIT_GREP_OUT" == "x" ]] && exit 0 + echo "$GIT_GREP_OUT" + echo "There are .dart files which use http imports" + echo "Using http package breaks proxy integration" + echo "Please use ProxyWrapper.getHttpClient() from package:cw_core/utils/proxy_wrapper.dart" + exit 1 + \ No newline at end of file diff --git a/.github/workflows/no_print_in_dart.yaml b/.github/workflows/no_print_in_dart.yaml new file mode 100644 index 000000000..507793bd8 --- /dev/null +++ b/.github/workflows/no_print_in_dart.yaml @@ -0,0 +1,19 @@ +name: No print statements in dart files + +on: [pull_request] + +jobs: + PR_test_build: + runs-on: ubuntu-24.04 + + steps: + - uses: actions/checkout@v4 + - name: Check for print() statements in dart code (use printV() instead) + if: github.event_name == 'pull_request' + run: | + GIT_GREP_OUT="$(git grep ' print(' | (grep .dart: || test $? = 1) | (grep -v print_verbose.dart || test $? = 1) | (grep -v print_verbose_dummy.dart || test $? = 1) || true)" + [[ "x$GIT_GREP_OUT" == "x" ]] && exit 0 + echo "$GIT_GREP_OUT" + echo "There are .dart files which use print() statements" + echo "Please use printV from package:cw_core/utils/print_verbose.dart" + exit 1 diff --git a/.github/workflows/no_restricted_imports.yaml b/.github/workflows/no_restricted_imports.yaml new file mode 100644 index 000000000..03c3de018 --- /dev/null +++ b/.github/workflows/no_restricted_imports.yaml @@ -0,0 +1,47 @@ +name: No restricted imports in lib directory + +on: [pull_request] + +jobs: + check_restricted_imports: + runs-on: ubuntu-24.04 + + steps: + - uses: actions/checkout@v4 + - name: Check for restricted imports in lib directory + if: github.event_name == 'pull_request' + run: | + RESTRICTED_PACKAGES=( + "cw_bitcoin" + "cw_bitcoin_cash" + "cw_ethereum" + "cw_evm" + "cw_haven" + "cw_mweb" + "cw_nano" + "cw_polygon" + "cw_solana" + "cw_tron" + "cw_wownero" + "cw_zano" + ) + + FOUND_RESTRICTED=false + + for package in "${RESTRICTED_PACKAGES[@]}"; do + GREP_RESULT=$(find lib -type f -name "*.dart" -exec grep -l "import.*package:$package" {} \; || true) + + if [ -n "$GREP_RESULT" ]; then + echo "Found restricted import of '$package' in the following files:" + echo "$GREP_RESULT" + FOUND_RESTRICTED=true + fi + done + + if [ "$FOUND_RESTRICTED" = true ]; then + echo "Error: Restricted package imports found in lib/ directory" + echo "Please remove these imports as they are not allowed in the lib/ directory" + exit 1 + else + echo "No restricted imports found. All good!" + fi \ No newline at end of file diff --git a/.github/workflows/pr_test_build.yml b/.github/workflows/pr_test_build.yml deleted file mode 100644 index 88dd2c1eb..000000000 --- a/.github/workflows/pr_test_build.yml +++ /dev/null @@ -1,199 +0,0 @@ -name: PR Test Build - -on: - pull_request: - branches: [main] - workflow_dispatch: - inputs: - branch: - description: 'Branch name to build' - required: true - default: 'main' - -jobs: - PR_test_build: - runs-on: ubuntu-20.04 - env: - STORE_PASS: test@cake_wallet - KEY_PASS: test@cake_wallet - - steps: - - name: is pr - if: github.event_name == 'pull_request' - run: echo "BRANCH_NAME=${GITHUB_HEAD_REF}" >> $GITHUB_ENV - - - name: is not pr - if: github.event_name != 'pull_request' - run: echo "BRANCH_NAME=${{ github.event.inputs.branch }}" >> $GITHUB_ENV - - - name: Free Up GitHub Actions Ubuntu Runner Disk Space - run: | - sudo rm -rf /usr/share/dotnet - sudo rm -rf /opt/ghc - sudo rm -rf "/usr/local/share/boost" - sudo rm -rf "$AGENT_TOOLSDIRECTORY" - - - uses: actions/checkout@v2 - - uses: actions/setup-java@v1 - with: - java-version: "11.x" - - - name: Flutter action - uses: subosito/flutter-action@v1 - with: - flutter-version: "3.10.x" - channel: stable - - - name: Install package dependencies - run: sudo apt-get install -y curl unzip automake build-essential file pkg-config git python libtool libtinfo5 cmake clang - - - name: Execute Build and Setup Commands - run: | - sudo mkdir -p /opt/android - sudo chown $USER /opt/android - cd /opt/android - -y curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh - cargo install cargo-ndk - git clone https://github.com/cake-tech/cake_wallet.git --branch ${{ env.BRANCH_NAME }} - cd cake_wallet/scripts/android/ - ./install_ndk.sh - source ./app_env.sh cakewallet - chmod +x pubspec_gen.sh - ./app_config.sh - - - name: Cache Externals - id: cache-externals - uses: actions/cache@v3 - with: - path: | - /opt/android/cake_wallet/cw_haven/android/.cxx - /opt/android/cake_wallet/cw_haven/ios/External - /opt/android/cake_wallet/cw_monero/android/.cxx - /opt/android/cake_wallet/cw_monero/ios/External - /opt/android/cake_wallet/cw_shared_external/ios/External - key: ${{ hashFiles('**/build_monero.sh', '**/build_haven.sh', '**/monero_api.cpp') }} - - - if: ${{ steps.cache-externals.outputs.cache-hit != 'true' }} - name: Generate Externals - run: | - cd /opt/android/cake_wallet/scripts/android/ - source ./app_env.sh cakewallet - ./build_all.sh - ./copy_monero_deps.sh - - - name: Install Flutter dependencies - run: | - cd /opt/android/cake_wallet - flutter pub get - - - name: Generate KeyStore - run: | - cd /opt/android/cake_wallet/android/app - keytool -genkey -v -keystore key.jks -keyalg RSA -keysize 2048 -validity 10000 -alias testKey -noprompt -dname "CN=CakeWallet, OU=CakeWallet, O=CakeWallet, L=Florida, S=America, C=USA" -storepass $STORE_PASS -keypass $KEY_PASS - - - name: Generate key properties - run: | - cd /opt/android/cake_wallet - flutter packages pub run tool/generate_android_key_properties.dart keyAlias=testKey storeFile=key.jks storePassword=$STORE_PASS keyPassword=$KEY_PASS - - - name: Generate localization - run: | - cd /opt/android/cake_wallet - flutter packages pub run tool/generate_localization.dart - - - name: Build generated code - run: | - cd /opt/android/cake_wallet - cd cw_core && flutter pub get && flutter packages pub run build_runner build --delete-conflicting-outputs && cd .. - cd cw_evm && flutter pub get && flutter packages pub run build_runner build --delete-conflicting-outputs && cd .. - cd cw_monero && flutter pub get && flutter packages pub run build_runner build --delete-conflicting-outputs && cd .. - cd cw_bitcoin && flutter pub get && flutter packages pub run build_runner build --delete-conflicting-outputs && cd .. - cd cw_haven && flutter pub get && flutter packages pub run build_runner build --delete-conflicting-outputs && cd .. - cd cw_bitcoin_cash && flutter pub get && flutter packages pub run build_runner build --delete-conflicting-outputs && cd .. - cd cw_nano && flutter pub get && flutter packages pub run build_runner build --delete-conflicting-outputs && cd .. - cd cw_ethereum && flutter pub get && cd .. - cd cw_polygon && flutter pub get && cd .. - flutter packages pub run build_runner build --delete-conflicting-outputs - - - name: Add secrets - run: | - cd /opt/android/cake_wallet - touch lib/.secrets.g.dart - touch cw_evm/lib/.secrets.g.dart - echo "const salt = '${{ secrets.SALT }}';" > lib/.secrets.g.dart - echo "const keychainSalt = '${{ secrets.KEY_CHAIN_SALT }}';" >> lib/.secrets.g.dart - echo "const key = '${{ secrets.KEY }}';" >> lib/.secrets.g.dart - echo "const walletSalt = '${{ secrets.WALLET_SALT }}';" >> lib/.secrets.g.dart - echo "const shortKey = '${{ secrets.SHORT_KEY }}';" >> lib/.secrets.g.dart - echo "const backupSalt = '${{ secrets.BACKUP_SALT }}';" >> lib/.secrets.g.dart - echo "const backupKeychainSalt = '${{ secrets.BACKUP_KEY_CHAIN_SALT }}';" >> lib/.secrets.g.dart - echo "const changeNowApiKey = '${{ secrets.CHANGE_NOW_API_KEY }}';" >> lib/.secrets.g.dart - echo "const changeNowApiKeyDesktop = '${{ secrets.CHANGE_NOW_API_KEY_DESKTOP }}';" >> lib/.secrets.g.dart - echo "const wyreSecretKey = '${{ secrets.WYRE_SECRET_KEY }}';" >> lib/.secrets.g.dart - echo "const wyreApiKey = '${{ secrets.WYRE_API_KEY }}';" >> lib/.secrets.g.dart - echo "const wyreAccountId = '${{ secrets.WYRE_ACCOUNT_ID }}';" >> lib/.secrets.g.dart - echo "const moonPayApiKey = '${{ secrets.MOON_PAY_API_KEY }}';" >> lib/.secrets.g.dart - echo "const moonPaySecretKey = '${{ secrets.MOON_PAY_SECRET_KEY }}';" >> lib/.secrets.g.dart - echo "const sideShiftAffiliateId = '${{ secrets.SIDE_SHIFT_AFFILIATE_ID }}';" >> lib/.secrets.g.dart - echo "const simpleSwapApiKey = '${{ secrets.SIMPLE_SWAP_API_KEY }}';" >> lib/.secrets.g.dart - echo "const simpleSwapApiKeyDesktop = '${{ secrets.SIMPLE_SWAP_API_KEY_DESKTOP }}';" >> lib/.secrets.g.dart - echo "const onramperApiKey = '${{ secrets.ONRAMPER_API_KEY }}';" >> lib/.secrets.g.dart - echo "const anypayToken = '${{ secrets.ANY_PAY_TOKEN }}';" >> lib/.secrets.g.dart - echo "const ioniaClientId = '${{ secrets.IONIA_CLIENT_ID }}';" >> lib/.secrets.g.dart - echo "const twitterBearerToken = '${{ secrets.TWITTER_BEARER_TOKEN }}';" >> lib/.secrets.g.dart - echo "const trocadorApiKey = '${{ secrets.TROCADOR_API_KEY }}';" >> lib/.secrets.g.dart - echo "const trocadorExchangeMarkup = '${{ secrets.TROCADOR_EXCHANGE_MARKUP }}';" >> lib/.secrets.g.dart - echo "const anonPayReferralCode = '${{ secrets.ANON_PAY_REFERRAL_CODE }}';" >> lib/.secrets.g.dart - echo "const fiatApiKey = '${{ secrets.FIAT_API_KEY }}';" >> lib/.secrets.g.dart - echo "const payfuraApiKey = '${{ secrets.PAYFURA_API_KEY }}';" >> lib/.secrets.g.dart - echo "const etherScanApiKey = '${{ secrets.ETHER_SCAN_API_KEY }}';" >> cw_evm/lib/.secrets.g.dart - echo "const chatwootWebsiteToken = '${{ secrets.CHATWOOT_WEBSITE_TOKEN }}';" >> lib/.secrets.g.dart - echo "const exolixApiKey = '${{ secrets.EXOLIX_API_KEY }}';" >> lib/.secrets.g.dart - echo "const robinhoodApplicationId = '${{ secrets.ROBINHOOD_APPLICATION_ID }}';" >> lib/.secrets.g.dart - echo "const robinhoodCIdApiSecret = '${{ secrets.ROBINHOOD_CID_CLIENT_SECRET }}';" >> lib/.secrets.g.dart - echo "const walletConnectProjectId = '${{ secrets.WALLET_CONNECT_PROJECT_ID }}';" >> lib/.secrets.g.dart - echo "const moralisApiKey = '${{ secrets.MORALIS_API_KEY }}';" >> lib/.secrets.g.dart - echo "const polygonScanApiKey = '${{ secrets.POLYGON_SCAN_API_KEY }}';" >> cw_evm/lib/.secrets.g.dart - - - name: Rename app - run: echo -e "id=com.cakewallet.test\nname=${{ env.BRANCH_NAME }}" > /opt/android/cake_wallet/android/app.properties - - - name: Build - run: | - cd /opt/android/cake_wallet - flutter build apk --release - -# - name: Push to App Center -# run: | -# echo 'Installing App Center CLI tools' -# npm install -g appcenter-cli -# echo "Publishing test to App Center" -# appcenter distribute release \ -# --group "Testers" \ -# --file "/opt/android/cake_wallet/build/app/outputs/apk/release/app-release.apk" \ -# --release-notes ${{ env.BRANCH_NAME }} \ -# --app Cake-Labs/Cake-Wallet \ -# --token ${{ secrets.APP_CENTER_TOKEN }} \ -# --quiet - - - name: Rename apk file - run: | - cd /opt/android/cake_wallet/build/app/outputs/apk/release - mkdir test-apk - cp app-release.apk test-apk/${{env.BRANCH_NAME}}.apk - - - name: Upload Artifact - uses: kittaakos/upload-artifact-as-is@v0 - with: - path: /opt/android/cake_wallet/build/app/outputs/apk/release/test-apk/ - - - name: Send Test APK - continue-on-error: true - uses: adrey/slack-file-upload-action@1.0.5 - with: - token: ${{ secrets.SLACK_APP_TOKEN }} - path: /opt/android/cake_wallet/build/app/outputs/apk/release/app-release.apk - channel: ${{ secrets.SLACK_APK_CHANNEL }} - title: "${{ env.BRANCH_NAME }}.apk" - filename: ${{ env.BRANCH_NAME }}.apk - initial_comment: ${{ github.event.head_commit.message }} diff --git a/.github/workflows/pr_test_build_android.yml b/.github/workflows/pr_test_build_android.yml new file mode 100644 index 000000000..f7c226ce4 --- /dev/null +++ b/.github/workflows/pr_test_build_android.yml @@ -0,0 +1,317 @@ +name: Cake Wallet Android + +on: [pull_request] + +defaults: + run: + shell: bash +jobs: + PR_test_build: + runs-on: linux-amd64 + container: + image: ghcr.io/cake-tech/cake_wallet:debian12-flutter3.27.0-go1.24.1-ruststablenightly + env: + STORE_PASS: test@cake_wallet + KEY_PASS: test@cake_wallet + MONEROC_CACHE_DIR_ROOT: /opt/generic_cache + BRANCH_NAME: ${{ github.head_ref || github.ref_name }} + ANDROID_AVD_HOME: /root/.android/avd + volumes: + - /opt/cw_cache_android/root/.cache:/root/.cache + - /opt/cw_cache_android/root/.android/avd/:/root/.android/avd + - /opt/cw_cache_android/root/.ccache:/root/.ccache + - /opt/cw_cache_android/root/.pub-cache/:/root/.pub-cache + - /opt/cw_cache_android/root/.gradle/:/root/.gradle + - /opt/cw_cache_android/root/.android/:/root/.android + - /opt/cw_cache_android/root/go/pkg:/root/go/pkg + - /opt/cw_cache_android/opt/generic_cache:/opt/generic_cache + - /dev/kvm:/dev/kvm + strategy: + matrix: + api-level: [29] + + steps: + - name: Fix github actions messing up $HOME... + run: 'echo HOME=/root | sudo tee -a $GITHUB_ENV' + - uses: actions/checkout@v4 + with: + ref: ${{ github.event.pull_request.head.sha }} + - name: configure git + run: | + git config --global --add safe.directory '*' + git config --global user.email "ci@cakewallet.com" + git config --global user.name "CakeWallet CI" + - name: Get the full commit message + run: | + FULL_MESSAGE="$(git log -1 --pretty=%B)" + echo "message<> $GITHUB_ENV + echo "$FULL_MESSAGE" >> $GITHUB_ENV + echo "EOF" >> $GITHUB_ENV + + - name: Add secrets + run: | + touch lib/.secrets.g.dart + touch cw_evm/lib/.secrets.g.dart + touch cw_solana/lib/.secrets.g.dart + touch cw_core/lib/.secrets.g.dart + touch cw_nano/lib/.secrets.g.dart + touch cw_tron/lib/.secrets.g.dart + if [[ "x${{ secrets.SALT }}" == "x" ]]; + then + echo "const salt = '954f787f12622067f7e548d9450c3832';" > lib/.secrets.g.dart + else + echo "const salt = '${{ secrets.SALT }}';" > lib/.secrets.g.dart + fi + if [[ "x${{ secrets.KEY_CHAIN_SALT }}" == "x" ]]; + then + echo "const keychainSalt = '2d2beba777dbf7dff7013b7a';" >> lib/.secrets.g.dart + else + echo "const keychainSalt = '${{ secrets.KEY_CHAIN_SALT }}';" >> lib/.secrets.g.dart + fi + if [[ "x${{ secrets.KEY }}" == "x" ]]; + then + echo "const key = '638e98820ec10a2945e968435c9397a3';" >> lib/.secrets.g.dart + else + echo "const key = '${{ secrets.KEY }}';" >> lib/.secrets.g.dart + fi + if [[ "x${{ secrets.WALLET_SALT }}" == "x" ]]; + then + echo "const walletSalt = '8f7f1b70';" >> lib/.secrets.g.dart + else + echo "const walletSalt = '${{ secrets.WALLET_SALT }}';" >> lib/.secrets.g.dart + fi + if [[ "x${{ secrets.SHORT_KEY }}" == "x" ]]; + then + echo "const shortKey = '653f270c2c152bc7ec864afe';" >> lib/.secrets.g.dart + else + echo "const shortKey = '${{ secrets.SHORT_KEY }}';" >> lib/.secrets.g.dart + fi + if [[ "x${{ secrets.BACKUP_SALT }}" == "x" ]]; + then + echo "const backupSalt = 'bf630d24ff0b6f60';" >> lib/.secrets.g.dart + else + echo "const backupSalt = '${{ secrets.BACKUP_SALT }}';" >> lib/.secrets.g.dart + fi + if [[ "x${{ secrets.BACKUP_KEY_CHAIN_SALT }}" == "x" ]]; + then + echo "const backupKeychainSalt = 'bf630d24ff0b6f60';" >> lib/.secrets.g.dart + else + echo "const backupKeychainSalt = '${{ secrets.BACKUP_KEY_CHAIN_SALT }}';" >> lib/.secrets.g.dart + fi + echo "const changeNowCakeWalletApiKey = '${{ secrets.CHANGE_NOW_API_KEY }}';" >> lib/.secrets.g.dart + echo "const changeNowMoneroApiKey = '${{ secrets.CHANGE_NOW_API_KEY }}';" >> lib/.secrets.g.dart + echo "const wyreSecretKey = '${{ secrets.WYRE_SECRET_KEY }}';" >> lib/.secrets.g.dart + echo "const wyreApiKey = '${{ secrets.WYRE_API_KEY }}';" >> lib/.secrets.g.dart + echo "const wyreAccountId = '${{ secrets.WYRE_ACCOUNT_ID }}';" >> lib/.secrets.g.dart + echo "const moonPayApiKey = '${{ secrets.MOON_PAY_API_KEY }}';" >> lib/.secrets.g.dart + echo "const moonPaySecretKey = '${{ secrets.MOON_PAY_SECRET_KEY }}';" >> lib/.secrets.g.dart + echo "const sideShiftAffiliateId = '${{ secrets.SIDE_SHIFT_AFFILIATE_ID }}';" >> lib/.secrets.g.dart + echo "const simpleSwapApiKey = '${{ secrets.SIMPLE_SWAP_API_KEY }}';" >> lib/.secrets.g.dart + echo "const simpleSwapApiKeyDesktop = '${{ secrets.SIMPLE_SWAP_API_KEY_DESKTOP }}';" >> lib/.secrets.g.dart + echo "const onramperApiKey = '${{ secrets.ONRAMPER_API_KEY }}';" >> lib/.secrets.g.dart + echo "const anypayToken = '${{ secrets.ANY_PAY_TOKEN }}';" >> lib/.secrets.g.dart + echo "const ioniaClientId = '${{ secrets.IONIA_CLIENT_ID }}';" >> lib/.secrets.g.dart + echo "const twitterBearerToken = '${{ secrets.TWITTER_BEARER_TOKEN }}';" >> lib/.secrets.g.dart + echo "const trocadorApiKey = '${{ secrets.TROCADOR_API_KEY }}';" >> lib/.secrets.g.dart + echo "const trocadorMoneroApiKey = '${{ secrets.TROCADOR_API_KEY }}';" >> lib/.secrets.g.dart + echo "const trocadorExchangeMarkup = '${{ secrets.TROCADOR_EXCHANGE_MARKUP }}';" >> lib/.secrets.g.dart + echo "const anonPayReferralCode = '${{ secrets.ANON_PAY_REFERRAL_CODE }}';" >> lib/.secrets.g.dart + echo "const fiatApiKey = '${{ secrets.FIAT_API_KEY }}';" >> lib/.secrets.g.dart + echo "const ankrApiKey = '${{ secrets.ANKR_API_KEY }}';" >> lib/.secrets.g.dart + echo "const chainStackApiKey = '${{ secrets.CHAIN_STACK_API_KEY }}';" >> lib/.secrets.g.dart + echo "const etherScanApiKey = '${{ secrets.ETHER_SCAN_API_KEY }}';" >> lib/.secrets.g.dart + echo "const polygonScanApiKey = '${{ secrets.POLYGON_SCAN_API_KEY }}';" >> lib/.secrets.g.dart + echo "const etherScanApiKey = '${{ secrets.ETHER_SCAN_API_KEY }}';" >> cw_evm/lib/.secrets.g.dart + echo "const moralisApiKey = '${{ secrets.MORALIS_API_KEY }}';" >> cw_evm/lib/.secrets.g.dart + echo "const nowNodesApiKey = '${{ secrets.EVM_NOWNODES_API_KEY }}';" >> cw_evm/lib/.secrets.g.dart + echo "const chatwootWebsiteToken = '${{ secrets.CHATWOOT_WEBSITE_TOKEN }}';" >> lib/.secrets.g.dart + echo "const exolixCakeWalletApiKey = '${{ secrets.EXOLIX_API_KEY }}';" >> lib/.secrets.g.dart + echo "const exolixMoneroApiKey = '${{ secrets.EXOLIX_API_KEY }}';" >> lib/.secrets.g.dart + echo "const robinhoodApplicationId = '${{ secrets.ROBINHOOD_APPLICATION_ID }}';" >> lib/.secrets.g.dart + echo "const exchangeHelperApiKey = '${{ secrets.ROBINHOOD_CID_CLIENT_SECRET }}';" >> lib/.secrets.g.dart + echo "const walletConnectProjectId = '${{ secrets.WALLET_CONNECT_PROJECT_ID }}';" >> lib/.secrets.g.dart + echo "const moralisApiKey = '${{ secrets.MORALIS_API_KEY }}';" >> lib/.secrets.g.dart + echo "const polygonScanApiKey = '${{ secrets.POLYGON_SCAN_API_KEY }}';" >> cw_evm/lib/.secrets.g.dart + echo "const ankrApiKey = '${{ secrets.ANKR_API_KEY }}';" >> cw_solana/lib/.secrets.g.dart + echo "const chainStackApiKey = '${{ secrets.CHAIN_STACK_API_KEY }}';" >> cw_solana/lib/.secrets.g.dart + echo "const testCakePayApiKey = '${{ secrets.TEST_CAKE_PAY_API_KEY }}';" >> lib/.secrets.g.dart + echo "const cakePayApiKey = '${{ secrets.CAKE_PAY_API_KEY }}';" >> lib/.secrets.g.dart + echo "const authorization = '${{ secrets.CAKE_PAY_AUTHORIZATION }}';" >> lib/.secrets.g.dart + echo "const CSRFToken = '${{ secrets.CSRF_TOKEN }}';" >> lib/.secrets.g.dart + echo "const swapTradeExchangeMarkup = '${{ secrets.SWAPTRADE_EXCHANGE_MARKUP }}';" >> lib/.secrets.g.dart + echo "const nano2ApiKey = '${{ secrets.NANO2_API_KEY }}';" >> cw_nano/lib/.secrets.g.dart + echo "const nanoNowNodesApiKey = '${{ secrets.NANO_NOW_NODES_API_KEY }}';" >> cw_nano/lib/.secrets.g.dart + echo "const tronGridApiKey = '${{ secrets.TRON_GRID_API_KEY }}';" >> cw_tron/lib/.secrets.g.dart + echo "const tronNowNodesApiKey = '${{ secrets.TRON_NOW_NODES_API_KEY }}';" >> cw_tron/lib/.secrets.g.dart + echo "const meldTestApiKey = '${{ secrets.MELD_TEST_API_KEY }}';" >> lib/.secrets.g.dart + echo "const meldTestPublicKey = '${{ secrets.MELD_TEST_PUBLIC_KEY}}';" >> lib/.secrets.g.dart + echo "const letsExchangeBearerToken = '${{ secrets.LETS_EXCHANGE_TOKEN }}';" >> lib/.secrets.g.dart + echo "const letsExchangeAffiliateId = '${{ secrets.LETS_EXCHANGE_AFFILIATE_ID }}';" >> lib/.secrets.g.dart + echo "const stealthExBearerToken = '${{ secrets.STEALTH_EX_BEARER_TOKEN }}';" >> lib/.secrets.g.dart + echo "const stealthExAdditionalFeePercent = '${{ secrets.STEALTH_EX_ADDITIONAL_FEE_PERCENT }}';" >> lib/.secrets.g.dart + # for tests + echo "const moneroTestWalletSeeds ='${{ secrets.MONERO_TEST_WALLET_SEEDS }}';" >> lib/.secrets.g.dart + echo "const moneroLegacyTestWalletSeeds = '${{ secrets.MONERO_LEGACY_TEST_WALLET_SEEDS }}';" >> lib/.secrets.g.dart + echo "const bitcoinTestWalletSeeds = '${{ secrets.BITCOIN_TEST_WALLET_SEEDS }}';" >> lib/.secrets.g.dart + echo "const ethereumTestWalletSeeds = '${{ secrets.ETHEREUM_TEST_WALLET_SEEDS }}';" >> lib/.secrets.g.dart + echo "const litecoinTestWalletSeeds = '${{ secrets.LITECOIN_TEST_WALLET_SEEDS }}';" >> lib/.secrets.g.dart + echo "const bitcoinCashTestWalletSeeds = '${{ secrets.BITCOIN_CASH_TEST_WALLET_SEEDS }}';" >> lib/.secrets.g.dart + echo "const polygonTestWalletSeeds = '${{ secrets.POLYGON_TEST_WALLET_SEEDS }}';" >> lib/.secrets.g.dart + echo "const solanaTestWalletSeeds = '${{ secrets.SOLANA_TEST_WALLET_SEEDS }}';" >> lib/.secrets.g.dart + echo "const tronTestWalletSeeds = '${{ secrets.TRON_TEST_WALLET_SEEDS }}';" >> lib/.secrets.g.dart + echo "const nanoTestWalletSeeds = '${{ secrets.NANO_TEST_WALLET_SEEDS }}';" >> lib/.secrets.g.dart + echo "const wowneroTestWalletSeeds = '${{ secrets.WOWNERO_TEST_WALLET_SEEDS }}';" >> lib/.secrets.g.dart + echo "const moneroTestWalletReceiveAddress = '${{ secrets.MONERO_TEST_WALLET_RECEIVE_ADDRESS }}';" >> lib/.secrets.g.dart + echo "const bitcoinTestWalletReceiveAddress = '${{ secrets.BITCOIN_TEST_WALLET_RECEIVE_ADDRESS }}';" >> lib/.secrets.g.dart + echo "const ethereumTestWalletReceiveAddress = '${{ secrets.ETHEREUM_TEST_WALLET_RECEIVE_ADDRESS }}';" >> lib/.secrets.g.dart + echo "const litecoinTestWalletReceiveAddress = '${{ secrets.LITECOIN_TEST_WALLET_RECEIVE_ADDRESS }}';" >> lib/.secrets.g.dart + echo "const bitcoinCashTestWalletReceiveAddress = '${{ secrets.BITCOIN_CASH_TEST_WALLET_RECEIVE_ADDRESS }}';" >> lib/.secrets.g.dart + echo "const polygonTestWalletReceiveAddress = '${{ secrets.POLYGON_TEST_WALLET_RECEIVE_ADDRESS }}';" >> lib/.secrets.g.dart + echo "const solanaTestWalletReceiveAddress = '${{ secrets.SOLANA_TEST_WALLET_RECEIVE_ADDRESS }}';" >> lib/.secrets.g.dart + echo "const tronTestWalletReceiveAddress = '${{ secrets.TRON_TEST_WALLET_RECEIVE_ADDRESS }}';" >> lib/.secrets.g.dart + echo "const nanoTestWalletReceiveAddress = '${{ secrets.NANO_TEST_WALLET_RECEIVE_ADDRESS }}';" >> lib/.secrets.g.dart + echo "const wowneroTestWalletReceiveAddress = '${{ secrets.WOWNERO_TEST_WALLET_RECEIVE_ADDRESS }}';" >> lib/.secrets.g.dart + echo "const moneroTestWalletBlockHeight = '${{ secrets.MONERO_TEST_WALLET_BLOCK_HEIGHT }}';" >> lib/.secrets.g.dart + # end of test secrets + echo "const chainflipApiKey = '${{ secrets.CHAINFLIP_API_KEY }}';" >> lib/.secrets.g.dart + echo "const chainflipAffiliateFee = '${{ secrets.CHAINFLIP_AFFILIATE_FEE }}';" >> lib/.secrets.g.dart + echo "const kryptonimApiKey = '${{ secrets.KRYPTONIM_API_KEY }}';" >> lib/.secrets.g.dart + echo "const walletGroupSalt = '${{ secrets.WALLET_GROUP_SALT }}';" >> lib/.secrets.g.dart + + - name: prepare monero_c and cache + run: | + export MONEROC_HASH=$(cat scripts/prepare_moneroc.sh | grep 'git checkout' | xargs | awk '{ print $3 }') + echo MONEROC_HASH=$MONEROC_HASH >> /etc/environment + mkdir -p "$MONEROC_CACHE_DIR_ROOT/moneroc-$MONEROC_HASH/monero_c" + pushd scripts + ln -s "$MONEROC_CACHE_DIR_ROOT/moneroc-$MONEROC_HASH/monero_c" + ./prepare_moneroc.sh + popd + pushd scripts/monero_c + mkdir -p "$MONEROC_CACHE_DIR_ROOT/_cache/contrib/depends/built" || true + mkdir -p "$MONEROC_CACHE_DIR_ROOT/_cache/monero/contrib/depends/built" || true + mkdir -p "$MONEROC_CACHE_DIR_ROOT/_cache/wownero/contrib/depends/built" || true + mkdir -p "$MONEROC_CACHE_DIR_ROOT/_cache/contrib/depends/sources" || true + mkdir -p "$MONEROC_CACHE_DIR_ROOT/_cache/monero/contrib/depends/sources" || true + mkdir -p "$MONEROC_CACHE_DIR_ROOT/_cache/wownero/contrib/depends/sources" || true + + rm -rf "$PWD/contrib/depends/built" "$PWD/monero/contrib/depends/built" "$PWD/wownero/contrib/depends/built" + rm -rf "$PWD/contrib/depends/sources" "$PWD/monero/contrib/depends/sources" "$PWD/wownero/contrib/depends/sources" + mkdir -p contrib/depends || true + ln -sf "$MONEROC_CACHE_DIR_ROOT/_cache/contrib/depends/built" "$PWD/contrib/depends/built" + ln -sf "$MONEROC_CACHE_DIR_ROOT/_cache/monero/contrib/depends/built" "$PWD/monero/contrib/depends/built" + ln -sf "$MONEROC_CACHE_DIR_ROOT/_cache/wownero/contrib/depends/built" "$PWD/wownero/contrib/depends/built" + ln -sf "$MONEROC_CACHE_DIR_ROOT/_cache/contrib/depends/sources" "$PWD/contrib/depends/sources" + ln -sf "$MONEROC_CACHE_DIR_ROOT/_cache/monero/contrib/depends/sources" "$PWD/monero/contrib/depends/sources" + ln -sf "$MONEROC_CACHE_DIR_ROOT/_cache/wownero/contrib/depends/sources" "$PWD/wownero/contrib/depends/sources" + popd + + - name: Generate KeyStore + run: | + pushd /opt/generic_cache + if [[ ! -f key.jks ]]; + then + keytool -genkey -v -keystore key.jks -keyalg RSA -keysize 2048 -validity 10000 -alias testKey -noprompt -dname "CN=CakeWallet, OU=CakeWallet, O=CakeWallet, L=Florida, S=America, C=USA" -storepass $STORE_PASS -keypass $KEY_PASS + else + echo "$PWD/key.jks exist, not generating" + fi + popd + cp /opt/generic_cache/key.jks android/app + + - name: Execute Build and Setup Commands + run: | + pushd scripts/android + source ./app_env.sh cakewallet + ./app_config.sh + popd + + - name: Build monero_c + run: | + pushd scripts/android/ + source ./app_env.sh cakewallet + ./build_monero_all.sh + popd + + - name: Install Flutter dependencies + run: | + flutter pub get + + - name: Build mwebd + run: | + set -x -e + export MWEBD_HASH=$(cat scripts/android/build_mwebd.sh | grep 'git reset --hard' | xargs | awk '{ print $4 }') + echo MWEBD_HASH=$MWEBD_HASH >> /etc/environment + pushd scripts/android + gomobile init; + ./build_mwebd.sh --dont-install + popd + + - name: Build Decred + run: | + set -x -e + pushd scripts/android + ./build_decred.sh + popd + + - name: Build generated code + run: | + flutter --version + flutter clean + rm -rf .dart_tool + rm pubspec.lock + flutter pub get + ./model_generator.sh async + + - name: Generate key properties + run: | + dart run tool/generate_android_key_properties.dart keyAlias=testKey storeFile=key.jks storePassword=$STORE_PASS keyPassword=$KEY_PASS + + - name: Generate localization + run: | + dart run tool/generate_localization.dart + + - name: Rename app + run: | + sanitized_branch_name=${BRANCH_NAME#origin/} # Remove 'origin/' prefix if it exists + sanitized_branch_name=${sanitized_branch_name:0:16} # Take only the first 16 characters + sanitized_branch_name=$(echo "$sanitized_branch_name" | tr '[:upper:]' '[:lower:]') # Convert to lowercase + sanitized_branch_name=$(echo "$sanitized_branch_name" | sed 's/[^a-z0-9]//g') # Remove all special characters + + echo -e "id=com.cakewallet.test_${sanitized_branch_name}\nname=${BRANCH_NAME}" > android/app.properties + + - name: Build + run: | + flutter build apk --dart-define=hasDevOptions=true --release --split-per-abi + + - name: Rename apk file + run: | + cd build/app/outputs/flutter-apk + mkdir test-apk + cp app-arm64-v8a-release.apk test-apk/${BRANCH_NAME}.apk + cp app-x86_64-release.apk test-apk/${BRANCH_NAME}_x86.apk + + - name: Find APK file + id: find_apk + run: | + set -x + apk_file=$(ls build/app/outputs/flutter-apk/test-apk/${BRANCH_NAME}.apk || exit 1) + echo "APK_FILE=$apk_file" >> $GITHUB_ENV + + - name: Upload artifact to slack + if: ${{ !contains(env.message, 'skip slack') }} + continue-on-error: true + uses: adrey/slack-file-upload-action@1.0.5 + with: + token: ${{ secrets.SLACK_APP_TOKEN }} + path: ${{ env.APK_FILE }} + channel: ${{ secrets.SLACK_APK_CHANNEL }} + initial_comment: ${{ env.message }} + + - name: cleanup + run: rm -rf build/app/outputs/flutter-apk/test-apk/ + + - name: Upload Artifact to github + uses: actions/upload-artifact@v4 + with: + path: ${{ github.workspace }}/build/app/outputs/flutter-apk + name: "android apk" diff --git a/.github/workflows/pr_test_build_linux.yml b/.github/workflows/pr_test_build_linux.yml new file mode 100644 index 000000000..f057b19e5 --- /dev/null +++ b/.github/workflows/pr_test_build_linux.yml @@ -0,0 +1,317 @@ +name: Cake Wallet Linux + +on: [pull_request] + +defaults: + run: + shell: bash +jobs: + PR_test_build: + runs-on: linux-amd64 + container: + image: ghcr.io/cake-tech/cake_wallet:debian12-flutter3.27.0-go1.24.1-ruststablenightly + env: + STORE_PASS: test@cake_wallet + KEY_PASS: test@cake_wallet + MONEROC_CACHE_DIR_ROOT: /opt/generic_cache + BRANCH_NAME: ${{ github.head_ref || github.ref_name }} + DESKTOP_FORCE_MOBILE: Y + volumes: + - /opt/cw_cache_linux/root/.cache:/root/.cache + - /opt/cw_cache_linux/root/.ccache:/root/.ccache + - /opt/cw_cache_linux/root/.pub-cache/:/root/.pub-cache + - /opt/cw_cache_linux/root/go/pkg:/root/go/pkg + - /opt/cw_cache_linux/opt/generic_cache:/opt/generic_cache + + steps: + - name: Fix github actions messing up $HOME... + run: 'echo HOME=/root | sudo tee -a $GITHUB_ENV' + - uses: actions/checkout@v4 + with: + ref: ${{ github.event.pull_request.head.sha }} + repository: ${{ github.event.pull_request.head.repo.full_name }} + - name: configure git + run: | + git config --global --add safe.directory '*' + git config --global user.email "ci@cakewallet.com" + git config --global user.name "CakeWallet CI" + - name: Get the full commit message + run: | + FULL_MESSAGE="$(git log -1 --pretty=%B)" + echo "message<> $GITHUB_ENV + echo "$FULL_MESSAGE" >> $GITHUB_ENV + echo "EOF" >> $GITHUB_ENV + - name: Add secrets + run: | + touch lib/.secrets.g.dart + touch cw_evm/lib/.secrets.g.dart + touch cw_solana/lib/.secrets.g.dart + touch cw_core/lib/.secrets.g.dart + touch cw_nano/lib/.secrets.g.dart + touch cw_tron/lib/.secrets.g.dart + if [[ "x${{ secrets.SALT }}" == "x" ]]; + then + echo "const salt = '954f787f12622067f7e548d9450c3832';" > lib/.secrets.g.dart + else + echo "const salt = '${{ secrets.SALT }}';" > lib/.secrets.g.dart + fi + if [[ "x${{ secrets.KEY_CHAIN_SALT }}" == "x" ]]; + then + echo "const keychainSalt = '2d2beba777dbf7dff7013b7a';" >> lib/.secrets.g.dart + else + echo "const keychainSalt = '${{ secrets.KEY_CHAIN_SALT }}';" >> lib/.secrets.g.dart + fi + if [[ "x${{ secrets.KEY }}" == "x" ]]; + then + echo "const key = '638e98820ec10a2945e968435c9397a3';" >> lib/.secrets.g.dart + else + echo "const key = '${{ secrets.KEY }}';" >> lib/.secrets.g.dart + fi + if [[ "x${{ secrets.WALLET_SALT }}" == "x" ]]; + then + echo "const walletSalt = '8f7f1b70';" >> lib/.secrets.g.dart + else + echo "const walletSalt = '${{ secrets.WALLET_SALT }}';" >> lib/.secrets.g.dart + fi + if [[ "x${{ secrets.SHORT_KEY }}" == "x" ]]; + then + echo "const shortKey = '653f270c2c152bc7ec864afe';" >> lib/.secrets.g.dart + else + echo "const shortKey = '${{ secrets.SHORT_KEY }}';" >> lib/.secrets.g.dart + fi + if [[ "x${{ secrets.BACKUP_SALT }}" == "x" ]]; + then + echo "const backupSalt = 'bf630d24ff0b6f60';" >> lib/.secrets.g.dart + else + echo "const backupSalt = '${{ secrets.BACKUP_SALT }}';" >> lib/.secrets.g.dart + fi + if [[ "x${{ secrets.BACKUP_KEY_CHAIN_SALT }}" == "x" ]]; + then + echo "const backupKeychainSalt = 'bf630d24ff0b6f60';" >> lib/.secrets.g.dart + else + echo "const backupKeychainSalt = '${{ secrets.BACKUP_KEY_CHAIN_SALT }}';" >> lib/.secrets.g.dart + fi + echo "const changeNowCakeWalletApiKey = '${{ secrets.CHANGE_NOW_API_KEY }}';" >> lib/.secrets.g.dart + echo "const changeNowMoneroApiKey = '${{ secrets.CHANGE_NOW_API_KEY }}';" >> lib/.secrets.g.dart + echo "const wyreSecretKey = '${{ secrets.WYRE_SECRET_KEY }}';" >> lib/.secrets.g.dart + echo "const wyreApiKey = '${{ secrets.WYRE_API_KEY }}';" >> lib/.secrets.g.dart + echo "const wyreAccountId = '${{ secrets.WYRE_ACCOUNT_ID }}';" >> lib/.secrets.g.dart + echo "const moonPayApiKey = '${{ secrets.MOON_PAY_API_KEY }}';" >> lib/.secrets.g.dart + echo "const moonPaySecretKey = '${{ secrets.MOON_PAY_SECRET_KEY }}';" >> lib/.secrets.g.dart + echo "const sideShiftAffiliateId = '${{ secrets.SIDE_SHIFT_AFFILIATE_ID }}';" >> lib/.secrets.g.dart + echo "const simpleSwapApiKey = '${{ secrets.SIMPLE_SWAP_API_KEY }}';" >> lib/.secrets.g.dart + echo "const simpleSwapApiKeyDesktop = '${{ secrets.SIMPLE_SWAP_API_KEY_DESKTOP }}';" >> lib/.secrets.g.dart + echo "const onramperApiKey = '${{ secrets.ONRAMPER_API_KEY }}';" >> lib/.secrets.g.dart + echo "const anypayToken = '${{ secrets.ANY_PAY_TOKEN }}';" >> lib/.secrets.g.dart + echo "const ioniaClientId = '${{ secrets.IONIA_CLIENT_ID }}';" >> lib/.secrets.g.dart + echo "const twitterBearerToken = '${{ secrets.TWITTER_BEARER_TOKEN }}';" >> lib/.secrets.g.dart + echo "const trocadorApiKey = '${{ secrets.TROCADOR_API_KEY }}';" >> lib/.secrets.g.dart + echo "const trocadorMoneroApiKey = '${{ secrets.TROCADOR_API_KEY }}';" >> lib/.secrets.g.dart + echo "const trocadorExchangeMarkup = '${{ secrets.TROCADOR_EXCHANGE_MARKUP }}';" >> lib/.secrets.g.dart + echo "const anonPayReferralCode = '${{ secrets.ANON_PAY_REFERRAL_CODE }}';" >> lib/.secrets.g.dart + echo "const fiatApiKey = '${{ secrets.FIAT_API_KEY }}';" >> lib/.secrets.g.dart + echo "const ankrApiKey = '${{ secrets.ANKR_API_KEY }}';" >> lib/.secrets.g.dart + echo "const chainStackApiKey = '${{ secrets.CHAIN_STACK_API_KEY }}';" >> lib/.secrets.g.dart + echo "const etherScanApiKey = '${{ secrets.ETHER_SCAN_API_KEY }}';" >> lib/.secrets.g.dart + echo "const polygonScanApiKey = '${{ secrets.POLYGON_SCAN_API_KEY }}';" >> lib/.secrets.g.dart + echo "const etherScanApiKey = '${{ secrets.ETHER_SCAN_API_KEY }}';" >> cw_evm/lib/.secrets.g.dart + echo "const moralisApiKey = '${{ secrets.MORALIS_API_KEY }}';" >> cw_evm/lib/.secrets.g.dart + echo "const nowNodesApiKey = '${{ secrets.EVM_NOWNODES_API_KEY }}';" >> cw_evm/lib/.secrets.g.dart + echo "const chatwootWebsiteToken = '${{ secrets.CHATWOOT_WEBSITE_TOKEN }}';" >> lib/.secrets.g.dart + echo "const exolixCakeWalletApiKey = '${{ secrets.EXOLIX_API_KEY }}';" >> lib/.secrets.g.dart + echo "const exolixMoneroApiKey = '${{ secrets.EXOLIX_API_KEY }}';" >> lib/.secrets.g.dart + echo "const robinhoodApplicationId = '${{ secrets.ROBINHOOD_APPLICATION_ID }}';" >> lib/.secrets.g.dart + echo "const exchangeHelperApiKey = '${{ secrets.ROBINHOOD_CID_CLIENT_SECRET }}';" >> lib/.secrets.g.dart + echo "const walletConnectProjectId = '${{ secrets.WALLET_CONNECT_PROJECT_ID }}';" >> lib/.secrets.g.dart + echo "const moralisApiKey = '${{ secrets.MORALIS_API_KEY }}';" >> lib/.secrets.g.dart + echo "const polygonScanApiKey = '${{ secrets.POLYGON_SCAN_API_KEY }}';" >> cw_evm/lib/.secrets.g.dart + echo "const ankrApiKey = '${{ secrets.ANKR_API_KEY }}';" >> cw_solana/lib/.secrets.g.dart + echo "const chainStackApiKey = '${{ secrets.CHAIN_STACK_API_KEY }}';" >> cw_solana/lib/.secrets.g.dart + echo "const testCakePayApiKey = '${{ secrets.TEST_CAKE_PAY_API_KEY }}';" >> lib/.secrets.g.dart + echo "const cakePayApiKey = '${{ secrets.CAKE_PAY_API_KEY }}';" >> lib/.secrets.g.dart + echo "const authorization = '${{ secrets.CAKE_PAY_AUTHORIZATION }}';" >> lib/.secrets.g.dart + echo "const CSRFToken = '${{ secrets.CSRF_TOKEN }}';" >> lib/.secrets.g.dart + echo "const swapTradeExchangeMarkup = '${{ secrets.SWAPTRADE_EXCHANGE_MARKUP }}';" >> lib/.secrets.g.dart + echo "const nano2ApiKey = '${{ secrets.NANO2_API_KEY }}';" >> cw_nano/lib/.secrets.g.dart + echo "const nanoNowNodesApiKey = '${{ secrets.NANO_NOW_NODES_API_KEY }}';" >> cw_nano/lib/.secrets.g.dart + echo "const tronGridApiKey = '${{ secrets.TRON_GRID_API_KEY }}';" >> cw_tron/lib/.secrets.g.dart + echo "const tronNowNodesApiKey = '${{ secrets.TRON_NOW_NODES_API_KEY }}';" >> cw_tron/lib/.secrets.g.dart + echo "const meldTestApiKey = '${{ secrets.MELD_TEST_API_KEY }}';" >> lib/.secrets.g.dart + echo "const meldTestPublicKey = '${{ secrets.MELD_TEST_PUBLIC_KEY}}';" >> lib/.secrets.g.dart + echo "const letsExchangeBearerToken = '${{ secrets.LETS_EXCHANGE_TOKEN }}';" >> lib/.secrets.g.dart + echo "const letsExchangeAffiliateId = '${{ secrets.LETS_EXCHANGE_AFFILIATE_ID }}';" >> lib/.secrets.g.dart + echo "const stealthExBearerToken = '${{ secrets.STEALTH_EX_BEARER_TOKEN }}';" >> lib/.secrets.g.dart + echo "const stealthExAdditionalFeePercent = '${{ secrets.STEALTH_EX_ADDITIONAL_FEE_PERCENT }}';" >> lib/.secrets.g.dart + # tests + echo "const moneroTestWalletSeeds ='${{ secrets.MONERO_TEST_WALLET_SEEDS }}';" >> lib/.secrets.g.dart + echo "const moneroLegacyTestWalletSeeds = '${{ secrets.MONERO_LEGACY_TEST_WALLET_SEEDS }}';" >> lib/.secrets.g.dart + echo "const bitcoinTestWalletSeeds = '${{ secrets.BITCOIN_TEST_WALLET_SEEDS }}';" >> lib/.secrets.g.dart + echo "const ethereumTestWalletSeeds = '${{ secrets.ETHEREUM_TEST_WALLET_SEEDS }}';" >> lib/.secrets.g.dart + echo "const litecoinTestWalletSeeds = '${{ secrets.LITECOIN_TEST_WALLET_SEEDS }}';" >> lib/.secrets.g.dart + echo "const bitcoinCashTestWalletSeeds = '${{ secrets.BITCOIN_CASH_TEST_WALLET_SEEDS }}';" >> lib/.secrets.g.dart + echo "const polygonTestWalletSeeds = '${{ secrets.POLYGON_TEST_WALLET_SEEDS }}';" >> lib/.secrets.g.dart + echo "const solanaTestWalletSeeds = '${{ secrets.SOLANA_TEST_WALLET_SEEDS }}';" >> lib/.secrets.g.dart + echo "const tronTestWalletSeeds = '${{ secrets.TRON_TEST_WALLET_SEEDS }}';" >> lib/.secrets.g.dart + echo "const nanoTestWalletSeeds = '${{ secrets.NANO_TEST_WALLET_SEEDS }}';" >> lib/.secrets.g.dart + echo "const wowneroTestWalletSeeds = '${{ secrets.WOWNERO_TEST_WALLET_SEEDS }}';" >> lib/.secrets.g.dart + echo "const moneroTestWalletReceiveAddress = '${{ secrets.MONERO_TEST_WALLET_RECEIVE_ADDRESS }}';" >> lib/.secrets.g.dart + echo "const bitcoinTestWalletReceiveAddress = '${{ secrets.BITCOIN_TEST_WALLET_RECEIVE_ADDRESS }}';" >> lib/.secrets.g.dart + echo "const ethereumTestWalletReceiveAddress = '${{ secrets.ETHEREUM_TEST_WALLET_RECEIVE_ADDRESS }}';" >> lib/.secrets.g.dart + echo "const litecoinTestWalletReceiveAddress = '${{ secrets.LITECOIN_TEST_WALLET_RECEIVE_ADDRESS }}';" >> lib/.secrets.g.dart + echo "const bitcoinCashTestWalletReceiveAddress = '${{ secrets.BITCOIN_CASH_TEST_WALLET_RECEIVE_ADDRESS }}';" >> lib/.secrets.g.dart + echo "const polygonTestWalletReceiveAddress = '${{ secrets.POLYGON_TEST_WALLET_RECEIVE_ADDRESS }}';" >> lib/.secrets.g.dart + echo "const solanaTestWalletReceiveAddress = '${{ secrets.SOLANA_TEST_WALLET_RECEIVE_ADDRESS }}';" >> lib/.secrets.g.dart + echo "const tronTestWalletReceiveAddress = '${{ secrets.TRON_TEST_WALLET_RECEIVE_ADDRESS }}';" >> lib/.secrets.g.dart + echo "const nanoTestWalletReceiveAddress = '${{ secrets.NANO_TEST_WALLET_RECEIVE_ADDRESS }}';" >> lib/.secrets.g.dart + echo "const wowneroTestWalletReceiveAddress = '${{ secrets.WOWNERO_TEST_WALLET_RECEIVE_ADDRESS }}';" >> lib/.secrets.g.dart + echo "const moneroTestWalletBlockHeight = '${{ secrets.MONERO_TEST_WALLET_BLOCK_HEIGHT }}';" >> lib/.secrets.g.dart + # end of test secrets + echo "const chainflipApiKey = '${{ secrets.CHAINFLIP_API_KEY }}';" >> lib/.secrets.g.dart + echo "const chainflipAffiliateFee = '${{ secrets.CHAINFLIP_AFFILIATE_FEE }}';" >> lib/.secrets.g.dart + echo "const kryptonimApiKey = '${{ secrets.KRYPTONIM_API_KEY }}';" >> lib/.secrets.g.dart + echo "const walletGroupSalt = '${{ secrets.WALLET_GROUP_SALT }}';" >> lib/.secrets.g.dart + + - name: prepare monero_c and cache + run: | + export MONEROC_HASH=$(cat scripts/prepare_moneroc.sh | grep 'git checkout' | xargs | awk '{ print $3 }') + echo MONEROC_HASH=$MONEROC_HASH >> /etc/environment + mkdir -p "$MONEROC_CACHE_DIR_ROOT/moneroc-$MONEROC_HASH/monero_c" + pushd scripts + ln -s "$MONEROC_CACHE_DIR_ROOT/moneroc-$MONEROC_HASH/monero_c" + ./prepare_moneroc.sh + popd + pushd scripts/monero_c + mkdir -p "$MONEROC_CACHE_DIR_ROOT/_cache/contrib/depends/built" || true + mkdir -p "$MONEROC_CACHE_DIR_ROOT/_cache/monero/contrib/depends/built" || true + mkdir -p "$MONEROC_CACHE_DIR_ROOT/_cache/wownero/contrib/depends/built" || true + mkdir -p "$MONEROC_CACHE_DIR_ROOT/_cache/contrib/depends/sources" || true + mkdir -p "$MONEROC_CACHE_DIR_ROOT/_cache/monero/contrib/depends/sources" || true + mkdir -p "$MONEROC_CACHE_DIR_ROOT/_cache/wownero/contrib/depends/sources" || true + + rm -rf "$PWD/contrib/depends/built" "$PWD/monero/contrib/depends/built" "$PWD/wownero/contrib/depends/built" + rm -rf "$PWD/contrib/depends/sources" "$PWD/monero/contrib/depends/sources" "$PWD/wownero/contrib/depends/sources" + mkdir -p contrib/depends || true + ln -sf "$MONEROC_CACHE_DIR_ROOT/_cache/contrib/depends/built" "$PWD/contrib/depends/built" + ln -sf "$MONEROC_CACHE_DIR_ROOT/_cache/monero/contrib/depends/built" "$PWD/monero/contrib/depends/built" + ln -sf "$MONEROC_CACHE_DIR_ROOT/_cache/wownero/contrib/depends/built" "$PWD/wownero/contrib/depends/built" + ln -sf "$MONEROC_CACHE_DIR_ROOT/_cache/contrib/depends/sources" "$PWD/contrib/depends/sources" + ln -sf "$MONEROC_CACHE_DIR_ROOT/_cache/monero/contrib/depends/sources" "$PWD/monero/contrib/depends/sources" + ln -sf "$MONEROC_CACHE_DIR_ROOT/_cache/wownero/contrib/depends/sources" "$PWD/wownero/contrib/depends/sources" + popd + + - name: Execute Build and Setup Commands + run: | + pushd scripts/linux + source ./app_env.sh cakewallet + ./app_config.sh + popd + + - name: Build monero_c + run: | + pushd scripts/linux/ + source ./app_env.sh cakewallet + ./build_monero_all.sh + popd + + - name: Install Flutter dependencies + run: | + flutter pub get + + - name: Build generated code + run: | + ./model_generator.sh async + + - name: Generate localization + run: | + dart run tool/generate_localization.dart + + - name: Build linux + run: | + flutter build linux --dart-define=hasDevOptions=true --release + + - name: Compress release + run: | + pushd build/linux/x64/release + zip -r cakewallet_linux.zip bundle + popd + + - name: Upload Artifact to github + uses: actions/upload-artifact@v4 + with: + path: ${{ github.workspace }}/build/linux/x64/release/cakewallet_linux.zip + name: cakewallet_linux + + - name: Prepare virtual desktop + if: ${{ contains(env.message, 'run tests') }} + run: | + nohup Xvfb :99 -screen 0 720x1280x16 & + echo DISPLAY=:99 | sudo tee -a $GITHUB_ENV + dbus-daemon --system --fork + nohup NetworkManager & + nohup ffmpeg -framerate 60 -video_size 720x1280 -f x11grab -i :99 -c:v libx264 -c:a aac /opt/screen_grab.mkv & + + # Note for people adding tests: + # - Tests are ran on Linux, with some things being mocked out. + # - Screen recording is being provided for the entire length of the test, you can download it in github articats. + # - Screen recordeding is encrypted, look at step "Stop screen recording, encrypt and upload", and add your key if you want + # Reason for encryption is the fact that we restore the wallet from seed, and we don't want to leak that, while there + # isn't much in those wallets anyway, we still wouldn't like to leak it to anyone who is able to access github. + + - name: Test [confirm_seeds_flow_test] + if: ${{ contains(env.message, 'run tests') }} + timeout-minutes: 20 + run: | + xmessage -timeout 30 "confirm_seeds_flow_test" & + rm -rf ~/.local/share/com.example.cake_wallet/ ~/Documents/cake_wallet/ ~/cake_wallet + exec timeout --signal=SIGKILL 900 flutter drive --driver=test_driver/integration_test.dart --target=integration_test/test_suites/confirm_seeds_flow_test.dart + - name: Test [create_wallet_flow_test] + if: ${{ contains(env.message, 'run tests') }} + timeout-minutes: 20 + run: | + xmessage -timeout 30 "create_wallet_flow_test" & + rm -rf ~/.local/share/com.example.cake_wallet/ ~/Documents/cake_wallet/ ~/cake_wallet + exec timeout --signal=SIGKILL 900 flutter drive --driver=test_driver/integration_test.dart --target=integration_test/test_suites/create_wallet_flow_test.dart + - name: Test [exchange_flow_test] + if: ${{ contains(env.message, 'run tests') }} + timeout-minutes: 20 + run: | + xmessage -timeout 30 "exchange_flow_test" & + rm -rf ~/.local/share/com.example.cake_wallet/ ~/Documents/cake_wallet/ ~/cake_wallet + exec timeout --signal=SIGKILL 900 flutter drive --driver=test_driver/integration_test.dart --target=integration_test/test_suites/exchange_flow_test.dart + - name: Test [restore_wallet_through_seeds_flow_test] + if: ${{ contains(env.message, 'run tests') }} + timeout-minutes: 20 + run: | + xmessage -timeout 30 "restore_wallet_through_seeds_flow_test" & + rm -rf ~/.local/share/com.example.cake_wallet/ ~/Documents/cake_wallet/ ~/cake_wallet + exec timeout --signal=SIGKILL 900 flutter drive --driver=test_driver/integration_test.dart --target=integration_test/test_suites/restore_wallet_through_seeds_flow_test.dart + - name: Test [cw_monero] + timeout-minutes: 2 + run: cd cw_monero && flutter test + - name: Stop screen recording, encrypt and upload + if: always() + run: | + if [[ ! -f "/opt/screen_grab.mkv" ]]; + then + exit 0; + fi + killall ffmpeg + sleep 5 + killall -9 ffmpeg || true + sleep 5 + # Feel free to add your own public key if you wish + gpg --keyserver hkps://keyserver.ubuntu.com --recv-keys 6B3199AD9B3D23B8 # konstantin@cakewallet.com + gpg --keyserver hkps://keyserver.ubuntu.com --recv-keys 35C8DBAFB8D9ACAC # cyjan@mrcyjanek.net + gpg --trust-model always --encrypt --output /opt/screen_grab.mkv.gpg \ + --recipient 6B3199AD9B3D23B8 \ + --recipient 35C8DBAFB8D9ACAC \ + /opt/screen_grab.mkv + rm /opt/screen_grab.mkv + mv /opt/screen_grab.mkv.gpg ./screen_grab.mkv.gpg + - name: Upload Artifact to github + if: always() + continue-on-error: true + uses: actions/upload-artifact@v4 + with: + path: ${{ github.workspace }}/screen_grab.mkv.gpg + name: tests_screen_grab diff --git a/.gitignore b/.gitignore index f084f8d0d..84a7ecdcd 100644 --- a/.gitignore +++ b/.gitignore @@ -9,6 +9,7 @@ .history .svn/ .fvm/ +.fvmrc # IntelliJ related *.iml @@ -86,14 +87,20 @@ cw_monero/cw_monero/android/.cxx/ **/*.g.dart android/key.properties +android/app/key.jks **/tool/.secrets-prod.json **/tool/.secrets-test.json **/tool/.secrets-config.json **/tool/.evm-secrets-config.json **/tool/.ethereum-secrets-config.json +**/tool/.solana-secrets-config.json +**/tool/.nano-secrets-config.json +**/tool/.tron-secrets-config.json **/lib/.secrets.g.dart **/cw_evm/lib/.secrets.g.dart +**/cw_solana/lib/.secrets.g.dart +**/cw_tron/lib/.secrets.g.dart vendor/ @@ -120,7 +127,7 @@ cw_shared_external/ios/External/ cw_haven/ios/External/ cw_haven/android/.externalNativeBuild/ cw_haven/android/.cxx/ - +cw_zano/ios/External/ lib/bitcoin/bitcoin.dart lib/monero/monero.dart lib/haven/haven.dart @@ -128,10 +135,34 @@ lib/ethereum/ethereum.dart lib/bitcoin_cash/bitcoin_cash.dart lib/nano/nano.dart lib/polygon/polygon.dart +lib/solana/solana.dart +lib/tron/tron.dart +lib/wownero/wownero.dart +lib/zano/zano.dart +lib/decred/decred.dart + +ios/Runner/Assets.xcassets/AppIcon.appiconset/AppIcon@2x.png +ios/Runner/Assets.xcassets/AppIcon.appiconset/AppIcon@2x~ipad.png +ios/Runner/Assets.xcassets/AppIcon.appiconset/AppIcon@3x.png +ios/Runner/Assets.xcassets/AppIcon.appiconset/AppIcon-20@2x.png +ios/Runner/Assets.xcassets/AppIcon.appiconset/AppIcon-20@2x~ipad.png +ios/Runner/Assets.xcassets/AppIcon.appiconset/AppIcon-20@3x.png +ios/Runner/Assets.xcassets/AppIcon.appiconset/AppIcon-20~ipad.png +ios/Runner/Assets.xcassets/AppIcon.appiconset/AppIcon-29.png +ios/Runner/Assets.xcassets/AppIcon.appiconset/AppIcon-29@2x.png +ios/Runner/Assets.xcassets/AppIcon.appiconset/AppIcon-29@2x~ipad.png +ios/Runner/Assets.xcassets/AppIcon.appiconset/AppIcon-29@3x.png +ios/Runner/Assets.xcassets/AppIcon.appiconset/AppIcon-29~ipad.png +ios/Runner/Assets.xcassets/AppIcon.appiconset/AppIcon-40@2x.png +ios/Runner/Assets.xcassets/AppIcon.appiconset/AppIcon-40@2x~ipad.png +ios/Runner/Assets.xcassets/AppIcon.appiconset/AppIcon-40@3x.png +ios/Runner/Assets.xcassets/AppIcon.appiconset/AppIcon-40~ipad.png +ios/Runner/Assets.xcassets/AppIcon.appiconset/AppIcon-60@2x~car.png +ios/Runner/Assets.xcassets/AppIcon.appiconset/AppIcon-60@3x~car.png +ios/Runner/Assets.xcassets/AppIcon.appiconset/AppIcon-83.5@2x~ipad.png +ios/Runner/Assets.xcassets/AppIcon.appiconset/AppIcon~ios-marketing.png +ios/Runner/Assets.xcassets/AppIcon.appiconset/AppIcon~ipad.png -ios/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_180.png -ios/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_120.png -ios/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_1024.png ios/Runner/Info.plist android/app/src/main/res/mipmap-* android/app/src/main/res/drawable/ic_launcher.png @@ -148,6 +179,10 @@ assets/images/app_logo.png macos/Runner/Info.plist macos/Runner/DebugProfile.entitlements macos/Runner/Release.entitlements +macos/Runner/Runner.entitlements +lib/core/secure_storage.dart + +lib/core/secure_storage.dart macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_16.png macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_32.png @@ -157,3 +192,38 @@ macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_256.png macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_512.png macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_1024.png macos/Runner/Configs/AppInfo.xcconfig +macos/Runner.xcodeproj/project.pbxproj + + +integration_test/playground.dart + +# Monero.dart (Monero_C) +scripts/monero_c +# iOS generated framework bin +ios/MoneroWallet.framework/MoneroWallet +ios/WowneroWallet.framework/WowneroWallet +ios/ZanoWallet.framework/ZanoWallet +*_libwallet2_api_c.dylib + +.flatpak-builder +cake_wallet.flatpak +flatpak-build/ + +# macOS +**/Flutter/ephemeral/ +**/Pods/ +**/macos/Flutter/GeneratedPluginRegistrant.swift +**/macos/Flutter/ephemeral +**/xcuserdata/ + +# Windows +**/windows/flutter/ephemeral/ +**/windows/flutter/generated_plugin_registrant.cc +**/windows/flutter/generated_plugin_registrant.h +**/windows/flutter/generated_plugins.cmake + +# Linux +**/linux/flutter/ephemeral/ +**/linux/flutter/generated_plugin_registrant.cc +**/linux/flutter/generated_plugin_registrant.h +**/linux/flutter/generated_plugins.cmake diff --git a/.metadata b/.metadata index cdddb9350..c7b8dc9f8 100644 --- a/.metadata +++ b/.metadata @@ -1,11 +1,11 @@ # This file tracks properties of this Flutter project. # Used by Flutter tool to assess capabilities and perform upgrades etc. # -# This file should be version controlled. +# This file should be version controlled and should not be manually edited. version: - revision: e3c29ec00c9c825c891d75054c63fcc46454dca1 - channel: stable + revision: "367f9ea16bfae1ca451b9cc27c1366870b187ae2" + channel: "stable" project_type: app @@ -13,11 +13,17 @@ project_type: app migration: platforms: - platform: root - create_revision: e3c29ec00c9c825c891d75054c63fcc46454dca1 - base_revision: e3c29ec00c9c825c891d75054c63fcc46454dca1 + create_revision: 367f9ea16bfae1ca451b9cc27c1366870b187ae2 + base_revision: 367f9ea16bfae1ca451b9cc27c1366870b187ae2 + - platform: windows + create_revision: 367f9ea16bfae1ca451b9cc27c1366870b187ae2 + base_revision: 367f9ea16bfae1ca451b9cc27c1366870b187ae2 - platform: macos - create_revision: e3c29ec00c9c825c891d75054c63fcc46454dca1 - base_revision: e3c29ec00c9c825c891d75054c63fcc46454dca1 + create_revision: 367f9ea16bfae1ca451b9cc27c1366870b187ae2 + base_revision: 367f9ea16bfae1ca451b9cc27c1366870b187ae2 + - platform: linux + create_revision: 367f9ea16bfae1ca451b9cc27c1366870b187ae2 + base_revision: 367f9ea16bfae1ca451b9cc27c1366870b187ae2 # User provided section diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 000000000..151b7af20 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,190 @@ +# docker buildx build --push --pull --platform linux/amd64,linux/arm64 . -f Dockerfile -t ghcr.io/cake-tech/cake_wallet:debian12-flutter3.27.0-go1.24.1-ruststablenightly + +# Heavily inspired by cirrusci images +# https://github.com/cirruslabs/docker-images-android/blob/master/sdk/tools/Dockerfile +# https://github.com/cirruslabs/docker-images-android/blob/master/sdk/34/Dockerfile +# https://github.com/cirruslabs/docker-images-android/blob/master/sdk/34-ndk/Dockerfile +# https://github.com/cirruslabs/docker-images-flutter/blob/master/sdk/Dockerfile + +FROM docker.io/debian:12 + +LABEL org.opencontainers.image.source=https://github.com/cake-tech/cake_wallet + +# Set necessary environment variables +# Set Go version to latest known-working version +ENV GOLANG_VERSION=1.24.1 + +# Pin Flutter version to latest known-working version +ENV FLUTTER_VERSION=3.27.0 + +# Pin Android Studio, platform, and build tools versions to latest known-working version +# Comes from https://developer.android.com/studio/#command-tools +ENV ANDROID_SDK_TOOLS_VERSION=13114758 +# Comes from https://developer.android.com/studio/releases/build-tools +ENV ANDROID_PLATFORM_VERSION=35 +ENV ANDROID_BUILD_TOOLS_VERSION=34.0.0 + +# If we ever need to migrate the home directory... +RUN sed -i 's|^root:[^:]*:[^:]*:[^:]*:[^:]*:/root:|root:x:0:0:root:/root:|' /etc/passwd +# mkdir -p /root && rm -rf /root && cp -a /root /root +ENV HOME=/root +ENV ANDROID_HOME=/opt/android-sdk-linux \ + LANG=en_US.UTF-8 \ + LC_ALL=en_US.UTF-8 \ + LANGUAGE=en_US:en + +# Set Android SDK paths +ENV ANDROID_SDK_ROOT=$ANDROID_HOME \ + PATH=${PATH}:${ANDROID_HOME}/cmdline-tools/latest/bin:${ANDROID_HOME}/platform-tools:${ANDROID_HOME}/emulator + +# Upgrade base image +RUN apt-get update \ + && apt-get upgrade -y + +# Install all build dependencies +RUN set -o xtrace \ + && cd /opt \ + && apt-get install -y --no-install-recommends --no-install-suggests \ + # Core dependencies + bc build-essential curl default-jdk git jq lcov libglu1-mesa libpulse0 libsqlite3-dev libstdc++6 locales openssh-client ruby-bundler ruby-full software-properties-common sudo unzip wget zip \ + # for x86 emulators + libatk-bridge2.0-0 libgdk-pixbuf2.0-0 libgtk-3-0 libnspr4 libnss3-dev libsqlite3-dev libxtst6 libxss1 lftp sqlite3 xxd \ + # Linux desktop dependencies + clang cmake libgtk-3-dev ninja-build pkg-config \ + # monero_c dependencies + autoconf automake build-essential ccache gperf libtool llvm \ + # extra stuff for KVM + bridge-utils libvirt-clients libvirt-daemon-system qemu-kvm udev \ + # Linux test dependencies + ffmpeg network-manager x11-utils xvfb psmisc \ + # aarch64-linux-gnu dependencies + g++-aarch64-linux-gnu gcc-aarch64-linux-gnu \ + # x86_64-linux-gnu dependencies + g++-x86-64-linux-gnu gcc-x86-64-linux-gnu \ + # flatpak dependencies + flatpak flatpak-builder binutils elfutils patch unzip xz-utils zstd \ + && apt clean && rm -rf /var/lib/apt/lists/* /tmp/* /var/tmp/* \ + && sh -c 'echo "en_US.UTF-8 UTF-8" > /etc/locale.gen' \ + && locale-gen \ + && update-locale LANG=en_US.UTF-8 + +ENV FLATPAK_RUNTIME_VERSION=24.08 +RUN flatpak remote-add --if-not-exists flathub https://flathub.org/repo/flathub.flatpakrepo \ + && flatpak install -y flathub org.freedesktop.Platform//${FLATPAK_RUNTIME_VERSION} \ + && flatpak install -y flathub org.freedesktop.Sdk//${FLATPAK_RUNTIME_VERSION} + +# Install nodejs for Github Actions +RUN curl -fsSL https://deb.nodesource.com/setup_23.x | bash - && \ + apt-get install -y --no-install-recommends nodejs && \ + apt-get clean && rm -rf /var/lib/apt/lists/* /tmp/* /var/tmp/* + +# Install Go +ENV PATH=${PATH}:/usr/local/go/bin:${HOME}/go/bin +ENV GOROOT=/usr/local/go +ENV GOPATH=${HOME}/go +RUN ARCH=$(uname -m) && \ + if [ "$ARCH" = "x86_64" ]; then \ + wget https://go.dev/dl/go${GOLANG_VERSION}.linux-amd64.tar.gz -O go.tar.gz; \ + elif [ "$ARCH" = "aarch64" ]; then \ + wget https://go.dev/dl/go${GOLANG_VERSION}.linux-arm64.tar.gz -O go.tar.gz; \ + else \ + echo "Unsupported architecture: $ARCH"; exit 1; \ + fi && \ + rm -rf /usr/local/go && \ + tar -C /usr/local -xzf go.tar.gz && \ + rm go.tar.gz && \ + go install golang.org/x/mobile/cmd/gomobile@latest && \ + gomobile init + +RUN git config --global user.email "czarek@cakewallet.com" \ + && git config --global user.name "CakeWallet CI" + + +# Install Android SDK commandline tools and emulator +RUN ARCH=$(uname -m) && \ + if [ "$ARCH" != "x86_64" ]; then exit 0; fi \ + && wget -q https://dl.google.com/android/repository/commandlinetools-linux-${ANDROID_SDK_TOOLS_VERSION}_latest.zip -O android-sdk-tools.zip \ + && mkdir -p ${ANDROID_HOME}/cmdline-tools/ \ + && unzip -q android-sdk-tools.zip -d ${ANDROID_HOME}/cmdline-tools/ \ + && mv ${ANDROID_HOME}/cmdline-tools/cmdline-tools ${ANDROID_HOME}/cmdline-tools/latest \ + && chown -R root:root $ANDROID_HOME \ + && rm android-sdk-tools.zip \ + && echo '%sudo ALL=(ALL) NOPASSWD:ALL' >> /etc/sudoers \ + && yes | sdkmanager --licenses \ + && wget -O /usr/bin/android-wait-for-emulator https://raw.githubusercontent.com/travis-ci/travis-cookbooks/master/community-cookbooks/android-sdk/files/default/android-wait-for-emulator \ + && chmod +x /usr/bin/android-wait-for-emulator \ + && sdkmanager platform-tools \ + && mkdir -p ${HOME}/.android \ + && touch ${HOME}/.android/repositories.cfg \ + + +# Handle emulator not being available on linux/arm64 (https://issuetracker.google.com/issues/227219818) +RUN ARCH=$(uname -m) && \ + if [ "$ARCH" != "x86_64" ]; then exit 0; fi \ + && sdkmanager emulator + +# Pre-install extra Android SDK dependencies in order to not have to download them for each build +RUN ARCH=$(uname -m) && \ + if [ "$ARCH" != "x86_64" ]; then exit 0; fi \ + && yes | sdkmanager \ + "platforms;android-$ANDROID_PLATFORM_VERSION" \ + "build-tools;$ANDROID_BUILD_TOOLS_VERSION" \ + "platforms;android-33" \ + "platforms;android-34" \ + "platforms;android-35" \ + "build-tools;33.0.2" \ + "build-tools;33.0.1" \ + "build-tools;33.0.0" \ + "build-tools;35.0.0" + +# Install extra NDK dependency for sp_scanner +ENV ANDROID_NDK_VERSION=27.2.12479018 +RUN ARCH=$(uname -m) && \ + if [ "$ARCH" != "x86_64" ]; then exit 0; fi \ + && yes | sdkmanager "ndk;$ANDROID_NDK_VERSION" \ + "ndk;27.0.12077973" + +# Install dependencies for tests +# Comes from https://github.com/ReactiveCircus/android-emulator-runner +RUN ARCH=$(uname -m) && \ + if [ "$ARCH" != "x86_64" ]; then exit 0; fi \ + && yes | sdkmanager \ + "system-images;android-29;default;x86_64" \ + "system-images;android-31;default;x86_64" \ + "platforms;android-29" \ + "platforms;android-31" + +# Fake the KVM status so the Android emulator doesn't complain (that much) +RUN (addgroup kvm || true) && \ + adduser root kvm && \ + mkdir -p /etc/udev/rules.d/ && \ + echo 'KERNEL=="kvm", GROUP="kvm", MODE="0666", OPTIONS+="static_node=kvm"' | tee /etc/udev/rules.d/99-kvm4all.rules + +# Install rustup, rust toolchains, and cargo-ndk +ENV PATH=${HOME}/.cargo/bin:${PATH} +RUN curl https://sh.rustup.rs -sSf | bash -s -- -y && \ + cargo install cargo-ndk && \ + for toolchain in stable nightly; \ + do \ + for target in aarch64-linux-android armv7-linux-androideabi i686-linux-android x86_64-linux-android x86_64-unknown-linux-gnu aarch64-unknown-linux-gnu aarch64-unknown-linux-gnu; \ + do \ + rustup target add --toolchain $toolchain $target; \ + done \ + done + +# Download and install Flutter +ENV HOME=${HOME} +ENV FLUTTER_HOME=${HOME}/sdks/flutter/${FLUTTER_VERSION} +ENV FLUTTER_ROOT=$FLUTTER_HOME +ENV PATH=${PATH}:${FLUTTER_HOME}/bin:${FLUTTER_HOME}/bin/cache/dart-sdk/bin + +RUN git clone --branch ${FLUTTER_VERSION} https://github.com/flutter/flutter.git ${FLUTTER_HOME} && \ + cd ${FLUTTER_HOME} && \ + git fetch -a + +RUN yes | flutter doctor --android-licenses \ + && flutter doctor \ + && chown -R root:root ${FLUTTER_HOME} + +# Download and pre-cache necessary Flutter artifacts to speed up builds +RUN flutter precache diff --git a/LICENSE.md b/LICENSE.md index 4268b9710..09bb6208b 100644 --- a/LICENSE.md +++ b/LICENSE.md @@ -1,6 +1,6 @@ MIT License -Copyright (c) 2018-2023 Cake Labs LLC +Copyright (c) 2018-2025 Cake Labs LLC 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/PRIVACY.md b/PRIVACY.md index 76cfcc4d3..a5c8eddfb 100644 --- a/PRIVACY.md +++ b/PRIVACY.md @@ -5,7 +5,7 @@ Last modified: January 24, 2024 Introduction ============ - Cake Labs LLC ("Cake Labs", "Company", or "We") respect your privacy and are committed to protecting it through our compliance with this policy. + Cake Labs LLC ("Cake Labs", "Company", or "We") respects your privacy and are committed to protecting it through our compliance with this policy. This policy describes the types of information we may collect from you or that you may provide when you use the App (our "App") and our practices for collecting, using, maintaining, protecting, and disclosing that information. @@ -13,7 +13,7 @@ Introduction - On this App. - In email, text, and other electronic messages between you and this App. It does not apply to information collected by: - - Us offline or through any other means, including on any other App operated by Company or any third party (including our affiliates and subsidiaries); or + - Us offline or through any other means, including on any other App operated by the Company or any third party (including our affiliates and subsidiaries); or - Any third party (including our affiliates and subsidiaries), including through any application or content (including advertising) that may link to or be accessible from or on the App. Please read this policy carefully to understand our policies and practices regarding your information and how we will treat it. If you do not agree with our policies and practices, you have the choice to not use the App. By accessing or using this App, you agree to this privacy policy. This policy may change from time to time. Your continued use of this App after we make changes is deemed to be acceptance of those changes, so please check the policy periodically for updates. diff --git a/README.md b/README.md index 7b739f980..ea796dbf2 100644 --- a/README.md +++ b/README.md @@ -1,15 +1,38 @@ -# Cake Wallet for Mobile and Desktop +
-## Open Source Multi-Currency Wallet +![logo](.github/assets/Logo_CakeWallet.png) -## Links +
-* Website: https://cakewallet.com -* App Store (iOS / MacOS): https://cakewallet.com/ios -* Google Play: https://cakewallet.com/gp -* F-Droid: https://fdroid.cakelabs.com -* APK: https://github.com/cake-tech/cake_wallet/releases -* Linux: https://github.com/cake-tech/cake_wallet/releases +![devices](.github/assets/devices.png) + +
+ +[](https://apps.apple.com/us/app/cake-wallet/id1334702542?platform=iphone) +[](https://play.google.com/store/apps/details?id=com.cakewallet.cake_wallet) +[](https://fdroid.cakelabs.com) +[](https://apps.apple.com/us/app/cake-wallet/id1334702542?platform=mac) +[](https://github.com/cake-tech/cake_wallet/releases) + +
+ +# Cake Wallet + +[Cake Wallet](https://cakewallet.com) is an open-source, non-custodial, and private multi-currency crypto wallet for Android, iOS, macOS, and Linux. + +Cake Wallet includes support for several cryptocurrencies, including: +* Monero (XMR) +* Bitcoin (BTC) +* Ethereum (ETH) +* Litecoin (LTC) +* Bitcoin Cash (BCH) +* Polygon (POL) +* Solana (SOL) +* Tron (TRX) +* Nano (XNO) +* Zano (ZANO) +* Decred (DCR) +* Wownero (WOW) ## Features @@ -24,7 +47,7 @@ * Create several wallets * Select your own custom nodes/servers * Address book -* Backup to external location or iCloud +* Backup to an external location or iCloud * Send to OpenAlias, Unstoppable Domains, Yats, and FIO Crypto Handles * Set desired network fee level * Store local transaction notes @@ -61,10 +84,6 @@ * Automatically generate new addresses * Specify multiple recipients for batch sending -### Haven Specific Features - -* Send, receive, and store XHV and all xAssets like xUSD, xEUR, xAG, etc. - # Monero.com by Cake Wallet for Android and iOS ## Open Source Monero-Only Wallet @@ -79,6 +98,17 @@ * F-Droid: https://fdroid.cakelabs.com * APK: https://github.com/cake-tech/cake_wallet/releases +### APK Verification + +APK releases on GitHub, Accrescent, and F-Droid use the same key. They can easily be verified using [apksigner](https://developer.android.com/tools/apksigner#options-verify) or [AppVerifier](https://github.com/soupslurpr/AppVerifier). + +See below for Cake Wallet's SHA-256 signing certificate hash: + +``` +com.cakewallet.cake_wallet +C5:40:53:AB:0F:10:D9:54:17:62:A3:DA:76:65:AE:3D:BA:5E:7C:74:3A:B4:F1:08:A5:34:9D:62:AC:10:6E:F5 +``` + # Support We have 24/7 free support. Please contact support@cakewallet.com @@ -141,7 +171,9 @@ The only parts to be translated, if needed, are the values m and s after the var 4. Add the language to `lib/entities/language_service.dart` under both `supportedLocales` and `localeCountryCode`. Use the name of the language in the local language and in English in parentheses after for `supportedLocales`. Use the [ISO 3166-1 alpha-3 code](https://en.wikipedia.org/wiki/ISO_3166-1_alpha-3) for `localeCountryCode`. You must choose one country, so choose the country with the most native speakers of this language or is otherwise best associated with this language. -5. Add a relevant flag to `assets/images/flags/XXXX.png`, replacing XXXX with the 3 digit localeCountryCode. The image must be 42x26 pixels with a 3 pixels of transparent margin on all 4 sides. You can resize the flag with [paint.net](https://www.getpaint.net/) to 36x20 pixels, expand the canvas to 42x26 pixels with the flag anchored in the middle, and then manually delete the 3 pixels on each side to make transparent. Or you can use another program like Photoshop. +5. Add a relevant flag to `assets/images/flags/XXXX.png`, replacing XXXX with the 3 letters localeCountryCode. The image must be 42x26 pixels with 3 pixels of transparent margin on all 4 sides. You can resize the flag with [paint.net](https://www.getpaint.net/) to 36x20 pixels, expand the canvas to 42x26 pixels with the flag anchored in the middle, and then manually delete the 3 pixels on each side to make it transparent. Or you can use another program like Photoshop. + +6. Add the new language code to `tool/utils/translation/translation_constants.dart` ## Add a new fiat currency diff --git a/analysis_options.yaml b/analysis_options.yaml index 396904041..bd35233ba 100644 --- a/analysis_options.yaml +++ b/analysis_options.yaml @@ -1,5 +1,6 @@ include: package:lints/recommended.yaml + analyzer: exclude: [ build/**, @@ -10,7 +11,19 @@ analyzer: lib/generated/*.dart, cw_monero/ios/External/**, cw_shared_external/**, - shared_external/**] + shared_external/**, + lib/bitcoin/cw_bitcoin.dart, + lib/bitcoin_cash/cw_bitcoin_cash.dart, + lib/ethereum/cw_ethereum.dart, + lib/haven/cw_haven.dart, + lib/monero/cw_monero.dart, + lib/nano/cw_nano.dart, + lib/polygon/cw_polygon.dart, + lib/solana/cw_solana.dart, + lib/tron/cw_tron.dart, + lib/wownero/cw_wownero.dart, + lib/zano/cw_zano.dart, + ] language: strict-casts: true strict-raw-types: true @@ -72,4 +85,4 @@ linter: # - unawaited_futures # - unnecessary_getters_setters # - unrelated_type_equality_checks -# - valid_regexps \ No newline at end of file +# - valid_regexps diff --git a/android/app/build.gradle b/android/app/build.gradle index 5e27aeb9e..4a8045bb3 100644 --- a/android/app/build.gradle +++ b/android/app/build.gradle @@ -1,3 +1,9 @@ +plugins { + id "com.android.application" + id "kotlin-android" + id "dev.flutter.flutter-gradle-plugin" +} + def localProperties = new Properties() def localPropertiesFile = rootProject.file('local.properties') if (localPropertiesFile.exists()) { @@ -6,11 +12,6 @@ if (localPropertiesFile.exists()) { } } -def flutterRoot = localProperties.getProperty('flutter.sdk') -if (flutterRoot == null) { - throw new GradleException("Flutter SDK not found. Define location with flutter.sdk in the local.properties file.") -} - def flutterVersionCode = localProperties.getProperty('flutter.versionCode') if (flutterVersionCode == null) { flutterVersionCode = '1' @@ -21,9 +22,6 @@ if (flutterVersionName == null) { flutterVersionName = '1.0' } -apply plugin: 'com.android.application' -apply from: "$flutterRoot/packages/flutter_tools/gradle/flutter.gradle" - def keystoreProperties = new Properties() def keystorePropertiesFile = rootProject.file('key.properties') if (keystorePropertiesFile.exists()) { @@ -37,16 +35,27 @@ if (appPropertiesFile.exists()) { } android { - compileSdkVersion 34 + compileSdkVersion 35 + buildToolsVersion "35.0.0" lintOptions { disable 'InvalidPackage' } + compileOptions { + coreLibraryDesugaringEnabled true + + sourceCompatibility JavaVersion.VERSION_17 + targetCompatibility JavaVersion.VERSION_17 + } + + + namespace "com.cakewallet.cake_wallet" + defaultConfig { applicationId appProperties['id'] minSdkVersion 24 - targetSdkVersion 33 + targetSdkVersion 34 versionCode flutterVersionCode.toInteger() versionName flutterVersionName testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" @@ -72,15 +81,14 @@ android { buildTypes { release { signingConfig signingConfigs.release - - shrinkResources false - minifyEnabled false - proguardFiles getDefaultProguardFile('proguard-android.txt'), 'proguard-rules.pro' } + debug { + signingConfig signingConfigs.release + } } - ndkVersion "25.1.8937393" + ndkVersion "27.0.12077973" } flutter { @@ -91,5 +99,10 @@ dependencies { testImplementation 'junit:junit:4.12' androidTestImplementation 'androidx.test:runner:1.3.0' androidTestImplementation 'androidx.test.espresso:espresso-core:3.3.0' - implementation 'com.unstoppabledomains:resolution:5.0.0' + coreLibraryDesugaring 'com.android.tools:desugar_jdk_libs:2.1.5' +} +configurations { + implementation.exclude module:'proto-google-common-protos' + implementation.exclude module:'protolite-well-known-types' + implementation.exclude module:'protobuf-javalite' } diff --git a/android/app/proguard-rules.pro b/android/app/proguard-rules.pro index d24d7f10a..a733bae9e 100644 --- a/android/app/proguard-rules.pro +++ b/android/app/proguard-rules.pro @@ -5,4 +5,98 @@ -keep class io.flutter.view.** { *; } -keep class io.flutter.** { *; } -keep class io.flutter.plugins.** { *; } --dontwarn io.flutter.embedding.** \ No newline at end of file +-dontwarn io.flutter.embedding.** +-dontwarn com.google.android.play.core.splitcompat.SplitCompatApplication + +# start reown +-dontwarn com.github.luben.zstd.BufferPool +-dontwarn com.github.luben.zstd.ZstdInputStream +-dontwarn com.github.luben.zstd.ZstdOutputStream +-dontwarn com.google.api.client.http.GenericUrl +-dontwarn com.google.api.client.http.HttpHeaders +-dontwarn com.google.api.client.http.HttpRequest +-dontwarn com.google.api.client.http.HttpRequestFactory +-dontwarn com.google.api.client.http.HttpResponse +-dontwarn com.google.api.client.http.HttpTransport +-dontwarn com.google.api.client.http.javanet.NetHttpTransport$Builder +-dontwarn com.google.api.client.http.javanet.NetHttpTransport +-dontwarn java.awt.Color +-dontwarn java.awt.Dimension +-dontwarn java.awt.Graphics2D +-dontwarn java.awt.Graphics +-dontwarn java.awt.Image +-dontwarn java.awt.Point +-dontwarn java.awt.Polygon +-dontwarn java.awt.Shape +-dontwarn java.awt.color.ColorSpace +-dontwarn java.awt.geom.AffineTransform +-dontwarn java.awt.image.BufferedImage +-dontwarn java.awt.image.ColorModel +-dontwarn java.awt.image.ComponentColorModel +-dontwarn java.awt.image.ComponentSampleModel +-dontwarn java.awt.image.DataBuffer +-dontwarn java.awt.image.DataBufferByte +-dontwarn java.awt.image.DataBufferInt +-dontwarn java.awt.image.DataBufferUShort +-dontwarn java.awt.image.ImageObserver +-dontwarn java.awt.image.MultiPixelPackedSampleModel +-dontwarn java.awt.image.Raster +-dontwarn java.awt.image.RenderedImage +-dontwarn java.awt.image.SampleModel +-dontwarn java.awt.image.SinglePixelPackedSampleModel +-dontwarn java.awt.image.WritableRaster +-dontwarn java.beans.BeanInfo +-dontwarn java.beans.FeatureDescriptor +-dontwarn java.beans.IntrospectionException +-dontwarn java.beans.Introspector +-dontwarn java.beans.PropertyDescriptor +-dontwarn java.lang.reflect.InaccessibleObjectException +-dontwarn javax.imageio.IIOImage +-dontwarn javax.imageio.ImageIO +-dontwarn javax.imageio.ImageWriteParam +-dontwarn javax.imageio.ImageWriter +-dontwarn javax.imageio.metadata.IIOMetadata +-dontwarn javax.imageio.stream.ImageOutputStream +-dontwarn javax.swing.JComponent +-dontwarn javax.swing.JFileChooser +-dontwarn javax.swing.JFrame +-dontwarn javax.swing.JPanel +-dontwarn javax.swing.ProgressMonitor +-dontwarn javax.swing.SwingUtilities +-dontwarn org.brotli.dec.BrotliInputStream +-dontwarn org.joda.time.Instant +-dontwarn org.objectweb.asm.AnnotationVisitor +-dontwarn org.objectweb.asm.Attribute +-dontwarn org.objectweb.asm.ClassReader +-dontwarn org.objectweb.asm.ClassVisitor +-dontwarn org.objectweb.asm.FieldVisitor +-dontwarn org.objectweb.asm.Label +-dontwarn org.objectweb.asm.MethodVisitor +-dontwarn org.objectweb.asm.Type +-dontwarn org.tukaani.xz.ARMOptions +-dontwarn org.tukaani.xz.ARMThumbOptions +-dontwarn org.tukaani.xz.DeltaOptions +-dontwarn org.tukaani.xz.FilterOptions +-dontwarn org.tukaani.xz.FinishableOutputStream +-dontwarn org.tukaani.xz.FinishableWrapperOutputStream +-dontwarn org.tukaani.xz.IA64Options +-dontwarn org.tukaani.xz.LZMA2InputStream +-dontwarn org.tukaani.xz.LZMA2Options +-dontwarn org.tukaani.xz.LZMAInputStream +-dontwarn org.tukaani.xz.LZMAOutputStream +-dontwarn org.tukaani.xz.MemoryLimitException +-dontwarn org.tukaani.xz.PowerPCOptions +-dontwarn org.tukaani.xz.SPARCOptions +-dontwarn org.tukaani.xz.SingleXZInputStream +-dontwarn org.tukaani.xz.UnsupportedOptionsException +-dontwarn org.tukaani.xz.X86Options +-dontwarn org.tukaani.xz.XZ +-dontwarn org.tukaani.xz.XZInputStream +-dontwarn org.tukaani.xz.XZOutputStream +-dontwarn us.hebi.matlab.mat.ejml.Mat5Ejml +-dontwarn us.hebi.matlab.mat.format.Mat5 +-dontwarn us.hebi.matlab.mat.format.Mat5File +-dontwarn us.hebi.matlab.mat.types.Array +-dontwarn us.hebi.matlab.mat.types.MatFile$Entry +-dontwarn us.hebi.matlab.mat.types.MatFile +# end reown \ No newline at end of file diff --git a/android/app/src/debug/AndroidManifest.xml b/android/app/src/debug/AndroidManifest.xml index dc767a55d..f880684a6 100644 --- a/android/app/src/debug/AndroidManifest.xml +++ b/android/app/src/debug/AndroidManifest.xml @@ -1,5 +1,4 @@ - + diff --git a/android/app/src/main/AndroidManifestBase.xml b/android/app/src/main/AndroidManifestBase.xml index 180190914..8283a7c8c 100644 --- a/android/app/src/main/AndroidManifestBase.xml +++ b/android/app/src/main/AndroidManifestBase.xml @@ -1,14 +1,33 @@ - + - - - - - + + - + + + + + + + + + + + + + + + + + + + + + + + + + android:requestLegacyExternalStorage="true" + android:extractNativeLibs="true"> + + + + + + + + + + + + + + + + + + + + + + + + result.success(bytes)); break; - case "getUnstoppableDomainAddress": - int version = Build.VERSION.SDK_INT; - if (version >= UNSTOPPABLE_DOMAIN_MIN_VERSION_SDK) { - getUnstoppableDomainAddress(call, result); - } else { - handler.post(() -> result.success("")); - } - break; case "setIsAppSecure": isAppSecure = call.argument("isAppSecure"); if (isAppSecure) { @@ -85,23 +73,6 @@ public class MainActivity extends FlutterFragmentActivity { } } - private void getUnstoppableDomainAddress(@NonNull MethodCall call, @NonNull MethodChannel.Result result) { - DomainResolution resolution = new Resolution(); - Handler handler = new Handler(Looper.getMainLooper()); - String domain = call.argument("domain"); - String ticker = call.argument("ticker"); - - AsyncTask.execute(() -> { - try { - String address = resolution.getAddress(domain, ticker); - handler.post(() -> result.success(address)); - } catch (Exception e) { - System.out.println("Expected Address, but got " + e.getMessage()); - handler.post(() -> result.success("")); - } - }); - } - private void disableBatteryOptimization() { String packageName = getPackageName(); PowerManager pm = (PowerManager) getSystemService(POWER_SERVICE); diff --git a/android/app/src/main/java/com/cakewallet/haven/MainActivity.java b/android/app/src/main/java/com/cakewallet/haven/MainActivity.java index d0a465d22..83a790683 100644 --- a/android/app/src/main/java/com/cakewallet/haven/MainActivity.java +++ b/android/app/src/main/java/com/cakewallet/haven/MainActivity.java @@ -19,14 +19,10 @@ import android.net.Uri; import android.os.PowerManager; import android.provider.Settings; -import com.unstoppabledomains.resolution.DomainResolution; -import com.unstoppabledomains.resolution.Resolution; - import java.security.SecureRandom; public class MainActivity extends FlutterFragmentActivity { final String UTILS_CHANNEL = "com.cake_wallet/native_utils"; - final int UNSTOPPABLE_DOMAIN_MIN_VERSION_SDK = 24; @Override public void configureFlutterEngine(@NonNull FlutterEngine flutterEngine) { @@ -51,14 +47,6 @@ public class MainActivity extends FlutterFragmentActivity { random.nextBytes(bytes); handler.post(() -> result.success(bytes)); break; - case "getUnstoppableDomainAddress": - int version = Build.VERSION.SDK_INT; - if (version >= UNSTOPPABLE_DOMAIN_MIN_VERSION_SDK) { - getUnstoppableDomainAddress(call, result); - } else { - handler.post(() -> result.success("")); - } - break; case "disableBatteryOptimization": disableBatteryOptimization(); handler.post(() -> result.success(null)); @@ -75,23 +63,6 @@ public class MainActivity extends FlutterFragmentActivity { } } - private void getUnstoppableDomainAddress(@NonNull MethodCall call, @NonNull MethodChannel.Result result) { - DomainResolution resolution = new Resolution(); - Handler handler = new Handler(Looper.getMainLooper()); - String domain = call.argument("domain"); - String ticker = call.argument("ticker"); - - AsyncTask.execute(() -> { - try { - String address = resolution.getAddress(domain, ticker); - handler.post(() -> result.success(address)); - } catch (Exception e) { - System.out.println("Expected Address, but got " + e.getMessage()); - handler.post(() -> result.success("")); - } - }); - } - private void disableBatteryOptimization() { String packageName = getPackageName(); PowerManager pm = (PowerManager) getSystemService(POWER_SERVICE); diff --git a/android/app/src/main/java/com/monero/app/MainActivity.java b/android/app/src/main/java/com/monero/app/MainActivity.java index 49c368ec7..e6306d27b 100644 --- a/android/app/src/main/java/com/monero/app/MainActivity.java +++ b/android/app/src/main/java/com/monero/app/MainActivity.java @@ -19,14 +19,10 @@ import android.net.Uri; import android.os.PowerManager; import android.provider.Settings; -import com.unstoppabledomains.resolution.DomainResolution; -import com.unstoppabledomains.resolution.Resolution; - import java.security.SecureRandom; public class MainActivity extends FlutterFragmentActivity { final String UTILS_CHANNEL = "com.cake_wallet/native_utils"; - final int UNSTOPPABLE_DOMAIN_MIN_VERSION_SDK = 24; boolean isAppSecure = false; @Override @@ -52,14 +48,6 @@ public class MainActivity extends FlutterFragmentActivity { random.nextBytes(bytes); handler.post(() -> result.success(bytes)); break; - case "getUnstoppableDomainAddress": - int version = Build.VERSION.SDK_INT; - if (version >= UNSTOPPABLE_DOMAIN_MIN_VERSION_SDK) { - getUnstoppableDomainAddress(call, result); - } else { - handler.post(() -> result.success("")); - } - break; case "setIsAppSecure": isAppSecure = call.argument("isAppSecure"); if (isAppSecure) { @@ -84,23 +72,6 @@ public class MainActivity extends FlutterFragmentActivity { } } - private void getUnstoppableDomainAddress(@NonNull MethodCall call, @NonNull MethodChannel.Result result) { - DomainResolution resolution = new Resolution(); - Handler handler = new Handler(Looper.getMainLooper()); - String domain = call.argument("domain"); - String ticker = call.argument("ticker"); - - AsyncTask.execute(() -> { - try { - String address = resolution.getAddress(domain, ticker); - handler.post(() -> result.success(address)); - } catch (Exception e) { - System.out.println("Expected Address, but got " + e.getMessage()); - handler.post(() -> result.success("")); - } - }); - } - private void disableBatteryOptimization() { String packageName = getPackageName(); PowerManager pm = (PowerManager) getSystemService(POWER_SERVICE); diff --git a/cw_haven/ios/Assets/.gitkeep b/android/app/src/main/jniLibs/arm64-v8a/.gitkeep similarity index 100% rename from cw_haven/ios/Assets/.gitkeep rename to android/app/src/main/jniLibs/arm64-v8a/.gitkeep diff --git a/android/app/src/main/jniLibs/arm64-v8a/libmonero_libwallet2_api_c.so b/android/app/src/main/jniLibs/arm64-v8a/libmonero_libwallet2_api_c.so new file mode 120000 index 000000000..6cdcd70a2 --- /dev/null +++ b/android/app/src/main/jniLibs/arm64-v8a/libmonero_libwallet2_api_c.so @@ -0,0 +1 @@ +../../../../../../scripts/monero_c/release/monero/aarch64-linux-android_libwallet2_api_c.so \ No newline at end of file diff --git a/android/app/src/main/jniLibs/arm64-v8a/libwownero_libwallet2_api_c.so b/android/app/src/main/jniLibs/arm64-v8a/libwownero_libwallet2_api_c.so new file mode 120000 index 000000000..8f6150ee3 --- /dev/null +++ b/android/app/src/main/jniLibs/arm64-v8a/libwownero_libwallet2_api_c.so @@ -0,0 +1 @@ +../../../../../../scripts/monero_c/release/wownero/aarch64-linux-android_libwallet2_api_c.so \ No newline at end of file diff --git a/android/app/src/main/jniLibs/arm64-v8a/libzano_libwallet2_api_c.so b/android/app/src/main/jniLibs/arm64-v8a/libzano_libwallet2_api_c.so new file mode 120000 index 000000000..49ddd0f47 --- /dev/null +++ b/android/app/src/main/jniLibs/arm64-v8a/libzano_libwallet2_api_c.so @@ -0,0 +1 @@ +../../../../../../scripts/monero_c/release/zano/aarch64-linux-android_libwallet2_api_c.so \ No newline at end of file diff --git a/cw_monero/ios/Assets/.gitkeep b/android/app/src/main/jniLibs/armeabi-v7a/.gitkeep similarity index 100% rename from cw_monero/ios/Assets/.gitkeep rename to android/app/src/main/jniLibs/armeabi-v7a/.gitkeep diff --git a/android/app/src/main/jniLibs/armeabi-v7a/libmonero_libwallet2_api_c.so b/android/app/src/main/jniLibs/armeabi-v7a/libmonero_libwallet2_api_c.so new file mode 120000 index 000000000..c0d56dd3b --- /dev/null +++ b/android/app/src/main/jniLibs/armeabi-v7a/libmonero_libwallet2_api_c.so @@ -0,0 +1 @@ +../../../../../../scripts/monero_c/release/monero/armv7a-linux-androideabi_libwallet2_api_c.so \ No newline at end of file diff --git a/android/app/src/main/jniLibs/armeabi-v7a/libwownero_libwallet2_api_c.so b/android/app/src/main/jniLibs/armeabi-v7a/libwownero_libwallet2_api_c.so new file mode 120000 index 000000000..5a71e87b1 --- /dev/null +++ b/android/app/src/main/jniLibs/armeabi-v7a/libwownero_libwallet2_api_c.so @@ -0,0 +1 @@ +../../../../../../scripts/monero_c/release/wownero/armv7a-linux-androideabi_libwallet2_api_c.so \ No newline at end of file diff --git a/android/app/src/main/jniLibs/armeabi-v7a/libzano_libwallet2_api_c.so b/android/app/src/main/jniLibs/armeabi-v7a/libzano_libwallet2_api_c.so new file mode 120000 index 000000000..43f9b98b2 --- /dev/null +++ b/android/app/src/main/jniLibs/armeabi-v7a/libzano_libwallet2_api_c.so @@ -0,0 +1 @@ +../../../../../../scripts/monero_c/release/zano/armv7a-linux-androideabi_libwallet2_api_c.so \ No newline at end of file diff --git a/android/app/src/main/jniLibs/x86/.gitkeep b/android/app/src/main/jniLibs/x86/.gitkeep new file mode 100644 index 000000000..e69de29bb diff --git a/android/app/src/main/jniLibs/x86_64/.gitkeep b/android/app/src/main/jniLibs/x86_64/.gitkeep new file mode 100644 index 000000000..e69de29bb diff --git a/android/app/src/main/jniLibs/x86_64/libmonero_libwallet2_api_c.so b/android/app/src/main/jniLibs/x86_64/libmonero_libwallet2_api_c.so new file mode 120000 index 000000000..654be50b9 --- /dev/null +++ b/android/app/src/main/jniLibs/x86_64/libmonero_libwallet2_api_c.so @@ -0,0 +1 @@ +../../../../../../scripts/monero_c/release/monero/x86_64-linux-android_libwallet2_api_c.so \ No newline at end of file diff --git a/android/app/src/main/jniLibs/x86_64/libwownero_libwallet2_api_c.so b/android/app/src/main/jniLibs/x86_64/libwownero_libwallet2_api_c.so new file mode 120000 index 000000000..bb3da908f --- /dev/null +++ b/android/app/src/main/jniLibs/x86_64/libwownero_libwallet2_api_c.so @@ -0,0 +1 @@ +../../../../../../scripts/monero_c/release/wownero/x86_64-linux-android_libwallet2_api_c.so \ No newline at end of file diff --git a/android/app/src/main/jniLibs/x86_64/libzano_libwallet2_api_c.so b/android/app/src/main/jniLibs/x86_64/libzano_libwallet2_api_c.so new file mode 120000 index 000000000..8c37d73c2 --- /dev/null +++ b/android/app/src/main/jniLibs/x86_64/libzano_libwallet2_api_c.so @@ -0,0 +1 @@ +../../../../../../scripts/monero_c/release/zano/x86_64-linux-android_libwallet2_api_c.so \ No newline at end of file diff --git a/android/app/src/profile/AndroidManifest.xml b/android/app/src/profile/AndroidManifest.xml index dc767a55d..f880684a6 100644 --- a/android/app/src/profile/AndroidManifest.xml +++ b/android/app/src/profile/AndroidManifest.xml @@ -1,5 +1,4 @@ - + diff --git a/android/build.gradle b/android/build.gradle index 8286d9cb9..d42aa24b4 100644 --- a/android/build.gradle +++ b/android/build.gradle @@ -1,21 +1,8 @@ -buildscript { - ext.kotlin_version = '1.7.10' - repositories { - google() - jcenter() - } - - dependencies { - classpath 'com.android.tools.build:gradle:7.3.0' - classpath 'com.google.gms:google-services:4.3.8' - classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:$kotlin_version" - } -} - allprojects { repositories { google() - jcenter() + mavenCentral() + maven { url "https://jitpack.io" } } } diff --git a/android/gradle.properties b/android/gradle.properties index 38c8d4544..d130e538a 100644 --- a/android/gradle.properties +++ b/android/gradle.properties @@ -1,4 +1,4 @@ -org.gradle.jvmargs=-Xmx1536M +org.gradle.jvmargs=-Xmx4096M android.enableR8=true android.useAndroidX=true android.enableJetifier=true diff --git a/android/gradle/wrapper/gradle-wrapper.properties b/android/gradle/wrapper/gradle-wrapper.properties index 733c691d3..0cc4e42b9 100644 --- a/android/gradle/wrapper/gradle-wrapper.properties +++ b/android/gradle/wrapper/gradle-wrapper.properties @@ -3,4 +3,4 @@ distributionBase=GRADLE_USER_HOME distributionPath=wrapper/dists zipStoreBase=GRADLE_USER_HOME zipStorePath=wrapper/dists -distributionUrl=https\://services.gradle.org/distributions/gradle-7.4-all.zip +distributionUrl=https\://services.gradle.org/distributions/gradle-8.10.2-all.zip diff --git a/android/settings.gradle b/android/settings.gradle index 5a2f14fb1..468f9b55f 100644 --- a/android/settings.gradle +++ b/android/settings.gradle @@ -1,15 +1,26 @@ -include ':app' +pluginManagement { + def flutterSdkPath = { + def properties = new Properties() + file("local.properties").withInputStream { properties.load(it) } + def flutterSdkPath = properties.getProperty("flutter.sdk") + assert flutterSdkPath != null, "flutter.sdk not set in local.properties" + return flutterSdkPath + }() -def flutterProjectRoot = rootProject.projectDir.parentFile.toPath() + includeBuild("$flutterSdkPath/packages/flutter_tools/gradle") -def plugins = new Properties() -def pluginsFile = new File(flutterProjectRoot.toFile(), '.flutter-plugins') -if (pluginsFile.exists()) { - pluginsFile.withReader('UTF-8') { reader -> plugins.load(reader) } + repositories { + google() + mavenCentral() + gradlePluginPortal() + } } -plugins.each { name, path -> - def pluginDirectory = flutterProjectRoot.resolve(path).resolve('android').toFile() - include ":$name" - project(":$name").projectDir = pluginDirectory +plugins { + id "dev.flutter.flutter-plugin-loader" version "1.0.0" + id "com.android.application" version "8.7.1" apply false + id "org.jetbrains.kotlin.android" version "2.0.21" apply false + id "com.google.gms.google-services" version "4.3.8" apply false } + +include ":app" \ No newline at end of file diff --git a/assets/banano_node_list.yml b/assets/banano_node_list.yml new file mode 100644 index 000000000..dc6852867 --- /dev/null +++ b/assets/banano_node_list.yml @@ -0,0 +1,5 @@ +- + uri: kaliumapi.appditto.com + path: /api + useSSL: true + is_default: true \ No newline at end of file diff --git a/assets/bitcoin_cash_electrum_server_list.yml b/assets/bitcoin_cash_electrum_server_list.yml index d76668169..948e5f3dc 100644 --- a/assets/bitcoin_cash_electrum_server_list.yml +++ b/assets/bitcoin_cash_electrum_server_list.yml @@ -1,3 +1,10 @@ - uri: bitcoincash.stackwallet.com:50002 - is_default: true \ No newline at end of file + is_default: true + useSSL: true +- + uri: bch.aftrek.org:50002 + useSSL: true +- + uri: node.minisatoshi.cash:50002 + useSSL: true diff --git a/assets/bitcoin_electrum_server_list.yml b/assets/bitcoin_electrum_server_list.yml index 2b6649271..83da6a0b2 100644 --- a/assets/bitcoin_electrum_server_list.yml +++ b/assets/bitcoin_electrum_server_list.yml @@ -1,2 +1,9 @@ - - uri: electrum.cakewallet.com:50002 \ No newline at end of file + uri: btc-electrum.cakewallet.com:50002 + useSSL: true + isDefault: true +- + uri: electrs.cakewallet.com:50001 +- + uri: fulcrum.sethforprivacy.com:50002 + useSSL: true diff --git a/assets/decred_node_list.yml b/assets/decred_node_list.yml new file mode 100644 index 000000000..cb171e701 --- /dev/null +++ b/assets/decred_node_list.yml @@ -0,0 +1,6 @@ +- + uri: default-spv-nodes + is_default: true +- + uri: dcrd.sethforprivacy.com:9108 + useSSL: true \ No newline at end of file diff --git a/assets/ethereum_server_list.yml b/assets/ethereum_server_list.yml index 125085d88..ed425c3c7 100644 --- a/assets/ethereum_server_list.yml +++ b/assets/ethereum_server_list.yml @@ -1,10 +1,14 @@ - - uri: ethereum.publicnode.com + uri: ethereum-rpc.publicnode.com + useSSL: true + isDefault: true - uri: eth.llamarpc.com - uri: rpc.flashbots.net - uri: eth-mainnet.public.blastapi.io +- + uri: eth.nownodes.io - uri: ethereum.publicnode.com \ No newline at end of file diff --git a/assets/faq/faq_pl.json b/assets/faq/faq_pl.json index a38d79068..b41841cd8 100644 --- a/assets/faq/faq_pl.json +++ b/assets/faq/faq_pl.json @@ -13,7 +13,7 @@ }, { "question" : "Co oznaczają słowa „seed” i „keys”?", - "answer" : "Twoje klucze kodują prywatne informacje w twoim portfelu i pozwalają wydać monety i zobaczyć przychodzące transakcje.\nTwoje ziarno to tylko wersja twojego klucza prywatnego napisana w sposób, który łatwiej Ci zapisać. Wasze nasiona i klucze są w rzeczywistości takie same, tylko w różnych formach!\nNigdy nie dawaj nikomu swojego ziarna ani kluczy. Twoje fundusze zostaną skradzione, jeśli wydasz swoje nasiona lub klucze. Zapisz jednak swoje ziarno i przechowuj je w bezpiecznym miejscu (pozwoli to przywrócić portfel, jeśli zgubisz telefon).\n" + "answer" : "Twoje klucze i fraza seed zawierają prywatne informacje o twoim portfelu i pozwalają wysyłać kryptowalutę oraz zobaczyć przychodzące transakcje.\nFraza „seed” to wersja twojego klucza prywatnego napisana w sposób, który łatwiej Ci zapisać. Wasze frazy seed i klucze są w rzeczywistości takie same, tylko w różnych formach zapisu!\nNigdy nie dawaj nikomu swojej frazy seed ani swoich kluczy. Twoje fundusze zostaną skradzione, jeśli upublicznisz frazę seed lub klucze. Zapisz jednak swoją frazę seed i przechowuj ją w bezpiecznym miejscu (pozwoli to przywrócić portfel, jeśli zgubisz telefon).\n" }, { "question" : "Ile portfeli mogę utworzyć?", @@ -24,11 +24,11 @@ "answer" : "Stuknij menu •••, wybierz „Portfele”, a następnie „Przywróć portfel”. Następnie wprowadź dane początkowe (lub klucze) i opcjonalnie wprowadź datę przed pierwszą transakcją w portfelu (przyspieszy to proces synchronizacji .) Może być konieczne pozostawienie aplikacji otwartej przez 15-30 minut, aby całkowicie przywrócić portfel.\n" }, { - "question" : "Co mogę zrobić, jeśli stracę nasiona?", - "answer" : "Jeśli zapomniałeś o nasieniu, prawdopodobnie gdzieś je zapisałeś. Sprawdź swoje notatki i rozejrzyj się po komputerze. Jeśli nie możesz go nigdzie znaleźć, być może utworzono kopię zapasową Cake Wallet (w takim przypadku będziesz mógł przywrócić dane z tej kopii zapasowej). Jeśli żadna z tych czynności nie działa, niestety nic nie możemy zrobić.\n" + "question" : "Co mogę zrobić, jeśli zapomniałem frazę seed?", + "answer" : "Jeśli zapomniałeś swoją frazę seed, prawdopodobnie gdzieś je zapisałeś. Sprawdź swoje notatki i rozejrzyj się po komputerze. Jeśli nie możesz go nigdzie znaleźć, być może utworzono kopię zapasową Cake Wallet (w takim przypadku będziesz mógł przywrócić dane z tej kopii zapasowej). Jeśli żadna z tych czynności nie działa, niestety nic nie możemy zrobić.\n" }, { - "question" : "Czy zbierasz jakieś informacje o moim portfelu?", + "question" : "Czy zbieracie jakieś informacje o moim portfelu?", "answer" : "Portfel Cake NIE gromadzi ani nie rejestruje żadnych informacji o Twoim portfelu. Dbamy o Twoją prywatność.\n" }, { @@ -37,7 +37,7 @@ }, { "question" : "Co to są „podadresy” i jak z nich korzystać?", - "answer" : "Podadres jest w zasadzie unikalnym adresem, który można wygenerować w dowolnym momencie. Monety wysłane do niego nadal będą pojawiać się w głównym portfelu, ale osoba wysyłająca monety nie może podać Twojego głównego adresu. Podadresy zawsze zaczynają się od „8”.\nMożesz utworzyć nowy podadres na ekranie Odbieranie, dotykając „+” obok przycisku Podadresy. Wprowadź nazwę podadresu i dotknij „Dodaj”. Następnie dotknij nazwy podadresu, gdy chcesz go użyć!\nJeśli jesteś paranoikiem, prawdopodobnie za każdym razem, gdy otrzymasz Monero, powinieneś utworzyć nowy podadres.\n" + "answer" : "Podadres jest w unikalnym adresem, który można wygenerować w dowolnym momencie. Monety wysłane do niego nadal będą pojawiać się w głównym portfelu, ale osoba wysyłająca monety nie zna Twojego głównego adresu. Podadresy zawsze zaczynają się od „8”.\nMożesz utworzyć nowy podadres na ekranie Odbieranie, dotykając „+” obok przycisku Podadresy. Wprowadź nazwę podadresu i dotknij „Dodaj”. Następnie dotknij nazwy podadresu, gdy chcesz go użyć!\nJeśli jesteś paranoikiem, prawdopodobnie za każdym razem, gdy otrzymasz Monero, powinieneś utworzyć nowy podadres.\n" }, { "question" : "Co to jest ID transakcji?", diff --git a/assets/images/2.0x/decred.png b/assets/images/2.0x/decred.png new file mode 100644 index 000000000..2f4919cec Binary files /dev/null and b/assets/images/2.0x/decred.png differ diff --git a/assets/images/2.0x/decred_menu.png b/assets/images/2.0x/decred_menu.png new file mode 100644 index 000000000..4a41efef1 Binary files /dev/null and b/assets/images/2.0x/decred_menu.png differ diff --git a/assets/images/2fa.png b/assets/images/2fa.png new file mode 100644 index 000000000..36c99beab Binary files /dev/null and b/assets/images/2fa.png differ diff --git a/assets/images/2fa_warning_dark.svg b/assets/images/2fa_warning_dark.svg new file mode 100644 index 000000000..c9fcad341 --- /dev/null +++ b/assets/images/2fa_warning_dark.svg @@ -0,0 +1,86 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/assets/images/2fa_warning_light.svg b/assets/images/2fa_warning_light.svg new file mode 100644 index 000000000..087d8e99b --- /dev/null +++ b/assets/images/2fa_warning_light.svg @@ -0,0 +1,101 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/assets/images/3.0x/decred.png b/assets/images/3.0x/decred.png new file mode 100644 index 000000000..b2c9ac818 Binary files /dev/null and b/assets/images/3.0x/decred.png differ diff --git a/assets/images/3.0x/decred_menu.png b/assets/images/3.0x/decred_menu.png new file mode 100644 index 000000000..e55b3fb5c Binary files /dev/null and b/assets/images/3.0x/decred_menu.png differ diff --git a/assets/images/apple_pay_logo.png b/assets/images/apple_pay_logo.png new file mode 100644 index 000000000..346007e3b Binary files /dev/null and b/assets/images/apple_pay_logo.png differ diff --git a/assets/images/apple_pay_round_dark.svg b/assets/images/apple_pay_round_dark.svg new file mode 100644 index 000000000..82443bfb4 --- /dev/null +++ b/assets/images/apple_pay_round_dark.svg @@ -0,0 +1,9 @@ + + + + + + + + + \ No newline at end of file diff --git a/assets/images/apple_pay_round_light.svg b/assets/images/apple_pay_round_light.svg new file mode 100644 index 000000000..2beb1248f --- /dev/null +++ b/assets/images/apple_pay_round_light.svg @@ -0,0 +1,9 @@ + + + + + + + + + \ No newline at end of file diff --git a/assets/images/avdo_icon.png b/assets/images/avdo_icon.png new file mode 100644 index 000000000..c02e2760d Binary files /dev/null and b/assets/images/avdo_icon.png differ diff --git a/assets/images/bank.png b/assets/images/bank.png new file mode 100644 index 000000000..9dc68147a Binary files /dev/null and b/assets/images/bank.png differ diff --git a/assets/images/bank_dark.svg b/assets/images/bank_dark.svg new file mode 100644 index 000000000..670120796 --- /dev/null +++ b/assets/images/bank_dark.svg @@ -0,0 +1,8 @@ + + + + + + + + \ No newline at end of file diff --git a/assets/images/bank_light.svg b/assets/images/bank_light.svg new file mode 100644 index 000000000..804716289 --- /dev/null +++ b/assets/images/bank_light.svg @@ -0,0 +1,8 @@ + + + + + + + + \ No newline at end of file diff --git a/assets/images/birthday_cake.png b/assets/images/birthday_cake.png index 84b084fba..293cd10f6 100644 Binary files a/assets/images/birthday_cake.png and b/assets/images/birthday_cake.png differ diff --git a/assets/images/bluetooth.png b/assets/images/bluetooth.png new file mode 100644 index 000000000..8268b742c Binary files /dev/null and b/assets/images/bluetooth.png differ diff --git a/assets/images/bonk_icon.png b/assets/images/bonk_icon.png new file mode 100644 index 000000000..c59537eab Binary files /dev/null and b/assets/images/bonk_icon.png differ diff --git a/assets/images/btc_lock_dark.png b/assets/images/btc_lock_dark.png new file mode 100644 index 000000000..f5b3d7e27 Binary files /dev/null and b/assets/images/btc_lock_dark.png differ diff --git a/assets/images/btc_lock_light.png b/assets/images/btc_lock_light.png new file mode 100644 index 000000000..4320d96e3 Binary files /dev/null and b/assets/images/btc_lock_light.png differ diff --git a/assets/images/buy.png b/assets/images/buy.png index ff4549d5a..32c116e6b 100644 Binary files a/assets/images/buy.png and b/assets/images/buy.png differ diff --git a/assets/images/buy_sell.png b/assets/images/buy_sell.png new file mode 100644 index 000000000..0fbffe56f Binary files /dev/null and b/assets/images/buy_sell.png differ diff --git a/assets/images/cake_logo.png b/assets/images/cake_logo.png index 8a85bf225..abbb4e62b 100644 Binary files a/assets/images/cake_logo.png and b/assets/images/cake_logo.png differ diff --git a/assets/images/cake_logo_dark.svg b/assets/images/cake_logo_dark.svg new file mode 100644 index 000000000..095077443 --- /dev/null +++ b/assets/images/cake_logo_dark.svg @@ -0,0 +1,3 @@ + + + diff --git a/assets/images/cake_logo_light.svg b/assets/images/cake_logo_light.svg new file mode 100644 index 000000000..767e205d1 --- /dev/null +++ b/assets/images/cake_logo_light.svg @@ -0,0 +1,3 @@ + + + diff --git a/assets/images/cakewallet_android_icon.png b/assets/images/cakewallet_android_icon.png old mode 100755 new mode 100644 index 59cc69414..7f15c62f5 Binary files a/assets/images/cakewallet_android_icon.png and b/assets/images/cakewallet_android_icon.png differ diff --git a/assets/images/cakewallet_android_icon/mipmap-anydpi-v26/ic_launcher.xml b/assets/images/cakewallet_android_icon/mipmap-anydpi-v26/ic_launcher.xml index 00d924171..345888d26 100644 --- a/assets/images/cakewallet_android_icon/mipmap-anydpi-v26/ic_launcher.xml +++ b/assets/images/cakewallet_android_icon/mipmap-anydpi-v26/ic_launcher.xml @@ -1,5 +1,6 @@ - - + + + \ No newline at end of file diff --git a/assets/images/cakewallet_android_icon/mipmap-hdpi/ic_launcher.png b/assets/images/cakewallet_android_icon/mipmap-hdpi/ic_launcher.png index 10d0a1a82..89c9b0571 100644 Binary files a/assets/images/cakewallet_android_icon/mipmap-hdpi/ic_launcher.png and b/assets/images/cakewallet_android_icon/mipmap-hdpi/ic_launcher.png differ diff --git a/assets/images/cakewallet_android_icon/mipmap-hdpi/ic_launcher_adaptive_back.png b/assets/images/cakewallet_android_icon/mipmap-hdpi/ic_launcher_adaptive_back.png deleted file mode 100644 index 5b0fde827..000000000 Binary files a/assets/images/cakewallet_android_icon/mipmap-hdpi/ic_launcher_adaptive_back.png and /dev/null differ diff --git a/assets/images/cakewallet_android_icon/mipmap-hdpi/ic_launcher_adaptive_fore.png b/assets/images/cakewallet_android_icon/mipmap-hdpi/ic_launcher_adaptive_fore.png deleted file mode 100644 index 9c16f0a27..000000000 Binary files a/assets/images/cakewallet_android_icon/mipmap-hdpi/ic_launcher_adaptive_fore.png and /dev/null differ diff --git a/assets/images/cakewallet_android_icon/mipmap-hdpi/ic_launcher_background.png b/assets/images/cakewallet_android_icon/mipmap-hdpi/ic_launcher_background.png new file mode 100644 index 000000000..19669488f Binary files /dev/null and b/assets/images/cakewallet_android_icon/mipmap-hdpi/ic_launcher_background.png differ diff --git a/assets/images/cakewallet_android_icon/mipmap-hdpi/ic_launcher_foreground.png b/assets/images/cakewallet_android_icon/mipmap-hdpi/ic_launcher_foreground.png new file mode 100644 index 000000000..1411a5da5 Binary files /dev/null and b/assets/images/cakewallet_android_icon/mipmap-hdpi/ic_launcher_foreground.png differ diff --git a/assets/images/cakewallet_android_icon/mipmap-hdpi/ic_launcher_monochrome.png b/assets/images/cakewallet_android_icon/mipmap-hdpi/ic_launcher_monochrome.png new file mode 100644 index 000000000..1411a5da5 Binary files /dev/null and b/assets/images/cakewallet_android_icon/mipmap-hdpi/ic_launcher_monochrome.png differ diff --git a/assets/images/cakewallet_android_icon/mipmap-mdpi/ic_launcher.png b/assets/images/cakewallet_android_icon/mipmap-mdpi/ic_launcher.png index 8c59ec33e..8f1d1c28b 100644 Binary files a/assets/images/cakewallet_android_icon/mipmap-mdpi/ic_launcher.png and b/assets/images/cakewallet_android_icon/mipmap-mdpi/ic_launcher.png differ diff --git a/assets/images/cakewallet_android_icon/mipmap-mdpi/ic_launcher_adaptive_back.png b/assets/images/cakewallet_android_icon/mipmap-mdpi/ic_launcher_adaptive_back.png deleted file mode 100644 index 5d25e42e7..000000000 Binary files a/assets/images/cakewallet_android_icon/mipmap-mdpi/ic_launcher_adaptive_back.png and /dev/null differ diff --git a/assets/images/cakewallet_android_icon/mipmap-mdpi/ic_launcher_adaptive_fore.png b/assets/images/cakewallet_android_icon/mipmap-mdpi/ic_launcher_adaptive_fore.png deleted file mode 100644 index 021fe65de..000000000 Binary files a/assets/images/cakewallet_android_icon/mipmap-mdpi/ic_launcher_adaptive_fore.png and /dev/null differ diff --git a/assets/images/cakewallet_android_icon/mipmap-mdpi/ic_launcher_background.png b/assets/images/cakewallet_android_icon/mipmap-mdpi/ic_launcher_background.png new file mode 100644 index 000000000..75025cfd5 Binary files /dev/null and b/assets/images/cakewallet_android_icon/mipmap-mdpi/ic_launcher_background.png differ diff --git a/assets/images/cakewallet_android_icon/mipmap-mdpi/ic_launcher_foreground.png b/assets/images/cakewallet_android_icon/mipmap-mdpi/ic_launcher_foreground.png new file mode 100644 index 000000000..e8c47adb3 Binary files /dev/null and b/assets/images/cakewallet_android_icon/mipmap-mdpi/ic_launcher_foreground.png differ diff --git a/assets/images/cakewallet_android_icon/mipmap-mdpi/ic_launcher_monochrome.png b/assets/images/cakewallet_android_icon/mipmap-mdpi/ic_launcher_monochrome.png new file mode 100644 index 000000000..e8c47adb3 Binary files /dev/null and b/assets/images/cakewallet_android_icon/mipmap-mdpi/ic_launcher_monochrome.png differ diff --git a/assets/images/cakewallet_android_icon/mipmap-xhdpi/ic_launcher.png b/assets/images/cakewallet_android_icon/mipmap-xhdpi/ic_launcher.png index 10c3acd7f..f775a8fac 100644 Binary files a/assets/images/cakewallet_android_icon/mipmap-xhdpi/ic_launcher.png and b/assets/images/cakewallet_android_icon/mipmap-xhdpi/ic_launcher.png differ diff --git a/assets/images/cakewallet_android_icon/mipmap-xhdpi/ic_launcher_adaptive_back.png b/assets/images/cakewallet_android_icon/mipmap-xhdpi/ic_launcher_adaptive_back.png deleted file mode 100644 index c4b66dc58..000000000 Binary files a/assets/images/cakewallet_android_icon/mipmap-xhdpi/ic_launcher_adaptive_back.png and /dev/null differ diff --git a/assets/images/cakewallet_android_icon/mipmap-xhdpi/ic_launcher_adaptive_fore.png b/assets/images/cakewallet_android_icon/mipmap-xhdpi/ic_launcher_adaptive_fore.png deleted file mode 100644 index b440b154d..000000000 Binary files a/assets/images/cakewallet_android_icon/mipmap-xhdpi/ic_launcher_adaptive_fore.png and /dev/null differ diff --git a/assets/images/cakewallet_android_icon/mipmap-xhdpi/ic_launcher_background.png b/assets/images/cakewallet_android_icon/mipmap-xhdpi/ic_launcher_background.png new file mode 100644 index 000000000..9784f16c8 Binary files /dev/null and b/assets/images/cakewallet_android_icon/mipmap-xhdpi/ic_launcher_background.png differ diff --git a/assets/images/cakewallet_android_icon/mipmap-xhdpi/ic_launcher_foreground.png b/assets/images/cakewallet_android_icon/mipmap-xhdpi/ic_launcher_foreground.png new file mode 100644 index 000000000..6ba8eb301 Binary files /dev/null and b/assets/images/cakewallet_android_icon/mipmap-xhdpi/ic_launcher_foreground.png differ diff --git a/assets/images/cakewallet_android_icon/mipmap-xhdpi/ic_launcher_monochrome.png b/assets/images/cakewallet_android_icon/mipmap-xhdpi/ic_launcher_monochrome.png new file mode 100644 index 000000000..6ba8eb301 Binary files /dev/null and b/assets/images/cakewallet_android_icon/mipmap-xhdpi/ic_launcher_monochrome.png differ diff --git a/assets/images/cakewallet_android_icon/mipmap-xxhdpi/ic_launcher.png b/assets/images/cakewallet_android_icon/mipmap-xxhdpi/ic_launcher.png index 813a3678d..31458fa02 100644 Binary files a/assets/images/cakewallet_android_icon/mipmap-xxhdpi/ic_launcher.png and b/assets/images/cakewallet_android_icon/mipmap-xxhdpi/ic_launcher.png differ diff --git a/assets/images/cakewallet_android_icon/mipmap-xxhdpi/ic_launcher_adaptive_back.png b/assets/images/cakewallet_android_icon/mipmap-xxhdpi/ic_launcher_adaptive_back.png deleted file mode 100644 index 75dc0219d..000000000 Binary files a/assets/images/cakewallet_android_icon/mipmap-xxhdpi/ic_launcher_adaptive_back.png and /dev/null differ diff --git a/assets/images/cakewallet_android_icon/mipmap-xxhdpi/ic_launcher_adaptive_fore.png b/assets/images/cakewallet_android_icon/mipmap-xxhdpi/ic_launcher_adaptive_fore.png deleted file mode 100644 index 90afb19e8..000000000 Binary files a/assets/images/cakewallet_android_icon/mipmap-xxhdpi/ic_launcher_adaptive_fore.png and /dev/null differ diff --git a/assets/images/cakewallet_android_icon/mipmap-xxhdpi/ic_launcher_background.png b/assets/images/cakewallet_android_icon/mipmap-xxhdpi/ic_launcher_background.png new file mode 100644 index 000000000..04ef206c8 Binary files /dev/null and b/assets/images/cakewallet_android_icon/mipmap-xxhdpi/ic_launcher_background.png differ diff --git a/assets/images/cakewallet_android_icon/mipmap-xxhdpi/ic_launcher_foreground.png b/assets/images/cakewallet_android_icon/mipmap-xxhdpi/ic_launcher_foreground.png new file mode 100644 index 000000000..cc93d633b Binary files /dev/null and b/assets/images/cakewallet_android_icon/mipmap-xxhdpi/ic_launcher_foreground.png differ diff --git a/assets/images/cakewallet_android_icon/mipmap-xxhdpi/ic_launcher_monochrome.png b/assets/images/cakewallet_android_icon/mipmap-xxhdpi/ic_launcher_monochrome.png new file mode 100644 index 000000000..cc93d633b Binary files /dev/null and b/assets/images/cakewallet_android_icon/mipmap-xxhdpi/ic_launcher_monochrome.png differ diff --git a/assets/images/cakewallet_android_icon/mipmap-xxxhdpi/ic_launcher.png b/assets/images/cakewallet_android_icon/mipmap-xxxhdpi/ic_launcher.png index 671422b96..158afbbf9 100644 Binary files a/assets/images/cakewallet_android_icon/mipmap-xxxhdpi/ic_launcher.png and b/assets/images/cakewallet_android_icon/mipmap-xxxhdpi/ic_launcher.png differ diff --git a/assets/images/cakewallet_android_icon/mipmap-xxxhdpi/ic_launcher_adaptive_back.png b/assets/images/cakewallet_android_icon/mipmap-xxxhdpi/ic_launcher_adaptive_back.png deleted file mode 100644 index 46b1e2cb1..000000000 Binary files a/assets/images/cakewallet_android_icon/mipmap-xxxhdpi/ic_launcher_adaptive_back.png and /dev/null differ diff --git a/assets/images/cakewallet_android_icon/mipmap-xxxhdpi/ic_launcher_adaptive_fore.png b/assets/images/cakewallet_android_icon/mipmap-xxxhdpi/ic_launcher_adaptive_fore.png deleted file mode 100644 index 0a2025220..000000000 Binary files a/assets/images/cakewallet_android_icon/mipmap-xxxhdpi/ic_launcher_adaptive_fore.png and /dev/null differ diff --git a/assets/images/cakewallet_android_icon/mipmap-xxxhdpi/ic_launcher_background.png b/assets/images/cakewallet_android_icon/mipmap-xxxhdpi/ic_launcher_background.png new file mode 100644 index 000000000..66a5487a2 Binary files /dev/null and b/assets/images/cakewallet_android_icon/mipmap-xxxhdpi/ic_launcher_background.png differ diff --git a/assets/images/cakewallet_android_icon/mipmap-xxxhdpi/ic_launcher_foreground.png b/assets/images/cakewallet_android_icon/mipmap-xxxhdpi/ic_launcher_foreground.png new file mode 100644 index 000000000..0ecd56e8c Binary files /dev/null and b/assets/images/cakewallet_android_icon/mipmap-xxxhdpi/ic_launcher_foreground.png differ diff --git a/assets/images/cakewallet_android_icon/mipmap-xxxhdpi/ic_launcher_monochrome.png b/assets/images/cakewallet_android_icon/mipmap-xxxhdpi/ic_launcher_monochrome.png new file mode 100644 index 000000000..0ecd56e8c Binary files /dev/null and b/assets/images/cakewallet_android_icon/mipmap-xxxhdpi/ic_launcher_monochrome.png differ diff --git a/assets/images/cakewallet_app_logo.png b/assets/images/cakewallet_app_logo.png deleted file mode 100644 index 59cc69414..000000000 Binary files a/assets/images/cakewallet_app_logo.png and /dev/null differ diff --git a/assets/images/cakewallet_icon_1024.png b/assets/images/cakewallet_icon_1024.png index 64682cd1d..35cf42245 100644 Binary files a/assets/images/cakewallet_icon_1024.png and b/assets/images/cakewallet_icon_1024.png differ diff --git a/assets/images/cakewallet_icon_120.png b/assets/images/cakewallet_icon_120.png index 1a2c1b99c..6e45f6423 100644 Binary files a/assets/images/cakewallet_icon_120.png and b/assets/images/cakewallet_icon_120.png differ diff --git a/assets/images/cakewallet_icon_180.png b/assets/images/cakewallet_icon_180.png index ff69a866a..da585ca42 100644 Binary files a/assets/images/cakewallet_icon_180.png and b/assets/images/cakewallet_icon_180.png differ diff --git a/assets/images/cakewallet_logo.png b/assets/images/cakewallet_logo.png index bf6896ad2..c465fa26c 100644 Binary files a/assets/images/cakewallet_logo.png and b/assets/images/cakewallet_logo.png differ diff --git a/assets/images/card.svg b/assets/images/card.svg new file mode 100644 index 000000000..95530cdc9 --- /dev/null +++ b/assets/images/card.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/assets/images/card_dark.svg b/assets/images/card_dark.svg new file mode 100644 index 000000000..2e5bcf986 --- /dev/null +++ b/assets/images/card_dark.svg @@ -0,0 +1,7 @@ + + + + + + + \ No newline at end of file diff --git a/assets/images/cards.png b/assets/images/cards.png new file mode 100644 index 000000000..b263bc742 Binary files /dev/null and b/assets/images/cards.png differ diff --git a/assets/images/chainflip.png b/assets/images/chainflip.png new file mode 100644 index 000000000..e588e6361 Binary files /dev/null and b/assets/images/chainflip.png differ diff --git a/assets/images/contact.png b/assets/images/contact.png new file mode 100644 index 000000000..5cf96694b Binary files /dev/null and b/assets/images/contact.png differ diff --git a/assets/images/contact_icon.svg b/assets/images/contact_icon.svg new file mode 100644 index 000000000..6dbfcd5f4 --- /dev/null +++ b/assets/images/contact_icon.svg @@ -0,0 +1,3 @@ + + + diff --git a/assets/images/dcr_icon.png b/assets/images/dcr_icon.png index 609873611..757cd0388 100644 Binary files a/assets/images/dcr_icon.png and b/assets/images/dcr_icon.png differ diff --git a/assets/images/decred.png b/assets/images/decred.png new file mode 100644 index 000000000..0b12f2ef0 Binary files /dev/null and b/assets/images/decred.png differ diff --git a/assets/images/decred_icon.png b/assets/images/decred_icon.png new file mode 100644 index 000000000..9391abc3d Binary files /dev/null and b/assets/images/decred_icon.png differ diff --git a/assets/images/decred_menu.png b/assets/images/decred_menu.png new file mode 100644 index 000000000..5c67923c5 Binary files /dev/null and b/assets/images/decred_menu.png differ diff --git a/assets/images/deuro_icon.png b/assets/images/deuro_icon.png new file mode 100644 index 000000000..4dc068ff8 Binary files /dev/null and b/assets/images/deuro_icon.png differ diff --git a/assets/images/dfx_dark.png b/assets/images/dfx_dark.png index cbba87372..6ac112eae 100644 Binary files a/assets/images/dfx_dark.png and b/assets/images/dfx_dark.png differ diff --git a/assets/images/dfx_light.png b/assets/images/dfx_light.png index e4836be3e..a045d3e68 100644 Binary files a/assets/images/dfx_light.png and b/assets/images/dfx_light.png differ diff --git a/assets/images/digibyte.png b/assets/images/digibyte.png new file mode 100644 index 000000000..0045c6852 Binary files /dev/null and b/assets/images/digibyte.png differ diff --git a/assets/images/discord.png b/assets/images/discord.png new file mode 100644 index 000000000..23fe37a36 Binary files /dev/null and b/assets/images/discord.png differ diff --git a/assets/images/discourse.png b/assets/images/discourse.png new file mode 100644 index 000000000..b8bab2c5d Binary files /dev/null and b/assets/images/discourse.png differ diff --git a/assets/images/dollar_coin.svg b/assets/images/dollar_coin.svg new file mode 100644 index 000000000..22218f332 --- /dev/null +++ b/assets/images/dollar_coin.svg @@ -0,0 +1,12 @@ + + + + + + + + + + + + \ No newline at end of file diff --git a/assets/images/flags/abw.png b/assets/images/flags/abw.png new file mode 100644 index 000000000..d049d3a43 Binary files /dev/null and b/assets/images/flags/abw.png differ diff --git a/assets/images/flags/afg.png b/assets/images/flags/afg.png new file mode 100644 index 000000000..f2ea25144 Binary files /dev/null and b/assets/images/flags/afg.png differ diff --git a/assets/images/flags/ago.png b/assets/images/flags/ago.png new file mode 100644 index 000000000..b04d7dfa6 Binary files /dev/null and b/assets/images/flags/ago.png differ diff --git a/assets/images/flags/aia.png b/assets/images/flags/aia.png new file mode 100644 index 000000000..193f0ff41 Binary files /dev/null and b/assets/images/flags/aia.png differ diff --git a/assets/images/flags/and.png b/assets/images/flags/and.png new file mode 100644 index 000000000..fc5d9a89b Binary files /dev/null and b/assets/images/flags/and.png differ diff --git a/assets/images/flags/are.png b/assets/images/flags/are.png index ae68c4ff2..2df30486a 100644 Binary files a/assets/images/flags/are.png and b/assets/images/flags/are.png differ diff --git a/assets/images/flags/arg.png b/assets/images/flags/arg.png index c5bd233d2..d87458455 100644 Binary files a/assets/images/flags/arg.png and b/assets/images/flags/arg.png differ diff --git a/assets/images/flags/arm.png b/assets/images/flags/arm.png new file mode 100644 index 000000000..0e4c356e2 Binary files /dev/null and b/assets/images/flags/arm.png differ diff --git a/assets/images/flags/asm.png b/assets/images/flags/asm.png new file mode 100644 index 000000000..fd3818eda Binary files /dev/null and b/assets/images/flags/asm.png differ diff --git a/assets/images/flags/atf.png b/assets/images/flags/atf.png new file mode 100644 index 000000000..af77e45d5 Binary files /dev/null and b/assets/images/flags/atf.png differ diff --git a/assets/images/flags/atg.png b/assets/images/flags/atg.png new file mode 100644 index 000000000..d9a3d9f9e Binary files /dev/null and b/assets/images/flags/atg.png differ diff --git a/assets/images/flags/aus.png b/assets/images/flags/aus.png index c8837731c..2364fa9ef 100644 Binary files a/assets/images/flags/aus.png and b/assets/images/flags/aus.png differ diff --git a/assets/images/flags/aut.png b/assets/images/flags/aut.png new file mode 100644 index 000000000..1be1ff483 Binary files /dev/null and b/assets/images/flags/aut.png differ diff --git a/assets/images/flags/aze.png b/assets/images/flags/aze.png new file mode 100644 index 000000000..834b1e696 Binary files /dev/null and b/assets/images/flags/aze.png differ diff --git a/assets/images/flags/bel.png b/assets/images/flags/bel.png new file mode 100644 index 000000000..1c06c5fa7 Binary files /dev/null and b/assets/images/flags/bel.png differ diff --git a/assets/images/flags/bes.png b/assets/images/flags/bes.png new file mode 100644 index 000000000..b00bfb1f5 Binary files /dev/null and b/assets/images/flags/bes.png differ diff --git a/assets/images/flags/bgd.png b/assets/images/flags/bgd.png index 0f8c5cfe5..802233d1c 100644 Binary files a/assets/images/flags/bgd.png and b/assets/images/flags/bgd.png differ diff --git a/assets/images/flags/bgr.png b/assets/images/flags/bgr.png index a89509f1f..fc53406b4 100644 Binary files a/assets/images/flags/bgr.png and b/assets/images/flags/bgr.png differ diff --git a/assets/images/flags/bhr.png b/assets/images/flags/bhr.png new file mode 100644 index 000000000..135c254cb Binary files /dev/null and b/assets/images/flags/bhr.png differ diff --git a/assets/images/flags/blz.png b/assets/images/flags/blz.png new file mode 100644 index 000000000..06b23f161 Binary files /dev/null and b/assets/images/flags/blz.png differ diff --git a/assets/images/flags/bmu.png b/assets/images/flags/bmu.png new file mode 100644 index 000000000..6e253a8e6 Binary files /dev/null and b/assets/images/flags/bmu.png differ diff --git a/assets/images/flags/bol.png b/assets/images/flags/bol.png new file mode 100644 index 000000000..4996ddbcc Binary files /dev/null and b/assets/images/flags/bol.png differ diff --git a/assets/images/flags/bra.png b/assets/images/flags/bra.png index ecac6f5a3..6c4ba8968 100644 Binary files a/assets/images/flags/bra.png and b/assets/images/flags/bra.png differ diff --git a/assets/images/flags/brn.png b/assets/images/flags/brn.png new file mode 100644 index 000000000..bd1d1cc9a Binary files /dev/null and b/assets/images/flags/brn.png differ diff --git a/assets/images/flags/btn.png b/assets/images/flags/btn.png new file mode 100644 index 000000000..962e5e5bb Binary files /dev/null and b/assets/images/flags/btn.png differ diff --git a/assets/images/flags/bvt.png b/assets/images/flags/bvt.png new file mode 100644 index 000000000..4acde7fbf Binary files /dev/null and b/assets/images/flags/bvt.png differ diff --git a/assets/images/flags/bwa.png b/assets/images/flags/bwa.png new file mode 100644 index 000000000..5b7eff92a Binary files /dev/null and b/assets/images/flags/bwa.png differ diff --git a/assets/images/flags/cad.png b/assets/images/flags/cad.png index 106cea5b9..a96ea16f6 100644 Binary files a/assets/images/flags/cad.png and b/assets/images/flags/cad.png differ diff --git a/assets/images/flags/cck.png b/assets/images/flags/cck.png new file mode 100644 index 000000000..d255ab91a Binary files /dev/null and b/assets/images/flags/cck.png differ diff --git a/assets/images/flags/che.png b/assets/images/flags/che.png index 427db0fbc..eab82b708 100644 Binary files a/assets/images/flags/che.png and b/assets/images/flags/che.png differ diff --git a/assets/images/flags/chl.png b/assets/images/flags/chl.png index 73a38f406..4328f0d08 100644 Binary files a/assets/images/flags/chl.png and b/assets/images/flags/chl.png differ diff --git a/assets/images/flags/chn.png b/assets/images/flags/chn.png index 7a03dd26e..d326f7afa 100644 Binary files a/assets/images/flags/chn.png and b/assets/images/flags/chn.png differ diff --git a/assets/images/flags/cmr.png b/assets/images/flags/cmr.png new file mode 100644 index 000000000..2bc6ad13c Binary files /dev/null and b/assets/images/flags/cmr.png differ diff --git a/assets/images/flags/cok.png b/assets/images/flags/cok.png new file mode 100644 index 000000000..49386516d Binary files /dev/null and b/assets/images/flags/cok.png differ diff --git a/assets/images/flags/col.png b/assets/images/flags/col.png index 9a0fc6ac1..798cd6374 100644 Binary files a/assets/images/flags/col.png and b/assets/images/flags/col.png differ diff --git a/assets/images/flags/cpv.png b/assets/images/flags/cpv.png new file mode 100644 index 000000000..0683d931f Binary files /dev/null and b/assets/images/flags/cpv.png differ diff --git a/assets/images/flags/cri.png b/assets/images/flags/cri.png new file mode 100644 index 000000000..029bbfc49 Binary files /dev/null and b/assets/images/flags/cri.png differ diff --git a/assets/images/flags/cuw.png b/assets/images/flags/cuw.png new file mode 100644 index 000000000..92a36b728 Binary files /dev/null and b/assets/images/flags/cuw.png differ diff --git a/assets/images/flags/cxr.png b/assets/images/flags/cxr.png new file mode 100644 index 000000000..e644a49ea Binary files /dev/null and b/assets/images/flags/cxr.png differ diff --git a/assets/images/flags/cyp.png b/assets/images/flags/cyp.png new file mode 100644 index 000000000..ba5246809 Binary files /dev/null and b/assets/images/flags/cyp.png differ diff --git a/assets/images/flags/czk.png b/assets/images/flags/czk.png index a6c13a773..e4578e86c 100644 Binary files a/assets/images/flags/czk.png and b/assets/images/flags/czk.png differ diff --git a/assets/images/flags/deu.png b/assets/images/flags/deu.png index 95b88a0ea..9bebd76f3 100644 Binary files a/assets/images/flags/deu.png and b/assets/images/flags/deu.png differ diff --git a/assets/images/flags/dji.png b/assets/images/flags/dji.png new file mode 100644 index 000000000..185c5322b Binary files /dev/null and b/assets/images/flags/dji.png differ diff --git a/assets/images/flags/dma.png b/assets/images/flags/dma.png new file mode 100644 index 000000000..7f61af95e Binary files /dev/null and b/assets/images/flags/dma.png differ diff --git a/assets/images/flags/dnk.png b/assets/images/flags/dnk.png index 69dd1b2b8..e02d42bfe 100644 Binary files a/assets/images/flags/dnk.png and b/assets/images/flags/dnk.png differ diff --git a/assets/images/flags/dza.png b/assets/images/flags/dza.png new file mode 100644 index 000000000..342284223 Binary files /dev/null and b/assets/images/flags/dza.png differ diff --git a/assets/images/flags/ecu.png b/assets/images/flags/ecu.png new file mode 100644 index 000000000..efb3acb43 Binary files /dev/null and b/assets/images/flags/ecu.png differ diff --git a/assets/images/flags/egy.png b/assets/images/flags/egy.png index 062ee21cf..692e7de78 100644 Binary files a/assets/images/flags/egy.png and b/assets/images/flags/egy.png differ diff --git a/assets/images/flags/esp.png b/assets/images/flags/esp.png index 0193a6a44..6b12aff49 100644 Binary files a/assets/images/flags/esp.png and b/assets/images/flags/esp.png differ diff --git a/assets/images/flags/est.png b/assets/images/flags/est.png new file mode 100644 index 000000000..c2e417b93 Binary files /dev/null and b/assets/images/flags/est.png differ diff --git a/assets/images/flags/eth.png b/assets/images/flags/eth.png new file mode 100644 index 000000000..5b4970a67 Binary files /dev/null and b/assets/images/flags/eth.png differ diff --git a/assets/images/flags/eur.png b/assets/images/flags/eur.png index 1312b0200..2500968a9 100644 Binary files a/assets/images/flags/eur.png and b/assets/images/flags/eur.png differ diff --git a/assets/images/flags/fin.png b/assets/images/flags/fin.png new file mode 100644 index 000000000..b6bc2f9a9 Binary files /dev/null and b/assets/images/flags/fin.png differ diff --git a/assets/images/flags/fji.png b/assets/images/flags/fji.png new file mode 100644 index 000000000..4700ad579 Binary files /dev/null and b/assets/images/flags/fji.png differ diff --git a/assets/images/flags/flk.png b/assets/images/flags/flk.png new file mode 100644 index 000000000..66ff172c2 Binary files /dev/null and b/assets/images/flags/flk.png differ diff --git a/assets/images/flags/fra.png b/assets/images/flags/fra.png index 91dce8ff2..2f432f4af 100644 Binary files a/assets/images/flags/fra.png and b/assets/images/flags/fra.png differ diff --git a/assets/images/flags/fro.png b/assets/images/flags/fro.png new file mode 100644 index 000000000..2c3ed5f6b Binary files /dev/null and b/assets/images/flags/fro.png differ diff --git a/assets/images/flags/fsm.png b/assets/images/flags/fsm.png new file mode 100644 index 000000000..b8aedd34e Binary files /dev/null and b/assets/images/flags/fsm.png differ diff --git a/assets/images/flags/gab.png b/assets/images/flags/gab.png new file mode 100644 index 000000000..c8e1cbd1f Binary files /dev/null and b/assets/images/flags/gab.png differ diff --git a/assets/images/flags/gbr.png b/assets/images/flags/gbr.png index 151f06db5..8b53f8851 100644 Binary files a/assets/images/flags/gbr.png and b/assets/images/flags/gbr.png differ diff --git a/assets/images/flags/geo.png b/assets/images/flags/geo.png new file mode 100644 index 000000000..46c83a589 Binary files /dev/null and b/assets/images/flags/geo.png differ diff --git a/assets/images/flags/ggi.png b/assets/images/flags/ggi.png new file mode 100644 index 000000000..fbc403f16 Binary files /dev/null and b/assets/images/flags/ggi.png differ diff --git a/assets/images/flags/ggy.png b/assets/images/flags/ggy.png new file mode 100644 index 000000000..a882b4a59 Binary files /dev/null and b/assets/images/flags/ggy.png differ diff --git a/assets/images/flags/gha.png b/assets/images/flags/gha.png index 8d6801e81..6e38331bb 100644 Binary files a/assets/images/flags/gha.png and b/assets/images/flags/gha.png differ diff --git a/assets/images/flags/glp.png b/assets/images/flags/glp.png new file mode 100644 index 000000000..8bd0a69bf Binary files /dev/null and b/assets/images/flags/glp.png differ diff --git a/assets/images/flags/gmb.png b/assets/images/flags/gmb.png new file mode 100644 index 000000000..fa641ca1a Binary files /dev/null and b/assets/images/flags/gmb.png differ diff --git a/assets/images/flags/grc.png b/assets/images/flags/grc.png new file mode 100644 index 000000000..d7b37b0c7 Binary files /dev/null and b/assets/images/flags/grc.png differ diff --git a/assets/images/flags/grd.png b/assets/images/flags/grd.png new file mode 100644 index 000000000..7138a28d7 Binary files /dev/null and b/assets/images/flags/grd.png differ diff --git a/assets/images/flags/grl.png b/assets/images/flags/grl.png new file mode 100644 index 000000000..53e45988b Binary files /dev/null and b/assets/images/flags/grl.png differ diff --git a/assets/images/flags/gtm.png b/assets/images/flags/gtm.png index 2083ad806..8841a352a 100644 Binary files a/assets/images/flags/gtm.png and b/assets/images/flags/gtm.png differ diff --git a/assets/images/flags/guf.png b/assets/images/flags/guf.png new file mode 100644 index 000000000..07a2d5070 Binary files /dev/null and b/assets/images/flags/guf.png differ diff --git a/assets/images/flags/gum.png b/assets/images/flags/gum.png new file mode 100644 index 000000000..828c5f3d9 Binary files /dev/null and b/assets/images/flags/gum.png differ diff --git a/assets/images/flags/guy.png b/assets/images/flags/guy.png new file mode 100644 index 000000000..5845c6db9 Binary files /dev/null and b/assets/images/flags/guy.png differ diff --git a/assets/images/flags/hau.png b/assets/images/flags/hau.png index 7583b5daf..2bfb0a71c 100644 Binary files a/assets/images/flags/hau.png and b/assets/images/flags/hau.png differ diff --git a/assets/images/flags/hkg.png b/assets/images/flags/hkg.png index 85925604e..8c4cd78ae 100644 Binary files a/assets/images/flags/hkg.png and b/assets/images/flags/hkg.png differ diff --git a/assets/images/flags/hmd.png b/assets/images/flags/hmd.png new file mode 100644 index 000000000..8c2931c4e Binary files /dev/null and b/assets/images/flags/hmd.png differ diff --git a/assets/images/flags/hrv.png b/assets/images/flags/hrv.png index 9c87c5d0e..43c936b1e 100644 Binary files a/assets/images/flags/hrv.png and b/assets/images/flags/hrv.png differ diff --git a/assets/images/flags/hun.png b/assets/images/flags/hun.png index 9722561a8..397910aa3 100644 Binary files a/assets/images/flags/hun.png and b/assets/images/flags/hun.png differ diff --git a/assets/images/flags/idn.png b/assets/images/flags/idn.png index 52c965921..d1f01d8b5 100644 Binary files a/assets/images/flags/idn.png and b/assets/images/flags/idn.png differ diff --git a/assets/images/flags/ind.png b/assets/images/flags/ind.png index ef721a2aa..45d4a7109 100644 Binary files a/assets/images/flags/ind.png and b/assets/images/flags/ind.png differ diff --git a/assets/images/flags/iot.png b/assets/images/flags/iot.png new file mode 100644 index 000000000..2863320f5 Binary files /dev/null and b/assets/images/flags/iot.png differ diff --git a/assets/images/flags/irl.png b/assets/images/flags/irl.png new file mode 100644 index 000000000..2126054d3 Binary files /dev/null and b/assets/images/flags/irl.png differ diff --git a/assets/images/flags/irn.png b/assets/images/flags/irn.png index 151a03919..ec59e48c3 100644 Binary files a/assets/images/flags/irn.png and b/assets/images/flags/irn.png differ diff --git a/assets/images/flags/isl.png b/assets/images/flags/isl.png index ed545e905..d24f29abb 100644 Binary files a/assets/images/flags/isl.png and b/assets/images/flags/isl.png differ diff --git a/assets/images/flags/isr.png b/assets/images/flags/isr.png index 9f815dcbd..f28dff1ad 100644 Binary files a/assets/images/flags/isr.png and b/assets/images/flags/isr.png differ diff --git a/assets/images/flags/ita.png b/assets/images/flags/ita.png index 768f5a181..8af7190b8 100644 Binary files a/assets/images/flags/ita.png and b/assets/images/flags/ita.png differ diff --git a/assets/images/flags/jam.png b/assets/images/flags/jam.png new file mode 100644 index 000000000..97bce2de3 Binary files /dev/null and b/assets/images/flags/jam.png differ diff --git a/assets/images/flags/jey.png b/assets/images/flags/jey.png new file mode 100644 index 000000000..e144d060e Binary files /dev/null and b/assets/images/flags/jey.png differ diff --git a/assets/images/flags/jor.png b/assets/images/flags/jor.png new file mode 100644 index 000000000..6e5d2bbb8 Binary files /dev/null and b/assets/images/flags/jor.png differ diff --git a/assets/images/flags/jpn.png b/assets/images/flags/jpn.png index a13ef4178..b5c6fb4c4 100644 Binary files a/assets/images/flags/jpn.png and b/assets/images/flags/jpn.png differ diff --git a/assets/images/flags/kaz.png b/assets/images/flags/kaz.png new file mode 100644 index 000000000..db52cf078 Binary files /dev/null and b/assets/images/flags/kaz.png differ diff --git a/assets/images/flags/ken.png b/assets/images/flags/ken.png new file mode 100644 index 000000000..2570c185d Binary files /dev/null and b/assets/images/flags/ken.png differ diff --git a/assets/images/flags/kir.png b/assets/images/flags/kir.png new file mode 100644 index 000000000..c8b69d702 Binary files /dev/null and b/assets/images/flags/kir.png differ diff --git a/assets/images/flags/kor.png b/assets/images/flags/kor.png index 36e867ea8..ccc181522 100644 Binary files a/assets/images/flags/kor.png and b/assets/images/flags/kor.png differ diff --git a/assets/images/flags/kwt.png b/assets/images/flags/kwt.png new file mode 100644 index 000000000..f21563b5f Binary files /dev/null and b/assets/images/flags/kwt.png differ diff --git a/assets/images/flags/lbn.png b/assets/images/flags/lbn.png new file mode 100644 index 000000000..2a9ae1dd0 Binary files /dev/null and b/assets/images/flags/lbn.png differ diff --git a/assets/images/flags/lie.png b/assets/images/flags/lie.png new file mode 100644 index 000000000..0b8c77442 Binary files /dev/null and b/assets/images/flags/lie.png differ diff --git a/assets/images/flags/lka.png b/assets/images/flags/lka.png new file mode 100644 index 000000000..2b6492daa Binary files /dev/null and b/assets/images/flags/lka.png differ diff --git a/assets/images/flags/ltu.png b/assets/images/flags/ltu.png new file mode 100644 index 000000000..dec6babea Binary files /dev/null and b/assets/images/flags/ltu.png differ diff --git a/assets/images/flags/lux.png b/assets/images/flags/lux.png new file mode 100644 index 000000000..9cc1c65b5 Binary files /dev/null and b/assets/images/flags/lux.png differ diff --git a/assets/images/flags/lva.png b/assets/images/flags/lva.png new file mode 100644 index 000000000..b3312700d Binary files /dev/null and b/assets/images/flags/lva.png differ diff --git a/assets/images/flags/mar.png b/assets/images/flags/mar.png index 65b31c892..dda3a0131 100644 Binary files a/assets/images/flags/mar.png and b/assets/images/flags/mar.png differ diff --git a/assets/images/flags/mco.png b/assets/images/flags/mco.png new file mode 100644 index 000000000..6c12bd624 Binary files /dev/null and b/assets/images/flags/mco.png differ diff --git a/assets/images/flags/mex.png b/assets/images/flags/mex.png index 9531a3ea2..f81102912 100644 Binary files a/assets/images/flags/mex.png and b/assets/images/flags/mex.png differ diff --git a/assets/images/flags/mlt.png b/assets/images/flags/mlt.png new file mode 100644 index 000000000..12809815f Binary files /dev/null and b/assets/images/flags/mlt.png differ diff --git a/assets/images/flags/mmr.png b/assets/images/flags/mmr.png index 7fc6e1661..c2f9fec10 100644 Binary files a/assets/images/flags/mmr.png and b/assets/images/flags/mmr.png differ diff --git a/assets/images/flags/mnp.png b/assets/images/flags/mnp.png new file mode 100644 index 000000000..3e6c538e4 Binary files /dev/null and b/assets/images/flags/mnp.png differ diff --git a/assets/images/flags/mrt.png b/assets/images/flags/mrt.png new file mode 100644 index 000000000..0fd8d757e Binary files /dev/null and b/assets/images/flags/mrt.png differ diff --git a/assets/images/flags/msr.png b/assets/images/flags/msr.png new file mode 100644 index 000000000..2d2af3aef Binary files /dev/null and b/assets/images/flags/msr.png differ diff --git a/assets/images/flags/mtq.png b/assets/images/flags/mtq.png new file mode 100644 index 000000000..1897c94e7 Binary files /dev/null and b/assets/images/flags/mtq.png differ diff --git a/assets/images/flags/mwi.png b/assets/images/flags/mwi.png new file mode 100644 index 000000000..7ddfbb17b Binary files /dev/null and b/assets/images/flags/mwi.png differ diff --git a/assets/images/flags/mys.png b/assets/images/flags/mys.png index 022476291..d151a3e4f 100644 Binary files a/assets/images/flags/mys.png and b/assets/images/flags/mys.png differ diff --git a/assets/images/flags/myt.png b/assets/images/flags/myt.png new file mode 100644 index 000000000..c149a2a79 Binary files /dev/null and b/assets/images/flags/myt.png differ diff --git a/assets/images/flags/ner.png b/assets/images/flags/ner.png new file mode 100644 index 000000000..87bb8211f Binary files /dev/null and b/assets/images/flags/ner.png differ diff --git a/assets/images/flags/nfk.png b/assets/images/flags/nfk.png new file mode 100644 index 000000000..6e68aee4f Binary files /dev/null and b/assets/images/flags/nfk.png differ diff --git a/assets/images/flags/nga.png b/assets/images/flags/nga.png index ebfd82449..26efc073e 100644 Binary files a/assets/images/flags/nga.png and b/assets/images/flags/nga.png differ diff --git a/assets/images/flags/niu.png b/assets/images/flags/niu.png new file mode 100644 index 000000000..acb36780d Binary files /dev/null and b/assets/images/flags/niu.png differ diff --git a/assets/images/flags/nld.png b/assets/images/flags/nld.png index 62dbc2058..220937426 100644 Binary files a/assets/images/flags/nld.png and b/assets/images/flags/nld.png differ diff --git a/assets/images/flags/nor.png b/assets/images/flags/nor.png index bd226c0a6..dfb43c5d5 100644 Binary files a/assets/images/flags/nor.png and b/assets/images/flags/nor.png differ diff --git a/assets/images/flags/nzl.png b/assets/images/flags/nzl.png index 11c6ade9c..306fdc778 100644 Binary files a/assets/images/flags/nzl.png and b/assets/images/flags/nzl.png differ diff --git a/assets/images/flags/omn.png b/assets/images/flags/omn.png new file mode 100644 index 000000000..6b6cd8b3b Binary files /dev/null and b/assets/images/flags/omn.png differ diff --git a/assets/images/flags/pak.png b/assets/images/flags/pak.png index 1462650e4..b8c966ea1 100644 Binary files a/assets/images/flags/pak.png and b/assets/images/flags/pak.png differ diff --git a/assets/images/flags/per.png b/assets/images/flags/per.png new file mode 100644 index 000000000..490a26441 Binary files /dev/null and b/assets/images/flags/per.png differ diff --git a/assets/images/flags/phl.png b/assets/images/flags/phl.png index b453f3933..3c2ce7bf3 100644 Binary files a/assets/images/flags/phl.png and b/assets/images/flags/phl.png differ diff --git a/assets/images/flags/plw.png b/assets/images/flags/plw.png new file mode 100644 index 000000000..6f6ff993a Binary files /dev/null and b/assets/images/flags/plw.png differ diff --git a/assets/images/flags/pol.png b/assets/images/flags/pol.png index 30d5a9371..1188eccd1 100644 Binary files a/assets/images/flags/pol.png and b/assets/images/flags/pol.png differ diff --git a/assets/images/flags/pri.png b/assets/images/flags/pri.png new file mode 100644 index 000000000..cb0c54cd6 Binary files /dev/null and b/assets/images/flags/pri.png differ diff --git a/assets/images/flags/prt.png b/assets/images/flags/prt.png index ff5a25fa9..268676679 100644 Binary files a/assets/images/flags/prt.png and b/assets/images/flags/prt.png differ diff --git a/assets/images/flags/pyf.png b/assets/images/flags/pyf.png new file mode 100644 index 000000000..66a5da6b8 Binary files /dev/null and b/assets/images/flags/pyf.png differ diff --git a/assets/images/flags/qat.png b/assets/images/flags/qat.png new file mode 100644 index 000000000..1e8461e91 Binary files /dev/null and b/assets/images/flags/qat.png differ diff --git a/assets/images/flags/rou.png b/assets/images/flags/rou.png index 49b36b438..db1e24ca2 100644 Binary files a/assets/images/flags/rou.png and b/assets/images/flags/rou.png differ diff --git a/assets/images/flags/rus.png b/assets/images/flags/rus.png index 2633dcbd0..460c7b813 100644 Binary files a/assets/images/flags/rus.png and b/assets/images/flags/rus.png differ diff --git a/assets/images/flags/saf.png b/assets/images/flags/saf.png index 3b9cbded8..9accc6b5f 100644 Binary files a/assets/images/flags/saf.png and b/assets/images/flags/saf.png differ diff --git a/assets/images/flags/sau.png b/assets/images/flags/sau.png index 97951983a..255dabedd 100644 Binary files a/assets/images/flags/sau.png and b/assets/images/flags/sau.png differ diff --git a/assets/images/flags/sgp.png b/assets/images/flags/sgp.png index 5782ea144..a677561d4 100644 Binary files a/assets/images/flags/sgp.png and b/assets/images/flags/sgp.png differ diff --git a/assets/images/flags/slb.png b/assets/images/flags/slb.png new file mode 100644 index 000000000..d63061a0b Binary files /dev/null and b/assets/images/flags/slb.png differ diff --git a/assets/images/flags/slv.png b/assets/images/flags/slv.png new file mode 100644 index 000000000..e597e45b3 Binary files /dev/null and b/assets/images/flags/slv.png differ diff --git a/assets/images/flags/svk.png b/assets/images/flags/svk.png new file mode 100644 index 000000000..06bed756e Binary files /dev/null and b/assets/images/flags/svk.png differ diff --git a/assets/images/flags/svn.png b/assets/images/flags/svn.png new file mode 100644 index 000000000..a791163bd Binary files /dev/null and b/assets/images/flags/svn.png differ diff --git a/assets/images/flags/swe.png b/assets/images/flags/swe.png index ef73086f6..e5ee36d2f 100644 Binary files a/assets/images/flags/swe.png and b/assets/images/flags/swe.png differ diff --git a/assets/images/flags/tha.png b/assets/images/flags/tha.png index 1bdb04d00..a99cd4d48 100644 Binary files a/assets/images/flags/tha.png and b/assets/images/flags/tha.png differ diff --git a/assets/images/flags/tkm.png b/assets/images/flags/tkm.png new file mode 100644 index 000000000..0c3ff8755 Binary files /dev/null and b/assets/images/flags/tkm.png differ diff --git a/assets/images/flags/ton.png b/assets/images/flags/ton.png new file mode 100644 index 000000000..84cf20ef5 Binary files /dev/null and b/assets/images/flags/ton.png differ diff --git a/assets/images/flags/tur.png b/assets/images/flags/tur.png index 166c6313a..e86b5a85e 100644 Binary files a/assets/images/flags/tur.png and b/assets/images/flags/tur.png differ diff --git a/assets/images/flags/tuv.png b/assets/images/flags/tuv.png new file mode 100644 index 000000000..15478f191 Binary files /dev/null and b/assets/images/flags/tuv.png differ diff --git a/assets/images/flags/twn.png b/assets/images/flags/twn.png index 4af8ba78d..34a2b37db 100644 Binary files a/assets/images/flags/twn.png and b/assets/images/flags/twn.png differ diff --git a/assets/images/flags/ukr.png b/assets/images/flags/ukr.png index 61071e338..c4fe57fcc 100644 Binary files a/assets/images/flags/ukr.png and b/assets/images/flags/ukr.png differ diff --git a/assets/images/flags/ury.png b/assets/images/flags/ury.png new file mode 100644 index 000000000..c41e2780a Binary files /dev/null and b/assets/images/flags/ury.png differ diff --git a/assets/images/flags/usa.png b/assets/images/flags/usa.png index a8c44ce75..30fc880b7 100644 Binary files a/assets/images/flags/usa.png and b/assets/images/flags/usa.png differ diff --git a/assets/images/flags/vat.png b/assets/images/flags/vat.png new file mode 100644 index 000000000..d6c99cc1f Binary files /dev/null and b/assets/images/flags/vat.png differ diff --git a/assets/images/flags/ven.png b/assets/images/flags/ven.png index fcc25ef2b..c189b0545 100644 Binary files a/assets/images/flags/ven.png and b/assets/images/flags/ven.png differ diff --git a/assets/images/flags/vir.png b/assets/images/flags/vir.png new file mode 100644 index 000000000..a57f924b2 Binary files /dev/null and b/assets/images/flags/vir.png differ diff --git a/assets/images/flags/vnm.png b/assets/images/flags/vnm.png index 3cbbf878f..d313c9912 100644 Binary files a/assets/images/flags/vnm.png and b/assets/images/flags/vnm.png differ diff --git a/assets/images/flags/vut.png b/assets/images/flags/vut.png new file mode 100644 index 000000000..3c4d6e429 Binary files /dev/null and b/assets/images/flags/vut.png differ diff --git a/assets/images/flip_icon.png b/assets/images/flip_icon.png new file mode 100644 index 000000000..e588e6361 Binary files /dev/null and b/assets/images/flip_icon.png differ diff --git a/assets/images/gmt_icon.png b/assets/images/gmt_icon.png new file mode 100644 index 000000000..25c8a00b6 Binary files /dev/null and b/assets/images/gmt_icon.png differ diff --git a/assets/images/google_pay_icon.png b/assets/images/google_pay_icon.png new file mode 100644 index 000000000..a3ca38311 Binary files /dev/null and b/assets/images/google_pay_icon.png differ diff --git a/assets/images/hardware_wallet/ledger_flex.png b/assets/images/hardware_wallet/ledger_flex.png new file mode 100644 index 000000000..fa39f241f Binary files /dev/null and b/assets/images/hardware_wallet/ledger_flex.png differ diff --git a/assets/images/hardware_wallet/ledger_nano_s.png b/assets/images/hardware_wallet/ledger_nano_s.png new file mode 100644 index 000000000..02777aeb6 Binary files /dev/null and b/assets/images/hardware_wallet/ledger_nano_s.png differ diff --git a/assets/images/hardware_wallet/ledger_nano_x.png b/assets/images/hardware_wallet/ledger_nano_x.png new file mode 100644 index 000000000..e9328a5ef Binary files /dev/null and b/assets/images/hardware_wallet/ledger_nano_x.png differ diff --git a/assets/images/hardware_wallet/ledger_stax.png b/assets/images/hardware_wallet/ledger_stax.png new file mode 100644 index 000000000..06c9c848e Binary files /dev/null and b/assets/images/hardware_wallet/ledger_stax.png differ diff --git a/assets/images/hero/cw_welcome_dark.svg b/assets/images/hero/cw_welcome_dark.svg new file mode 100644 index 000000000..5479cb1ee --- /dev/null +++ b/assets/images/hero/cw_welcome_dark.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/assets/images/hero/cw_welcome_light.svg b/assets/images/hero/cw_welcome_light.svg new file mode 100644 index 000000000..ece7d1f84 --- /dev/null +++ b/assets/images/hero/cw_welcome_light.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/assets/images/history.svg b/assets/images/history.svg new file mode 100644 index 000000000..f308ab7e3 --- /dev/null +++ b/assets/images/history.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/assets/images/hnt_icon.png b/assets/images/hnt_icon.png new file mode 100644 index 000000000..8b64b76dd Binary files /dev/null and b/assets/images/hnt_icon.png differ diff --git a/assets/images/home_screen_setting_icon.svg b/assets/images/home_screen_setting_icon.svg new file mode 100644 index 000000000..7b3aa7b4c --- /dev/null +++ b/assets/images/home_screen_setting_icon.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/assets/images/ios_icons/cakewallet_ios_icons/AppIcon-20@2x.png b/assets/images/ios_icons/cakewallet_ios_icons/AppIcon-20@2x.png new file mode 100644 index 000000000..3fd15f3ce Binary files /dev/null and b/assets/images/ios_icons/cakewallet_ios_icons/AppIcon-20@2x.png differ diff --git a/assets/images/ios_icons/cakewallet_ios_icons/AppIcon-20@2x~ipad.png b/assets/images/ios_icons/cakewallet_ios_icons/AppIcon-20@2x~ipad.png new file mode 100644 index 000000000..3fd15f3ce Binary files /dev/null and b/assets/images/ios_icons/cakewallet_ios_icons/AppIcon-20@2x~ipad.png differ diff --git a/assets/images/ios_icons/cakewallet_ios_icons/AppIcon-20@3x.png b/assets/images/ios_icons/cakewallet_ios_icons/AppIcon-20@3x.png new file mode 100644 index 000000000..b6ff994f6 Binary files /dev/null and b/assets/images/ios_icons/cakewallet_ios_icons/AppIcon-20@3x.png differ diff --git a/assets/images/ios_icons/cakewallet_ios_icons/AppIcon-20~ipad.png b/assets/images/ios_icons/cakewallet_ios_icons/AppIcon-20~ipad.png new file mode 100644 index 000000000..4be1a2317 Binary files /dev/null and b/assets/images/ios_icons/cakewallet_ios_icons/AppIcon-20~ipad.png differ diff --git a/assets/images/ios_icons/cakewallet_ios_icons/AppIcon-29.png b/assets/images/ios_icons/cakewallet_ios_icons/AppIcon-29.png new file mode 100644 index 000000000..219f2c6be Binary files /dev/null and b/assets/images/ios_icons/cakewallet_ios_icons/AppIcon-29.png differ diff --git a/assets/images/ios_icons/cakewallet_ios_icons/AppIcon-29@2x.png b/assets/images/ios_icons/cakewallet_ios_icons/AppIcon-29@2x.png new file mode 100644 index 000000000..ae94bb0ac Binary files /dev/null and b/assets/images/ios_icons/cakewallet_ios_icons/AppIcon-29@2x.png differ diff --git a/assets/images/ios_icons/cakewallet_ios_icons/AppIcon-29@2x~ipad.png b/assets/images/ios_icons/cakewallet_ios_icons/AppIcon-29@2x~ipad.png new file mode 100644 index 000000000..ae94bb0ac Binary files /dev/null and b/assets/images/ios_icons/cakewallet_ios_icons/AppIcon-29@2x~ipad.png differ diff --git a/assets/images/ios_icons/cakewallet_ios_icons/AppIcon-29@3x.png b/assets/images/ios_icons/cakewallet_ios_icons/AppIcon-29@3x.png new file mode 100644 index 000000000..0ef9d3bbf Binary files /dev/null and b/assets/images/ios_icons/cakewallet_ios_icons/AppIcon-29@3x.png differ diff --git a/assets/images/ios_icons/cakewallet_ios_icons/AppIcon-29~ipad.png b/assets/images/ios_icons/cakewallet_ios_icons/AppIcon-29~ipad.png new file mode 100644 index 000000000..219f2c6be Binary files /dev/null and b/assets/images/ios_icons/cakewallet_ios_icons/AppIcon-29~ipad.png differ diff --git a/assets/images/ios_icons/cakewallet_ios_icons/AppIcon-40@2x.png b/assets/images/ios_icons/cakewallet_ios_icons/AppIcon-40@2x.png new file mode 100644 index 000000000..9fdb32376 Binary files /dev/null and b/assets/images/ios_icons/cakewallet_ios_icons/AppIcon-40@2x.png differ diff --git a/assets/images/ios_icons/cakewallet_ios_icons/AppIcon-40@2x~ipad.png b/assets/images/ios_icons/cakewallet_ios_icons/AppIcon-40@2x~ipad.png new file mode 100644 index 000000000..9fdb32376 Binary files /dev/null and b/assets/images/ios_icons/cakewallet_ios_icons/AppIcon-40@2x~ipad.png differ diff --git a/assets/images/ios_icons/cakewallet_ios_icons/AppIcon-40@3x.png b/assets/images/ios_icons/cakewallet_ios_icons/AppIcon-40@3x.png new file mode 100644 index 000000000..485f8b37b Binary files /dev/null and b/assets/images/ios_icons/cakewallet_ios_icons/AppIcon-40@3x.png differ diff --git a/assets/images/ios_icons/cakewallet_ios_icons/AppIcon-40~ipad.png b/assets/images/ios_icons/cakewallet_ios_icons/AppIcon-40~ipad.png new file mode 100644 index 000000000..3fd15f3ce Binary files /dev/null and b/assets/images/ios_icons/cakewallet_ios_icons/AppIcon-40~ipad.png differ diff --git a/assets/images/ios_icons/cakewallet_ios_icons/AppIcon-60@2x~car.png b/assets/images/ios_icons/cakewallet_ios_icons/AppIcon-60@2x~car.png new file mode 100644 index 000000000..485f8b37b Binary files /dev/null and b/assets/images/ios_icons/cakewallet_ios_icons/AppIcon-60@2x~car.png differ diff --git a/assets/images/ios_icons/cakewallet_ios_icons/AppIcon-60@3x~car.png b/assets/images/ios_icons/cakewallet_ios_icons/AppIcon-60@3x~car.png new file mode 100644 index 000000000..50148e6dc Binary files /dev/null and b/assets/images/ios_icons/cakewallet_ios_icons/AppIcon-60@3x~car.png differ diff --git a/assets/images/ios_icons/cakewallet_ios_icons/AppIcon-83.5@2x~ipad.png b/assets/images/ios_icons/cakewallet_ios_icons/AppIcon-83.5@2x~ipad.png new file mode 100644 index 000000000..8f290ada2 Binary files /dev/null and b/assets/images/ios_icons/cakewallet_ios_icons/AppIcon-83.5@2x~ipad.png differ diff --git a/assets/images/ios_icons/cakewallet_ios_icons/AppIcon@2x.png b/assets/images/ios_icons/cakewallet_ios_icons/AppIcon@2x.png new file mode 100644 index 000000000..485f8b37b Binary files /dev/null and b/assets/images/ios_icons/cakewallet_ios_icons/AppIcon@2x.png differ diff --git a/assets/images/ios_icons/cakewallet_ios_icons/AppIcon@2x~ipad.png b/assets/images/ios_icons/cakewallet_ios_icons/AppIcon@2x~ipad.png new file mode 100644 index 000000000..b11d7332f Binary files /dev/null and b/assets/images/ios_icons/cakewallet_ios_icons/AppIcon@2x~ipad.png differ diff --git a/assets/images/ios_icons/cakewallet_ios_icons/AppIcon@3x.png b/assets/images/ios_icons/cakewallet_ios_icons/AppIcon@3x.png new file mode 100644 index 000000000..50148e6dc Binary files /dev/null and b/assets/images/ios_icons/cakewallet_ios_icons/AppIcon@3x.png differ diff --git a/assets/images/ios_icons/cakewallet_ios_icons/AppIcon~ios-marketing.png b/assets/images/ios_icons/cakewallet_ios_icons/AppIcon~ios-marketing.png new file mode 100644 index 000000000..1fce95553 Binary files /dev/null and b/assets/images/ios_icons/cakewallet_ios_icons/AppIcon~ios-marketing.png differ diff --git a/assets/images/ios_icons/cakewallet_ios_icons/AppIcon~ipad.png b/assets/images/ios_icons/cakewallet_ios_icons/AppIcon~ipad.png new file mode 100644 index 000000000..7d4a82186 Binary files /dev/null and b/assets/images/ios_icons/cakewallet_ios_icons/AppIcon~ipad.png differ diff --git a/assets/images/ios_icons/monero_ios_icons/AppIcon-20@2x.png b/assets/images/ios_icons/monero_ios_icons/AppIcon-20@2x.png new file mode 100644 index 000000000..7ea540caf Binary files /dev/null and b/assets/images/ios_icons/monero_ios_icons/AppIcon-20@2x.png differ diff --git a/assets/images/ios_icons/monero_ios_icons/AppIcon-20@2x~ipad.png b/assets/images/ios_icons/monero_ios_icons/AppIcon-20@2x~ipad.png new file mode 100644 index 000000000..7ea540caf Binary files /dev/null and b/assets/images/ios_icons/monero_ios_icons/AppIcon-20@2x~ipad.png differ diff --git a/assets/images/ios_icons/monero_ios_icons/AppIcon-20@3x.png b/assets/images/ios_icons/monero_ios_icons/AppIcon-20@3x.png new file mode 100644 index 000000000..6ac773754 Binary files /dev/null and b/assets/images/ios_icons/monero_ios_icons/AppIcon-20@3x.png differ diff --git a/assets/images/ios_icons/monero_ios_icons/AppIcon-20~ipad.png b/assets/images/ios_icons/monero_ios_icons/AppIcon-20~ipad.png new file mode 100644 index 000000000..57864a9b3 Binary files /dev/null and b/assets/images/ios_icons/monero_ios_icons/AppIcon-20~ipad.png differ diff --git a/assets/images/ios_icons/monero_ios_icons/AppIcon-29.png b/assets/images/ios_icons/monero_ios_icons/AppIcon-29.png new file mode 100644 index 000000000..27f817dfc Binary files /dev/null and b/assets/images/ios_icons/monero_ios_icons/AppIcon-29.png differ diff --git a/assets/images/ios_icons/monero_ios_icons/AppIcon-29@2x.png b/assets/images/ios_icons/monero_ios_icons/AppIcon-29@2x.png new file mode 100644 index 000000000..0455b4409 Binary files /dev/null and b/assets/images/ios_icons/monero_ios_icons/AppIcon-29@2x.png differ diff --git a/assets/images/ios_icons/monero_ios_icons/AppIcon-29@2x~ipad.png b/assets/images/ios_icons/monero_ios_icons/AppIcon-29@2x~ipad.png new file mode 100644 index 000000000..0455b4409 Binary files /dev/null and b/assets/images/ios_icons/monero_ios_icons/AppIcon-29@2x~ipad.png differ diff --git a/assets/images/ios_icons/monero_ios_icons/AppIcon-29@3x.png b/assets/images/ios_icons/monero_ios_icons/AppIcon-29@3x.png new file mode 100644 index 000000000..1b8a73481 Binary files /dev/null and b/assets/images/ios_icons/monero_ios_icons/AppIcon-29@3x.png differ diff --git a/assets/images/ios_icons/monero_ios_icons/AppIcon-29~ipad.png b/assets/images/ios_icons/monero_ios_icons/AppIcon-29~ipad.png new file mode 100644 index 000000000..27f817dfc Binary files /dev/null and b/assets/images/ios_icons/monero_ios_icons/AppIcon-29~ipad.png differ diff --git a/assets/images/ios_icons/monero_ios_icons/AppIcon-40@2x.png b/assets/images/ios_icons/monero_ios_icons/AppIcon-40@2x.png new file mode 100644 index 000000000..963612d0c Binary files /dev/null and b/assets/images/ios_icons/monero_ios_icons/AppIcon-40@2x.png differ diff --git a/assets/images/ios_icons/monero_ios_icons/AppIcon-40@2x~ipad.png b/assets/images/ios_icons/monero_ios_icons/AppIcon-40@2x~ipad.png new file mode 100644 index 000000000..963612d0c Binary files /dev/null and b/assets/images/ios_icons/monero_ios_icons/AppIcon-40@2x~ipad.png differ diff --git a/assets/images/ios_icons/monero_ios_icons/AppIcon-40@3x.png b/assets/images/ios_icons/monero_ios_icons/AppIcon-40@3x.png new file mode 100644 index 000000000..b6da404cb Binary files /dev/null and b/assets/images/ios_icons/monero_ios_icons/AppIcon-40@3x.png differ diff --git a/assets/images/ios_icons/monero_ios_icons/AppIcon-40~ipad.png b/assets/images/ios_icons/monero_ios_icons/AppIcon-40~ipad.png new file mode 100644 index 000000000..7ea540caf Binary files /dev/null and b/assets/images/ios_icons/monero_ios_icons/AppIcon-40~ipad.png differ diff --git a/assets/images/ios_icons/monero_ios_icons/AppIcon-60@2x~car.png b/assets/images/ios_icons/monero_ios_icons/AppIcon-60@2x~car.png new file mode 100644 index 000000000..b6da404cb Binary files /dev/null and b/assets/images/ios_icons/monero_ios_icons/AppIcon-60@2x~car.png differ diff --git a/assets/images/ios_icons/monero_ios_icons/AppIcon-60@3x~car.png b/assets/images/ios_icons/monero_ios_icons/AppIcon-60@3x~car.png new file mode 100644 index 000000000..37f7651a5 Binary files /dev/null and b/assets/images/ios_icons/monero_ios_icons/AppIcon-60@3x~car.png differ diff --git a/assets/images/ios_icons/monero_ios_icons/AppIcon-83.5@2x~ipad.png b/assets/images/ios_icons/monero_ios_icons/AppIcon-83.5@2x~ipad.png new file mode 100644 index 000000000..21aa12463 Binary files /dev/null and b/assets/images/ios_icons/monero_ios_icons/AppIcon-83.5@2x~ipad.png differ diff --git a/assets/images/ios_icons/monero_ios_icons/AppIcon@2x.png b/assets/images/ios_icons/monero_ios_icons/AppIcon@2x.png new file mode 100644 index 000000000..b6da404cb Binary files /dev/null and b/assets/images/ios_icons/monero_ios_icons/AppIcon@2x.png differ diff --git a/assets/images/ios_icons/monero_ios_icons/AppIcon@2x~ipad.png b/assets/images/ios_icons/monero_ios_icons/AppIcon@2x~ipad.png new file mode 100644 index 000000000..b6b63a61e Binary files /dev/null and b/assets/images/ios_icons/monero_ios_icons/AppIcon@2x~ipad.png differ diff --git a/assets/images/ios_icons/monero_ios_icons/AppIcon@3x.png b/assets/images/ios_icons/monero_ios_icons/AppIcon@3x.png new file mode 100644 index 000000000..37f7651a5 Binary files /dev/null and b/assets/images/ios_icons/monero_ios_icons/AppIcon@3x.png differ diff --git a/assets/images/ios_icons/monero_ios_icons/AppIcon~ios-marketing.png b/assets/images/ios_icons/monero_ios_icons/AppIcon~ios-marketing.png new file mode 100644 index 000000000..0c977110a Binary files /dev/null and b/assets/images/ios_icons/monero_ios_icons/AppIcon~ios-marketing.png differ diff --git a/assets/images/ios_icons/monero_ios_icons/AppIcon~ipad.png b/assets/images/ios_icons/monero_ios_icons/AppIcon~ipad.png new file mode 100644 index 000000000..849c5612a Binary files /dev/null and b/assets/images/ios_icons/monero_ios_icons/AppIcon~ipad.png differ diff --git a/assets/images/kryptonim_dark.png b/assets/images/kryptonim_dark.png new file mode 100644 index 000000000..646d550ba Binary files /dev/null and b/assets/images/kryptonim_dark.png differ diff --git a/assets/images/kryptonim_light.png b/assets/images/kryptonim_light.png new file mode 100644 index 000000000..85e64a3f2 Binary files /dev/null and b/assets/images/kryptonim_light.png differ diff --git a/assets/images/letsexchange_icon.svg b/assets/images/letsexchange_icon.svg new file mode 100644 index 000000000..104b43a6b --- /dev/null +++ b/assets/images/letsexchange_icon.svg @@ -0,0 +1,5 @@ + + + \ No newline at end of file diff --git a/assets/images/macos_icons/cakewallet_macos_icons/cakewallet_macos_1024.png b/assets/images/macos_icons/cakewallet_macos_icons/cakewallet_macos_1024.png index 73101354a..c465fa26c 100644 Binary files a/assets/images/macos_icons/cakewallet_macos_icons/cakewallet_macos_1024.png and b/assets/images/macos_icons/cakewallet_macos_icons/cakewallet_macos_1024.png differ diff --git a/assets/images/macos_icons/cakewallet_macos_icons/cakewallet_macos_128.png b/assets/images/macos_icons/cakewallet_macos_icons/cakewallet_macos_128.png index 9ceee3c5e..e79db4d99 100644 Binary files a/assets/images/macos_icons/cakewallet_macos_icons/cakewallet_macos_128.png and b/assets/images/macos_icons/cakewallet_macos_icons/cakewallet_macos_128.png differ diff --git a/assets/images/macos_icons/cakewallet_macos_icons/cakewallet_macos_16.png b/assets/images/macos_icons/cakewallet_macos_icons/cakewallet_macos_16.png index ef46cd805..2d030845b 100644 Binary files a/assets/images/macos_icons/cakewallet_macos_icons/cakewallet_macos_16.png and b/assets/images/macos_icons/cakewallet_macos_icons/cakewallet_macos_16.png differ diff --git a/assets/images/macos_icons/cakewallet_macos_icons/cakewallet_macos_256.png b/assets/images/macos_icons/cakewallet_macos_icons/cakewallet_macos_256.png index 6547a1b1b..eef607c2c 100644 Binary files a/assets/images/macos_icons/cakewallet_macos_icons/cakewallet_macos_256.png and b/assets/images/macos_icons/cakewallet_macos_icons/cakewallet_macos_256.png differ diff --git a/assets/images/macos_icons/cakewallet_macos_icons/cakewallet_macos_32.png b/assets/images/macos_icons/cakewallet_macos_icons/cakewallet_macos_32.png index e436872e8..a09896830 100644 Binary files a/assets/images/macos_icons/cakewallet_macos_icons/cakewallet_macos_32.png and b/assets/images/macos_icons/cakewallet_macos_icons/cakewallet_macos_32.png differ diff --git a/assets/images/macos_icons/cakewallet_macos_icons/cakewallet_macos_512.png b/assets/images/macos_icons/cakewallet_macos_icons/cakewallet_macos_512.png index 157b00493..a98c5faf8 100644 Binary files a/assets/images/macos_icons/cakewallet_macos_icons/cakewallet_macos_512.png and b/assets/images/macos_icons/cakewallet_macos_icons/cakewallet_macos_512.png differ diff --git a/assets/images/macos_icons/cakewallet_macos_icons/cakewallet_macos_64.png b/assets/images/macos_icons/cakewallet_macos_icons/cakewallet_macos_64.png index a46ed4535..d8141a250 100644 Binary files a/assets/images/macos_icons/cakewallet_macos_icons/cakewallet_macos_64.png and b/assets/images/macos_icons/cakewallet_macos_icons/cakewallet_macos_64.png differ diff --git a/assets/images/meld_logo.svg b/assets/images/meld_logo.svg new file mode 100644 index 000000000..1d9211d64 --- /dev/null +++ b/assets/images/meld_logo.svg @@ -0,0 +1,30 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/assets/images/menu.svg b/assets/images/menu.svg new file mode 100644 index 000000000..0a4cc3784 --- /dev/null +++ b/assets/images/menu.svg @@ -0,0 +1,3 @@ + + + diff --git a/assets/images/monerocom_android_icon/mipmap-anydpi-v26/ic_launcher.xml b/assets/images/monerocom_android_icon/mipmap-anydpi-v26/ic_launcher.xml index 00d924171..c8bd4b26c 100644 --- a/assets/images/monerocom_android_icon/mipmap-anydpi-v26/ic_launcher.xml +++ b/assets/images/monerocom_android_icon/mipmap-anydpi-v26/ic_launcher.xml @@ -2,4 +2,5 @@ + \ No newline at end of file diff --git a/assets/images/monerocom_android_icon/mipmap-hdpi/ic_launcher_adaptive_mono.png b/assets/images/monerocom_android_icon/mipmap-hdpi/ic_launcher_adaptive_mono.png new file mode 100644 index 000000000..af8126ea9 Binary files /dev/null and b/assets/images/monerocom_android_icon/mipmap-hdpi/ic_launcher_adaptive_mono.png differ diff --git a/assets/images/monerocom_android_icon/mipmap-mdpi/ic_launcher_adaptive_mono.png b/assets/images/monerocom_android_icon/mipmap-mdpi/ic_launcher_adaptive_mono.png new file mode 100644 index 000000000..0778b841f Binary files /dev/null and b/assets/images/monerocom_android_icon/mipmap-mdpi/ic_launcher_adaptive_mono.png differ diff --git a/assets/images/monerocom_android_icon/mipmap-xhdpi/ic_launcher_adaptive_mono.png b/assets/images/monerocom_android_icon/mipmap-xhdpi/ic_launcher_adaptive_mono.png new file mode 100644 index 000000000..318914c59 Binary files /dev/null and b/assets/images/monerocom_android_icon/mipmap-xhdpi/ic_launcher_adaptive_mono.png differ diff --git a/assets/images/monerocom_android_icon/mipmap-xxhdpi/ic_launcher_adaptive_mono.png b/assets/images/monerocom_android_icon/mipmap-xxhdpi/ic_launcher_adaptive_mono.png new file mode 100644 index 000000000..b1165abe8 Binary files /dev/null and b/assets/images/monerocom_android_icon/mipmap-xxhdpi/ic_launcher_adaptive_mono.png differ diff --git a/assets/images/monerocom_android_icon/mipmap-xxxhdpi/ic_launcher_adaptive_mono.png b/assets/images/monerocom_android_icon/mipmap-xxxhdpi/ic_launcher_adaptive_mono.png new file mode 100644 index 000000000..ea11a01d3 Binary files /dev/null and b/assets/images/monerocom_android_icon/mipmap-xxxhdpi/ic_launcher_adaptive_mono.png differ diff --git a/assets/images/moonpay.png b/assets/images/moonpay.png index b02af6c00..088c93d59 100644 Binary files a/assets/images/moonpay.png and b/assets/images/moonpay.png differ diff --git a/assets/images/moonpay_dark.png b/assets/images/moonpay_dark.png index 872e322e2..21de98eb4 100644 Binary files a/assets/images/moonpay_dark.png and b/assets/images/moonpay_dark.png differ diff --git a/assets/images/moonpay_light.png b/assets/images/moonpay_light.png index c76ae6e74..3d3de2e4f 100644 Binary files a/assets/images/moonpay_light.png and b/assets/images/moonpay_light.png differ diff --git a/assets/images/mweb_logo.png b/assets/images/mweb_logo.png new file mode 100644 index 000000000..92317203e Binary files /dev/null and b/assets/images/mweb_logo.png differ diff --git a/assets/images/nanogpt.png b/assets/images/nanogpt.png new file mode 100644 index 000000000..958400452 Binary files /dev/null and b/assets/images/nanogpt.png differ diff --git a/assets/images/notif.svg b/assets/images/notif.svg new file mode 100644 index 000000000..b1ff5b4fa --- /dev/null +++ b/assets/images/notif.svg @@ -0,0 +1,3 @@ + + + diff --git a/assets/images/notification_icon.svg b/assets/images/notification_icon.svg new file mode 100644 index 000000000..360d0b4e6 --- /dev/null +++ b/assets/images/notification_icon.svg @@ -0,0 +1,3 @@ + + + diff --git a/assets/images/passphrase_dark.png b/assets/images/passphrase_dark.png new file mode 100644 index 000000000..f72d1e1a1 Binary files /dev/null and b/assets/images/passphrase_dark.png differ diff --git a/assets/images/passphrase_key.svg b/assets/images/passphrase_key.svg new file mode 100644 index 000000000..c577dc30a --- /dev/null +++ b/assets/images/passphrase_key.svg @@ -0,0 +1,22 @@ + + + + + + + + + + + + + + + + + + + + + + diff --git a/assets/images/passphrase_light.png b/assets/images/passphrase_light.png new file mode 100644 index 000000000..f86f68156 Binary files /dev/null and b/assets/images/passphrase_light.png differ diff --git a/assets/images/payjoin.png b/assets/images/payjoin.png new file mode 100644 index 000000000..1ba3dccdb Binary files /dev/null and b/assets/images/payjoin.png differ diff --git a/assets/images/qr-cake.png b/assets/images/qr-cake.png new file mode 100644 index 000000000..7c54dedb0 Binary files /dev/null and b/assets/images/qr-cake.png differ diff --git a/assets/images/ray_icon.png b/assets/images/ray_icon.png new file mode 100644 index 000000000..0d48e54a6 Binary files /dev/null and b/assets/images/ray_icon.png differ diff --git a/assets/images/receive.png b/assets/images/receive.png new file mode 100644 index 000000000..180a4e5b3 Binary files /dev/null and b/assets/images/receive.png differ diff --git a/assets/images/revolut.png b/assets/images/revolut.png new file mode 100644 index 000000000..bbe342592 Binary files /dev/null and b/assets/images/revolut.png differ diff --git a/assets/images/seed_verified.png b/assets/images/seed_verified.png new file mode 100644 index 000000000..e03706193 Binary files /dev/null and b/assets/images/seed_verified.png differ diff --git a/assets/images/seed_verified_dark.png b/assets/images/seed_verified_dark.png new file mode 100644 index 000000000..cbefa5d8c Binary files /dev/null and b/assets/images/seed_verified_dark.png differ diff --git a/assets/images/seed_verified_light.png b/assets/images/seed_verified_light.png new file mode 100644 index 000000000..a51eddcd7 Binary files /dev/null and b/assets/images/seed_verified_light.png differ diff --git a/assets/images/seed_warning_dark.svg b/assets/images/seed_warning_dark.svg new file mode 100644 index 000000000..0a47254ff --- /dev/null +++ b/assets/images/seed_warning_dark.svg @@ -0,0 +1,86 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/assets/images/seed_warning_light.svg b/assets/images/seed_warning_light.svg new file mode 100644 index 000000000..cab27ec31 --- /dev/null +++ b/assets/images/seed_warning_light.svg @@ -0,0 +1,111 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/assets/images/send.png b/assets/images/send.png deleted file mode 100644 index aef504999..000000000 Binary files a/assets/images/send.png and /dev/null differ diff --git a/assets/images/send2.png b/assets/images/send2.png new file mode 100644 index 000000000..85fc570a8 Binary files /dev/null and b/assets/images/send2.png differ diff --git a/assets/images/skrill.svg b/assets/images/skrill.svg new file mode 100644 index 000000000..b264b57eb --- /dev/null +++ b/assets/images/skrill.svg @@ -0,0 +1,15 @@ + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/assets/images/status_website_image.png b/assets/images/status_website_image.png new file mode 100644 index 000000000..017bb64e1 Binary files /dev/null and b/assets/images/status_website_image.png differ diff --git a/assets/images/stealthex.png b/assets/images/stealthex.png new file mode 100644 index 000000000..311d47b74 Binary files /dev/null and b/assets/images/stealthex.png differ diff --git a/assets/images/swap.png b/assets/images/swap.png new file mode 100644 index 000000000..fe3fc0893 Binary files /dev/null and b/assets/images/swap.png differ diff --git a/assets/images/swap_trade.png b/assets/images/swap_trade.png new file mode 100644 index 000000000..cfa32b382 Binary files /dev/null and b/assets/images/swap_trade.png differ diff --git a/assets/images/tbtc.png b/assets/images/tbtc.png new file mode 100644 index 000000000..bd4323edf Binary files /dev/null and b/assets/images/tbtc.png differ diff --git a/assets/images/thorchain.png b/assets/images/thorchain.png new file mode 100644 index 000000000..674b60f82 Binary files /dev/null and b/assets/images/thorchain.png differ diff --git a/assets/images/ton_icon.png b/assets/images/ton_icon.png new file mode 100644 index 000000000..90f9968cc Binary files /dev/null and b/assets/images/ton_icon.png differ diff --git a/assets/images/tor_logo.svg b/assets/images/tor_logo.svg new file mode 100644 index 000000000..ebd00324d --- /dev/null +++ b/assets/images/tor_logo.svg @@ -0,0 +1,76 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/assets/images/trocador.png b/assets/images/trocador.png index 67c9f221c..37e643de4 100644 Binary files a/assets/images/trocador.png and b/assets/images/trocador.png differ diff --git a/assets/images/usb.png b/assets/images/usb.png new file mode 100644 index 000000000..1163c573d Binary files /dev/null and b/assets/images/usb.png differ diff --git a/assets/images/usd_round_dark.svg b/assets/images/usd_round_dark.svg new file mode 100644 index 000000000..f329dd617 --- /dev/null +++ b/assets/images/usd_round_dark.svg @@ -0,0 +1,4 @@ + + + + \ No newline at end of file diff --git a/assets/images/usd_round_light.svg b/assets/images/usd_round_light.svg new file mode 100644 index 000000000..f5965c597 --- /dev/null +++ b/assets/images/usd_round_light.svg @@ -0,0 +1,2 @@ + + diff --git a/assets/images/usdtbsc_icon.png b/assets/images/usdtbsc_icon.png new file mode 100644 index 000000000..9f2cda237 Binary files /dev/null and b/assets/images/usdtbsc_icon.png differ diff --git a/assets/images/wallet_group_confirmed_dark.png b/assets/images/wallet_group_confirmed_dark.png new file mode 100644 index 000000000..a047cb29c Binary files /dev/null and b/assets/images/wallet_group_confirmed_dark.png differ diff --git a/assets/images/wallet_group_confirmed_light.png b/assets/images/wallet_group_confirmed_light.png new file mode 100644 index 000000000..851d32300 Binary files /dev/null and b/assets/images/wallet_group_confirmed_light.png differ diff --git a/assets/images/wallet_group_empty_dark.png b/assets/images/wallet_group_empty_dark.png new file mode 100644 index 000000000..e613d876e Binary files /dev/null and b/assets/images/wallet_group_empty_dark.png differ diff --git a/assets/images/wallet_group_empty_light.png b/assets/images/wallet_group_empty_light.png new file mode 100644 index 000000000..f795648ae Binary files /dev/null and b/assets/images/wallet_group_empty_light.png differ diff --git a/assets/images/wallet_group_options_dark.png b/assets/images/wallet_group_options_dark.png new file mode 100644 index 000000000..479aac57c Binary files /dev/null and b/assets/images/wallet_group_options_dark.png differ diff --git a/assets/images/wallet_group_options_light.png b/assets/images/wallet_group_options_light.png new file mode 100644 index 000000000..308930520 Binary files /dev/null and b/assets/images/wallet_group_options_light.png differ diff --git a/assets/images/wallet_name.png b/assets/images/wallet_name.png deleted file mode 100644 index f586682bd..000000000 Binary files a/assets/images/wallet_name.png and /dev/null differ diff --git a/assets/images/wallet_name_light.png b/assets/images/wallet_name_light.png deleted file mode 100644 index 0199c1b30..000000000 Binary files a/assets/images/wallet_name_light.png and /dev/null differ diff --git a/assets/images/wallet_new.png b/assets/images/wallet_new.png new file mode 100644 index 000000000..47c43bfca Binary files /dev/null and b/assets/images/wallet_new.png differ diff --git a/assets/images/wallet_type.png b/assets/images/wallet_type.png deleted file mode 100644 index 4e0eba8b5..000000000 Binary files a/assets/images/wallet_type.png and /dev/null differ diff --git a/assets/images/wallet_type_light.png b/assets/images/wallet_type_light.png deleted file mode 100644 index e36c0d3aa..000000000 Binary files a/assets/images/wallet_type_light.png and /dev/null differ diff --git a/assets/images/wallet_type_wallet_dark.png b/assets/images/wallet_type_wallet_dark.png new file mode 100644 index 000000000..b840f5547 Binary files /dev/null and b/assets/images/wallet_type_wallet_dark.png differ diff --git a/assets/images/wallet_type_wallet_light.png b/assets/images/wallet_type_wallet_light.png new file mode 100644 index 000000000..ee759a109 Binary files /dev/null and b/assets/images/wallet_type_wallet_light.png differ diff --git a/assets/images/wallets.png b/assets/images/wallets.png new file mode 100644 index 000000000..62ea20039 Binary files /dev/null and b/assets/images/wallets.png differ diff --git a/assets/images/welcome.png b/assets/images/welcome.png deleted file mode 100644 index f1132d253..000000000 Binary files a/assets/images/welcome.png and /dev/null differ diff --git a/assets/images/welcome_dark_theme.svg b/assets/images/welcome_dark_theme.svg new file mode 100644 index 000000000..8f60c931a --- /dev/null +++ b/assets/images/welcome_dark_theme.svg @@ -0,0 +1,215 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/assets/images/welcome_light.png b/assets/images/welcome_light.png deleted file mode 100644 index 6feff85d1..000000000 Binary files a/assets/images/welcome_light.png and /dev/null differ diff --git a/assets/images/welcome_light_theme.svg b/assets/images/welcome_light_theme.svg new file mode 100644 index 000000000..178b2853d --- /dev/null +++ b/assets/images/welcome_light_theme.svg @@ -0,0 +1,14 @@ + + + + + + + + + + + + + + diff --git a/assets/images/welcome_wallet_dark.png b/assets/images/welcome_wallet_dark.png new file mode 100644 index 000000000..771a600d3 Binary files /dev/null and b/assets/images/welcome_wallet_dark.png differ diff --git a/assets/images/welcome_wallet_light.png b/assets/images/welcome_wallet_light.png new file mode 100644 index 000000000..2a738be0b Binary files /dev/null and b/assets/images/welcome_wallet_light.png differ diff --git a/assets/images/wownero_icon.png b/assets/images/wownero_icon.png new file mode 100644 index 000000000..a3da77b9e Binary files /dev/null and b/assets/images/wownero_icon.png differ diff --git a/assets/images/wownero_menu.png b/assets/images/wownero_menu.png new file mode 100644 index 000000000..a3da77b9e Binary files /dev/null and b/assets/images/wownero_menu.png differ diff --git a/assets/images/wyre-icon.png b/assets/images/wyre-icon.png deleted file mode 100644 index a2810948e..000000000 Binary files a/assets/images/wyre-icon.png and /dev/null differ diff --git a/assets/images/wyre.png b/assets/images/wyre.png deleted file mode 100644 index a16bbdc8b..000000000 Binary files a/assets/images/wyre.png and /dev/null differ diff --git a/assets/images/xoswap.svg b/assets/images/xoswap.svg new file mode 100644 index 000000000..ef83c58b1 --- /dev/null +++ b/assets/images/xoswap.svg @@ -0,0 +1,26 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/assets/images/yat_crypto.png b/assets/images/yat_crypto.png deleted file mode 100644 index fbd5d2483..000000000 Binary files a/assets/images/yat_crypto.png and /dev/null differ diff --git a/assets/images/zano_icon.png b/assets/images/zano_icon.png new file mode 100644 index 000000000..fd48fd6a5 Binary files /dev/null and b/assets/images/zano_icon.png differ diff --git a/assets/litecoin_electrum_server_list.yml b/assets/litecoin_electrum_server_list.yml index e61d0996c..550b900e1 100644 --- a/assets/litecoin_electrum_server_list.yml +++ b/assets/litecoin_electrum_server_list.yml @@ -1,2 +1,19 @@ - - uri: ltc-electrum.cakewallet.com:50002 \ No newline at end of file + uri: ltc-electrum.cakewallet.com:50002 + useSSL: true + isDefault: true +- + uri: litecoin.stackwallet.com:20063 + useSSL: true +- + uri: electrum-ltc.bysh.me:50002 + useSSL: true +- + uri: lightweight.fiatfaucet.com:50002 + useSSL: true +- + uri: electrum.ltc.xurious.com:50002 + useSSL: true +- + uri: backup.electrum-ltc.org:443 + useSSL: true diff --git a/assets/nano_node_list.yml b/assets/nano_node_list.yml index 63b4baec1..cda931b5e 100644 --- a/assets/nano_node_list.yml +++ b/assets/nano_node_list.yml @@ -1,6 +1,31 @@ +- + uri: nano.nownodes.io + useSSL: true - uri: rpc.nano.to - useSSL: true is_default: true + useSSL: true - - uri: node.perish.co:9076 \ No newline at end of file + uri: node.nautilus.io + path: /api + useSSL: true +- + uri: app.natrium.io + path: /api + useSSL: true +- + uri: rainstorm.city + path: /api + useSSL: true +- + uri: node.somenano.com + path: /proxy + useSSL: true +- + uri: nanoslo.0x.no + path: /proxy + useSSL: true +- + uri: www.bitrequest.app + port: 8020 + useSSL: true \ No newline at end of file diff --git a/assets/nano_pow_node_list.yml b/assets/nano_pow_node_list.yml index b90845034..3bbc7c3fb 100644 --- a/assets/nano_pow_node_list.yml +++ b/assets/nano_pow_node_list.yml @@ -6,4 +6,4 @@ uri: workers.perish.co - uri: worker.nanoriver.cc - useSSL: true \ No newline at end of file + useSSL: true diff --git a/assets/node_list.yml b/assets/node_list.yml index bc7a9dc4a..917dadfd9 100644 --- a/assets/node_list.yml +++ b/assets/node_list.yml @@ -1,6 +1,8 @@ - uri: xmr-node.cakewallet.com:18081 is_default: true + trusted: true + useSSL: true - uri: cakexmrl7bonq7ovjka5kuwuyd3f7qnkz6z6s6dmsy3uckwra7bvggyd.onion:18081 is_default: false @@ -11,12 +13,3 @@ - uri: nodes.hashvault.pro:18081 is_default: false -- - uri: node.c3pool.com:18081 - is_default: false -- - uri: node.community.rino.io:18081 - is_default: false -- - uri: node.moneroworld.com:18089 - is_default: false diff --git a/assets/polygon_node_list.yml b/assets/polygon_node_list.yml index 34504269d..3b2cdcdc3 100644 --- a/assets/polygon_node_list.yml +++ b/assets/polygon_node_list.yml @@ -1,6 +1,10 @@ - uri: polygon-rpc.com - - uri: polygon-bor.publicnode.com + uri: polygon-bor-rpc.publicnode.com + useSSL: true + isDefault: true - - uri: polygon.llamarpc.com \ No newline at end of file + uri: polygon.llamarpc.com +- + uri: matic.nownodes.io \ No newline at end of file diff --git a/assets/solana_node_list.yml b/assets/solana_node_list.yml new file mode 100644 index 000000000..3ba74d980 --- /dev/null +++ b/assets/solana_node_list.yml @@ -0,0 +1,13 @@ +- + uri: rpc.ankr.com + useSSL: true +- + uri: api.mainnet-beta.solana.com:443 + useSSL: true +- + uri: solana-rpc.publicnode.com:443 + useSSL: true +- + uri: solana-mainnet.core.chainstack.com + useSSL: true + is_default: true \ No newline at end of file diff --git a/assets/text/Monerocom_Release_Notes.txt b/assets/text/Monerocom_Release_Notes.txt index 2b783ee1e..faf57258a 100644 --- a/assets/text/Monerocom_Release_Notes.txt +++ b/assets/text/Monerocom_Release_Notes.txt @@ -1,3 +1,4 @@ -Security and Privacy enhancements -Usability enhancements +Add built-in Tor support (experimental) +Ledger improvements +UI/UX improvements Bug fixes \ No newline at end of file diff --git a/assets/text/Release_Notes.txt b/assets/text/Release_Notes.txt index 411ed609b..c49b895e3 100644 --- a/assets/text/Release_Notes.txt +++ b/assets/text/Release_Notes.txt @@ -1,4 +1,9 @@ -List previously used Bitcoin addresses -Security and Privacy enhancements -Usability enhancements +Add built-in Tor support (experimental) +Add dEuro investments +Solana fixes/enhancements +Polygon fixes/enhancements +WalletConnect improvements +Ledger improvements +Payjoin improvements +UI/UX improvements Bug fixes \ No newline at end of file diff --git a/assets/tron_node_list.yml b/assets/tron_node_list.yml new file mode 100644 index 000000000..1e34de712 --- /dev/null +++ b/assets/tron_node_list.yml @@ -0,0 +1,11 @@ +- + uri: tron-rpc.publicnode.com:443 + is_default: false + useSSL: true +- + uri: api.trongrid.io + is_default: true + useSSL: true +- + uri: trx.nownodes.io + useSSL: true \ No newline at end of file diff --git a/assets/wownero_node_list.yml b/assets/wownero_node_list.yml new file mode 100644 index 000000000..a4873b3e4 --- /dev/null +++ b/assets/wownero_node_list.yml @@ -0,0 +1,12 @@ +- + uri: node3.monerodevs.org:34568 + is_default: true + useSSL: false +- + uri: node2.monerodevs.org:34568 + is_default: false + useSSL: false +- + uri: node.monerodevs.org:34568 + is_default: false + useSSL: false diff --git a/assets/zano_node_list.yml b/assets/zano_node_list.yml new file mode 100644 index 000000000..f7b874fcb --- /dev/null +++ b/assets/zano_node_list.yml @@ -0,0 +1,7 @@ +- + uri: 37.27.100.59:10500 + useSSL: false +- + uri: zano.cakewallet.com:11211 + is_default: true + useSSL: false \ No newline at end of file diff --git a/cakewallet.bat b/cakewallet.bat new file mode 100644 index 000000000..1904c5710 --- /dev/null +++ b/cakewallet.bat @@ -0,0 +1,51 @@ +@echo off +set cw_win_app_config=--monero --bitcoin --ethereum --polygon --nano --bitcoinCash --solana --tron +set cw_root=%cd% +set cw_archive_name=Cake Wallet.zip +set cw_archive_path=%cw_root%\%cw_archive_name% +set secrets_file_path=lib\.secrets.g.dart +set release_dir=build\windows\x64\runner\Release +@REM Path could be different +if [%~1]==[] (set tools_root=C:\Program Files\Microsoft Visual Studio\2022\Community\VC\Redist\MSVC\14.38.33135\x64\Microsoft.VC143.CRT) else (set tools_root=%1) +@REM Generate android manifest file +cd scripts +bash.exe gen_android_manifest.sh +cd /d %cw_root% +echo === Generating pubspec.yaml === +copy /Y pubspec_description.yaml pubspec.yaml > nul +call flutter pub get > nul +call dart run tool\generate_pubspec.dart +call flutter pub get > nul +call dart run tool\configure.dart %cw_win_app_config% + +IF NOT EXIST "%secrets_file_path%" ( + echo === Generating new secrets file === + call dart run tool\generate_new_secrets.dart +) ELSE (echo === Using previously/already generated secrets file: %secrets_file_path% ===) + +echo === Generating mobx models === +for /d %%i in (cw_core cw_monero cw_bitcoin cw_ethereum cw_evm cw_polygon cw_nano cw_bitcoin_cash cw_solana cw_tron .) do ( + cd %%i + call flutter pub get > nul + call dart run build_runner build --delete-conflicting-outputs > nul + cd /d %cw_root% +) + +echo === Generating localization files === +call dart run tool\generate_localization.dart + +echo === Building the application executable file === +call flutter build windows --dart-define-from-file=env.json --release + +echo === Prepare distribution actions. Copy needed files to the application bundle === +copy /Y "%tools_root%\msvcp140.dll" "%release_dir%\" > nul +copy /Y "%tools_root%\vcruntime140.dll" "%release_dir%\" > nul +copy /Y "%tools_root%\vcruntime140_1.dll" "%release_dir%\" > nul + +echo === Generate the application archive === +xcopy /s /e /v /Y "%release_dir%\*.*" "build\Cake Wallet\" > nul +tar acf "%cw_archive_name%" -C build\ "Cake Wallet" + +echo === Open Explorer with the application archive === +echo Cake Wallet created archive at: %cw_archive_path% +%SystemRoot%\explorer.exe /select, %cw_archive_path% diff --git a/com.cakewallet.CakeWallet.yml b/com.cakewallet.CakeWallet.yml new file mode 100644 index 000000000..6a19c3dda --- /dev/null +++ b/com.cakewallet.CakeWallet.yml @@ -0,0 +1,33 @@ +app-id: com.cakewallet.CakeWallet +runtime: org.freedesktop.Platform +runtime-version: '24.08' +sdk: org.freedesktop.Sdk +command: cake_wallet +separate-locales: false +finish-args: + - --share=ipc + - --socket=fallback-x11 + - --socket=wayland + - --device=dri + - --socket=pulseaudio + - --share=network + - --filesystem=home +modules: + - name: cake_wallet + buildsystem: simple + build-commands: + - "cp -R bundle /app/cake_wallet" + - "chmod +x /app/cake_wallet/cake_wallet" + - "mkdir -p /app/bin" + - "ln -s /app/cake_wallet/cake_wallet /app/bin/cake_wallet" + - "mkdir -p /app/share/icons/hicolor/scalable/apps" + - "cp cakewallet_icon_180.png /app/share/icons/hicolor/scalable/apps/com.cakewallet.CakeWallet.png" + - "mkdir -p /app/share/applications" + - "cp com.cakewallet.CakeWallet.desktop /app/share/applications" + sources: + - type: dir + path: build/linux/x64/release + - type: file + path: assets/images/cakewallet_icon_180.png + - type: file + path: linux/com.cakewallet.CakeWallet.desktop diff --git a/configure_cake_wallet.sh b/configure_cake_wallet.sh index 5009cd9e3..a083ec7ff 100755 --- a/configure_cake_wallet.sh +++ b/configure_cake_wallet.sh @@ -1,11 +1,15 @@ +#!/bin/bash +set -x -e IOS="ios" ANDROID="android" +MACOS="macos" +LINUX="linux" -PLATFORMS=($IOS $ANDROID) +PLATFORMS=($IOS $ANDROID $MACOS $LINUX) PLATFORM=$1 if ! [[ " ${PLATFORMS[*]} " =~ " ${PLATFORM} " ]]; then - echo "specify platform: ./configure_cake_wallet.sh ios|android" + echo "specify platform: ./configure_cake_wallet.sh ios|android|macos|linux" exit 1 fi @@ -14,22 +18,24 @@ if [ "$PLATFORM" == "$IOS" ]; then cd scripts/ios fi +if [ "$PLATFORM" == "$MACOS" ]; then + echo "Configuring for macOS" + cd scripts/macos +fi + if [ "$PLATFORM" == "$ANDROID" ]; then echo "Configuring for Android" cd scripts/android fi +if [ "$PLATFORM" == "$LINUX" ]; then + echo "Configuring for linux" + cd scripts/linux +fi + source ./app_env.sh cakewallet ./app_config.sh cd ../.. && flutter pub get -flutter packages pub run tool/generate_localization.dart -cd cw_core && flutter pub get && flutter packages pub run build_runner build --delete-conflicting-outputs && cd .. -cd cw_evm && flutter pub get && flutter packages pub run build_runner build --delete-conflicting-outputs && cd .. -cd cw_monero && flutter pub get && flutter packages pub run build_runner build --delete-conflicting-outputs && cd .. -cd cw_bitcoin && flutter pub get && flutter packages pub run build_runner build --delete-conflicting-outputs && cd .. -cd cw_haven && flutter pub get && flutter packages pub run build_runner build --delete-conflicting-outputs && cd .. -cd cw_nano && flutter pub get && flutter packages pub run build_runner build --delete-conflicting-outputs && cd .. -cd cw_bitcoin_cash && flutter pub get && flutter packages pub run build_runner build --delete-conflicting-outputs && cd .. -cd cw_ethereum && flutter pub get && cd .. -cd cw_polygon && flutter pub get && cd .. -flutter packages pub run build_runner build --delete-conflicting-outputs +dart run tool/generate_localization.dart +#./model_generator.sh +#cd macos && pod install diff --git a/cw_bitcoin/lib/address_from_output.dart b/cw_bitcoin/lib/address_from_output.dart index d06ffe402..0d985b237 100644 --- a/cw_bitcoin/lib/address_from_output.dart +++ b/cw_bitcoin/lib/address_from_output.dart @@ -1,23 +1,38 @@ -import 'dart:typed_data'; -import 'package:bitcoin_flutter/bitcoin_flutter.dart' as bitcoin; -import 'package:bitcoin_flutter/src/payments/index.dart' show PaymentData; +import 'package:bitcoin_base/bitcoin_base.dart'; -String addressFromOutput(Uint8List script, bitcoin.NetworkType networkType) { +String addressFromOutputScript(Script script, BasedUtxoNetwork network) { try { - return bitcoin.P2PKH( - data: PaymentData(output: script), - network: networkType) - .data - .address!; + return addressFromScript(script, network).toAddress(network); } catch (_) {} - try { - return bitcoin.P2WPKH( - data: PaymentData(output: script), - network: networkType) - .data - .address!; - } catch(_) {} - return ''; -} \ No newline at end of file +} + +BitcoinBaseAddress addressFromScript(Script script, + [BasedUtxoNetwork network = BitcoinNetwork.mainnet]) { + final addressType = script.getAddressType(); + if (addressType == null) { + throw ArgumentError("Invalid script"); + } + + switch (addressType) { + case P2pkhAddressType.p2pkh: + return P2pkhAddress.fromScriptPubkey( + script: script, network: BitcoinNetwork.mainnet); + case P2shAddressType.p2pkhInP2sh: + case P2shAddressType.p2pkInP2sh: + return P2shAddress.fromScriptPubkey( + script: script, network: BitcoinNetwork.mainnet); + case SegwitAddresType.p2wpkh: + return P2wpkhAddress.fromScriptPubkey( + script: script, network: BitcoinNetwork.mainnet); + case SegwitAddresType.p2wsh: + return P2wshAddress.fromScriptPubkey( + script: script, network: BitcoinNetwork.mainnet); + case SegwitAddresType.p2tr: + return P2trAddress.fromScriptPubkey( + script: script, network: BitcoinNetwork.mainnet); + } + + throw ArgumentError("Invalid script"); +} diff --git a/cw_bitcoin/lib/address_to_output_script.dart b/cw_bitcoin/lib/address_to_output_script.dart deleted file mode 100644 index 01c7b67a5..000000000 --- a/cw_bitcoin/lib/address_to_output_script.dart +++ /dev/null @@ -1,29 +0,0 @@ -import 'dart:typed_data'; -import 'package:bitcoin_flutter/bitcoin_flutter.dart' as bitcoin; -import 'package:bs58check/bs58check.dart' as bs58check; -import 'package:bitcoin_flutter/src/utils/constants/op.dart'; -import 'package:bitcoin_flutter/src/utils/script.dart' as bscript; -import 'package:bitcoin_flutter/src/address.dart'; - -Uint8List p2shAddressToOutputScript(String address) { - final decodeBase58 = bs58check.decode(address); - final hash = decodeBase58.sublist(1); - return bscript.compile([OPS['OP_HASH160'], hash, OPS['OP_EQUAL']]); -} - -Uint8List addressToOutputScript( - String address, bitcoin.NetworkType networkType) { - try { - // FIXME: improve validation for p2sh addresses - // 3 for bitcoin - // m for litecoin - if (address.startsWith('3') || address.toLowerCase().startsWith('m')) { - return p2shAddressToOutputScript(address); - } - - return Address.addressToOutputScript(address, networkType); - } catch (err) { - print(err); - return Uint8List(0); - } -} diff --git a/cw_bitcoin/lib/bitcoin_address_record.dart b/cw_bitcoin/lib/bitcoin_address_record.dart index 676edb4a5..1509f913a 100644 --- a/cw_bitcoin/lib/bitcoin_address_record.dart +++ b/cw_bitcoin/lib/bitcoin_address_record.dart @@ -1,8 +1,10 @@ import 'dart:convert'; -import 'package:bitbox/bitbox.dart' as bitbox; +import 'package:mobx/mobx.dart'; -class BitcoinAddressRecord { - BitcoinAddressRecord( +import 'package:bitcoin_base/bitcoin_base.dart'; + +abstract class BaseBitcoinAddressRecord { + BaseBitcoinAddressRecord( this.address, { required this.index, this.isHidden = false, @@ -10,22 +12,15 @@ class BitcoinAddressRecord { int balance = 0, String name = '', bool isUsed = false, + required this.type, + required this.network, }) : _txCount = txCount, _balance = balance, _name = name, - _isUsed = isUsed; + _isUsed = Observable(isUsed); - factory BitcoinAddressRecord.fromJSON(String jsonSource) { - final decoded = json.decode(jsonSource) as Map; - - return BitcoinAddressRecord(decoded['address'] as String, - index: decoded['index'] as int, - isHidden: decoded['isHidden'] as bool? ?? false, - isUsed: decoded['isUsed'] as bool? ?? false, - txCount: decoded['txCount'] as int? ?? 0, - name: decoded['name'] as String? ?? '', - balance: decoded['balance'] as int? ?? 0); - } + @override + bool operator ==(Object o) => o is BaseBitcoinAddressRecord && address == o.address; final String address; bool isHidden; @@ -33,7 +28,8 @@ class BitcoinAddressRecord { int _txCount; int _balance; String _name; - bool _isUsed; + final Observable _isUsed; + BasedUtxoNetwork? network; int get txCount => _txCount; @@ -45,26 +41,125 @@ class BitcoinAddressRecord { set balance(int value) => _balance = value; - bool get isUsed => _isUsed; + bool get isUsed => _isUsed.value; - void setAsUsed() => _isUsed = true; + void setAsUsed() => _isUsed.value = true; void setNewName(String label) => _name = label; - @override - bool operator ==(Object o) => o is BitcoinAddressRecord && address == o.address; - - @override int get hashCode => address.hashCode; - String get cashAddr => bitbox.Address.toCashAddress(address); + BitcoinAddressType type; + String toJSON(); +} + +class BitcoinAddressRecord extends BaseBitcoinAddressRecord { + BitcoinAddressRecord( + super.address, { + required super.index, + super.isHidden = false, + super.txCount = 0, + super.balance = 0, + super.name = '', + super.isUsed = false, + required super.type, + String? scriptHash, + required super.network, + }) : scriptHash = scriptHash ?? + (network != null ? BitcoinAddressUtils.scriptHash(address, network: network) : null); + + factory BitcoinAddressRecord.fromJSON(String jsonSource, {BasedUtxoNetwork? network}) { + final decoded = json.decode(jsonSource) as Map; + + return BitcoinAddressRecord( + decoded['address'] as String, + index: decoded['index'] as int, + isHidden: decoded['isHidden'] as bool? ?? false, + isUsed: decoded['isUsed'] as bool? ?? false, + txCount: decoded['txCount'] as int? ?? 0, + name: decoded['name'] as String? ?? '', + balance: decoded['balance'] as int? ?? 0, + type: decoded['type'] != null && decoded['type'] != '' + ? BitcoinAddressType.values + .firstWhere((type) => type.toString() == decoded['type'] as String) + : SegwitAddresType.p2wpkh, + scriptHash: decoded['scriptHash'] as String?, + network: network, + ); + } + + String? scriptHash; + + String getScriptHash(BasedUtxoNetwork network) { + if (scriptHash != null) return scriptHash!; + scriptHash = BitcoinAddressUtils.scriptHash(address, network: network); + return scriptHash!; + } + + @override String toJSON() => json.encode({ 'address': address, 'index': index, 'isHidden': isHidden, + 'isUsed': isUsed, 'txCount': txCount, 'name': name, - 'isUsed': isUsed, 'balance': balance, + 'type': type.toString(), + 'scriptHash': scriptHash, + }); +} + +class BitcoinSilentPaymentAddressRecord extends BaseBitcoinAddressRecord { + BitcoinSilentPaymentAddressRecord( + super.address, { + required super.index, + super.isHidden = false, + super.txCount = 0, + super.balance = 0, + super.name = '', + super.isUsed = false, + required this.silentPaymentTweak, + required super.network, + required super.type, + }) : super(); + + factory BitcoinSilentPaymentAddressRecord.fromJSON(String jsonSource, + {BasedUtxoNetwork? network}) { + final decoded = json.decode(jsonSource) as Map; + + return BitcoinSilentPaymentAddressRecord( + decoded['address'] as String, + index: decoded['index'] as int, + isHidden: decoded['isHidden'] as bool? ?? false, + isUsed: decoded['isUsed'] as bool? ?? false, + txCount: decoded['txCount'] as int? ?? 0, + name: decoded['name'] as String? ?? '', + balance: decoded['balance'] as int? ?? 0, + network: (decoded['network'] as String?) == null + ? network + : BasedUtxoNetwork.fromName(decoded['network'] as String), + silentPaymentTweak: decoded['silent_payment_tweak'] as String?, + type: decoded['type'] != null && decoded['type'] != '' + ? BitcoinAddressType.values + .firstWhere((type) => type.toString() == decoded['type'] as String) + : SilentPaymentsAddresType.p2sp, + ); + } + + final String? silentPaymentTweak; + + @override + String toJSON() => json.encode({ + 'address': address, + 'index': index, + 'isHidden': isHidden, + 'isUsed': isUsed, + 'txCount': txCount, + 'name': name, + 'balance': balance, + 'type': type.toString(), + 'network': network?.value, + 'silent_payment_tweak': silentPaymentTweak, }); } diff --git a/cw_bitcoin/lib/bitcoin_commit_transaction_exception.dart b/cw_bitcoin/lib/bitcoin_commit_transaction_exception.dart index 3e21bae81..7bf488f3f 100644 --- a/cw_bitcoin/lib/bitcoin_commit_transaction_exception.dart +++ b/cw_bitcoin/lib/bitcoin_commit_transaction_exception.dart @@ -1,4 +1,8 @@ class BitcoinCommitTransactionException implements Exception { + String errorMessage; + BitcoinCommitTransactionException(this.errorMessage); + @override - String toString() => 'Transaction commit is failed.'; -} \ No newline at end of file + String toString() => errorMessage; +} + diff --git a/cw_bitcoin/lib/bitcoin_hardware_wallet_service.dart b/cw_bitcoin/lib/bitcoin_hardware_wallet_service.dart new file mode 100644 index 000000000..c8715b239 --- /dev/null +++ b/cw_bitcoin/lib/bitcoin_hardware_wallet_service.dart @@ -0,0 +1,46 @@ +import 'dart:async'; + +import 'package:bitcoin_base/bitcoin_base.dart'; +import 'package:blockchain_utils/blockchain_utils.dart'; +import 'package:cw_bitcoin/utils.dart'; +import 'package:cw_core/hardware/hardware_account_data.dart'; +import 'package:ledger_bitcoin/ledger_bitcoin.dart'; +import 'package:ledger_flutter_plus/ledger_flutter_plus.dart'; +import 'package:cw_core/utils/print_verbose.dart'; + +class BitcoinHardwareWalletService { + BitcoinHardwareWalletService(this.ledgerConnection); + + final LedgerConnection ledgerConnection; + + Future> getAvailableAccounts( + {int index = 0, int limit = 5}) async { + final bitcoinLedgerApp = BitcoinLedgerApp(ledgerConnection); + + final masterFp = await bitcoinLedgerApp.getMasterFingerprint(); + + final accounts = []; + final indexRange = List.generate(limit, (i) => i + index); + + for (final i in indexRange) { + final derivationPath = "m/84'/0'/$i'"; + final xpub = + await bitcoinLedgerApp.getXPubKey(derivationPath: derivationPath); + Bip32Slip10Secp256k1 hd = + Bip32Slip10Secp256k1.fromExtendedKey(xpub).childKey(Bip32KeyIndex(0)); + + final address = generateP2WPKHAddress( + hd: hd, index: 0, network: BitcoinNetwork.mainnet); + + accounts.add(HardwareAccountData( + address: address, + accountIndex: i, + derivationPath: derivationPath, + masterFingerprint: masterFp, + xpub: xpub, + )); + } + + return accounts; + } +} diff --git a/cw_bitcoin/lib/bitcoin_mnemonic.dart b/cw_bitcoin/lib/bitcoin_mnemonic.dart index 9163fcb11..21ff3891e 100644 --- a/cw_bitcoin/lib/bitcoin_mnemonic.dart +++ b/cw_bitcoin/lib/bitcoin_mnemonic.dart @@ -1,12 +1,14 @@ import 'dart:convert'; import 'dart:math'; import 'dart:typed_data'; + import 'package:crypto/crypto.dart'; -import 'package:unorm_dart/unorm_dart.dart' as unorm; import 'package:cryptography/cryptography.dart' as cryptography; import 'package:cw_core/sec_random_native.dart'; +import 'package:cw_core/utils/text_normalizer.dart'; const segwit = '100'; +const mweb = 'eb'; final wordlist = englishWordlist; double logBase(num x, num base) => log(x) / log(base); @@ -59,11 +61,7 @@ void maskBytes(Uint8List bytes, int bits) { } } -String bufferToBin(Uint8List data) { - final q1 = data.map((e) => e.toRadixString(2).padLeft(8, '0')); - final q2 = q1.join(''); - return q2; -} +String bufferToBin(Uint8List data) => data.map((e) => e.toRadixString(2).padLeft(8, '0')).join(''); String encode(Uint8List data) { final dataBitLen = data.length * 8; @@ -90,8 +88,7 @@ List prefixMatches(String source, List prefixes) { return prefixes.map((prefix) => hx.startsWith(prefix.toLowerCase())).toList(); } -Future generateMnemonic( - {int strength = 264, String prefix = segwit}) async { +Future generateElectrumMnemonic({int strength = 264, String prefix = segwit}) async { final wordBitlen = logBase(wordlist.length, 2).ceil(); final wordCount = strength / wordBitlen; final byteCount = ((wordCount * wordBitlen).ceil() / 8).ceil(); @@ -106,22 +103,30 @@ Future generateMnemonic( return result; } -Future mnemonicToSeedBytes(String mnemonic, {String prefix = segwit}) async { - final pbkdf2 = cryptography.Pbkdf2( - macAlgorithm: cryptography.Hmac.sha512(), - iterations: 2048, - bits: 512); +Future checkIfMnemonicIsElectrum2(String mnemonic) async { + return prefixMatches(mnemonic, [segwit]).first; +} + +Future getMnemonicHash(String mnemonic) async { + final hmacSha512 = Hmac(sha512, utf8.encode('Seed version')); + final digest = hmacSha512.convert(utf8.encode(normalizeText(mnemonic))); + return digest.toString(); +} + +Future mnemonicToSeedBytes(String mnemonic, + {String prefix = segwit, String passphrase = ''}) async { + final pbkdf2 = + cryptography.Pbkdf2(macAlgorithm: cryptography.Hmac.sha512(), iterations: 2048, bits: 512); final text = normalizeText(mnemonic); - // pbkdf2.deriveKey(secretKey: secretKey, nonce: nonce) + final passphraseBytes = utf8.encode(normalizeText(passphrase)); final key = await pbkdf2.deriveKey( secretKey: cryptography.SecretKey(text.codeUnits), - nonce: 'electrum'.codeUnits); + nonce: [...'electrum'.codeUnits, ...passphraseBytes]); final bytes = await key.extractBytes(); return Uint8List.fromList(bytes); } -bool matchesAnyPrefix(String mnemonic) => - prefixMatches(mnemonic, [segwit]).any((el) => el); +bool matchesAnyPrefix(String mnemonic) => prefixMatches(mnemonic, [segwit, mweb]).any((el) => el); bool validateMnemonic(String mnemonic, {String prefix = segwit}) { try { @@ -131,123 +136,6 @@ bool validateMnemonic(String mnemonic, {String prefix = segwit}) { } } -final COMBININGCODEPOINTS = combiningcodepoints(); - -List combiningcodepoints() { - final source = '300:34e|350:36f|483:487|591:5bd|5bf|5c1|5c2|5c4|5c5|5c7|610:61a|64b:65f|670|' + - '6d6:6dc|6df:6e4|6e7|6e8|6ea:6ed|711|730:74a|7eb:7f3|816:819|81b:823|825:827|' + - '829:82d|859:85b|8d4:8e1|8e3:8ff|93c|94d|951:954|9bc|9cd|a3c|a4d|abc|acd|b3c|' + - 'b4d|bcd|c4d|c55|c56|cbc|ccd|d4d|dca|e38:e3a|e48:e4b|eb8|eb9|ec8:ecb|f18|f19|' + - 'f35|f37|f39|f71|f72|f74|f7a:f7d|f80|f82:f84|f86|f87|fc6|1037|1039|103a|108d|' + - '135d:135f|1714|1734|17d2|17dd|18a9|1939:193b|1a17|1a18|1a60|1a75:1a7c|1a7f|' + - '1ab0:1abd|1b34|1b44|1b6b:1b73|1baa|1bab|1be6|1bf2|1bf3|1c37|1cd0:1cd2|' + - '1cd4:1ce0|1ce2:1ce8|1ced|1cf4|1cf8|1cf9|1dc0:1df5|1dfb:1dff|20d0:20dc|20e1|' + - '20e5:20f0|2cef:2cf1|2d7f|2de0:2dff|302a:302f|3099|309a|a66f|a674:a67d|a69e|' + - 'a69f|a6f0|a6f1|a806|a8c4|a8e0:a8f1|a92b:a92d|a953|a9b3|a9c0|aab0|aab2:aab4|' + - 'aab7|aab8|aabe|aabf|aac1|aaf6|abed|fb1e|fe20:fe2f|101fd|102e0|10376:1037a|' + - '10a0d|10a0f|10a38:10a3a|10a3f|10ae5|10ae6|11046|1107f|110b9|110ba|11100:11102|' + - '11133|11134|11173|111c0|111ca|11235|11236|112e9|112ea|1133c|1134d|11366:1136c|' + - '11370:11374|11442|11446|114c2|114c3|115bf|115c0|1163f|116b6|116b7|1172b|11c3f|' + - '16af0:16af4|16b30:16b36|1bc9e|1d165:1d169|1d16d:1d172|1d17b:1d182|1d185:1d18b|' + - '1d1aa:1d1ad|1d242:1d244|1e000:1e006|1e008:1e018|1e01b:1e021|1e023|1e024|' + - '1e026:1e02a|1e8d0:1e8d6|1e944:1e94a'; - - return source.split('|').map((e) { - if (e.contains(':')) { - return e.split(':').map((hex) => int.parse(hex, radix: 16)); - } - - return int.parse(e, radix: 16); - }).fold([], (List acc, element) { - if (element is List) { - for (var i = element[0] as int; i <= (element[1] as int); i++) {} - } else if (element is int) { - acc.add(element); - } - - return acc; - }).toList(); -} - -String removeCombiningCharacters(String source) { - return source - .split('') - .where((char) => !COMBININGCODEPOINTS.contains(char.codeUnits.first)) - .join(''); -} - -bool isCJK(String char) { - final n = char.codeUnitAt(0); - - for (var x in CJKINTERVALS) { - final imin = x[0] as num; - final imax = x[1] as num; - - if (n >= imin && n <= imax) return true; - } - - return false; -} - -String removeCJKSpaces(String source) { - final splitted = source.split(''); - final filtered = []; - - for (var i = 0; i < splitted.length; i++) { - final char = splitted[i]; - final isSpace = char.trim() == ''; - final prevIsCJK = i != 0 && isCJK(splitted[i - 1]); - final nextIsCJK = i != splitted.length - 1 && isCJK(splitted[i + 1]); - - if (!(isSpace && prevIsCJK && nextIsCJK)) { - filtered.add(char); - } - } - - return filtered.join(''); -} - -String normalizeText(String source) { - final res = removeCombiningCharacters(unorm.nfkd(source).toLowerCase()) - .trim() - .split('/\s+/') - .join(' '); - - return removeCJKSpaces(res); -} - -const CJKINTERVALS = [ - [0x4e00, 0x9fff, 'CJK Unified Ideographs'], - [0x3400, 0x4dbf, 'CJK Unified Ideographs Extension A'], - [0x20000, 0x2a6df, 'CJK Unified Ideographs Extension B'], - [0x2a700, 0x2b73f, 'CJK Unified Ideographs Extension C'], - [0x2b740, 0x2b81f, 'CJK Unified Ideographs Extension D'], - [0xf900, 0xfaff, 'CJK Compatibility Ideographs'], - [0x2f800, 0x2fa1d, 'CJK Compatibility Ideographs Supplement'], - [0x3190, 0x319f, 'Kanbun'], - [0x2e80, 0x2eff, 'CJK Radicals Supplement'], - [0x2f00, 0x2fdf, 'CJK Radicals'], - [0x31c0, 0x31ef, 'CJK Strokes'], - [0x2ff0, 0x2fff, 'Ideographic Description Characters'], - [0xe0100, 0xe01ef, 'Variation Selectors Supplement'], - [0x3100, 0x312f, 'Bopomofo'], - [0x31a0, 0x31bf, 'Bopomofo Extended'], - [0xff00, 0xffef, 'Halfwidth and Fullwidth Forms'], - [0x3040, 0x309f, 'Hiragana'], - [0x30a0, 0x30ff, 'Katakana'], - [0x31f0, 0x31ff, 'Katakana Phonetic Extensions'], - [0x1b000, 0x1b0ff, 'Kana Supplement'], - [0xac00, 0xd7af, 'Hangul Syllables'], - [0x1100, 0x11ff, 'Hangul Jamo'], - [0xa960, 0xa97f, 'Hangul Jamo Extended A'], - [0xd7b0, 0xd7ff, 'Hangul Jamo Extended B'], - [0x3130, 0x318f, 'Hangul Compatibility Jamo'], - [0xa4d0, 0xa4ff, 'Lisu'], - [0x16f00, 0x16f9f, 'Miao'], - [0xa000, 0xa48f, 'Yi Syllables'], - [0xa490, 0xa4cf, 'Yi Radicals'], -]; - final englishWordlist = [ 'abandon', 'ability', @@ -2297,4 +2185,4 @@ final englishWordlist = [ 'zero', 'zone', 'zoo' -]; \ No newline at end of file +]; diff --git a/cw_bitcoin_cash/lib/src/mnemonic.dart b/cw_bitcoin/lib/bitcoin_mnemonics_bip39.dart similarity index 59% rename from cw_bitcoin_cash/lib/src/mnemonic.dart rename to cw_bitcoin/lib/bitcoin_mnemonics_bip39.dart index b1f1ee984..ff02e875c 100644 --- a/cw_bitcoin_cash/lib/src/mnemonic.dart +++ b/cw_bitcoin/lib/bitcoin_mnemonics_bip39.dart @@ -2,10 +2,11 @@ import 'dart:typed_data'; import 'package:bip39/bip39.dart' as bip39; -class Mnemonic { +class MnemonicBip39 { /// Generate bip39 mnemonic static String generate({int strength = 128}) => bip39.generateMnemonic(strength: strength); /// Create root seed from mnemonic - static Uint8List toSeed(String mnemonic) => bip39.mnemonicToSeed(mnemonic); + static Uint8List toSeed(String mnemonic, {String? passphrase}) => + bip39.mnemonicToSeed(mnemonic, passphrase: passphrase ?? ''); } diff --git a/cw_bitcoin/lib/bitcoin_receive_page_option.dart b/cw_bitcoin/lib/bitcoin_receive_page_option.dart new file mode 100644 index 000000000..07083e111 --- /dev/null +++ b/cw_bitcoin/lib/bitcoin_receive_page_option.dart @@ -0,0 +1,75 @@ +import 'package:bitcoin_base/bitcoin_base.dart'; +import 'package:cw_core/receive_page_option.dart'; + +class BitcoinReceivePageOption implements ReceivePageOption { + static const p2wpkh = BitcoinReceivePageOption._('Segwit (P2WPKH) (Default)'); + static const p2sh = BitcoinReceivePageOption._('Segwit-Compatible (P2SH)'); + static const p2tr = BitcoinReceivePageOption._('Taproot (P2TR)'); + static const p2wsh = BitcoinReceivePageOption._('Segwit (P2WSH)'); + static const p2pkh = BitcoinReceivePageOption._('Legacy (P2PKH)'); + static const mweb = BitcoinReceivePageOption._('MWEB'); + + static const silent_payments = BitcoinReceivePageOption._('Silent Payments'); + + const BitcoinReceivePageOption._(this.value); + + final String value; + + String toString() { + return value; + } + + static const all = [ + BitcoinReceivePageOption.silent_payments, + BitcoinReceivePageOption.p2wpkh, + BitcoinReceivePageOption.p2tr, + BitcoinReceivePageOption.p2wsh, + BitcoinReceivePageOption.p2sh, + BitcoinReceivePageOption.p2pkh + ]; + + static const allLitecoin = [ + BitcoinReceivePageOption.p2wpkh, + BitcoinReceivePageOption.mweb, + ]; + + BitcoinAddressType toType() { + switch (this) { + case BitcoinReceivePageOption.p2tr: + return SegwitAddresType.p2tr; + case BitcoinReceivePageOption.p2wsh: + return SegwitAddresType.p2wsh; + case BitcoinReceivePageOption.p2pkh: + return P2pkhAddressType.p2pkh; + case BitcoinReceivePageOption.p2sh: + return P2shAddressType.p2wpkhInP2sh; + case BitcoinReceivePageOption.silent_payments: + return SilentPaymentsAddresType.p2sp; + case BitcoinReceivePageOption.mweb: + return SegwitAddresType.mweb; + case BitcoinReceivePageOption.p2wpkh: + default: + return SegwitAddresType.p2wpkh; + } + } + + factory BitcoinReceivePageOption.fromType(BitcoinAddressType type) { + switch (type) { + case SegwitAddresType.p2tr: + return BitcoinReceivePageOption.p2tr; + case SegwitAddresType.p2wsh: + return BitcoinReceivePageOption.p2wsh; + case SegwitAddresType.mweb: + return BitcoinReceivePageOption.mweb; + case P2pkhAddressType.p2pkh: + return BitcoinReceivePageOption.p2pkh; + case P2shAddressType.p2wpkhInP2sh: + return BitcoinReceivePageOption.p2sh; + case SilentPaymentsAddresType.p2sp: + return BitcoinReceivePageOption.silent_payments; + case SegwitAddresType.p2wpkh: + default: + return BitcoinReceivePageOption.p2wpkh; + } + } +} diff --git a/cw_bitcoin/lib/bitcoin_transaction_credentials.dart b/cw_bitcoin/lib/bitcoin_transaction_credentials.dart index bd8f1763c..7d6894e14 100644 --- a/cw_bitcoin/lib/bitcoin_transaction_credentials.dart +++ b/cw_bitcoin/lib/bitcoin_transaction_credentials.dart @@ -1,10 +1,19 @@ import 'package:cw_bitcoin/bitcoin_transaction_priority.dart'; import 'package:cw_core/output_info.dart'; +import 'package:cw_core/unspent_coin_type.dart'; class BitcoinTransactionCredentials { - BitcoinTransactionCredentials(this.outputs, {required this.priority, this.feeRate}); + BitcoinTransactionCredentials( + this.outputs, { + required this.priority, + this.feeRate, + this.coinTypeToSpendFrom = UnspentCoinType.any, + this.payjoinUri, + }); final List outputs; final BitcoinTransactionPriority? priority; final int? feeRate; + final UnspentCoinType coinTypeToSpendFrom; + final String? payjoinUri; } diff --git a/cw_bitcoin/lib/bitcoin_transaction_no_inputs_exception.dart b/cw_bitcoin/lib/bitcoin_transaction_no_inputs_exception.dart deleted file mode 100644 index fac7e93c4..000000000 --- a/cw_bitcoin/lib/bitcoin_transaction_no_inputs_exception.dart +++ /dev/null @@ -1,4 +0,0 @@ -class BitcoinTransactionNoInputsException implements Exception { - @override - String toString() => 'Not enough inputs available. Please select more under Coin Control'; -} diff --git a/cw_bitcoin/lib/bitcoin_transaction_priority.dart b/cw_bitcoin/lib/bitcoin_transaction_priority.dart index 10953a2e0..d1f45a545 100644 --- a/cw_bitcoin/lib/bitcoin_transaction_priority.dart +++ b/cw_bitcoin/lib/bitcoin_transaction_priority.dart @@ -4,13 +4,15 @@ class BitcoinTransactionPriority extends TransactionPriority { const BitcoinTransactionPriority({required String title, required int raw}) : super(title: title, raw: raw); - static const List all = [fast, medium, slow]; + static const List all = [fast, medium, slow, custom]; static const BitcoinTransactionPriority slow = BitcoinTransactionPriority(title: 'Slow', raw: 0); static const BitcoinTransactionPriority medium = BitcoinTransactionPriority(title: 'Medium', raw: 1); static const BitcoinTransactionPriority fast = BitcoinTransactionPriority(title: 'Fast', raw: 2); + static const BitcoinTransactionPriority custom = + BitcoinTransactionPriority(title: 'Custom', raw: 3); static BitcoinTransactionPriority deserialize({required int raw}) { switch (raw) { @@ -20,6 +22,8 @@ class BitcoinTransactionPriority extends TransactionPriority { return medium; case 2: return fast; + case 3: + return custom; default: throw Exception('Unexpected token: $raw for BitcoinTransactionPriority deserialize'); } @@ -33,13 +37,16 @@ class BitcoinTransactionPriority extends TransactionPriority { switch (this) { case BitcoinTransactionPriority.slow: - label = 'Slow ~24hrs'; // '${S.current.transaction_priority_slow} ~24hrs'; + label = 'Slow ~24hrs+'; // '${S.current.transaction_priority_slow} ~24hrs'; break; case BitcoinTransactionPriority.medium: label = 'Medium'; // S.current.transaction_priority_medium; break; case BitcoinTransactionPriority.fast: - label = 'Fast'; // S.current.transaction_priority_fast; + label = 'Fast'; + break; // S.current.transaction_priority_fast; + case BitcoinTransactionPriority.custom: + label = 'Custom'; break; default: break; @@ -48,7 +55,10 @@ class BitcoinTransactionPriority extends TransactionPriority { return label; } - String labelWithRate(int rate) => '${toString()} ($rate ${units}/byte)'; + String labelWithRate(int rate, int? customRate) { + final rateValue = this == custom ? customRate ??= 0 : rate; + return '${toString()} ($rateValue ${units}/byte)'; + } } class LitecoinTransactionPriority extends BitcoinTransactionPriority { @@ -77,7 +87,7 @@ class LitecoinTransactionPriority extends BitcoinTransactionPriority { } @override - String get units => 'Latoshi'; + String get units => 'Litoshi'; @override String toString() { diff --git a/cw_bitcoin/lib/bitcoin_transaction_wrong_balance_exception.dart b/cw_bitcoin/lib/bitcoin_transaction_wrong_balance_exception.dart deleted file mode 100644 index 3f379bea0..000000000 --- a/cw_bitcoin/lib/bitcoin_transaction_wrong_balance_exception.dart +++ /dev/null @@ -1,10 +0,0 @@ -import 'package:cw_core/crypto_currency.dart'; - -class BitcoinTransactionWrongBalanceException implements Exception { - BitcoinTransactionWrongBalanceException(this.currency); - - final CryptoCurrency currency; - - @override - String toString() => 'You do not have enough ${currency.title} to send this amount.'; -} \ No newline at end of file diff --git a/cw_bitcoin/lib/bitcoin_unspent.dart b/cw_bitcoin/lib/bitcoin_unspent.dart index 9c198c27c..3691a7a22 100644 --- a/cw_bitcoin/lib/bitcoin_unspent.dart +++ b/cw_bitcoin/lib/bitcoin_unspent.dart @@ -2,14 +2,66 @@ import 'package:cw_bitcoin/bitcoin_address_record.dart'; import 'package:cw_core/unspent_transaction_output.dart'; class BitcoinUnspent extends Unspent { - BitcoinUnspent(BitcoinAddressRecord addressRecord, String hash, int value, int vout) + BitcoinUnspent(BaseBitcoinAddressRecord addressRecord, String hash, int value, int vout) : bitcoinAddressRecord = addressRecord, super(addressRecord.address, hash, value, vout, null); - factory BitcoinUnspent.fromJSON( - BitcoinAddressRecord address, Map json) => - BitcoinUnspent(address, json['tx_hash'] as String, json['value'] as int, - json['tx_pos'] as int); + factory BitcoinUnspent.fromJSON(BaseBitcoinAddressRecord? address, Map json) => + BitcoinUnspent( + address ?? BitcoinAddressRecord.fromJSON(json['address_record'].toString()), + json['tx_hash'] as String, + json['value'] as int, + json['tx_pos'] as int, + ); - final BitcoinAddressRecord bitcoinAddressRecord; + Map toJson() { + final json = { + 'address_record': bitcoinAddressRecord.toJSON(), + 'tx_hash': hash, + 'value': value, + 'tx_pos': vout, + }; + return json; + } + + final BaseBitcoinAddressRecord bitcoinAddressRecord; +} + +class BitcoinSilentPaymentsUnspent extends BitcoinUnspent { + BitcoinSilentPaymentsUnspent( + BitcoinSilentPaymentAddressRecord addressRecord, + String hash, + int value, + int vout, { + required this.silentPaymentTweak, + required this.silentPaymentLabel, + }) : super(addressRecord, hash, value, vout); + + @override + factory BitcoinSilentPaymentsUnspent.fromJSON( + BitcoinSilentPaymentAddressRecord? address, Map json) => + BitcoinSilentPaymentsUnspent( + address ?? BitcoinSilentPaymentAddressRecord.fromJSON(json['address_record'].toString()), + json['tx_hash'] as String, + json['value'] as int, + json['tx_pos'] as int, + silentPaymentTweak: json['silent_payment_tweak'] as String?, + silentPaymentLabel: json['silent_payment_label'] as String?, + ); + + @override + Map toJson() { + final json = { + 'address_record': bitcoinAddressRecord.toJSON(), + 'tx_hash': hash, + 'value': value, + 'tx_pos': vout, + 'silent_payment_tweak': silentPaymentTweak, + 'silent_payment_label': silentPaymentLabel, + }; + return json; + } + + String? silentPaymentTweak; + String? silentPaymentLabel; } diff --git a/cw_bitcoin/lib/bitcoin_wallet.dart b/cw_bitcoin/lib/bitcoin_wallet.dart index 9cdb78f2d..9231022f6 100644 --- a/cw_bitcoin/lib/bitcoin_wallet.dart +++ b/cw_bitcoin/lib/bitcoin_wallet.dart @@ -1,95 +1,453 @@ -import 'package:cw_bitcoin/bitcoin_mnemonic.dart'; -import 'package:cw_core/crypto_currency.dart'; -import 'package:cw_core/unspent_coins_info.dart'; -import 'package:hive/hive.dart'; -import 'package:mobx/mobx.dart'; -import 'package:flutter/foundation.dart'; -import 'package:bitcoin_flutter/bitcoin_flutter.dart' as bitcoin; -import 'package:cw_bitcoin/electrum_wallet_snapshot.dart'; -import 'package:cw_bitcoin/electrum_wallet.dart'; -import 'package:cw_core/wallet_info.dart'; +import 'dart:convert'; + +import 'package:bip39/bip39.dart' as bip39; +import 'package:bitcoin_base/bitcoin_base.dart'; +import 'package:blockchain_utils/blockchain_utils.dart'; +import 'package:cw_bitcoin/address_from_output.dart'; import 'package:cw_bitcoin/bitcoin_address_record.dart'; -import 'package:cw_bitcoin/electrum_balance.dart'; +import 'package:cw_bitcoin/bitcoin_mnemonic.dart'; +import 'package:cw_bitcoin/bitcoin_transaction_credentials.dart'; import 'package:cw_bitcoin/bitcoin_wallet_addresses.dart'; +import 'package:cw_bitcoin/electrum_balance.dart'; +import 'package:cw_bitcoin/electrum_derivations.dart'; +import 'package:cw_bitcoin/electrum_wallet.dart'; +import 'package:cw_bitcoin/electrum_wallet_snapshot.dart'; +import 'package:cw_bitcoin/payjoin/manager.dart'; +import 'package:cw_bitcoin/payjoin/storage.dart'; +import 'package:cw_bitcoin/pending_bitcoin_transaction.dart'; +import 'package:cw_bitcoin/psbt/signer.dart'; +import 'package:cw_bitcoin/psbt/transaction_builder.dart'; +import 'package:cw_bitcoin/psbt/v0_deserialize.dart'; +import 'package:cw_bitcoin/psbt/v0_finalizer.dart'; +import 'package:cw_core/crypto_currency.dart'; +import 'package:cw_core/encryption_file_utils.dart'; +import 'package:cw_core/payjoin_session.dart'; +import 'package:cw_core/pending_transaction.dart'; +import 'package:cw_core/unspent_coins_info.dart'; +import 'package:cw_core/wallet_info.dart'; +import 'package:cw_core/wallet_keys_file.dart'; +import 'package:flutter/foundation.dart'; +import 'package:hive/hive.dart'; +import 'package:ledger_bitcoin/ledger_bitcoin.dart'; +import 'package:ledger_bitcoin/psbt.dart'; +import 'package:ledger_flutter_plus/ledger_flutter_plus.dart'; +import 'package:mobx/mobx.dart'; part 'bitcoin_wallet.g.dart'; class BitcoinWallet = BitcoinWalletBase with _$BitcoinWallet; abstract class BitcoinWalletBase extends ElectrumWallet with Store { - BitcoinWalletBase( - {required String mnemonic, - required String password, - required WalletInfo walletInfo, - required Box unspentCoinsInfo, - required Uint8List seedBytes, - List? initialAddresses, - ElectrumBalance? initialBalance, - int initialRegularAddressIndex = 0, - int initialChangeAddressIndex = 0}) - : super( - mnemonic: mnemonic, - password: password, - walletInfo: walletInfo, - unspentCoinsInfo: unspentCoinsInfo, - networkType: bitcoin.bitcoin, - initialAddresses: initialAddresses, - initialBalance: initialBalance, - seedBytes: seedBytes, - currency: CryptoCurrency.btc) { - walletAddresses = BitcoinWalletAddresses( - walletInfo, - electrumClient: electrumClient, + BitcoinWalletBase({ + required String password, + required WalletInfo walletInfo, + required Box unspentCoinsInfo, + required Box payjoinBox, + required EncryptionFileUtils encryptionFileUtils, + Uint8List? seedBytes, + String? mnemonic, + String? xpub, + String? addressPageType, + BasedUtxoNetwork? networkParam, + List? initialAddresses, + ElectrumBalance? initialBalance, + Map? initialRegularAddressIndex, + Map? initialChangeAddressIndex, + String? passphrase, + List? initialSilentAddresses, + int initialSilentAddressIndex = 0, + bool? alwaysScan, + }) : super( + mnemonic: mnemonic, + passphrase: passphrase, + xpub: xpub, + password: password, + walletInfo: walletInfo, + unspentCoinsInfo: unspentCoinsInfo, + network: networkParam == null + ? BitcoinNetwork.mainnet + : networkParam == BitcoinNetwork.mainnet + ? BitcoinNetwork.mainnet + : BitcoinNetwork.testnet, + initialAddresses: initialAddresses, + initialBalance: initialBalance, + seedBytes: seedBytes, + encryptionFileUtils: encryptionFileUtils, + currency: networkParam == BitcoinNetwork.testnet + ? CryptoCurrency.tbtc + : CryptoCurrency.btc, + alwaysScan: alwaysScan, + ) { + // in a standard BIP44 wallet, mainHd derivation path = m/84'/0'/0'/0 (account 0, index unspecified here) + // the sideHd derivation path = m/84'/0'/0'/1 (account 1, index unspecified here) + // String derivationPath = walletInfo.derivationInfo!.derivationPath!; + // String sideDerivationPath = derivationPath.substring(0, derivationPath.length - 1) + "1"; + // final hd = bitcoin.HDWallet.fromSeed(seedBytes, network: networkType); + + payjoinManager = PayjoinManager(PayjoinStorage(payjoinBox), this); + walletAddresses = BitcoinWalletAddresses(walletInfo, initialAddresses: initialAddresses, initialRegularAddressIndex: initialRegularAddressIndex, initialChangeAddressIndex: initialChangeAddressIndex, + initialSilentAddresses: initialSilentAddresses, + initialSilentAddressIndex: initialSilentAddressIndex, mainHd: hd, - sideHd: bitcoin.HDWallet.fromSeed(seedBytes, network: networkType) - .derivePath("m/0'/1"), - networkType: networkType); + sideHd: accountHD.childKey(Bip32KeyIndex(1)), + network: networkParam ?? network, + masterHd: + seedBytes != null ? Bip32Slip10Secp256k1.fromSeed(seedBytes) : null, + isHardwareWallet: walletInfo.isHardwareWallet, + payjoinManager: payjoinManager); + autorun((_) { - this.walletAddresses.isEnabledAutoGenerateSubaddress = this.isEnabledAutoGenerateSubaddress; + this.walletAddresses.isEnabledAutoGenerateSubaddress = + this.isEnabledAutoGenerateSubaddress; }); } + @override + bool get hasRescan => true; + static Future create({ required String mnemonic, required String password, required WalletInfo walletInfo, required Box unspentCoinsInfo, + required Box payjoinBox, + required EncryptionFileUtils encryptionFileUtils, + String? passphrase, + String? addressPageType, + BasedUtxoNetwork? network, List? initialAddresses, + List? initialSilentAddresses, ElectrumBalance? initialBalance, - int initialRegularAddressIndex = 0, - int initialChangeAddressIndex = 0 + Map? initialRegularAddressIndex, + Map? initialChangeAddressIndex, + int initialSilentAddressIndex = 0, }) async { + late Uint8List seedBytes; + + switch (walletInfo.derivationInfo?.derivationType) { + case DerivationType.bip39: + seedBytes = await bip39.mnemonicToSeed( + mnemonic, + passphrase: passphrase ?? "", + ); + break; + case DerivationType.electrum: + default: + seedBytes = + await mnemonicToSeedBytes(mnemonic, passphrase: passphrase ?? ""); + break; + } + return BitcoinWallet( - mnemonic: mnemonic, - password: password, - walletInfo: walletInfo, - unspentCoinsInfo: unspentCoinsInfo, - initialAddresses: initialAddresses, - initialBalance: initialBalance, - seedBytes: await mnemonicToSeedBytes(mnemonic), - initialRegularAddressIndex: initialRegularAddressIndex, - initialChangeAddressIndex: initialChangeAddressIndex); + mnemonic: mnemonic, + passphrase: passphrase ?? "", + password: password, + walletInfo: walletInfo, + unspentCoinsInfo: unspentCoinsInfo, + initialAddresses: initialAddresses, + initialSilentAddresses: initialSilentAddresses, + initialSilentAddressIndex: initialSilentAddressIndex, + initialBalance: initialBalance, + encryptionFileUtils: encryptionFileUtils, + seedBytes: seedBytes, + initialRegularAddressIndex: initialRegularAddressIndex, + initialChangeAddressIndex: initialChangeAddressIndex, + addressPageType: addressPageType, + networkParam: network, + payjoinBox: payjoinBox, + ); } static Future open({ required String name, required WalletInfo walletInfo, required Box unspentCoinsInfo, + required Box payjoinBox, required String password, + required EncryptionFileUtils encryptionFileUtils, + required bool alwaysScan, }) async { - final snp = await ElectrumWallletSnapshot.load(name, walletInfo.type, password); + final network = walletInfo.network != null + ? BasedUtxoNetwork.fromName(walletInfo.network!) + : BitcoinNetwork.mainnet; + + final hasKeysFile = await WalletKeysFile.hasKeysFile(name, walletInfo.type); + + ElectrumWalletSnapshot? snp = null; + + try { + snp = await ElectrumWalletSnapshot.load( + encryptionFileUtils, + name, + walletInfo.type, + password, + network, + ); + } catch (e) { + if (!hasKeysFile) rethrow; + } + + final WalletKeysData keysData; + // Migrate wallet from the old scheme to then new .keys file scheme + if (!hasKeysFile) { + keysData = WalletKeysData( + mnemonic: snp!.mnemonic, + xPub: snp.xpub, + passphrase: snp.passphrase, + ); + } else { + keysData = await WalletKeysFile.readKeysFile( + name, + walletInfo.type, + password, + encryptionFileUtils, + ); + } + + walletInfo.derivationInfo ??= DerivationInfo(); + + // set the default if not present: + walletInfo.derivationInfo!.derivationPath ??= + snp?.derivationPath ?? electrum_path; + walletInfo.derivationInfo!.derivationType ??= + snp?.derivationType ?? DerivationType.electrum; + + Uint8List? seedBytes = null; + final mnemonic = keysData.mnemonic; + final passphrase = keysData.passphrase; + + if (mnemonic != null) { + switch (walletInfo.derivationInfo!.derivationType) { + case DerivationType.electrum: + seedBytes = + await mnemonicToSeedBytes(mnemonic, passphrase: passphrase ?? ""); + break; + case DerivationType.bip39: + default: + seedBytes = await bip39.mnemonicToSeed( + mnemonic, + passphrase: passphrase ?? '', + ); + break; + } + } + return BitcoinWallet( - mnemonic: snp.mnemonic, + mnemonic: mnemonic, + xpub: keysData.xPub, password: password, + passphrase: passphrase, walletInfo: walletInfo, unspentCoinsInfo: unspentCoinsInfo, - initialAddresses: snp.addresses, - initialBalance: snp.balance, - seedBytes: await mnemonicToSeedBytes(snp.mnemonic), - initialRegularAddressIndex: snp.regularAddressIndex, - initialChangeAddressIndex: snp.changeAddressIndex); + initialAddresses: snp?.addresses, + initialSilentAddresses: snp?.silentAddresses, + initialSilentAddressIndex: snp?.silentAddressIndex ?? 0, + initialBalance: snp?.balance, + encryptionFileUtils: encryptionFileUtils, + seedBytes: seedBytes, + initialRegularAddressIndex: snp?.regularAddressIndex, + initialChangeAddressIndex: snp?.changeAddressIndex, + addressPageType: snp?.addressPageType, + networkParam: network, + alwaysScan: alwaysScan, + payjoinBox: payjoinBox); } -} \ No newline at end of file + + LedgerConnection? _ledgerConnection; + BitcoinLedgerApp? _bitcoinLedgerApp; + + @override + void setLedgerConnection(LedgerConnection connection) { + _ledgerConnection = connection; + _bitcoinLedgerApp = BitcoinLedgerApp(_ledgerConnection!, + derivationPath: walletInfo.derivationInfo!.derivationPath!); + } + + @override + Future close({bool shouldCleanup = false}) async { + payjoinManager.cleanupSessions(); + super.close(shouldCleanup: shouldCleanup); + } + + late final PayjoinManager payjoinManager; + + bool get isPayjoinAvailable => unspentCoinsInfo.values + .where((element) => + element.walletId == id && element.isSending && !element.isFrozen) + .isNotEmpty; + + Future buildPsbt({ + required List outputs, + required BigInt fee, + required BasedUtxoNetwork network, + required List utxos, + required Map publicKeys, + required Uint8List masterFingerprint, + String? memo, + bool enableRBF = false, + BitcoinOrdering inputOrdering = BitcoinOrdering.bip69, + BitcoinOrdering outputOrdering = BitcoinOrdering.bip69, + }) async { + final psbtReadyInputs = []; + for (final utxo in utxos) { + final rawTx = + await electrumClient.getTransactionHex(hash: utxo.utxo.txHash); + final publicKeyAndDerivationPath = + publicKeys[utxo.ownerDetails.address.pubKeyHash()]!; + + psbtReadyInputs.add(PSBTReadyUtxoWithAddress( + utxo: utxo.utxo, + rawTx: rawTx, + ownerDetails: utxo.ownerDetails, + ownerDerivationPath: publicKeyAndDerivationPath.derivationPath, + ownerMasterFingerprint: masterFingerprint, + ownerPublicKey: publicKeyAndDerivationPath.publicKey, + )); + } + + return PSBTTransactionBuild( + inputs: psbtReadyInputs, outputs: outputs, enableRBF: enableRBF) + .psbt; + } + + @override + Future buildHardwareWalletTransaction({ + required List outputs, + required BigInt fee, + required BasedUtxoNetwork network, + required List utxos, + required Map publicKeys, + String? memo, + bool enableRBF = false, + BitcoinOrdering inputOrdering = BitcoinOrdering.bip69, + BitcoinOrdering outputOrdering = BitcoinOrdering.bip69, + }) async { + final masterFingerprint = await _bitcoinLedgerApp!.getMasterFingerprint(); + + final psbt = await buildPsbt( + outputs: outputs, + fee: fee, + network: network, + utxos: utxos, + publicKeys: publicKeys, + masterFingerprint: masterFingerprint, + memo: memo, + enableRBF: enableRBF, + inputOrdering: inputOrdering, + outputOrdering: outputOrdering, + ); + + final rawHex = await _bitcoinLedgerApp!.signPsbt(psbt: psbt); + return BtcTransaction.fromRaw(BytesUtils.toHexString(rawHex)); + } + + @override + Future createTransaction(Object credentials) async { + credentials = credentials as BitcoinTransactionCredentials; + + final tx = (await super.createTransaction(credentials)) + as PendingBitcoinTransaction; + + final payjoinUri = credentials.payjoinUri; + if (payjoinUri == null) return tx; + + final transaction = await buildPsbt( + utxos: tx.utxos, + outputs: tx.outputs + .map((e) => BitcoinOutput( + address: addressFromScript(e.scriptPubKey), + value: e.amount, + isSilentPayment: e.isSilentPayment, + isChange: e.isChange, + )) + .toList(), + fee: BigInt.from(tx.fee), + network: network, + memo: credentials.outputs.first.memo, + outputOrdering: BitcoinOrdering.none, + enableRBF: true, + publicKeys: tx.publicKeys!, + masterFingerprint: Uint8List(0)); + + final originalPsbt = await signPsbt( + base64.encode(transaction.asPsbtV0()), getUtxoWithPrivateKeys()); + + tx.commitOverride = () async { + final sender = await payjoinManager.initSender( + payjoinUri, originalPsbt, int.parse(tx.feeRate)); + payjoinManager.spawnNewSender( + sender: sender, pjUrl: payjoinUri, amount: BigInt.from(tx.amount)); + }; + + return tx; + } + + List getUtxoWithPrivateKeys() => unspentCoins + .where((e) => (e.isSending && !e.isFrozen)) + .map((unspent) => UtxoWithPrivateKey.fromUnspent(unspent, this)) + .toList(); + + Future commitPsbt(String finalizedPsbt) { + final psbt = PsbtV2()..deserializeV0(base64.decode(finalizedPsbt)); + + final btcTx = + BtcTransaction.fromRaw(BytesUtils.toHexString(psbt.extract())); + + return PendingBitcoinTransaction( + btcTx, + type, + electrumClient: electrumClient, + amount: 0, + fee: 0, + feeRate: "", + network: network, + hasChange: true, + ).commit(); + } + + Future signPsbt( + String preProcessedPsbt, List utxos) async { + final psbt = PsbtV2()..deserializeV0(base64Decode(preProcessedPsbt)); + + await psbt.signWithUTXO(utxos, (txDigest, utxo, key, sighash) { + return utxo.utxo.isP2tr() + ? key.signTapRoot( + txDigest, + sighash: sighash, + tweak: utxo.utxo.isSilentPayment != true, + ) + : key.signInput(txDigest, sigHash: sighash); + }, (txId, vout) async { + final txHex = await electrumClient.getTransactionHex(hash: txId); + final output = BtcTransaction.fromRaw(txHex).outputs[vout]; + return TaprootAmountScriptPair(output.amount, output.scriptPubKey); + }); + + psbt.finalizeV0(); + return base64Encode(psbt.asPsbtV0()); + } + + @override + Future signMessage(String message, {String? address = null}) async { + if (walletInfo.isHardwareWallet) { + final addressEntry = address != null + ? walletAddresses.allAddresses + .firstWhere((element) => element.address == address) + : null; + final index = addressEntry?.index ?? 0; + final isChange = addressEntry?.isHidden == true ? 1 : 0; + final accountPath = walletInfo.derivationInfo?.derivationPath; + final derivationPath = + accountPath != null ? "$accountPath/$isChange/$index" : null; + + final signature = await _bitcoinLedgerApp!.signMessage( + message: ascii.encode(message), signDerivationPath: derivationPath); + return base64Encode(signature); + } + + return super.signMessage(message, address: address); + } +} diff --git a/cw_bitcoin/lib/bitcoin_wallet_addresses.dart b/cw_bitcoin/lib/bitcoin_wallet_addresses.dart index 36d37127d..d84d958be 100644 --- a/cw_bitcoin/lib/bitcoin_wallet_addresses.dart +++ b/cw_bitcoin/lib/bitcoin_wallet_addresses.dart @@ -1,34 +1,88 @@ -import 'package:bitcoin_flutter/bitcoin_flutter.dart' as bitcoin; -import 'package:cw_bitcoin/bitcoin_address_record.dart'; -import 'package:cw_bitcoin/electrum.dart'; +import 'package:bitcoin_base/bitcoin_base.dart'; +import 'package:blockchain_utils/bip/bip/bip32/bip32.dart'; import 'package:cw_bitcoin/electrum_wallet_addresses.dart'; +import 'package:cw_bitcoin/payjoin/manager.dart'; import 'package:cw_bitcoin/utils.dart'; +import 'package:cw_core/unspent_coin_type.dart'; +import 'package:cw_core/utils/print_verbose.dart'; import 'package:cw_core/wallet_info.dart'; import 'package:mobx/mobx.dart'; +import 'package:payjoin_flutter/receive.dart' as payjoin; part 'bitcoin_wallet_addresses.g.dart'; class BitcoinWalletAddresses = BitcoinWalletAddressesBase with _$BitcoinWalletAddresses; abstract class BitcoinWalletAddressesBase extends ElectrumWalletAddresses with Store { - BitcoinWalletAddressesBase(WalletInfo walletInfo, - {required bitcoin.HDWallet mainHd, - required bitcoin.HDWallet sideHd, - required bitcoin.NetworkType networkType, - required ElectrumClient electrumClient, - List? initialAddresses, - int initialRegularAddressIndex = 0, - int initialChangeAddressIndex = 0}) - : super(walletInfo, - initialAddresses: initialAddresses, - initialRegularAddressIndex: initialRegularAddressIndex, - initialChangeAddressIndex: initialChangeAddressIndex, - mainHd: mainHd, - sideHd: sideHd, - electrumClient: electrumClient, - networkType: networkType); + BitcoinWalletAddressesBase( + WalletInfo walletInfo, { + required super.mainHd, + required super.sideHd, + required super.network, + required super.isHardwareWallet, + required this.payjoinManager, + super.initialAddresses, + super.initialRegularAddressIndex, + super.initialChangeAddressIndex, + super.initialSilentAddresses, + super.initialSilentAddressIndex = 0, + super.masterHd, + }) : super(walletInfo); + + final PayjoinManager payjoinManager; + + payjoin.Receiver? currentPayjoinReceiver; + + @observable + String? payjoinEndpoint = null; @override - String getAddress({required int index, required bitcoin.HDWallet hd}) => - generateP2WPKHAddress(hd: hd, index: index, networkType: networkType); + String getAddress( + {required int index, + required Bip32Slip10Secp256k1 hd, + BitcoinAddressType? addressType, + UnspentCoinType coinTypeToSpendFrom = UnspentCoinType.any}) { + if (addressType == P2pkhAddressType.p2pkh) + return generateP2PKHAddress(hd: hd, index: index, network: network); + + if (addressType == SegwitAddresType.p2tr) + return generateP2TRAddress(hd: hd, index: index, network: network); + + if (addressType == SegwitAddresType.p2wsh) + return generateP2WSHAddress(hd: hd, index: index, network: network); + + if (addressType == P2shAddressType.p2wpkhInP2sh) + return generateP2SHAddress(hd: hd, index: index, network: network); + + return generateP2WPKHAddress(hd: hd, index: index, network: network); + } + + @action + Future initPayjoin() async { + try { + await payjoinManager.initPayjoin(); + currentPayjoinReceiver = await payjoinManager.getUnusedReceiver(primaryAddress); + payjoinEndpoint = (await currentPayjoinReceiver?.pjUri())?.pjEndpoint(); + + payjoinManager.resumeSessions(); + } catch (e) { + printV(e); + // Ignore Connectivity errors + if (!e.toString().contains("error sending request for url")) rethrow; + } + } + + @action + Future newPayjoinReceiver() async { + try { + currentPayjoinReceiver = await payjoinManager.getUnusedReceiver(primaryAddress); + payjoinEndpoint = (await currentPayjoinReceiver?.pjUri())?.pjEndpoint(); + + payjoinManager.spawnReceiver(receiver: currentPayjoinReceiver!); + } catch (e) { + printV(e); + // Ignore Connectivity errors + if (!e.toString().contains("error sending request for url")) rethrow; + } + } } diff --git a/cw_bitcoin/lib/bitcoin_wallet_creation_credentials.dart b/cw_bitcoin/lib/bitcoin_wallet_creation_credentials.dart index 37b272a1b..177d61e87 100644 --- a/cw_bitcoin/lib/bitcoin_wallet_creation_credentials.dart +++ b/cw_bitcoin/lib/bitcoin_wallet_creation_credentials.dart @@ -1,23 +1,65 @@ +import 'package:cw_core/hardware/hardware_account_data.dart'; import 'package:cw_core/wallet_credentials.dart'; import 'package:cw_core/wallet_info.dart'; class BitcoinNewWalletCredentials extends WalletCredentials { - BitcoinNewWalletCredentials({required String name, WalletInfo? walletInfo}) - : super(name: name, walletInfo: walletInfo); + BitcoinNewWalletCredentials({ + required String name, + WalletInfo? walletInfo, + String? password, + DerivationType? derivationType, + String? derivationPath, + String? passphrase, + this.mnemonic, + }) : super( + name: name, + walletInfo: walletInfo, + password: password, + passphrase: passphrase, + ); + + final String? mnemonic; } class BitcoinRestoreWalletFromSeedCredentials extends WalletCredentials { - BitcoinRestoreWalletFromSeedCredentials( - {required String name, required String password, required this.mnemonic, WalletInfo? walletInfo}) - : super(name: name, password: password, walletInfo: walletInfo); + BitcoinRestoreWalletFromSeedCredentials({ + required String name, + required String password, + required this.mnemonic, + WalletInfo? walletInfo, + required DerivationType derivationType, + required String derivationPath, + String? passphrase, + }) : super( + name: name, + password: password, + passphrase: passphrase, + walletInfo: walletInfo, + derivationInfo: DerivationInfo( + derivationType: derivationType, + derivationPath: derivationPath, + )); final String mnemonic; } class BitcoinRestoreWalletFromWIFCredentials extends WalletCredentials { - BitcoinRestoreWalletFromWIFCredentials( - {required String name, required String password, required this.wif, WalletInfo? walletInfo}) - : super(name: name, password: password, walletInfo: walletInfo); + BitcoinRestoreWalletFromWIFCredentials({ + required String name, + required String password, + required this.wif, + WalletInfo? walletInfo, + }) : super(name: name, password: password, walletInfo: walletInfo); final String wif; -} \ No newline at end of file +} + +class BitcoinRestoreWalletFromHardware extends WalletCredentials { + BitcoinRestoreWalletFromHardware({ + required String name, + required this.hwAccountData, + WalletInfo? walletInfo, + }) : super(name: name, walletInfo: walletInfo); + + final HardwareAccountData hwAccountData; +} diff --git a/cw_bitcoin/lib/bitcoin_wallet_service.dart b/cw_bitcoin/lib/bitcoin_wallet_service.dart index 736ec1044..317b25bcd 100644 --- a/cw_bitcoin/lib/bitcoin_wallet_service.dart +++ b/cw_bitcoin/lib/bitcoin_wallet_service.dart @@ -1,7 +1,11 @@ import 'dart:io'; +import 'package:bitcoin_base/bitcoin_base.dart'; import 'package:cw_bitcoin/bitcoin_mnemonic.dart'; -import 'package:cw_bitcoin/bitcoin_mnemonic_is_incorrect_exception.dart'; +import 'package:cw_bitcoin/bitcoin_mnemonics_bip39.dart'; +import 'package:cw_bitcoin/mnemonic_is_incorrect_exception.dart'; import 'package:cw_bitcoin/bitcoin_wallet_creation_credentials.dart'; +import 'package:cw_core/encryption_file_utils.dart'; +import 'package:cw_core/payjoin_session.dart'; import 'package:cw_core/unspent_coins_info.dart'; import 'package:cw_core/wallet_base.dart'; import 'package:cw_core/wallet_service.dart'; @@ -11,26 +15,57 @@ import 'package:cw_core/wallet_info.dart'; import 'package:cw_core/wallet_type.dart'; import 'package:hive/hive.dart'; import 'package:collection/collection.dart'; +import 'package:bip39/bip39.dart' as bip39; -class BitcoinWalletService extends WalletService { - BitcoinWalletService(this.walletInfoSource, this.unspentCoinsInfoSource); +class BitcoinWalletService extends WalletService< + BitcoinNewWalletCredentials, + BitcoinRestoreWalletFromSeedCredentials, + BitcoinRestoreWalletFromWIFCredentials, + BitcoinRestoreWalletFromHardware> { + BitcoinWalletService(this.walletInfoSource, this.unspentCoinsInfoSource, + this.payjoinSessionSource, this.alwaysScan, this.isDirect); final Box walletInfoSource; final Box unspentCoinsInfoSource; + final Box payjoinSessionSource; + final bool alwaysScan; + final bool isDirect; @override WalletType getType() => WalletType.bitcoin; @override - Future create(BitcoinNewWalletCredentials credentials) async { + Future create(BitcoinNewWalletCredentials credentials, {bool? isTestnet}) async { + final network = isTestnet == true ? BitcoinNetwork.testnet : BitcoinNetwork.mainnet; + credentials.walletInfo?.network = network.value; + + final String mnemonic; + switch ( credentials.walletInfo?.derivationInfo?.derivationType) { + case DerivationType.bip39: + final strength = credentials.seedPhraseLength == 24 ? 256 : 128; + + mnemonic = credentials.mnemonic ?? await MnemonicBip39.generate(strength: strength); + break; + case DerivationType.electrum: + default: + mnemonic = await generateElectrumMnemonic(); + break; + } + final wallet = await BitcoinWalletBase.create( - mnemonic: await generateMnemonic(), - password: credentials.password!, - walletInfo: credentials.walletInfo!, - unspentCoinsInfo: unspentCoinsInfoSource); + mnemonic: mnemonic, + password: credentials.password!, + passphrase: credentials.passphrase, + walletInfo: credentials.walletInfo!, + unspentCoinsInfo: unspentCoinsInfoSource, + payjoinBox: payjoinSessionSource, + network: network, + encryptionFileUtils: encryptionFileUtilsFor(isDirect), + ); + await wallet.save(); await wallet.init(); + return wallet; } @@ -44,20 +79,28 @@ class BitcoinWalletService extends WalletService info.id == WalletBase.idFor(name, getType()))!; try { final wallet = await BitcoinWalletBase.open( - password: password, - name: name, - walletInfo: walletInfo, - unspentCoinsInfo: unspentCoinsInfoSource); + password: password, + name: name, + walletInfo: walletInfo, + unspentCoinsInfo: unspentCoinsInfoSource, + payjoinBox: payjoinSessionSource, + alwaysScan: alwaysScan, + encryptionFileUtils: encryptionFileUtilsFor(isDirect), + ); await wallet.init(); saveBackup(name); return wallet; } catch (_) { await restoreWalletFilesFromBackup(name); final wallet = await BitcoinWalletBase.open( - password: password, - name: name, - walletInfo: walletInfo, - unspentCoinsInfo: unspentCoinsInfoSource); + password: password, + name: name, + walletInfo: walletInfo, + unspentCoinsInfo: unspentCoinsInfoSource, + payjoinBox: payjoinSessionSource, + alwaysScan: alwaysScan, + encryptionFileUtils: encryptionFileUtilsFor(isDirect), + ); await wallet.init(); return wallet; } @@ -69,6 +112,15 @@ class BitcoinWalletService extends WalletService info.id == WalletBase.idFor(wallet, getType()))!; await walletInfoSource.delete(walletInfo.key); + + final unspentCoinsToDelete = unspentCoinsInfoSource.values.where( + (unspentCoin) => unspentCoin.walletId == walletInfo.id).toList(); + + final keysToDelete = unspentCoinsToDelete.map((unspentCoin) => unspentCoin.key).toList(); + + if (keysToDelete.isNotEmpty) { + await unspentCoinsInfoSource.deleteAll(keysToDelete); + } } @override @@ -76,10 +128,14 @@ class BitcoinWalletService extends WalletService info.id == WalletBase.idFor(currentName, getType()))!; final currentWallet = await BitcoinWalletBase.open( - password: password, - name: currentName, - walletInfo: currentWalletInfo, - unspentCoinsInfo: unspentCoinsInfoSource); + password: password, + name: currentName, + walletInfo: currentWalletInfo, + unspentCoinsInfo: unspentCoinsInfoSource, + payjoinBox: payjoinSessionSource, + alwaysScan: alwaysScan, + encryptionFileUtils: encryptionFileUtilsFor(isDirect), + ); await currentWallet.renameWalletFiles(newName); await saveBackup(newName); @@ -92,20 +148,51 @@ class BitcoinWalletService extends WalletService restoreFromKeys(BitcoinRestoreWalletFromWIFCredentials credentials) async => + Future restoreFromHardwareWallet(BitcoinRestoreWalletFromHardware credentials, + {bool? isTestnet}) async { + final network = isTestnet == true ? BitcoinNetwork.testnet : BitcoinNetwork.mainnet; + credentials.walletInfo?.network = network.value; + credentials.walletInfo?.derivationInfo?.derivationPath = + credentials.hwAccountData.derivationPath; + final wallet = await BitcoinWallet( + password: credentials.password!, + xpub: credentials.hwAccountData.xpub, + walletInfo: credentials.walletInfo!, + unspentCoinsInfo: unspentCoinsInfoSource, + networkParam: network, + encryptionFileUtils: encryptionFileUtilsFor(isDirect), + payjoinBox: payjoinSessionSource, + ); + await wallet.save(); + await wallet.init(); + return wallet; + } + + @override + Future restoreFromKeys(BitcoinRestoreWalletFromWIFCredentials credentials, + {bool? isTestnet}) async => throw UnimplementedError(); @override - Future restoreFromSeed(BitcoinRestoreWalletFromSeedCredentials credentials) async { - if (!validateMnemonic(credentials.mnemonic)) { + Future restoreFromSeed(BitcoinRestoreWalletFromSeedCredentials credentials, + {bool? isTestnet}) async { + if (!validateMnemonic(credentials.mnemonic) && !bip39.validateMnemonic(credentials.mnemonic)) { throw BitcoinMnemonicIsIncorrectException(); } + final network = isTestnet == true ? BitcoinNetwork.testnet : BitcoinNetwork.mainnet; + credentials.walletInfo?.network = network.value; + final wallet = await BitcoinWalletBase.create( - password: credentials.password!, - mnemonic: credentials.mnemonic, - walletInfo: credentials.walletInfo!, - unspentCoinsInfo: unspentCoinsInfoSource); + password: credentials.password!, + passphrase: credentials.passphrase, + mnemonic: credentials.mnemonic, + walletInfo: credentials.walletInfo!, + unspentCoinsInfo: unspentCoinsInfoSource, + payjoinBox: payjoinSessionSource, + network: network, + encryptionFileUtils: encryptionFileUtilsFor(isDirect), + ); await wallet.save(); await wallet.init(); return wallet; diff --git a/cw_bitcoin/lib/electrum.dart b/cw_bitcoin/lib/electrum.dart index a05c251fe..2ddd30df6 100644 --- a/cw_bitcoin/lib/electrum.dart +++ b/cw_bitcoin/lib/electrum.dart @@ -2,15 +2,18 @@ import 'dart:async'; import 'dart:convert'; import 'dart:io'; import 'dart:typed_data'; -import 'package:bitcoin_flutter/bitcoin_flutter.dart'; +import 'package:bitcoin_base/bitcoin_base.dart'; import 'package:cw_bitcoin/bitcoin_amount_format.dart'; -import 'package:cw_bitcoin/script_hash.dart'; +import 'package:cw_core/utils/print_verbose.dart'; +import 'package:cw_core/utils/proxy_socket/abstract.dart'; +import 'package:cw_core/utils/proxy_wrapper.dart'; import 'package:flutter/foundation.dart'; import 'package:rxdart/rxdart.dart'; -import 'package:collection/collection.dart'; + +enum ConnectionStatus { connected, disconnected, connecting, failed } String jsonrpcparams(List params) { - final _params = params?.map((val) => '"${val.toString()}"')?.join(','); + final _params = params.map((val) => '"${val.toString()}"').join(','); return '[$_params]'; } @@ -22,10 +25,7 @@ String jsonrpc( '{"jsonrpc": "$version", "method": "$method", "id": "$id", "params": ${json.encode(params)}}\n'; class SocketTask { - SocketTask({ - required this.isSubscription, - this.completer, - this.subject}); + SocketTask({required this.isSubscription, this.completer, this.subject}); final Completer? completer; final BehaviorSubject? subject; @@ -37,53 +37,106 @@ class ElectrumClient { : _id = 0, _isConnected = false, _tasks = {}, + _errors = {}, unterminatedString = ''; static const connectionTimeout = Duration(seconds: 5); static const aliveTimerDuration = Duration(seconds: 4); bool get isConnected => _isConnected; - Socket? socket; - void Function(bool)? onConnectionStatusChange; + ProxySocket? socket; + void Function(ConnectionStatus)? onConnectionStatusChange; int _id; final Map _tasks; + Map get tasks => _tasks; + final Map _errors; + ConnectionStatus _connectionStatus = ConnectionStatus.disconnected; bool _isConnected; Timer? _aliveTimer; String unterminatedString; - Future connectToUri(Uri uri) async => + Uri? uri; + bool? useSSL; + + Future connectToUri(Uri uri, {bool? useSSL}) async { + this.uri = uri; + if (useSSL != null) { + this.useSSL = useSSL; + } await connect(host: uri.host, port: uri.port); + } Future connect({required String host, required int port}) async { + _setConnectionStatus(ConnectionStatus.connecting); + try { await socket?.close(); } catch (_) {} + socket = null; - socket = await SecureSocket.connect(host, port, - timeout: connectionTimeout, onBadCertificate: (_) => true); - _setIsConnected(true); - - socket!.listen((Uint8List event) { - try { - final msg = utf8.decode(event.toList()); - final messagesList = msg.split("\n"); - for (var message in messagesList) { - if (message.isEmpty) { - continue; - } - _parseResponse(message); - } - } catch (e) { - print(e.toString()); + final ssl = !(useSSL == false || (useSSL == null && uri.toString().contains("btc-electrum"))); + try { + socket = await ProxyWrapper().getSocksSocket(ssl, host, port, connectionTimeout: connectionTimeout); + } catch (e) { + printV("connect: $e"); + if (e is HandshakeException) { + useSSL = !(useSSL ?? false); } - }, onError: (Object error) { - print(error.toString()); - unterminatedString = ''; - _setIsConnected(false); - }, onDone: () { - unterminatedString = ''; - _setIsConnected(false); - }); + + if (_connectionStatus != ConnectionStatus.connecting) { + _setConnectionStatus(ConnectionStatus.failed); + } + + return; + } + + if (socket == null) { + if (_connectionStatus != ConnectionStatus.connecting) { + _setConnectionStatus(ConnectionStatus.failed); + } + + return; + } + + // use ping to determine actual connection status since we could've just not timed out yet: + // _setConnectionStatus(ConnectionStatus.connected); + socket!.listen( + (Uint8List event) { + try { + final msg = utf8.decode(event.toList()); + final messagesList = msg.split("\n"); + for (var message in messagesList) { + if (message.isEmpty) { + continue; + } + _parseResponse(message); + } + } catch (e) { + printV("socket.listen: $e"); + } + }, + onError: (Object error) { + final errorMsg = error.toString(); + printV(errorMsg); + unterminatedString = ''; + socket = null; + }, + onDone: () { + printV("SOCKET CLOSED!!!!!"); + unterminatedString = ''; + try { + if (host == socket?.address.host || socket == null) { + _setConnectionStatus(ConnectionStatus.disconnected); + socket?.destroy(); + socket = null; + } + } catch (e) { + printV("onDone: $e"); + } + }, + cancelOnError: true, + ); + keepAlive(); } @@ -104,27 +157,26 @@ class ElectrumClient { } if (isJSONStringCorrect(unterminatedString)) { - final response = - json.decode(unterminatedString) as Map; + final response = json.decode(unterminatedString) as Map; _handleResponse(response); unterminatedString = ''; } } on TypeError catch (e) { - if (!e.toString().contains('Map') && !e.toString().contains('Map')) { + if (!e.toString().contains('Map') && + !e.toString().contains('Map')) { return; } unterminatedString += message; if (isJSONStringCorrect(unterminatedString)) { - final response = - json.decode(unterminatedString) as Map; + final response = json.decode(unterminatedString) as Map; _handleResponse(response); // unterminatedString = null; unterminatedString = ''; } } catch (e) { - print(e.toString()); + printV("parse $e"); } } @@ -136,14 +188,14 @@ class ElectrumClient { Future ping() async { try { await callWithTimeout(method: 'server.ping'); - _setIsConnected(true); - } on RequestFailedTimeoutException catch (_) { - _setIsConnected(false); + _setConnectionStatus(ConnectionStatus.connected); + } catch (_) { + _setConnectionStatus(ConnectionStatus.disconnected); } } Future> version() => - call(method: 'server.version').then((dynamic result) { + call(method: 'server.version', params: ["", "1.4"]).then((dynamic result) { if (result is List) { return result.map((dynamic val) => val.toString()).toList(); } @@ -177,41 +229,21 @@ class ElectrumClient { return []; }); - Future>> getListUnspentWithAddress( - String address, NetworkType networkType) => - call( - method: 'blockchain.scripthash.listunspent', - params: [scriptHash(address, networkType: networkType)]) - .then((dynamic result) { - if (result is List) { - return result.map((dynamic val) { - if (val is Map) { - val['address'] = address; - return val; - } + Future>?> getListUnspent(String scriptHash) async { + final result = await call(method: 'blockchain.scripthash.listunspent', params: [scriptHash]); - return {}; - }).toList(); + if (result is List) { + return result.map((dynamic val) { + if (val is Map) { + return val; } - return []; - }); + return {}; + }).toList(); + } - Future>> getListUnspent(String scriptHash) => - call(method: 'blockchain.scripthash.listunspent', params: [scriptHash]) - .then((dynamic result) { - if (result is List) { - return result.map((dynamic val) { - if (val is Map) { - return val; - } - - return {}; - }).toList(); - } - - return []; - }); + return null; + } Future>> getMempool(String scriptHash) => call(method: 'blockchain.scripthash.get_mempool', params: [scriptHash]) @@ -229,10 +261,20 @@ class ElectrumClient { return []; }); - Future> getTransactionRaw( - {required String hash}) async => - callWithTimeout(method: 'blockchain.transaction.get', params: [hash, true], timeout: 10000) - .then((dynamic result) { + Future getTransaction({required String hash, required bool verbose}) async { + try { + final result = await callWithTimeout( + method: 'blockchain.transaction.get', params: [hash, verbose], timeout: 10000); + return result; + } on RequestFailedTimeoutException catch (_) { + return {}; + } catch (e) { + return {}; + } + } + + Future> getTransactionVerbose({required String hash}) => + getTransaction(hash: hash, verbose: true).then((dynamic result) { if (result is Map) { return result; } @@ -240,10 +282,8 @@ class ElectrumClient { return {}; }); - Future getTransactionHex( - {required String hash}) async => - callWithTimeout(method: 'blockchain.transaction.get', params: [hash, false], timeout: 10000) - .then((dynamic result) { + Future getTransactionHex({required String hash}) => + getTransaction(hash: hash, verbose: false).then((dynamic result) { if (result is String) { return result; } @@ -252,8 +292,13 @@ class ElectrumClient { }); Future broadcastTransaction( - {required String transactionRaw}) async => - call(method: 'blockchain.transaction.broadcast', params: [transactionRaw]) + {required String transactionRaw, + BasedUtxoNetwork? network, + Function(int)? idCallback}) async => + call( + method: 'blockchain.transaction.broadcast', + params: [transactionRaw], + idCallback: idCallback) .then((dynamic result) { if (result is String) { return result; @@ -262,19 +307,26 @@ class ElectrumClient { return ''; }); - Future> getMerkle( - {required String hash, required int height}) async => - await call( - method: 'blockchain.transaction.get_merkle', - params: [hash, height]) as Map; - - Future> getHeader({required int height}) async => - await call(method: 'blockchain.block.get_header', params: [height]) + Future> getMerkle({required String hash, required int height}) async => + await call(method: 'blockchain.transaction.get_merkle', params: [hash, height]) as Map; + Future> getHeader({required int height}) async => + await call(method: 'blockchain.block.get_header', params: [height]) as Map; + + BehaviorSubject? tweaksSubscribe({required int height, required int count}) { + return subscribe( + id: 'blockchain.tweaks.subscribe', + method: 'blockchain.tweaks.subscribe', + params: [height, count, false], + ); + } + + Future getTweaks({required int height}) async => + await callWithTimeout(method: 'blockchain.tweaks.subscribe', params: [height, 1, false]); + Future estimatefee({required int p}) => - call(method: 'blockchain.estimatefee', params: [p]) - .then((dynamic result) { + call(method: 'blockchain.estimatefee', params: [p]).then((dynamic result) { if (result is double) { return result; } @@ -314,20 +366,14 @@ class ElectrumClient { return []; }); - Future> feeRates() async { + Future> feeRates({BasedUtxoNetwork? network}) async { try { final topDoubleString = await estimatefee(p: 1); final middleDoubleString = await estimatefee(p: 5); - final bottomDoubleString = await estimatefee(p: 100); - final top = - (stringDoubleToBitcoinAmount(topDoubleString.toString()) / 1000) - .round(); - final middle = - (stringDoubleToBitcoinAmount(middleDoubleString.toString()) / 1000) - .round(); - final bottom = - (stringDoubleToBitcoinAmount(bottomDoubleString.toString()) / 1000) - .round(); + final bottomDoubleString = await estimatefee(p: 10); + final top = (stringDoubleToBitcoinAmount(topDoubleString.toString()) / 1000).round(); + final middle = (stringDoubleToBitcoinAmount(middleDoubleString.toString()) / 1000).round(); + final bottom = (stringDoubleToBitcoinAmount(bottomDoubleString.toString()) / 1000).round(); return [bottom, middle, top]; } catch (_) { @@ -335,6 +381,34 @@ class ElectrumClient { } } + // https://electrumx.readthedocs.io/en/latest/protocol-methods.html#blockchain-headers-subscribe + // example response: + // { + // "height": 520481, + // "hex": "00000020890208a0ae3a3892aa047c5468725846577cfcd9b512b50000000000000000005dc2b02f2d297a9064ee103036c14d678f9afc7e3d9409cf53fd58b82e938e8ecbeca05a2d2103188ce804c4" + // } + + Future getCurrentBlockChainTip() async { + try { + final result = await callWithTimeout(method: 'blockchain.headers.subscribe'); + if (result is Map) { + return result["height"] as int; + } + return null; + } on RequestFailedTimeoutException catch (_) { + return null; + } catch (e) { + printV("getCurrentBlockChainTip: ${e.toString()}"); + return null; + } + } + + BehaviorSubject? chainTipSubscribe() { + _id += 1; + return subscribe( + id: 'blockchain.headers.subscribe', method: 'blockchain.headers.subscribe'); + } + BehaviorSubject? scripthashUpdate(String scripthash) { _id += 1; return subscribe( @@ -344,25 +418,31 @@ class ElectrumClient { } BehaviorSubject? subscribe( - {required String id, - required String method, - List params = const []}) { + {required String id, required String method, List params = const []}) { try { + if (socket == null) { + return null; + } final subscription = BehaviorSubject(); _regisrySubscription(id, subscription); socket!.write(jsonrpc(method: method, id: _id, params: params)); return subscription; - } catch(e) { - print(e.toString()); + } catch (e) { + printV("subscribe $e"); return null; } } - Future call({required String method, List params = const []}) async { + Future call( + {required String method, List params = const [], Function(int)? idCallback}) async { + if (socket == null) { + return null; + } final completer = Completer(); _id += 1; final id = _id; + idCallback?.call(id); _registryTask(id, completer); socket!.write(jsonrpc(method: method, id: id, params: params)); @@ -370,10 +450,11 @@ class ElectrumClient { } Future callWithTimeout( - {required String method, - List params = const [], - int timeout = 4000}) async { + {required String method, List params = const [], int timeout = 5000}) async { try { + if (socket == null) { + return null; + } final completer = Completer(); _id += 1; final id = _id; @@ -386,19 +467,23 @@ class ElectrumClient { }); return completer.future; - } catch(e) { - print(e.toString()); + } catch (e) { + printV("callWithTimeout $e"); + rethrow; } } Future close() async { _aliveTimer?.cancel(); - await socket?.close(); + try { + await socket?.close(); + socket = null; + } catch (_) {} onConnectionStatusChange = null; } - void _registryTask(int id, Completer completer) => _tasks[id.toString()] = - SocketTask(completer: completer, isSubscription: false); + void _registryTask(int id, Completer completer) => + _tasks[id.toString()] = SocketTask(completer: completer, isSubscription: false); void _regisrySubscription(String id, BehaviorSubject subject) => _tasks[id] = SocketTask(subject: subject, isSubscription: true); @@ -419,9 +504,14 @@ class ElectrumClient { } } - void _methodHandler( - {required String method, required Map request}) { + void _methodHandler({required String method, required Map request}) { switch (method) { + case 'blockchain.headers.subscribe': + final params = request['params'] as List; + final id = 'blockchain.headers.subscribe'; + + _tasks[id]?.subject?.add(params.last); + break; case 'blockchain.scripthash.subscribe': final params = request['params'] as List; final scripthash = params.first as String?; @@ -429,17 +519,29 @@ class ElectrumClient { _tasks[id]?.subject?.add(params.last); break; + case 'blockchain.headers.subscribe': + final params = request['params'] as List; + _tasks[method]?.subject?.add(params.last); + break; + case 'blockchain.tweaks.subscribe': + final params = request['params'] as List; + _tasks[_tasks.keys.first]?.subject?.add(params.last); + break; default: break; } } - void _setIsConnected(bool isConnected) { - if (_isConnected != isConnected) { - onConnectionStatusChange?.call(isConnected); + void _setConnectionStatus(ConnectionStatus status) { + onConnectionStatusChange?.call(status); + _connectionStatus = status; + _isConnected = status == ConnectionStatus.connected; + if (!_isConnected) { + try { + socket?.destroy(); + } catch (_) {} + socket = null; } - - _isConnected = isConnected; } void _handleResponse(Map response) { @@ -447,15 +549,34 @@ class ElectrumClient { final id = response['id'] as String?; final result = response['result']; + try { + final error = response['error'] as Map?; + if (error != null) { + final errorMessage = error['message'] as String?; + if (errorMessage != null) { + _errors[id!] = errorMessage; + } + } + } catch (_) {} + + try { + final error = response['error'] as String?; + if (error != null) { + _errors[id!] = error; + } + } catch (_) {} + if (method is String) { _methodHandler(method: method, request: response); return; } - - if (id != null){ + + if (id != null) { _finish(id, result); } } + + String getErrorMessage(int id) => _errors[id.toString()] ?? ''; } // FIXME: move me diff --git a/cw_bitcoin/lib/electrum_balance.dart b/cw_bitcoin/lib/electrum_balance.dart index 165ea447e..aeb06f1f0 100644 --- a/cw_bitcoin/lib/electrum_balance.dart +++ b/cw_bitcoin/lib/electrum_balance.dart @@ -3,8 +3,18 @@ import 'package:cw_bitcoin/bitcoin_amount_format.dart'; import 'package:cw_core/balance.dart'; class ElectrumBalance extends Balance { - const ElectrumBalance({required this.confirmed, required this.unconfirmed, required this.frozen}) - : super(confirmed, unconfirmed); + ElectrumBalance({ + required this.confirmed, + required this.unconfirmed, + required this.frozen, + this.secondConfirmed = 0, + this.secondUnconfirmed = 0, + }) : super( + confirmed, + unconfirmed, + secondAvailable: secondConfirmed, + secondAdditional: secondUnconfirmed, + ); static ElectrumBalance? fromJSON(String? jsonSource) { if (jsonSource == null) { @@ -14,17 +24,22 @@ class ElectrumBalance extends Balance { final decoded = json.decode(jsonSource) as Map; return ElectrumBalance( - confirmed: decoded['confirmed'] as int? ?? 0, - unconfirmed: decoded['unconfirmed'] as int? ?? 0, - frozen: decoded['frozen'] as int? ?? 0); + confirmed: decoded['confirmed'] as int? ?? 0, + unconfirmed: decoded['unconfirmed'] as int? ?? 0, + frozen: decoded['frozen'] as int? ?? 0, + secondConfirmed: decoded['secondConfirmed'] as int? ?? 0, + secondUnconfirmed: decoded['secondUnconfirmed'] as int? ?? 0, + ); } - final int confirmed; - final int unconfirmed; + int confirmed; + int unconfirmed; final int frozen; + int secondConfirmed = 0; + int secondUnconfirmed = 0; @override - String get formattedAvailableBalance => bitcoinAmountToString(amount: confirmed - frozen); + String get formattedAvailableBalance => bitcoinAmountToString(amount: ((confirmed + unconfirmed) - frozen) ); @override String get formattedAdditionalBalance => bitcoinAmountToString(amount: unconfirmed); @@ -35,6 +50,21 @@ class ElectrumBalance extends Balance { return frozenFormatted == '0.0' ? '' : frozenFormatted; } - String toJSON() => - json.encode({'confirmed': confirmed, 'unconfirmed': unconfirmed, 'frozen': frozen}); + @override + String get formattedSecondAvailableBalance => bitcoinAmountToString(amount: secondConfirmed); + + @override + String get formattedSecondAdditionalBalance => bitcoinAmountToString(amount: secondUnconfirmed); + + @override + String get formattedFullAvailableBalance => + bitcoinAmountToString(amount: (confirmed + unconfirmed) + secondConfirmed - frozen); + + String toJSON() => json.encode({ + 'confirmed': confirmed, + 'unconfirmed': unconfirmed, + 'frozen': frozen, + 'secondConfirmed': secondConfirmed, + 'secondUnconfirmed': secondUnconfirmed, + }); } diff --git a/cw_bitcoin/lib/electrum_derivations.dart b/cw_bitcoin/lib/electrum_derivations.dart new file mode 100644 index 000000000..81a3626d2 --- /dev/null +++ b/cw_bitcoin/lib/electrum_derivations.dart @@ -0,0 +1,112 @@ +import 'package:cw_core/wallet_info.dart'; + +Map> electrum_derivations = { + DerivationType.electrum: [ + DerivationInfo( + derivationType: DerivationType.electrum, + derivationPath: "m/0'", + description: "Electrum", + scriptType: "p2wpkh", + ), + ], + DerivationType.bip39: [ + DerivationInfo( + derivationType: DerivationType.bip39, + derivationPath: "m/44'/0'/0'", + description: "Standard BIP44", + scriptType: "p2pkh", + ), + DerivationInfo( + derivationType: DerivationType.bip39, + derivationPath: "m/49'/0'/0'", + description: "Standard BIP49 compatibility segwit", + scriptType: "p2wpkh-p2sh", + ), + DerivationInfo( + derivationType: DerivationType.bip39, + derivationPath: "m/84'/0'/0'", + description: "Standard BIP84 native segwit", + scriptType: "p2wpkh", + ), + DerivationInfo( + derivationType: DerivationType.bip39, + derivationPath: "m/86'/0'/0'", + description: "Standard BIP86 Taproot", + scriptType: "p2tr", + ), + DerivationInfo( + derivationType: DerivationType.bip39, + derivationPath: "m/0'", + description: "Non-standard legacy", + scriptType: "p2pkh", + ), + DerivationInfo( + derivationType: DerivationType.bip39, + derivationPath: "m/0'", + description: "Non-standard compatibility segwit", + scriptType: "p2wpkh-p2sh", + ), + DerivationInfo( + derivationType: DerivationType.bip39, + derivationPath: "m/0'", + description: "Non-standard native segwit", + scriptType: "p2wpkh", + ), + DerivationInfo( + derivationType: DerivationType.bip39, + derivationPath: "m/44'/0'/0'", + description: "Samourai Deposit", + scriptType: "p2wpkh", + ), + DerivationInfo( + derivationType: DerivationType.bip39, + derivationPath: "m/49'/0'/0'", + description: "Samourai Deposit", + scriptType: "p2wpkh", + ), + DerivationInfo( + derivationType: DerivationType.bip39, + derivationPath: "m/84'/0'/2147483644'", + description: "Samourai Bad Bank (toxic change)", + scriptType: "p2wpkh", + ), + DerivationInfo( + derivationType: DerivationType.bip39, + derivationPath: "m/84'/0'/2147483645'", + description: "Samourai Whirlpool Pre Mix", + scriptType: "p2wpkh", + ), + DerivationInfo( + derivationType: DerivationType.bip39, + derivationPath: "m/84'/0'/2147483646'", + description: "Samourai Whirlpool Post Mix", + scriptType: "p2wpkh", + ), + DerivationInfo( + derivationType: DerivationType.bip39, + derivationPath: "m/44'/0'/2147483647'", + description: "Samourai Ricochet legacy", + scriptType: "p2pkh", + ), + DerivationInfo( + derivationType: DerivationType.bip39, + derivationPath: "m/49'/0'/2147483647'", + description: "Samourai Ricochet compatibility segwit", + scriptType: "p2wpkh-p2sh", + ), + DerivationInfo( + derivationType: DerivationType.bip39, + derivationPath: "m/84'/0'/2147483647'", + description: "Samourai Ricochet native segwit", + scriptType: "p2wpkh", + ), + DerivationInfo( + derivationType: DerivationType.bip39, + derivationPath: "m/84'/2'/0'", + description: "Default Litecoin", + scriptType: "p2wpkh", + ), + ], +}; + +String electrum_path = electrum_derivations[DerivationType.electrum]!.first.derivationPath!; diff --git a/cw_bitcoin/lib/electrum_transaction_history.dart b/cw_bitcoin/lib/electrum_transaction_history.dart index d478c3b12..d096d0e7b 100644 --- a/cw_bitcoin/lib/electrum_transaction_history.dart +++ b/cw_bitcoin/lib/electrum_transaction_history.dart @@ -1,37 +1,43 @@ import 'dart:convert'; +import 'package:cw_core/encryption_file_utils.dart'; import 'package:cw_bitcoin/electrum_transaction_info.dart'; import 'package:cw_core/pathForWallet.dart'; import 'package:cw_core/transaction_history.dart'; import 'package:cw_core/utils/file.dart'; +import 'package:cw_core/utils/print_verbose.dart'; import 'package:cw_core/wallet_info.dart'; import 'package:mobx/mobx.dart'; +import 'package:cw_core/transaction_history.dart'; +import 'package:cw_bitcoin/electrum_transaction_info.dart'; part 'electrum_transaction_history.g.dart'; const transactionsHistoryFileName = 'transactions.json'; -class ElectrumTransactionHistory = ElectrumTransactionHistoryBase - with _$ElectrumTransactionHistory; +class ElectrumTransactionHistory = ElectrumTransactionHistoryBase with _$ElectrumTransactionHistory; abstract class ElectrumTransactionHistoryBase extends TransactionHistoryBase with Store { ElectrumTransactionHistoryBase( - {required this.walletInfo, required String password}) + {required this.walletInfo, required String password, required this.encryptionFileUtils}) : _password = password, _height = 0 { transactions = ObservableMap(); } final WalletInfo walletInfo; + final EncryptionFileUtils encryptionFileUtils; String _password; int _height; - Future init() async => await _load(); + Future init() async { + clear(); + await _load(); + } @override - void addOne(ElectrumTransactionInfo transaction) => - transactions[transaction.id] = transaction; + void addOne(ElectrumTransactionInfo transaction) => transactions[transaction.id] = transaction; @override void addMany(Map transactions) => @@ -40,14 +46,16 @@ abstract class ElectrumTransactionHistoryBase @override Future save() async { try { - final dirPath = - await pathForWalletDir(name: walletInfo.name, type: walletInfo.type); + final dirPath = await pathForWalletDir(name: walletInfo.name, type: walletInfo.type); final path = '$dirPath/$transactionsHistoryFileName'; - final data = - json.encode({'height': _height, 'transactions': transactions}); - await writeData(path: path, password: _password, data: data); + final txjson = {}; + for (final tx in transactions.entries) { + txjson[tx.key] = tx.value.toJson(); + } + final data = json.encode({'height': _height, 'transactions': txjson}); + await encryptionFileUtils.write(path: path, password: _password, data: data); } catch (e) { - print('Error while save bitcoin transaction history: ${e.toString()}'); + printV('Error while save bitcoin transaction history: ${e.toString()}'); } } @@ -57,10 +65,9 @@ abstract class ElectrumTransactionHistoryBase } Future> _read() async { - final dirPath = - await pathForWalletDir(name: walletInfo.name, type: walletInfo.type); + final dirPath = await pathForWalletDir(name: walletInfo.name, type: walletInfo.type); final path = '$dirPath/$transactionsHistoryFileName'; - final content = await read(path: path, password: _password); + final content = await encryptionFileUtils.read(path: path, password: _password); return json.decode(content) as Map; } @@ -73,18 +80,21 @@ abstract class ElectrumTransactionHistoryBase final val = entry.value; if (val is Map) { - final tx = ElectrumTransactionInfo.fromJson(val, walletInfo.type); - _update(tx); + // removing transactions with invalid date + if (val['date'] == 1168650000) { + transactions.remove(entry.key); + } else { + final tx = ElectrumTransactionInfo.fromJson(val, walletInfo.type); + _update(tx); + } } }); _height = content['height'] as int; } catch (e) { - print(e); + printV(e); } } - void _update(ElectrumTransactionInfo transaction) => - transactions[transaction.id] = transaction; - + void _update(ElectrumTransactionInfo transaction) => transactions[transaction.id] = transaction; } diff --git a/cw_bitcoin/lib/electrum_transaction_info.dart b/cw_bitcoin/lib/electrum_transaction_info.dart index bf5ec2c4f..7a8b3b951 100644 --- a/cw_bitcoin/lib/electrum_transaction_info.dart +++ b/cw_bitcoin/lib/electrum_transaction_info.dart @@ -1,46 +1,64 @@ -import 'package:bitcoin_flutter/bitcoin_flutter.dart' as bitcoin; -import 'package:bitcoin_flutter/src/payments/index.dart' show PaymentData; +import 'dart:convert'; + +import 'package:bitcoin_base/bitcoin_base.dart'; import 'package:cw_bitcoin/address_from_output.dart'; import 'package:cw_bitcoin/bitcoin_address_record.dart'; import 'package:cw_bitcoin/bitcoin_amount_format.dart'; +import 'package:cw_bitcoin/bitcoin_unspent.dart'; import 'package:cw_core/transaction_direction.dart'; import 'package:cw_core/transaction_info.dart'; import 'package:cw_core/format_amount.dart'; import 'package:cw_core/wallet_type.dart'; +import 'package:hex/hex.dart'; class ElectrumTransactionBundle { ElectrumTransactionBundle(this.originalTransaction, - {required this.ins, - required this.confirmations, - this.time}); - final bitcoin.Transaction originalTransaction; - final List ins; + {required this.ins, required this.confirmations, this.time}); + + final BtcTransaction originalTransaction; + final List ins; final int? time; final int confirmations; } class ElectrumTransactionInfo extends TransactionInfo { - ElectrumTransactionInfo(this.type, - {required String id, - required int height, - required int amount, - int? fee, - required TransactionDirection direction, - required bool isPending, - required DateTime date, - required int confirmations}) { + List? unspents; + bool isReceivedSilentPayment; + + ElectrumTransactionInfo( + this.type, { + required String id, + int? height, + required int amount, + int? fee, + List? inputAddresses, + List? outputAddresses, + required TransactionDirection direction, + required bool isPending, + bool isReplaced = false, + required DateTime date, + required int confirmations, + String? to, + this.unspents, + this.isReceivedSilentPayment = false, + Map? additionalInfo, + }) { this.id = id; this.height = height; this.amount = amount; + this.inputAddresses = inputAddresses; + this.outputAddresses = outputAddresses; this.fee = fee; this.direction = direction; this.date = date; this.isPending = isPending; + this.isReplaced = isReplaced; this.confirmations = confirmations; + this.to = to; + this.additionalInfo = additionalInfo ?? {}; } - factory ElectrumTransactionInfo.fromElectrumVerbose( - Map obj, WalletType type, + factory ElectrumTransactionInfo.fromElectrumVerbose(Map obj, WalletType type, {required List addresses, required int height}) { final addressesSet = addresses.map((addr) => addr.address).toSet(); final id = obj['txid'] as String; @@ -58,10 +76,8 @@ class ElectrumTransactionInfo extends TransactionInfo { for (dynamic vin in vins) { final vout = vin['vout'] as int; final out = vin['tx']['vout'][vout] as Map; - final outAddresses = - (out['scriptPubKey']['addresses'] as List?)?.toSet(); - inputsAmount += - stringDoubleToBitcoinAmount((out['value'] as double? ?? 0).toString()); + final outAddresses = (out['scriptPubKey']['addresses'] as List?)?.toSet(); + inputsAmount += stringDoubleToBitcoinAmount((out['value'] as double? ?? 0).toString()); if (outAddresses?.intersection(addressesSet).isNotEmpty ?? false) { direction = TransactionDirection.outgoing; @@ -69,11 +85,9 @@ class ElectrumTransactionInfo extends TransactionInfo { } for (dynamic out in vout) { - final outAddresses = - out['scriptPubKey']['addresses'] as List? ?? []; + final outAddresses = out['scriptPubKey']['addresses'] as List? ?? []; final ntrs = outAddresses.toSet().intersection(addressesSet); - final value = stringDoubleToBitcoinAmount( - (out['value'] as double? ?? 0.0).toString()); + final value = stringDoubleToBitcoinAmount((out['value'] as double? ?? 0.0).toString()); totalOutAmount += value; if ((direction == TransactionDirection.incoming && ntrs.isNotEmpty) || @@ -88,6 +102,7 @@ class ElectrumTransactionInfo extends TransactionInfo { id: id, height: height, isPending: false, + isReplaced: false, fee: fee, direction: direction, amount: amount, @@ -96,46 +111,76 @@ class ElectrumTransactionInfo extends TransactionInfo { } factory ElectrumTransactionInfo.fromElectrumBundle( - ElectrumTransactionBundle bundle, - WalletType type, - bitcoin.NetworkType networkType, - {required Set addresses, - required int height}) { + ElectrumTransactionBundle bundle, WalletType type, BasedUtxoNetwork network, + {required Set addresses, int? height}) { final date = bundle.time != null - ? DateTime.fromMillisecondsSinceEpoch(bundle.time! * 1000) - : DateTime.now(); + ? DateTime.fromMillisecondsSinceEpoch(bundle.time! * 1000) + : DateTime.now(); var direction = TransactionDirection.incoming; var amount = 0; var inputAmount = 0; var totalOutAmount = 0; + List inputAddresses = []; + List outputAddresses = []; - for (var i = 0; i < bundle.originalTransaction.ins.length; i++) { - final input = bundle.originalTransaction.ins[i]; + for (var i = 0; i < bundle.originalTransaction.inputs.length; i++) { + final input = bundle.originalTransaction.inputs[i]; final inputTransaction = bundle.ins[i]; - final vout = input.index; - final outTransaction = inputTransaction.outs[vout!]; - final address = addressFromOutput(outTransaction.script!, networkType); - inputAmount += outTransaction.value!; - if (addresses.contains(address)) { + final outTransaction = inputTransaction.outputs[input.txIndex]; + inputAmount += outTransaction.amount.toInt(); + if (addresses.contains(addressFromOutputScript(outTransaction.scriptPubKey, network))) { direction = TransactionDirection.outgoing; + inputAddresses.add(addressFromOutputScript(outTransaction.scriptPubKey, network)); } } - for (final out in bundle.originalTransaction.outs) { - totalOutAmount += out.value!; - final address = addressFromOutput(out.script!, networkType); - final addressExists = addresses.contains(address); + final receivedAmounts = []; + for (final out in bundle.originalTransaction.outputs) { + totalOutAmount += out.amount.toInt(); + final addressExists = addresses.contains(addressFromOutputScript(out.scriptPubKey, network)); + final address = addressFromOutputScript(out.scriptPubKey, network); + + if (address.isNotEmpty) outputAddresses.add(address); + + // Check if the script contains OP_RETURN + final script = out.scriptPubKey.script; + if (script.contains('OP_RETURN')) { + final index = script.indexOf('OP_RETURN'); + if (index + 1 <= script.length) { + try { + final opReturnData = script[index + 1].toString(); + final decodedString = utf8.decode(HEX.decode(opReturnData)); + outputAddresses.add('OP_RETURN:$decodedString'); + } catch (_) { + outputAddresses.add('OP_RETURN:'); + } + } + } + + if (addressExists) { + receivedAmounts.add(out.amount.toInt()); + } + if ((direction == TransactionDirection.incoming && addressExists) || (direction == TransactionDirection.outgoing && !addressExists)) { - amount += out.value!; + amount += out.amount.toInt(); } } + if (receivedAmounts.length == bundle.originalTransaction.outputs.length) { + // Self-send + direction = TransactionDirection.incoming; + amount = receivedAmounts.reduce((a, b) => a + b); + } + final fee = inputAmount - totalOutAmount; return ElectrumTransactionInfo(type, - id: bundle.originalTransaction.getId(), + id: bundle.originalTransaction.txId(), height: height, isPending: bundle.confirmations == 0, + isReplaced: false, + inputAddresses: inputAddresses, + outputAddresses: outputAddresses, fee: fee, direction: direction, amount: amount, @@ -143,52 +188,34 @@ class ElectrumTransactionInfo extends TransactionInfo { confirmations: bundle.confirmations); } - factory ElectrumTransactionInfo.fromHexAndHeader(WalletType type, String hex, - {List? addresses, required int height, int? timestamp, required int confirmations}) { - final tx = bitcoin.Transaction.fromHex(hex); - var exist = false; - var amount = 0; + factory ElectrumTransactionInfo.fromJson(Map data, WalletType type) { + final inputAddresses = data['inputAddresses'] as List? ?? []; + final outputAddresses = data['outputAddresses'] as List? ?? []; + final unspents = data['unspents'] as List? ?? []; - if (addresses != null) { - tx.outs.forEach((out) { - try { - final p2pkh = bitcoin.P2PKH( - data: PaymentData(output: out.script), network: bitcoin.bitcoin); - exist = addresses.contains(p2pkh.data.address); - - if (exist) { - amount += out.value!; - } - } catch (_) {} - }); - } - - final date = timestamp != null - ? DateTime.fromMillisecondsSinceEpoch(timestamp * 1000) - : DateTime.now(); - - return ElectrumTransactionInfo(type, - id: tx.getId(), - height: height, - isPending: false, - fee: null, - direction: TransactionDirection.incoming, - amount: amount, - date: date, - confirmations: confirmations); - } - - factory ElectrumTransactionInfo.fromJson( - Map data, WalletType type) { - return ElectrumTransactionInfo(type, - id: data['id'] as String, - height: data['height'] as int, - amount: data['amount'] as int, - fee: data['fee'] as int, - direction: parseTransactionDirectionFromInt(data['direction'] as int), - date: DateTime.fromMillisecondsSinceEpoch(data['date'] as int), - isPending: data['isPending'] as bool, - confirmations: data['confirmations'] as int); + return ElectrumTransactionInfo( + type, + id: data['id'] as String, + height: data['height'] as int, + amount: data['amount'] as int, + fee: data['fee'] as int, + direction: parseTransactionDirectionFromInt(data['direction'] as int), + date: DateTime.fromMillisecondsSinceEpoch(data['date'] as int), + isPending: data['isPending'] as bool, + isReplaced: data['isReplaced'] as bool? ?? false, + confirmations: data['confirmations'] as int, + inputAddresses: + inputAddresses.isEmpty ? [] : inputAddresses.map((e) => e.toString()).toList(), + outputAddresses: + outputAddresses.isEmpty ? [] : outputAddresses.map((e) => e.toString()).toList(), + to: data['to'] as String?, + unspents: unspents + .map((unspent) => + BitcoinSilentPaymentsUnspent.fromJSON(null, unspent as Map)) + .toList(), + isReceivedSilentPayment: data['isReceivedSilentPayment'] as bool? ?? false, + additionalInfo: data['additionalInfo'] as Map?, + ); } final WalletType type; @@ -219,7 +246,11 @@ class ElectrumTransactionInfo extends TransactionInfo { direction: direction, date: date, isPending: isPending, - confirmations: info.confirmations); + isReplaced: isReplaced ?? false, + inputAddresses: inputAddresses, + outputAddresses: outputAddresses, + confirmations: info.confirmations, + additionalInfo: additionalInfo); } Map toJson() { @@ -230,8 +261,19 @@ class ElectrumTransactionInfo extends TransactionInfo { m['direction'] = direction.index; m['date'] = date.millisecondsSinceEpoch; m['isPending'] = isPending; + m['isReplaced'] = isReplaced; m['confirmations'] = confirmations; m['fee'] = fee; + m['to'] = to; + m['unspents'] = unspents?.map((e) => e.toJson()).toList() ?? []; + m['inputAddresses'] = inputAddresses; + m['outputAddresses'] = outputAddresses; + m['isReceivedSilentPayment'] = isReceivedSilentPayment; + m['additionalInfo'] = additionalInfo; return m; } + + String toString() { + return 'ElectrumTransactionInfo(id: $id, height: $height, amount: $amount, fee: $fee, direction: $direction, date: $date, isPending: $isPending, isReplaced: $isReplaced, confirmations: $confirmations, to: $to, unspent: $unspents, inputAddresses: $inputAddresses, outputAddresses: $outputAddresses, additionalInfo: $additionalInfo)'; + } } diff --git a/cw_bitcoin/lib/electrum_wallet.dart b/cw_bitcoin/lib/electrum_wallet.dart index 8a41c1733..bb9cea1bc 100644 --- a/cw_bitcoin/lib/electrum_wallet.dart +++ b/cw_bitcoin/lib/electrum_wallet.dart @@ -1,27 +1,36 @@ import 'dart:async'; import 'dart:convert'; import 'dart:io'; -import 'dart:math'; +import 'dart:isolate'; -import 'package:bitcoin_flutter/bitcoin_flutter.dart' as bitcoin; +import 'package:bitcoin_base/bitcoin_base.dart'; +import 'package:cw_core/utils/proxy_wrapper.dart'; +import 'package:cw_bitcoin/bitcoin_amount_format.dart'; +import 'package:cw_core/format_amount.dart'; +import 'package:cw_core/utils/print_verbose.dart'; +import 'package:cw_bitcoin/bitcoin_wallet.dart'; +import 'package:cw_bitcoin/litecoin_wallet.dart'; +import 'package:shared_preferences/shared_preferences.dart'; +import 'package:blockchain_utils/blockchain_utils.dart'; import 'package:collection/collection.dart'; -import 'package:cw_bitcoin/address_to_output_script.dart'; +import 'package:cw_bitcoin/address_from_output.dart'; import 'package:cw_bitcoin/bitcoin_address_record.dart'; import 'package:cw_bitcoin/bitcoin_transaction_credentials.dart'; -import 'package:cw_bitcoin/bitcoin_transaction_no_inputs_exception.dart'; import 'package:cw_bitcoin/bitcoin_transaction_priority.dart'; -import 'package:cw_bitcoin/bitcoin_transaction_wrong_balance_exception.dart'; import 'package:cw_bitcoin/bitcoin_unspent.dart'; import 'package:cw_bitcoin/bitcoin_wallet_keys.dart'; import 'package:cw_bitcoin/electrum.dart'; import 'package:cw_bitcoin/electrum_balance.dart'; +import 'package:cw_bitcoin/electrum_derivations.dart'; import 'package:cw_bitcoin/electrum_transaction_history.dart'; import 'package:cw_bitcoin/electrum_transaction_info.dart'; import 'package:cw_bitcoin/electrum_wallet_addresses.dart'; +import 'package:cw_bitcoin/exceptions.dart'; import 'package:cw_bitcoin/pending_bitcoin_transaction.dart'; -import 'package:cw_bitcoin/script_hash.dart'; import 'package:cw_bitcoin/utils.dart'; import 'package:cw_core/crypto_currency.dart'; +import 'package:cw_core/encryption_file_utils.dart'; +import 'package:cw_core/get_height_by_date.dart'; import 'package:cw_core/node.dart'; import 'package:cw_core/pathForWallet.dart'; import 'package:cw_core/pending_transaction.dart'; @@ -29,14 +38,18 @@ import 'package:cw_core/sync_status.dart'; import 'package:cw_core/transaction_direction.dart'; import 'package:cw_core/transaction_priority.dart'; import 'package:cw_core/unspent_coins_info.dart'; -import 'package:cw_core/utils/file.dart'; import 'package:cw_core/wallet_base.dart'; import 'package:cw_core/wallet_info.dart'; +import 'package:cw_core/wallet_keys_file.dart'; +import 'package:cw_core/wallet_type.dart'; +import 'package:cw_core/unspent_coin_type.dart'; import 'package:flutter/foundation.dart'; -import 'package:hex/hex.dart'; import 'package:hive/hive.dart'; +import 'package:ledger_flutter_plus/ledger_flutter_plus.dart' as ledger; import 'package:mobx/mobx.dart'; import 'package:rxdart/subjects.dart'; +import 'package:sp_scanner/sp_scanner.dart'; +import 'package:hex/hex.dart'; part 'electrum_wallet.g.dart'; @@ -44,21 +57,24 @@ class ElectrumWallet = ElectrumWalletBase with _$ElectrumWallet; abstract class ElectrumWalletBase extends WalletBase - with Store { - ElectrumWalletBase( - {required String password, - required WalletInfo walletInfo, - required Box unspentCoinsInfo, - required this.networkType, - required this.mnemonic, - required Uint8List seedBytes, - List? initialAddresses, - ElectrumClient? electrumClient, - ElectrumBalance? initialBalance, - CryptoCurrency? currency}) - : hd = currency == CryptoCurrency.bch - ? bitcoinCashHDWallet(seedBytes) - : bitcoin.HDWallet.fromSeed(seedBytes, network: networkType).derivePath("m/0'/0"), + with Store, WalletKeysFile { + ElectrumWalletBase({ + required String password, + required WalletInfo walletInfo, + required Box unspentCoinsInfo, + required this.network, + required this.encryptionFileUtils, + String? xpub, + String? mnemonic, + Uint8List? seedBytes, + this.passphrase, + List? initialAddresses, + ElectrumClient? electrumClient, + ElectrumBalance? initialBalance, + CryptoCurrency? currency, + this.alwaysScan, + }) : accountHD = + getAccountHDWallet(currency, network, seedBytes, xpub, walletInfo.derivationInfo), syncStatus = NotConnectedSyncStatus(), _password = password, _feeRates = [], @@ -68,25 +84,84 @@ abstract class ElectrumWalletBase _scripthashesUpdateSubject = {}, balance = ObservableMap.of(currency != null ? { - currency: - initialBalance ?? const ElectrumBalance(confirmed: 0, unconfirmed: 0, frozen: 0) + currency: initialBalance ?? + ElectrumBalance( + confirmed: 0, + unconfirmed: 0, + frozen: 0, + ) } : {}), this.unspentCoinsInfo = unspentCoinsInfo, + this.isTestnet = !network.isMainnet, + this._mnemonic = mnemonic, super(walletInfo) { this.electrumClient = electrumClient ?? ElectrumClient(); this.walletInfo = walletInfo; - transactionHistory = ElectrumTransactionHistory(walletInfo: walletInfo, password: password); + transactionHistory = ElectrumTransactionHistory( + walletInfo: walletInfo, + password: password, + encryptionFileUtils: encryptionFileUtils, + ); + + reaction((_) => syncStatus, _syncStatusReaction); + + sharedPrefs.complete(SharedPreferences.getInstance()); } - static bitcoin.HDWallet bitcoinCashHDWallet(Uint8List seedBytes) => - bitcoin.HDWallet.fromSeed(seedBytes).derivePath("m/44'/145'/0'/0"); + static Bip32Slip10Secp256k1 getAccountHDWallet(CryptoCurrency? currency, BasedUtxoNetwork network, + Uint8List? seedBytes, String? xpub, DerivationInfo? derivationInfo) { + if (seedBytes == null && xpub == null) { + throw Exception( + "To create a Wallet you need either a seed or an xpub. This should not happen"); + } + + if (seedBytes != null) { + switch (currency) { + case CryptoCurrency.btc: + case CryptoCurrency.ltc: + case CryptoCurrency.tbtc: + return Bip32Slip10Secp256k1.fromSeed(seedBytes, getKeyNetVersion(network)).derivePath( + _hardenedDerivationPath(derivationInfo?.derivationPath ?? electrum_path)) + as Bip32Slip10Secp256k1; + case CryptoCurrency.bch: + return bitcoinCashHDWallet(seedBytes); + default: + throw Exception("Unsupported currency"); + } + } + + return Bip32Slip10Secp256k1.fromExtendedKey(xpub!, getKeyNetVersion(network)); + } + + static Bip32Slip10Secp256k1 bitcoinCashHDWallet(Uint8List seedBytes) => + Bip32Slip10Secp256k1.fromSeed(seedBytes).derivePath("m/44'/145'/0'") as Bip32Slip10Secp256k1; static int estimatedTransactionSize(int inputsCount, int outputsCounts) => inputsCount * 68 + outputsCounts * 34 + 10; - final bitcoin.HDWallet hd; - final String mnemonic; + static Bip32KeyNetVersions? getKeyNetVersion(BasedUtxoNetwork network) { + switch (network) { + case LitecoinNetwork.mainnet: + return Bip44Conf.litecoinMainNet.altKeyNetVer; + default: + return null; + } + } + + bool? alwaysScan; + + final Bip32Slip10Secp256k1 accountHD; + final String? _mnemonic; + + Bip32Slip10Secp256k1 get hd => accountHD.childKey(Bip32KeyIndex(0)); + + Bip32Slip10Secp256k1 get sideHd => accountHD.childKey(Bip32KeyIndex(1)); + + final EncryptionFileUtils encryptionFileUtils; + + @override + final String? passphrase; @override @observable @@ -106,258 +181,1064 @@ abstract class ElectrumWalletBase @observable SyncStatus syncStatus; - List get scriptHashes => walletAddresses.addresses - .map((addr) => scriptHash(addr.address, networkType: networkType)) + Set get addressesSet => walletAddresses.allAddresses + .where((element) => element.type != SegwitAddresType.mweb) + .map((addr) => addr.address) + .toSet(); + + List get scriptHashes => walletAddresses.addressesByReceiveType + .where((addr) => RegexUtils.addressTypeFromStr(addr.address, network) is! MwebAddress) + .map((addr) => (addr as BitcoinAddressRecord).getScriptHash(network)) .toList(); - List get publicScriptHashes => walletAddresses.addresses + List get publicScriptHashes => walletAddresses.allAddresses .where((addr) => !addr.isHidden) - .map((addr) => scriptHash(addr.address, networkType: networkType)) + .where((addr) => RegexUtils.addressTypeFromStr(addr.address, network) is! MwebAddress) + .map((addr) => addr.getScriptHash(network)) .toList(); - String get xpub => hd.base58!; + String get xpub => accountHD.publicKey.toExtended; @override - String get seed => mnemonic; - - bitcoin.NetworkType networkType; + String? get seed => _mnemonic; @override - BitcoinWalletKeys get keys => - BitcoinWalletKeys(wif: hd.wif!, privateKey: hd.privKey!, publicKey: hd.pubKey!); + WalletKeysData get walletKeysData => + WalletKeysData(mnemonic: _mnemonic, xPub: xpub, passphrase: passphrase); + + @override + String get password => _password; + + BasedUtxoNetwork network; + + @override + bool isTestnet; + + bool get hasSilentPaymentsScanning => type == WalletType.bitcoin; + + @observable + bool nodeSupportsSilentPayments = true; + @observable + bool silentPaymentsScanningActive = false; + + bool _isTryingToConnect = false; + + Completer sharedPrefs = Completer(); + + Future checkIfMempoolAPIIsEnabled() async { + bool isMempoolAPIEnabled = (await sharedPrefs.future).getBool("use_mempool_fee_api") ?? true; + return isMempoolAPIEnabled; + } + + @action + Future setSilentPaymentsScanning(bool active) async { + silentPaymentsScanningActive = active; + + if (active) { + syncStatus = AttemptingScanSyncStatus(); + + final tip = await getUpdatedChainTip(); + + if (tip == walletInfo.restoreHeight) { + syncStatus = SyncedTipSyncStatus(tip); + return; + } + + if (tip > walletInfo.restoreHeight) { + _setListeners(walletInfo.restoreHeight, chainTipParam: currentChainTip); + } + } else { + alwaysScan = false; + + _isolate?.then((value) => value.kill(priority: Isolate.immediate)); + + if (electrumClient.isConnected) { + syncStatus = SyncedSyncStatus(); + } else { + syncStatus = NotConnectedSyncStatus(); + } + } + } + + int? currentChainTip; + + Future getCurrentChainTip() async { + if ((currentChainTip ?? 0) > 0) { + return currentChainTip!; + } + currentChainTip = await electrumClient.getCurrentBlockChainTip() ?? 0; + + return currentChainTip!; + } + + Future getUpdatedChainTip() async { + final newTip = await electrumClient.getCurrentBlockChainTip(); + if (newTip != null && newTip > (currentChainTip ?? 0)) { + currentChainTip = newTip; + } + return currentChainTip ?? 0; + } + + @override + BitcoinWalletKeys get keys => BitcoinWalletKeys( + wif: WifEncoder.encode(hd.privateKey.raw, netVer: network.wifNetVer), + privateKey: hd.privateKey.toHex(), + publicKey: hd.publicKey.toHex(), + ); String _password; List unspentCoins; List _feeRates; + + // ignore: prefer_final_fields Map?> _scripthashesUpdateSubject; + + // ignore: prefer_final_fields + BehaviorSubject? _chainTipUpdateSubject; bool _isTransactionUpdating; + Future? _isolate; void Function(FlutterErrorDetails)? _onError; + Timer? _autoSaveTimer; + StreamSubscription? _receiveStream; + Timer? _updateFeeRateTimer; + static const int _autoSaveInterval = 1; Future init() async { await walletAddresses.init(); await transactionHistory.init(); + await cleanUpDuplicateUnspentCoins(); await save(); + + _autoSaveTimer = + Timer.periodic(Duration(minutes: _autoSaveInterval), (_) async => await save()); + } + + @action + Future _setListeners(int height, {int? chainTipParam, bool? doSingleScan}) async { + if (this is! BitcoinWallet) return; + final chainTip = chainTipParam ?? await getUpdatedChainTip(); + + if (chainTip == height) { + syncStatus = SyncedSyncStatus(); + return; + } + + syncStatus = AttemptingScanSyncStatus(); + + if (_isolate != null) { + final runningIsolate = await _isolate!; + runningIsolate.kill(priority: Isolate.immediate); + } + + final receivePort = ReceivePort(); + _isolate = Isolate.spawn( + startRefresh, + ScanData( + sendPort: receivePort.sendPort, + silentAddress: walletAddresses.silentAddress!, + network: network, + height: height, + chainTip: chainTip, + electrumClient: ElectrumClient(), + transactionHistoryIds: transactionHistory.transactions.keys.toList(), + node: (await getNodeSupportsSilentPayments()) == true + ? ScanNode(node!.uri, node!.useSSL) + : null, + labels: walletAddresses.labels, + labelIndexes: walletAddresses.silentAddresses + .where((addr) => addr.type == SilentPaymentsAddresType.p2sp && addr.index >= 1) + .map((addr) => addr.index) + .toList(), + isSingleScan: doSingleScan ?? false, + )); + + await _receiveStream?.cancel(); + _receiveStream = receivePort.listen((var message) async { + if (message is Map) { + for (final map in message.entries) { + final txid = map.key; + final tx = map.value; + + if (tx.unspents != null) { + final existingTxInfo = transactionHistory.transactions[txid]; + final txAlreadyExisted = existingTxInfo != null; + + // Updating tx after re-scanned + if (txAlreadyExisted) { + existingTxInfo.amount = tx.amount; + existingTxInfo.confirmations = tx.confirmations; + existingTxInfo.height = tx.height; + + final newUnspents = tx.unspents! + .where((unspent) => !(existingTxInfo.unspents?.any((element) => + element.hash.contains(unspent.hash) && + element.vout == unspent.vout && + element.value == unspent.value) ?? + false)) + .toList(); + + if (newUnspents.isNotEmpty) { + newUnspents.forEach(_updateSilentAddressRecord); + + existingTxInfo.unspents ??= []; + existingTxInfo.unspents!.addAll(newUnspents); + + final newAmount = newUnspents.length > 1 + ? newUnspents.map((e) => e.value).reduce((value, unspent) => value + unspent) + : newUnspents[0].value; + + if (existingTxInfo.direction == TransactionDirection.incoming) { + existingTxInfo.amount += newAmount; + } + + // Updates existing TX + transactionHistory.addOne(existingTxInfo); + // Update balance record + balance[currency]!.confirmed += newAmount; + } + } else { + // else: First time seeing this TX after scanning + tx.unspents!.forEach(_updateSilentAddressRecord); + + // Add new TX record + transactionHistory.addMany(message); + // Update balance record + balance[currency]!.confirmed += tx.amount; + } + + await updateAllUnspents(); + } + } + } + + if (message is SyncResponse) { + if (message.syncStatus is UnsupportedSyncStatus) { + nodeSupportsSilentPayments = false; + } + + if (message.syncStatus is SyncingSyncStatus) { + var status = message.syncStatus as SyncingSyncStatus; + syncStatus = SyncingSyncStatus(status.blocksLeft, status.ptc); + } else { + syncStatus = message.syncStatus; + } + + await walletInfo.updateRestoreHeight(message.height); + } + }); + } + + void _updateSilentAddressRecord(BitcoinSilentPaymentsUnspent unspent) { + final silentAddress = walletAddresses.silentAddress!; + final silentPaymentAddress = SilentPaymentAddress( + version: silentAddress.version, + B_scan: silentAddress.B_scan, + B_spend: unspent.silentPaymentLabel != null + ? silentAddress.B_spend.tweakAdd( + BigintUtils.fromBytes(BytesUtils.fromHexString(unspent.silentPaymentLabel!)), + ) + : silentAddress.B_spend, + network: network, + ); + + final addressRecord = walletAddresses.silentAddresses + .firstWhereOrNull((address) => address.address == silentPaymentAddress.toString()); + addressRecord?.txCount += 1; + addressRecord?.balance += unspent.value; + + walletAddresses.addSilentAddresses( + [unspent.bitcoinAddressRecord as BitcoinSilentPaymentAddressRecord], + ); } @action @override Future startSync() async { try { - syncStatus = AttemptingSyncStatus(); - await walletAddresses.discoverAddresses(); + if (syncStatus is SyncronizingSyncStatus) { + return; + } + + syncStatus = SyncronizingSyncStatus(); + + if (hasSilentPaymentsScanning) { + await _setInitialHeight(); + } + + await subscribeForUpdates(); await updateTransactions(); - _subscribeForUpdates(); - await updateUnspent(); + + await updateAllUnspents(); await updateBalance(); - _feeRates = await electrumClient.feeRates(); + await updateFeeRates(); - Timer.periodic( - const Duration(minutes: 1), (timer) async => _feeRates = await electrumClient.feeRates()); + _updateFeeRateTimer ??= + Timer.periodic(const Duration(minutes: 1), (timer) async => await updateFeeRates()); - syncStatus = SyncedSyncStatus(); + if (alwaysScan == true) { + _setListeners(walletInfo.restoreHeight); + } else { + if (syncStatus is LostConnectionSyncStatus) return; + syncStatus = SyncedSyncStatus(); + } } catch (e, stacktrace) { - print(stacktrace); - print(e.toString()); + printV(stacktrace); + printV("startSync $e"); syncStatus = FailedSyncStatus(); } } + @action + Future updateFeeRates() async { + if (await checkIfMempoolAPIIsEnabled() && type == WalletType.bitcoin) { + try { + final response = await ProxyWrapper() + .get(clearnetUri: Uri.parse("https://mempool.cakewallet.com/api/v1/fees/recommended")) + .timeout(Duration(seconds: 15)); + final result = json.decode(response.body) as Map; + final slowFee = (result['economyFee'] as num?)?.toInt() ?? 0; + int mediumFee = (result['hourFee'] as num?)?.toInt() ?? 0; + int fastFee = (result['fastestFee'] as num?)?.toInt() ?? 0; + if (slowFee == mediumFee) { + mediumFee++; + } + while (fastFee <= mediumFee) { + fastFee++; + } + _feeRates = [slowFee, mediumFee, fastFee]; + return; + } catch (e) { + printV(e); + } + } + + final feeRates = await electrumClient.feeRates(network: network); + if (feeRates != [0, 0, 0]) { + _feeRates = feeRates; + } else if (isTestnet) { + _feeRates = [1, 1, 1]; + } + } + + Node? node; + + Future getNodeIsElectrs() async { + if (node == null) { + return false; + } + + final version = await electrumClient.version(); + + if (version.isNotEmpty) { + final server = version[0]; + + if (server.toLowerCase().contains('electrs')) { + node!.isElectrs = true; + node!.save(); + return node!.isElectrs!; + } + } + + node!.isElectrs = false; + node!.save(); + return node!.isElectrs!; + } + + Future getNodeSupportsSilentPayments() async { + // As of today (august 2024), only ElectrumRS supports silent payments + if (!(await getNodeIsElectrs())) { + return false; + } + + if (node == null) { + return false; + } + + try { + final tweaksResponse = await electrumClient.getTweaks(height: 0); + + if (tweaksResponse != null) { + node!.supportsSilentPayments = true; + node!.save(); + return node!.supportsSilentPayments!; + } + } on RequestFailedTimeoutException catch (_) { + node!.supportsSilentPayments = false; + node!.save(); + return node!.supportsSilentPayments!; + } catch (_) {} + + node!.supportsSilentPayments = false; + node!.save(); + return node!.supportsSilentPayments!; + } + @action @override Future connectToNode({required Node node}) async { + this.node = node; + + if (syncStatus is ConnectingSyncStatus) return; + try { syncStatus = ConnectingSyncStatus(); - await electrumClient.connectToUri(node.uri); - electrumClient.onConnectionStatusChange = (bool isConnected) { - if (!isConnected) { - syncStatus = LostConnectionSyncStatus(); - } - }; - syncStatus = ConnectedSyncStatus(); - } catch (e) { - print(e.toString()); + + await _receiveStream?.cancel(); + await electrumClient.close(); + + electrumClient.onConnectionStatusChange = _onConnectionStatusChange; + + await electrumClient.connectToUri(node.uri, useSSL: node.useSSL); + } catch (e, stacktrace) { + printV(stacktrace); + printV("connectToNode $e"); syncStatus = FailedSyncStatus(); } } - @override - Future createTransaction(Object credentials) async { - const minAmount = 546; - final transactionCredentials = credentials as BitcoinTransactionCredentials; - final inputs = []; - final outputs = transactionCredentials.outputs; - final hasMultiDestination = outputs.length > 1; - var allInputsAmount = 0; + int get _dustAmount => 546; - if (unspentCoins.isEmpty) { - await updateUnspent(); - } + bool _isBelowDust(int amount) => amount <= _dustAmount && network != BitcoinNetwork.testnet; - for (final utx in unspentCoins) { - if (utx.isSending) { - allInputsAmount += utx.value; - inputs.add(utx); - } - } + UtxoDetails _createUTXOS({ + required bool sendAll, + required bool paysToSilentPayment, + int credentialsAmount = 0, + int? inputsCount, + UnspentCoinType coinTypeToSpendFrom = UnspentCoinType.any, + }) { + List utxos = []; + List vinOutpoints = []; + List inputPrivKeyInfos = []; + final publicKeys = {}; + int allInputsAmount = 0; + bool spendsSilentPayment = false; + bool spendsUnconfirmedTX = false; - if (inputs.isEmpty) { - throw BitcoinTransactionNoInputsException(); - } - - final allAmountFee = transactionCredentials.feeRate != null - ? feeAmountWithFeeRate(transactionCredentials.feeRate!, inputs.length, outputs.length) - : feeAmountForPriority(transactionCredentials.priority!, inputs.length, outputs.length); - - final allAmount = allInputsAmount - allAmountFee; - - var credentialsAmount = 0; - var amount = 0; - var fee = 0; - - if (hasMultiDestination) { - if (outputs.any((item) => item.sendAll || item.formattedCryptoAmount! <= 0)) { - throw BitcoinTransactionWrongBalanceException(currency); + int leftAmount = credentialsAmount; + var availableInputs = unspentCoins.where((utx) { + if (!utx.isSending || utx.isFrozen) { + return false; } - credentialsAmount = outputs.fold(0, (acc, value) => acc + value.formattedCryptoAmount!); + switch (coinTypeToSpendFrom) { + case UnspentCoinType.mweb: + return utx.bitcoinAddressRecord.type == SegwitAddresType.mweb; + case UnspentCoinType.nonMweb: + return utx.bitcoinAddressRecord.type != SegwitAddresType.mweb; + case UnspentCoinType.any: + return true; + } + }).toList(); + final unconfirmedCoins = availableInputs.where((utx) => utx.confirmations == 0).toList(); - if (allAmount - credentialsAmount < minAmount) { - throw BitcoinTransactionWrongBalanceException(currency); + // sort the unconfirmed coins so that mweb coins are last: + availableInputs.sort((a, b) => a.bitcoinAddressRecord.type == SegwitAddresType.mweb ? 1 : -1); + + for (int i = 0; i < availableInputs.length; i++) { + final utx = availableInputs[i]; + if (!spendsUnconfirmedTX) spendsUnconfirmedTX = utx.confirmations == 0; + + if (paysToSilentPayment) { + // Check inputs for shared secret derivation + if (utx.bitcoinAddressRecord.type == SegwitAddresType.p2wsh) { + throw BitcoinTransactionSilentPaymentsNotSupported(); + } } - amount = credentialsAmount; + allInputsAmount += utx.value; + leftAmount = leftAmount - utx.value; - if (transactionCredentials.feeRate != null) { - fee = calculateEstimatedFeeWithFeeRate(transactionCredentials.feeRate!, amount, - outputsCount: outputs.length + 1); + final address = RegexUtils.addressTypeFromStr(utx.address, network); + ECPrivate? privkey; + bool? isSilentPayment = false; + + final hd = + utx.bitcoinAddressRecord.isHidden ? walletAddresses.sideHd : walletAddresses.mainHd; + + if (utx.bitcoinAddressRecord is BitcoinSilentPaymentAddressRecord) { + final unspentAddress = utx.bitcoinAddressRecord as BitcoinSilentPaymentAddressRecord; + privkey = walletAddresses.silentAddress!.b_spend.tweakAdd( + BigintUtils.fromBytes( + BytesUtils.fromHexString(unspentAddress.silentPaymentTweak!), + ), + ); + spendsSilentPayment = true; + isSilentPayment = true; + } else if (!isHardwareWallet) { + privkey = + generateECPrivate(hd: hd, index: utx.bitcoinAddressRecord.index, network: network); + } + + vinOutpoints.add(Outpoint(txid: utx.hash, index: utx.vout)); + String pubKeyHex; + + if (privkey != null) { + inputPrivKeyInfos.add(ECPrivateInfo( + privkey, + address.type == SegwitAddresType.p2tr, + tweak: !isSilentPayment, + )); + + pubKeyHex = privkey.getPublic().toHex(); } else { - fee = calculateEstimatedFee(transactionCredentials.priority, amount, - outputsCount: outputs.length + 1); - } - } else { - final output = outputs.first; - credentialsAmount = !output.sendAll ? output.formattedCryptoAmount! : 0; - - if (credentialsAmount > allAmount) { - throw BitcoinTransactionWrongBalanceException(currency); + pubKeyHex = hd.childKey(Bip32KeyIndex(utx.bitcoinAddressRecord.index)).publicKey.toHex(); } - amount = output.sendAll || allAmount - credentialsAmount < minAmount - ? allAmount - : credentialsAmount; + final derivationPath = + "${_hardenedDerivationPath(walletInfo.derivationInfo?.derivationPath ?? electrum_path)}" + "/${utx.bitcoinAddressRecord.isHidden ? "1" : "0"}" + "/${utx.bitcoinAddressRecord.index}"; + publicKeys[address.pubKeyHash()] = PublicKeyWithDerivationPath(pubKeyHex, derivationPath); - if (output.sendAll || amount == allAmount) { - fee = allAmountFee; - } else if (transactionCredentials.feeRate != null) { - fee = calculateEstimatedFeeWithFeeRate(transactionCredentials.feeRate!, amount); - } else { - fee = calculateEstimatedFee(transactionCredentials.priority, amount); - } - } + utxos.add( + UtxoWithAddress( + utxo: BitcoinUtxo( + txHash: utx.hash, + value: BigInt.from(utx.value), + vout: utx.vout, + scriptType: _getScriptType(address), + isSilentPayment: isSilentPayment, + ), + ownerDetails: UtxoAddressDetails( + publicKey: pubKeyHex, + address: address, + ), + ), + ); - if (fee == 0) { - throw BitcoinTransactionWrongBalanceException(currency); - } - - final totalAmount = amount + fee; - - if (totalAmount > balance[currency]!.confirmed || totalAmount > allInputsAmount) { - throw BitcoinTransactionWrongBalanceException(currency); - } - - final txb = bitcoin.TransactionBuilder(network: networkType); - final changeAddress = await walletAddresses.getChangeAddress(); - var leftAmount = totalAmount; - var totalInputAmount = 0; - - inputs.clear(); - - for (final utx in unspentCoins) { - if (utx.isSending) { - leftAmount = leftAmount - utx.value; - totalInputAmount += utx.value; - inputs.add(utx); - - if (leftAmount <= 0) { + // sendAll continues for all inputs + if (!sendAll) { + bool amountIsAcquired = leftAmount <= 0; + if ((inputsCount == null && amountIsAcquired) || inputsCount == i + 1) { break; } } } - if (inputs.isEmpty) { + if (utxos.isEmpty) { throw BitcoinTransactionNoInputsException(); } - if (amount <= 0 || totalInputAmount < totalAmount) { - throw BitcoinTransactionWrongBalanceException(currency); - } - - txb.setVersion(1); - inputs.forEach((input) { - if (input.isP2wpkh) { - final p2wpkh = bitcoin - .P2WPKH( - data: generatePaymentData( - hd: input.bitcoinAddressRecord.isHidden - ? walletAddresses.sideHd - : walletAddresses.mainHd, - index: input.bitcoinAddressRecord.index), - network: networkType) - .data; - - txb.addInput(input.hash, input.vout, null, p2wpkh.output); - } else { - txb.addInput(input.hash, input.vout); - } - }); - - outputs.forEach((item) { - final outputAmount = hasMultiDestination ? item.formattedCryptoAmount : amount; - final outputAddress = item.isParsedAddress ? item.extractedAddress! : item.address; - txb.addOutput(addressToOutputScript(outputAddress, networkType), outputAmount!); - }); - - final estimatedSize = estimatedTransactionSize(inputs.length, outputs.length + 1); - var feeAmount = 0; - - if (transactionCredentials.feeRate != null) { - feeAmount = transactionCredentials.feeRate! * estimatedSize; - } else { - feeAmount = feeRate(transactionCredentials.priority!) * estimatedSize; - } - - final changeValue = totalInputAmount - amount - feeAmount; - - if (changeValue > minAmount) { - txb.addOutput(changeAddress, changeValue); - } - - for (var i = 0; i < inputs.length; i++) { - final input = inputs[i]; - final keyPair = generateKeyPair( - hd: input.bitcoinAddressRecord.isHidden ? walletAddresses.sideHd : walletAddresses.mainHd, - index: input.bitcoinAddressRecord.index, - network: networkType); - final witnessValue = input.isP2wpkh ? input.value : null; - - txb.sign(vin: i, keyPair: keyPair, witnessValue: witnessValue); - } - - return PendingBitcoinTransaction(txb.build(), type, - electrumClient: electrumClient, amount: amount, fee: fee) - ..addListener((transaction) async { - transactionHistory.addOne(transaction); - await updateBalance(); - }); + return UtxoDetails( + availableInputs: availableInputs, + unconfirmedCoins: unconfirmedCoins, + utxos: utxos, + vinOutpoints: vinOutpoints, + inputPrivKeyInfos: inputPrivKeyInfos, + publicKeys: publicKeys, + allInputsAmount: allInputsAmount, + spendsSilentPayment: spendsSilentPayment, + spendsUnconfirmedTX: spendsUnconfirmedTX, + ); } + Future estimateSendAllTx( + List outputs, + int feeRate, { + String? memo, + bool hasSilentPayment = false, + UnspentCoinType coinTypeToSpendFrom = UnspentCoinType.any, + }) async { + final utxoDetails = _createUTXOS( + sendAll: true, + paysToSilentPayment: hasSilentPayment, + coinTypeToSpendFrom: coinTypeToSpendFrom, + ); + + int fee = await calcFee( + utxos: utxoDetails.utxos, + outputs: outputs, + network: network, + memo: memo, + feeRate: feeRate, + inputPrivKeyInfos: utxoDetails.inputPrivKeyInfos, + vinOutpoints: utxoDetails.vinOutpoints, + ); + + if (fee == 0) { + throw BitcoinTransactionNoFeeException(); + } + + // Here, when sending all, the output amount equals to the input value - fee to fully spend every input on the transaction and have no amount left for change + int amount = utxoDetails.allInputsAmount - fee; + + if (amount <= 0) { + throw BitcoinTransactionWrongBalanceException(amount: utxoDetails.allInputsAmount + fee); + } + + // Attempting to send less than the dust limit + if (_isBelowDust(amount)) { + throw BitcoinTransactionNoDustException(); + } + + if (outputs.length == 1) { + outputs[0] = BitcoinOutput(address: outputs.last.address, value: BigInt.from(amount)); + } + + return EstimatedTxResult( + utxos: utxoDetails.utxos, + inputPrivKeyInfos: utxoDetails.inputPrivKeyInfos, + publicKeys: utxoDetails.publicKeys, + fee: fee, + amount: amount, + isSendAll: true, + hasChange: false, + memo: memo, + spendsUnconfirmedTX: utxoDetails.spendsUnconfirmedTX, + spendsSilentPayment: utxoDetails.spendsSilentPayment, + ); + } + + Future estimateTxForAmount( + int credentialsAmount, + List outputs, + List updatedOutputs, + int feeRate, { + int? inputsCount, + String? memo, + bool? useUnconfirmed, + bool hasSilentPayment = false, + UnspentCoinType coinTypeToSpendFrom = UnspentCoinType.any, + }) async { + // Attempting to send less than the dust limit + if (_isBelowDust(credentialsAmount)) { + throw BitcoinTransactionNoDustException(); + } + + final utxoDetails = _createUTXOS( + sendAll: false, + credentialsAmount: credentialsAmount, + inputsCount: inputsCount, + paysToSilentPayment: hasSilentPayment, + coinTypeToSpendFrom: coinTypeToSpendFrom, + ); + + final spendingAllCoins = utxoDetails.availableInputs.length == utxoDetails.utxos.length; + final spendingAllConfirmedCoins = !utxoDetails.spendsUnconfirmedTX && + utxoDetails.utxos.length == + utxoDetails.availableInputs.length - utxoDetails.unconfirmedCoins.length; + + // How much is being spent - how much is being sent + int amountLeftForChangeAndFee = utxoDetails.allInputsAmount - credentialsAmount; + + if (amountLeftForChangeAndFee <= 0) { + if (!spendingAllCoins) { + return estimateTxForAmount( + credentialsAmount, + outputs, + updatedOutputs, + feeRate, + inputsCount: utxoDetails.utxos.length + 1, + memo: memo, + hasSilentPayment: hasSilentPayment, + coinTypeToSpendFrom: coinTypeToSpendFrom, + ); + } + + throw BitcoinTransactionWrongBalanceException(); + } + + final changeAddress = await walletAddresses.getChangeAddress( + inputs: utxoDetails.availableInputs, + outputs: updatedOutputs, + coinTypeToSpendFrom: coinTypeToSpendFrom, + ); + final address = RegexUtils.addressTypeFromStr(changeAddress.address, network); + updatedOutputs.add(BitcoinOutput( + address: address, + value: BigInt.from(amountLeftForChangeAndFee), + isChange: true, + )); + outputs.add(BitcoinOutput( + address: address, + value: BigInt.from(amountLeftForChangeAndFee), + isChange: true, + )); + + // Get Derivation path for change Address since it is needed in Litecoin and BitcoinCash hardware Wallets + final changeDerivationPath = + "${_hardenedDerivationPath(walletInfo.derivationInfo?.derivationPath ?? "m/0'")}" + "/${changeAddress.isHidden ? "1" : "0"}" + "/${changeAddress.index}"; + utxoDetails.publicKeys[address.pubKeyHash()] = + PublicKeyWithDerivationPath('', changeDerivationPath); + + // calcFee updates the silent payment outputs to calculate the tx size accounting + // for taproot addresses, but if more inputs are needed to make up for fees, + // the silent payment outputs need to be recalculated for the new inputs + var temp = outputs.map((output) => output).toList(); + int fee = await calcFee( + utxos: utxoDetails.utxos, + // Always take only not updated bitcoin outputs here so for every estimation + // the SP outputs are re-generated to the proper taproot addresses + outputs: temp, + network: network, + memo: memo, + feeRate: feeRate, + inputPrivKeyInfos: utxoDetails.inputPrivKeyInfos, + vinOutpoints: utxoDetails.vinOutpoints, + ); + + updatedOutputs.clear(); + updatedOutputs.addAll(temp); + + if (fee == 0) { + throw BitcoinTransactionNoFeeException(); + } + + int amount = credentialsAmount; + final lastOutput = updatedOutputs.last; + final amountLeftForChange = amountLeftForChangeAndFee - fee; + + if (_isBelowDust(amountLeftForChange)) { + // If has change that is lower than dust, will end up with tx rejected by network rules + // so remove the change amount + updatedOutputs.removeLast(); + outputs.removeLast(); + + if (amountLeftForChange < 0) { + if (!spendingAllCoins) { + return estimateTxForAmount( + credentialsAmount, + outputs, + updatedOutputs, + feeRate, + inputsCount: utxoDetails.utxos.length + 1, + memo: memo, + useUnconfirmed: useUnconfirmed ?? spendingAllConfirmedCoins, + hasSilentPayment: hasSilentPayment, + coinTypeToSpendFrom: coinTypeToSpendFrom, + ); + } else { + throw BitcoinTransactionWrongBalanceException(); + } + } + + // if the amount left for change is less than dust, but not less than 0 + // then add it to the fees + fee += amountLeftForChange; + + return EstimatedTxResult( + utxos: utxoDetails.utxos, + inputPrivKeyInfos: utxoDetails.inputPrivKeyInfos, + publicKeys: utxoDetails.publicKeys, + fee: fee, + amount: amount, + hasChange: false, + isSendAll: spendingAllCoins, + memo: memo, + spendsUnconfirmedTX: utxoDetails.spendsUnconfirmedTX, + spendsSilentPayment: utxoDetails.spendsSilentPayment, + ); + } else { + // Here, lastOutput already is change, return the amount left without the fee to the user's address. + updatedOutputs[updatedOutputs.length - 1] = BitcoinOutput( + address: lastOutput.address, + value: BigInt.from(amountLeftForChange), + isSilentPayment: lastOutput.isSilentPayment, + isChange: true, + ); + outputs[outputs.length - 1] = BitcoinOutput( + address: lastOutput.address, + value: BigInt.from(amountLeftForChange), + isSilentPayment: lastOutput.isSilentPayment, + isChange: true, + ); + + return EstimatedTxResult( + utxos: utxoDetails.utxos, + inputPrivKeyInfos: utxoDetails.inputPrivKeyInfos, + publicKeys: utxoDetails.publicKeys, + fee: fee, + amount: amount, + hasChange: true, + isSendAll: spendingAllCoins, + memo: memo, + spendsUnconfirmedTX: utxoDetails.spendsUnconfirmedTX, + spendsSilentPayment: utxoDetails.spendsSilentPayment, + ); + } + } + + Future calcFee({ + required List utxos, + required List outputs, + required BasedUtxoNetwork network, + String? memo, + required int feeRate, + List? inputPrivKeyInfos, + List? vinOutpoints, + }) async { + int estimatedSize; + if (network is BitcoinCashNetwork) { + estimatedSize = ForkedTransactionBuilder.estimateTransactionSize( + utxos: utxos, + outputs: outputs, + network: network, + memo: memo, + ); + } else { + estimatedSize = BitcoinTransactionBuilder.estimateTransactionSize( + utxos: utxos, + outputs: outputs, + network: network, + memo: memo, + inputPrivKeyInfos: inputPrivKeyInfos, + vinOutpoints: vinOutpoints, + ); + } + + return feeAmountWithFeeRate(feeRate, 0, 0, size: estimatedSize); + } + + @override + Future createTransaction(Object credentials) async { + try { + // start by updating unspent coins + await updateAllUnspents(); + + final outputs = []; + final transactionCredentials = credentials as BitcoinTransactionCredentials; + final hasMultiDestination = transactionCredentials.outputs.length > 1; + final sendAll = !hasMultiDestination && transactionCredentials.outputs.first.sendAll; + final memo = transactionCredentials.outputs.first.memo; + final coinTypeToSpendFrom = transactionCredentials.coinTypeToSpendFrom; + + int credentialsAmount = 0; + bool hasSilentPayment = false; + + for (final out in transactionCredentials.outputs) { + final outputAmount = out.formattedCryptoAmount!; + + if (!sendAll && _isBelowDust(outputAmount)) { + throw BitcoinTransactionNoDustException(); + } + + if (hasMultiDestination) { + if (out.sendAll) { + throw BitcoinTransactionWrongBalanceException(); + } + } + + credentialsAmount += outputAmount; + + final address = RegexUtils.addressTypeFromStr( + out.isParsedAddress ? out.extractedAddress! : out.address, network); + final isSilentPayment = address is SilentPaymentAddress; + + if (isSilentPayment) { + hasSilentPayment = true; + } + + if (sendAll) { + // The value will be changed after estimating the Tx size and deducting the fee from the total to be sent + outputs.add(BitcoinOutput( + address: address, + value: BigInt.from(0), + isSilentPayment: isSilentPayment, + )); + } else { + outputs.add(BitcoinOutput( + address: address, + value: BigInt.from(outputAmount), + isSilentPayment: isSilentPayment, + )); + } + } + + final feeRateInt = transactionCredentials.feeRate != null + ? transactionCredentials.feeRate! + : feeRate(transactionCredentials.priority!); + + EstimatedTxResult estimatedTx; + final updatedOutputs = outputs + .map((e) => BitcoinOutput( + address: e.address, + value: e.value, + isSilentPayment: e.isSilentPayment, + isChange: e.isChange, + )) + .toList(); + + if (sendAll) { + estimatedTx = await estimateSendAllTx( + updatedOutputs, + feeRateInt, + memo: memo, + hasSilentPayment: hasSilentPayment, + coinTypeToSpendFrom: coinTypeToSpendFrom, + ); + } else { + estimatedTx = await estimateTxForAmount( + credentialsAmount, + outputs, + updatedOutputs, + feeRateInt, + memo: memo, + hasSilentPayment: hasSilentPayment, + coinTypeToSpendFrom: coinTypeToSpendFrom, + ); + } + + if (walletInfo.isHardwareWallet) { + final transaction = await buildHardwareWalletTransaction( + utxos: estimatedTx.utxos, + outputs: updatedOutputs, + publicKeys: estimatedTx.publicKeys, + fee: BigInt.from(estimatedTx.fee), + network: network, + memo: estimatedTx.memo, + outputOrdering: BitcoinOrdering.none, + enableRBF: true, + ); + + return PendingBitcoinTransaction( + transaction, + type, + electrumClient: electrumClient, + amount: estimatedTx.amount, + fee: estimatedTx.fee, + feeRate: feeRateInt.toString(), + network: network, + hasChange: estimatedTx.hasChange, + isSendAll: estimatedTx.isSendAll, + hasTaprootInputs: false, // ToDo: (Konsti) Support Taproot + )..addListener((transaction) async { + transactionHistory.addOne(transaction); + await updateBalance(); + await updateAllUnspents(); + }); + } + + BasedBitcoinTransacationBuilder txb; + if (network is BitcoinCashNetwork) { + txb = ForkedTransactionBuilder( + utxos: estimatedTx.utxos, + outputs: updatedOutputs, + fee: BigInt.from(estimatedTx.fee), + network: network, + memo: estimatedTx.memo, + outputOrdering: BitcoinOrdering.none, + enableRBF: !estimatedTx.spendsUnconfirmedTX, + ); + } else { + txb = BitcoinTransactionBuilder( + utxos: estimatedTx.utxos, + outputs: updatedOutputs, + fee: BigInt.from(estimatedTx.fee), + network: network, + memo: estimatedTx.memo, + outputOrdering: BitcoinOrdering.none, + enableRBF: !estimatedTx.spendsUnconfirmedTX, + ); + } + + bool hasTaprootInputs = false; + + final transaction = txb.buildTransaction((txDigest, utxo, publicKey, sighash) { + String error = "Cannot find private key."; + + ECPrivateInfo? key; + + if (estimatedTx.inputPrivKeyInfos.isEmpty) { + error += "\nNo private keys generated."; + } else { + error += "\nAddress: ${utxo.ownerDetails.address.toAddress(network)}"; + + key = estimatedTx.inputPrivKeyInfos.firstWhereOrNull((element) { + final elemPubkey = element.privkey.getPublic().toHex(); + if (elemPubkey == publicKey) { + return true; + } else { + error += "\nExpected: $publicKey"; + error += "\nPubkey: $elemPubkey"; + return false; + } + }); + } + + if (key == null) { + throw Exception(error); + } + + if (utxo.utxo.isP2tr()) { + hasTaprootInputs = true; + return key.privkey.signTapRoot( + txDigest, + sighash: sighash, + tweak: utxo.utxo.isSilentPayment != true, + ); + } else { + return key.privkey.signInput(txDigest, sigHash: sighash); + } + }); + + return PendingBitcoinTransaction(transaction, type, + electrumClient: electrumClient, + amount: estimatedTx.amount, + fee: estimatedTx.fee, + feeRate: feeRateInt.toString(), + network: network, + hasChange: estimatedTx.hasChange, + isSendAll: estimatedTx.isSendAll, + hasTaprootInputs: hasTaprootInputs, + utxos: estimatedTx.utxos, + publicKeys: estimatedTx.publicKeys) + ..addListener((transaction) async { + transactionHistory.addOne(transaction); + if (estimatedTx.spendsSilentPayment) { + transactionHistory.transactions.values.forEach((tx) { + tx.unspents?.removeWhere( + (unspent) => estimatedTx.utxos.any((e) => e.utxo.txHash == unspent.hash)); + transactionHistory.addOne(tx); + }); + } + + unspentCoins + .removeWhere((utxo) => estimatedTx.utxos.any((e) => e.utxo.txHash == utxo.hash)); + + await updateBalance(); + await updateAllUnspents(); + }); + } catch (e) { + throw e; + } + } + + void setLedgerConnection(ledger.LedgerConnection connection) => throw UnimplementedError(); + + Future buildHardwareWalletTransaction({ + required List outputs, + required BigInt fee, + required BasedUtxoNetwork network, + required List utxos, + required Map publicKeys, + String? memo, + bool enableRBF = false, + BitcoinOrdering inputOrdering = BitcoinOrdering.bip69, + BitcoinOrdering outputOrdering = BitcoinOrdering.bip69, + }) async => + throw UnimplementedError(); + String toJSON() => json.encode({ - 'mnemonic': mnemonic, - 'account_index': walletAddresses.currentReceiveAddressIndex.toString(), - 'change_address_index': walletAddresses.currentChangeAddressIndex.toString(), - 'addresses': walletAddresses.addresses.map((addr) => addr.toJSON()).toList(), - 'balance': balance[currency]?.toJSON() + 'mnemonic': _mnemonic, + 'xpub': xpub, + 'passphrase': passphrase ?? '', + 'account_index': walletAddresses.currentReceiveAddressIndexByType, + 'change_address_index': walletAddresses.currentChangeAddressIndexByType, + 'addresses': walletAddresses.allAddresses.map((addr) => addr.toJSON()).toList(), + 'address_page_type': walletInfo.addressPageType == null + ? SegwitAddresType.p2wpkh.toString() + : walletInfo.addressPageType.toString(), + 'balance': balance[currency]?.toJSON(), + 'derivationTypeIndex': walletInfo.derivationInfo?.derivationType?.index, + 'derivationPath': walletInfo.derivationInfo?.derivationPath, + 'silent_addresses': walletAddresses.silentAddresses.map((addr) => addr.toJSON()).toList(), + 'silent_address_index': walletAddresses.currentSilentAddressIndex.toString(), + 'mweb_addresses': walletAddresses.mwebAddresses.map((addr) => addr.toJSON()).toList(), + 'alwaysScan': alwaysScan, }); int feeRate(TransactionPriority priority) { @@ -372,24 +1253,29 @@ abstract class ElectrumWalletBase } } - int feeAmountForPriority( - BitcoinTransactionPriority priority, int inputsCount, int outputsCount) => - feeRate(priority) * estimatedTransactionSize(inputsCount, outputsCount); + int feeAmountForPriority(TransactionPriority priority, int inputsCount, int outputsCount, + {int? size}) => + feeRate(priority) * (size ?? estimatedTransactionSize(inputsCount, outputsCount)); - int feeAmountWithFeeRate(int feeRate, int inputsCount, int outputsCount) => - feeRate * estimatedTransactionSize(inputsCount, outputsCount); + int feeAmountWithFeeRate(int feeRate, int inputsCount, int outputsCount, {int? size}) => + feeRate * (size ?? estimatedTransactionSize(inputsCount, outputsCount)); @override - int calculateEstimatedFee(TransactionPriority? priority, int? amount, {int? outputsCount}) { + int calculateEstimatedFee(TransactionPriority? priority, int? amount, + {int? outputsCount, int? size}) { if (priority is BitcoinTransactionPriority) { return calculateEstimatedFeeWithFeeRate(feeRate(priority), amount, - outputsCount: outputsCount); + outputsCount: outputsCount, size: size); } return 0; } - int calculateEstimatedFeeWithFeeRate(int feeRate, int? amount, {int? outputsCount}) { + int calculateEstimatedFeeWithFeeRate(int feeRate, int? amount, {int? outputsCount, int? size}) { + if (size != null) { + return feeAmountWithFeeRate(feeRate, 0, 0, size: size); + } + int inputsCount = 0; if (amount != null) { @@ -423,8 +1309,13 @@ abstract class ElectrumWalletBase @override Future save() async { + if (!(await WalletKeysFile.hasKeysFile(walletInfo.name, walletInfo.type))) { + await saveKeysFile(_password, encryptionFileUtils); + saveKeysFile(_password, encryptionFileUtils, true); + } + final path = await makePath(); - await write(path: path, password: _password, data: toJSON()); + await encryptionFileUtils.write(path: path, password: _password, data: toJSON()); await transactionHistory.save(); } @@ -457,261 +1348,917 @@ abstract class ElectrumWalletBase await transactionHistory.changePassword(password); } - bitcoin.ECPair keyPairFor({required int index}) => - generateKeyPair(hd: hd, index: index, network: networkType); - + @action @override - Future rescan({required int height}) async => throw UnimplementedError(); - - @override - Future close() async { - try { - await electrumClient.close(); - } catch (_) {} + Future rescan({required int height, bool? doSingleScan}) async { + silentPaymentsScanningActive = true; + _setListeners(height, doSingleScan: doSingleScan); } - Future makePath() async => pathForWallet(name: walletInfo.name, type: walletInfo.type); + @override + Future close({bool shouldCleanup = false}) async { + try { + await _receiveStream?.cancel(); + await electrumClient.close(); + } catch (_) {} + _autoSaveTimer?.cancel(); + _updateFeeRateTimer?.cancel(); + } - Future updateUnspent() async { - final unspent = await Future.wait(walletAddresses.addresses.map((address) => electrumClient - .getListUnspentWithAddress(address.address, networkType) - .then((unspent) => unspent.map((unspent) { - try { - return BitcoinUnspent.fromJSON(address, unspent); - } catch (_) { - return null; - } - }).whereNotNull()))); - unspentCoins = unspent.expand((e) => e).toList(); - unspentCoins.forEach((coin) async { - final tx = await fetchTransactionInfo(hash: coin.hash, height: 0); - coin.isChange = tx?.direction == TransactionDirection.outgoing; - }); + @action + Future updateAllUnspents() async { + List updatedUnspentCoins = []; - if (unspentCoinsInfo.isEmpty) { - unspentCoins.forEach((coin) => _addCoinInfo(coin)); - return; - } + final previousUnspentCoins = List.from(unspentCoins.where((utxo) => + utxo.bitcoinAddressRecord.type != SegwitAddresType.mweb && + utxo.bitcoinAddressRecord is! BitcoinSilentPaymentAddressRecord)); - if (unspentCoins.isNotEmpty) { - unspentCoins.forEach((coin) { - final coinInfoList = unspentCoinsInfo.values - .where((element) => element.walletId.contains(id) && element.hash.contains(coin.hash)); - - if (coinInfoList.isNotEmpty) { - final coinInfo = coinInfoList.first; - - coin.isFrozen = coinInfo.isFrozen; - coin.isSending = coinInfo.isSending; - coin.note = coinInfo.note; - } else { - _addCoinInfo(coin); + if (hasSilentPaymentsScanning) { + // Update unspents stored from scanned silent payment transactions + transactionHistory.transactions.values.forEach((tx) { + if (tx.unspents != null) { + updatedUnspentCoins.addAll(tx.unspents!); } }); } + // Set the balance of all non-silent payment and non-mweb addresses to 0 before updating + walletAddresses.allAddresses + .where((element) => element.type != SegwitAddresType.mweb) + .forEach((addr) { + if (addr is! BitcoinSilentPaymentAddressRecord) addr.balance = 0; + }); + + final addressFutures = walletAddresses.allAddresses + .where((element) => element.type != SegwitAddresType.mweb) + .map((address) => fetchUnspent(address)) + .toList(); + + final results = await Future.wait(addressFutures); + final failedCount = results.where((result) => result == null).length; + + if (failedCount == 0) { + for (final result in results) { + updatedUnspentCoins.addAll(result!); + } + unspentCoins = updatedUnspentCoins; + } else { + unspentCoins = handleFailedUtxoFetch( + failedCount: failedCount, + previousUnspentCoins: previousUnspentCoins, + updatedUnspentCoins: updatedUnspentCoins, + results: results, + ); + } + + final currentWalletUnspentCoins = + unspentCoinsInfo.values.where((element) => element.walletId == id); + + if (currentWalletUnspentCoins.length != updatedUnspentCoins.length) { + unspentCoins.forEach((coin) => addCoinInfo(coin)); + } + + await updateCoins(unspentCoins); await _refreshUnspentCoinsInfo(); } - Future _addCoinInfo(BitcoinUnspent coin) async { - final newInfo = UnspentCoinsInfo( - walletId: id, - hash: coin.hash, - isFrozen: coin.isFrozen, - isSending: coin.isSending, - noteRaw: coin.note, - address: coin.bitcoinAddressRecord.address, - value: coin.value, - vout: coin.vout, - isChange: coin.isChange, - ); + List handleFailedUtxoFetch({ + required int failedCount, + required List previousUnspentCoins, + required List updatedUnspentCoins, + required List?> results, + }) { + if (failedCount == results.length) { + printV("All UTXOs failed to fetch, falling back to previous UTXOs"); + return previousUnspentCoins; + } - await unspentCoinsInfo.add(newInfo); + final successfulUtxos = []; + for (final result in results) { + if (result != null) { + successfulUtxos.addAll(result); + } + } + + if (failedCount > 0 && successfulUtxos.isEmpty) { + printV("Some UTXOs failed, but no successful UTXOs, falling back to previous UTXOs"); + return previousUnspentCoins; + } + + if (failedCount > 0) { + printV("Some UTXOs failed, updating with successful UTXOs"); + updatedUnspentCoins.addAll(successfulUtxos); + } + + return updatedUnspentCoins; + } + + Future updateCoins(List newUnspentCoins) async { + if (newUnspentCoins.isEmpty) { + return; + } + + newUnspentCoins.forEach((coin) { + final coinInfoList = unspentCoinsInfo.values.where( + (element) => + element.walletId.contains(id) && + element.hash.contains(coin.hash) && + element.vout == coin.vout, + ); + + if (coinInfoList.isNotEmpty) { + final coinInfo = coinInfoList.first; + + coin.isFrozen = coinInfo.isFrozen; + coin.isSending = coinInfo.isSending; + coin.note = coinInfo.note; + + if (coin.bitcoinAddressRecord is! BitcoinSilentPaymentAddressRecord) + coin.bitcoinAddressRecord.balance += coinInfo.value; + } else { + addCoinInfo(coin); + } + }); + } + + @action + Future updateUnspentsForAddress(BitcoinAddressRecord address) async { + final newUnspentCoins = await fetchUnspent(address); + await updateCoins(newUnspentCoins ?? []); + } + + @action + Future?> fetchUnspent(BitcoinAddressRecord address) async { + List updatedUnspentCoins = []; + + final unspents = await electrumClient.getListUnspent(address.getScriptHash(network)); + + // Failed to fetch unspents + if (unspents == null) return null; + + await Future.wait(unspents.map((unspent) async { + try { + final coin = BitcoinUnspent.fromJSON(address, unspent); + final tx = await fetchTransactionInfo(hash: coin.hash); + coin.isChange = address.isHidden; + coin.confirmations = tx?.confirmations; + + updatedUnspentCoins.add(coin); + } catch (_) {} + })); + + return updatedUnspentCoins; + } + + @action + Future addCoinInfo(BitcoinUnspent coin) async { + // Check if the coin is already in the unspentCoinsInfo for the wallet + final existingCoinInfo = unspentCoinsInfo.values + .firstWhereOrNull((element) => element.walletId == walletInfo.id && element == coin); + + if (existingCoinInfo == null) { + final newInfo = UnspentCoinsInfo( + walletId: id, + hash: coin.hash, + isFrozen: coin.isFrozen, + isSending: coin.isSending, + noteRaw: coin.note, + address: coin.bitcoinAddressRecord.address, + value: coin.value, + vout: coin.vout, + isChange: coin.isChange, + isSilentPayment: coin is BitcoinSilentPaymentsUnspent, + ); + + await unspentCoinsInfo.add(newInfo); + } } Future _refreshUnspentCoinsInfo() async { try { - final List keys = []; + final List keys = []; final currentWalletUnspentCoins = - unspentCoinsInfo.values.where((element) => element.walletId.contains(id)); + unspentCoinsInfo.values.where((record) => record.walletId == id); - if (currentWalletUnspentCoins.isNotEmpty) { - currentWalletUnspentCoins.forEach((element) { - final existUnspentCoins = unspentCoins.where((coin) => element.hash.contains(coin.hash)); + for (final element in currentWalletUnspentCoins) { + if (RegexUtils.addressTypeFromStr(element.address, network) is MwebAddress) continue; - if (existUnspentCoins.isEmpty) { - keys.add(element.key); - } - }); + final existUnspentCoins = unspentCoins.where((coin) => element == coin); + + if (existUnspentCoins.isEmpty) { + keys.add(element.key); + } } if (keys.isNotEmpty) { await unspentCoinsInfo.deleteAll(keys); } } catch (e) { - print(e.toString()); + printV("refreshUnspentCoinsInfo $e"); } } - Future getTransactionExpanded( - {required String hash, required int height}) async { - final verboseTransaction = await electrumClient.getTransactionRaw(hash: hash); - final transactionHex = verboseTransaction['hex'] as String; - final original = bitcoin.Transaction.fromHex(transactionHex); - final ins = []; - final time = verboseTransaction['time'] as int?; - final confirmations = verboseTransaction['confirmations'] as int? ?? 0; + Future cleanUpDuplicateUnspentCoins() async { + final currentWalletUnspentCoins = + unspentCoinsInfo.values.where((element) => element.walletId == id); + final Map uniqueUnspentCoins = {}; + final List duplicateKeys = []; - for (final vin in original.ins) { - final id = HEX.encode(vin.hash!.reversed.toList()); - final txHex = await electrumClient.getTransactionHex(hash: id); - final tx = bitcoin.Transaction.fromHex(txHex); - ins.add(tx); + for (final unspentCoin in currentWalletUnspentCoins) { + final key = '${unspentCoin.hash}:${unspentCoin.vout}'; + if (!uniqueUnspentCoins.containsKey(key)) { + uniqueUnspentCoins[key] = unspentCoin; + } else { + duplicateKeys.add(unspentCoin.key); + } } - return ElectrumTransactionBundle(original, ins: ins, time: time, confirmations: confirmations); + if (duplicateKeys.isNotEmpty) await unspentCoinsInfo.deleteAll(duplicateKeys); } - Future fetchTransactionInfo( - {required String hash, required int height}) async { + int transactionVSize(String transactionHex) => BtcTransaction.fromRaw(transactionHex).getVSize(); + + Future canReplaceByFee(ElectrumTransactionInfo tx) async { try { - final tx = await getTransactionExpanded(hash: hash, height: height); - final addresses = walletAddresses.addresses.map((addr) => addr.address).toSet(); - return ElectrumTransactionInfo.fromElectrumBundle(tx, walletInfo.type, networkType, - addresses: addresses, height: height); - } catch (_) { + final bundle = await getTransactionExpanded(hash: tx.txHash); + _updateInputsAndOutputs(tx, bundle); + if (bundle.confirmations > 0) return null; + return bundle.originalTransaction.canReplaceByFee ? bundle.originalTransaction.toHex() : null; + } catch (e) { return null; } } - @override - Future> fetchTransactions() async { - final addressHashes = {}; - final normalizedHistories = >[]; - final newTxCounts = {}; + Future isChangeSufficientForFee(String txId, int newFee) async { + final bundle = await getTransactionExpanded(hash: txId); + final outputs = bundle.originalTransaction.outputs; - walletAddresses.addresses.forEach((addressRecord) { - final sh = scriptHash(addressRecord.address, networkType: networkType); - addressHashes[sh] = addressRecord; - newTxCounts[sh] = 0; - }); + final ownAddresses = walletAddresses.allAddresses.map((addr) => addr.address).toSet(); + final receiverAmount = outputs + .where((output) => + !ownAddresses.contains(addressFromOutputScript(output.scriptPubKey, network))) + .fold(0, (sum, output) => sum + output.amount.toInt()); + + if (receiverAmount == 0) { + throw Exception("Receiver output not found."); + } + + final availableInputs = unspentCoins.where((utxo) => utxo.isSending && !utxo.isFrozen).toList(); + int totalBalance = availableInputs.fold( + 0, (previousValue, element) => previousValue + element.value.toInt()); + + int allInputsAmount = 0; + for (int i = 0; i < bundle.originalTransaction.inputs.length; i++) { + final input = bundle.originalTransaction.inputs[i]; + final inputTransaction = bundle.ins[i]; + final vout = input.txIndex; + final outTransaction = inputTransaction.outputs[vout]; + allInputsAmount += outTransaction.amount.toInt(); + } + + int totalOutAmount = bundle.originalTransaction.outputs + .fold(0, (previousValue, element) => previousValue + element.amount.toInt()); + var currentFee = allInputsAmount - totalOutAmount; + + int remainingFee = (newFee - currentFee > 0) ? newFee - currentFee : newFee; + return totalBalance - receiverAmount - remainingFee >= _dustAmount; + } + + Future replaceByFee(String hash, int newFee) async { try { - final histories = addressHashes.keys.map((scriptHash) => - electrumClient.getHistory(scriptHash).then((history) => {scriptHash: history})); - final historyResults = await Future.wait(histories); + final bundle = await getTransactionExpanded(hash: hash); - historyResults.forEach((history) { - history.entries.forEach((historyItem) { - if (historyItem.value.isNotEmpty) { - final address = addressHashes[historyItem.key]; - address?.setAsUsed(); - newTxCounts[historyItem.key] = historyItem.value.length; - normalizedHistories.addAll(historyItem.value); + final utxos = []; + final outputs = []; + List privateKeys = []; + + var allInputsAmount = 0; + String? memo; + + // Add original inputs + for (var i = 0; i < bundle.originalTransaction.inputs.length; i++) { + final input = bundle.originalTransaction.inputs[i]; + final inputTransaction = bundle.ins[i]; + final vout = input.txIndex; + final outTransaction = inputTransaction.outputs[vout]; + final address = addressFromOutputScript(outTransaction.scriptPubKey, network); + allInputsAmount += outTransaction.amount.toInt(); + + final addressRecord = + walletAddresses.allAddresses.firstWhere((element) => element.address == address); + final btcAddress = RegexUtils.addressTypeFromStr(addressRecord.address, network); + final privkey = generateECPrivate( + hd: addressRecord.isHidden ? walletAddresses.sideHd : walletAddresses.mainHd, + index: addressRecord.index, + network: network); + + privateKeys.add(privkey); + + utxos.add( + UtxoWithAddress( + utxo: BitcoinUtxo( + txHash: input.txId, + value: outTransaction.amount, + vout: vout, + scriptType: _getScriptType(btcAddress), + ), + ownerDetails: + UtxoAddressDetails(publicKey: privkey.getPublic().toHex(), address: btcAddress), + ), + ); + } + + // Add original outputs + for (final out in bundle.originalTransaction.outputs) { + final script = out.scriptPubKey.script; + if (script.contains('OP_RETURN') && memo == null) { + final index = script.indexOf('OP_RETURN'); + if (index + 1 <= script.length) { + try { + final opReturnData = script[index + 1].toString(); + memo = utf8.decode(HEX.decode(opReturnData)); + continue; + } catch (_) { + throw Exception('Cannot decode OP_RETURN data'); + } } - }); - }); + } - for (var sh in addressHashes.keys) { - var balanceData = await electrumClient.getBalance(sh); - var addressRecord = addressHashes[sh]; - if (addressRecord != null) { - addressRecord.balance = balanceData['confirmed'] as int? ?? 0; + final address = addressFromOutputScript(out.scriptPubKey, network); + final btcAddress = RegexUtils.addressTypeFromStr(address, network); + outputs.add(BitcoinOutput(address: btcAddress, value: BigInt.from(out.amount.toInt()))); + } + + // Calculate the total amount and fees + int totalOutAmount = + outputs.fold(0, (previousValue, output) => previousValue + output.value.toInt()); + int currentFee = allInputsAmount - totalOutAmount; + int remainingFee = newFee - currentFee; + + if (remainingFee <= 0) { + throw Exception("New fee must be higher than the current fee."); + } + + // Deduct fee from change outputs first, if possible + if (remainingFee > 0) { + final changeAddresses = walletAddresses.allAddresses.where((element) => element.isHidden); + for (int i = outputs.length - 1; i >= 0; i--) { + final output = outputs[i]; + final isChange = changeAddresses + .any((element) => element.address == output.address.toAddress(network)); + + if (isChange) { + int outputAmount = output.value.toInt(); + if (outputAmount > _dustAmount) { + int deduction = (outputAmount - _dustAmount >= remainingFee) + ? remainingFee + : outputAmount - _dustAmount; + outputs[i] = BitcoinOutput( + address: output.address, value: BigInt.from(outputAmount - deduction)); + remainingFee -= deduction; + + if (remainingFee <= 0) break; + } + } } } - addressHashes.forEach((sh, addressRecord) { - addressRecord.txCount = newTxCounts[sh] ?? 0; + // If still not enough, add UTXOs until the fee is covered + if (remainingFee > 0) { + final unusedUtxos = unspentCoins + .where((utxo) => utxo.isSending && !utxo.isFrozen && utxo.confirmations! > 0) + .toList(); + + for (final utxo in unusedUtxos) { + final address = RegexUtils.addressTypeFromStr(utxo.address, network); + final privkey = generateECPrivate( + hd: utxo.bitcoinAddressRecord.isHidden + ? walletAddresses.sideHd + : walletAddresses.mainHd, + index: utxo.bitcoinAddressRecord.index, + network: network, + ); + privateKeys.add(privkey); + + utxos.add(UtxoWithAddress( + utxo: BitcoinUtxo( + txHash: utxo.hash, + value: BigInt.from(utxo.value), + vout: utxo.vout, + scriptType: _getScriptType(address)), + ownerDetails: + UtxoAddressDetails(publicKey: privkey.getPublic().toHex(), address: address), + )); + + allInputsAmount += utxo.value; + remainingFee -= utxo.value; + + if (remainingFee < 0) { + final changeOutput = outputs.firstWhereOrNull((output) => walletAddresses.allAddresses + .any((addr) => addr.address == output.address.toAddress(network))); + if (changeOutput != null) { + final newValue = changeOutput.value.toInt() + (-remainingFee); + outputs[outputs.indexOf(changeOutput)] = + BitcoinOutput(address: changeOutput.address, value: BigInt.from(newValue)); + } else { + final changeAddress = await walletAddresses.getChangeAddress(); + outputs.add(BitcoinOutput( + address: RegexUtils.addressTypeFromStr(changeAddress.address, network), + value: BigInt.from(-remainingFee))); + } + + remainingFee = 0; + break; + } + + if (remainingFee <= 0) break; + } + } + + // Deduct from the receiver's output if remaining fee is still greater than 0 + if (remainingFee > 0) { + for (int i = 0; i < outputs.length; i++) { + final output = outputs[i]; + int outputAmount = output.value.toInt(); + + if (outputAmount > _dustAmount) { + int deduction = (outputAmount - _dustAmount >= remainingFee) + ? remainingFee + : outputAmount - _dustAmount; + + outputs[i] = BitcoinOutput( + address: output.address, value: BigInt.from(outputAmount - deduction)); + remainingFee -= deduction; + + if (remainingFee <= 0) break; + } + } + } + + // Final check if the remaining fee couldn't be deducted + if (remainingFee > 0) { + throw Exception("Not enough funds to cover the fee."); + } + + // Identify all change outputs + final changeAddresses = walletAddresses.allAddresses.where((element) => element.isHidden); + final List changeOutputs = outputs + .where((output) => changeAddresses + .any((element) => element.address == output.address.toAddress(network))) + .toList(); + + int totalChangeAmount = + changeOutputs.fold(0, (sum, output) => sum + output.value.toInt()); + + // The final amount that the receiver will receive + int sendingAmount = allInputsAmount - newFee - totalChangeAmount; + + final txb = BitcoinTransactionBuilder( + utxos: utxos, + outputs: outputs, + fee: BigInt.from(newFee), + network: network, + memo: memo, + outputOrdering: BitcoinOrdering.none, + enableRBF: true, + ); + + final transaction = txb.buildTransaction((txDigest, utxo, publicKey, sighash) { + final key = + privateKeys.firstWhereOrNull((element) => element.getPublic().toHex() == publicKey); + if (key == null) { + throw Exception("Cannot find private key"); + } + + if (utxo.utxo.isP2tr()) { + return key.signTapRoot(txDigest, sighash: sighash); + } else { + return key.signInput(txDigest, sigHash: sighash); + } }); - final historiesWithDetails = await Future.wait(normalizedHistories.map((transaction) { - try { - return fetchTransactionInfo( - hash: transaction['tx_hash'] as String, height: transaction['height'] as int); - } catch (_) { - return Future.value(null); - } - })); - - return historiesWithDetails.fold>( - {}, (acc, tx) { - if (tx == null) { - return acc; - } - acc[tx.id] = acc[tx.id]?.updated(tx) ?? tx; - return acc; - }); + return PendingBitcoinTransaction( + transaction, + type, + electrumClient: electrumClient, + amount: sendingAmount, + fee: newFee, + network: network, + hasChange: changeOutputs.isNotEmpty, + feeRate: newFee.toString(), + )..addListener((transaction) async { + transactionHistory.transactions.values.forEach((tx) { + if (tx.id == hash) { + tx.isReplaced = true; + tx.isPending = false; + transactionHistory.addOne(tx); + } + }); + transactionHistory.addOne(transaction); + await updateBalance(); + await updateAllUnspents(); + }); } catch (e) { - print(e.toString()); + throw e; + } + } + + Future getTransactionExpanded( + {required String hash, int? height}) async { + String transactionHex; + int? time; + int? confirmations; + + final verboseTransaction = await electrumClient.getTransactionVerbose(hash: hash); + + if (verboseTransaction.isEmpty) { + transactionHex = await electrumClient.getTransactionHex(hash: hash); + + if (height != null && height > 0 && await checkIfMempoolAPIIsEnabled()) { + try { + final blockHash = await ProxyWrapper() + .get(clearnetUri: Uri.parse("https://mempool.cakewallet.com/api/v1/block-height/$height")) + .timeout(Duration(seconds: 15)); + + if (blockHash.statusCode == 200 && + blockHash.body.isNotEmpty && + jsonDecode(blockHash.body) != null) { + final blockResponse = await ProxyWrapper() + .get(clearnetUri: Uri.parse("https://mempool.cakewallet.com/api/v1/block/${blockHash}")) + .timeout(Duration(seconds: 15)); + + if (blockResponse.statusCode == 200 && + blockResponse.body.isNotEmpty && + jsonDecode(blockResponse.body)['timestamp'] != null) { + time = int.parse(jsonDecode(blockResponse.body)['timestamp'].toString()); + } + } + } catch (_) {} + } + } else { + transactionHex = verboseTransaction['hex'] as String; + time = verboseTransaction['time'] as int?; + confirmations = verboseTransaction['confirmations'] as int?; + } + + if (height != null) { + if (time == null && height > 0) { + time = (getDateByBitcoinHeight(height).millisecondsSinceEpoch / 1000).round(); + } + + if (confirmations == null) { + final tip = await getUpdatedChainTip(); + if (tip > 0 && height > 0) { + // Add one because the block itself is the first confirmation + confirmations = tip - height + 1; + } + } + } + + final original = BtcTransaction.fromRaw(transactionHex); + final ins = []; + + for (final vin in original.inputs) { + final verboseTransaction = await electrumClient.getTransactionVerbose(hash: vin.txId); + + final String inputTransactionHex; + + if (verboseTransaction.isEmpty) { + inputTransactionHex = await electrumClient.getTransactionHex(hash: hash); + } else { + inputTransactionHex = verboseTransaction['hex'] as String; + } + + ins.add(BtcTransaction.fromRaw(inputTransactionHex)); + } + + return ElectrumTransactionBundle( + original, + ins: ins, + time: time, + confirmations: confirmations ?? 0, + ); + } + + Future fetchTransactionInfo( + {required String hash, int? height, bool? retryOnFailure}) async { + try { + return ElectrumTransactionInfo.fromElectrumBundle( + await getTransactionExpanded(hash: hash, height: height), + walletInfo.type, + network, + addresses: addressesSet, + height: height, + ); + } catch (e) { + if (e is FormatException && retryOnFailure == true) { + await Future.delayed(const Duration(seconds: 2)); + return fetchTransactionInfo(hash: hash, height: height); + } + return null; + } + } + + bool isMine(Script script) { + final derivedAddress = addressFromOutputScript(script, network); + return addressesSet.contains(derivedAddress); + } + + @override + Future> fetchTransactions() async { + try { + final Map historiesWithDetails = {}; + + if (type == WalletType.bitcoin) { + await Future.wait(BITCOIN_ADDRESS_TYPES + .map((type) => fetchTransactionsForAddressType(historiesWithDetails, type))); + } else if (type == WalletType.bitcoinCash) { + await Future.wait(BITCOIN_CASH_ADDRESS_TYPES + .map((type) => fetchTransactionsForAddressType(historiesWithDetails, type))); + } else if (type == WalletType.litecoin) { + await Future.wait(LITECOIN_ADDRESS_TYPES + .where((type) => type != SegwitAddresType.mweb) + .map((type) => fetchTransactionsForAddressType(historiesWithDetails, type))); + } + + transactionHistory.transactions.values.forEach((tx) async { + final isPendingSilentPaymentUtxo = + (tx.isPending || tx.confirmations == 0) && historiesWithDetails[tx.id] == null; + + if (isPendingSilentPaymentUtxo) { + final info = + await fetchTransactionInfo(hash: tx.id, height: tx.height, retryOnFailure: true); + + if (info != null) { + tx.confirmations = info.confirmations; + tx.isPending = tx.confirmations == 0; + transactionHistory.addOne(tx); + await transactionHistory.save(); + } + } + }); + + return historiesWithDetails; + } catch (e) { + printV("fetchTransactions $e"); + return {}; + } + } + + Future fetchTransactionsForAddressType( + Map historiesWithDetails, + BitcoinAddressType type, + ) async { + final addressesByType = walletAddresses.allAddresses.where((addr) => addr.type == type); + final hiddenAddresses = addressesByType.where((addr) => addr.isHidden == true); + final receiveAddresses = addressesByType.where((addr) => addr.isHidden == false); + walletAddresses.hiddenAddresses.addAll(hiddenAddresses.map((e) => e.address)); + await walletAddresses.saveAddressesInBox(); + await Future.wait(addressesByType.map((addressRecord) async { + final history = await _fetchAddressHistory(addressRecord, await getCurrentChainTip()); + + if (history.isNotEmpty) { + addressRecord.txCount = history.length; + historiesWithDetails.addAll(history); + + final matchedAddresses = addressRecord.isHidden ? hiddenAddresses : receiveAddresses; + final isUsedAddressUnderGap = matchedAddresses.toList().indexOf(addressRecord) >= + matchedAddresses.length - + (addressRecord.isHidden + ? ElectrumWalletAddressesBase.defaultChangeAddressesCount + : ElectrumWalletAddressesBase.defaultReceiveAddressesCount); + + if (isUsedAddressUnderGap) { + final prevLength = walletAddresses.allAddresses.length; + + // Discover new addresses for the same address type until the gap limit is respected + await walletAddresses.discoverAddresses( + matchedAddresses.toList(), + addressRecord.isHidden, + (address) async { + await subscribeForUpdates(); + return _fetchAddressHistory(address, await getCurrentChainTip()) + .then((history) => history.isNotEmpty ? address.address : null); + }, + type: type, + ); + + final newLength = walletAddresses.allAddresses.length; + + if (newLength > prevLength) { + await fetchTransactionsForAddressType(historiesWithDetails, type); + } + } + } + })); + } + + Future> _fetchAddressHistory( + BitcoinAddressRecord addressRecord, int? currentHeight) async { + String txid = ""; + + try { + final Map historiesWithDetails = {}; + + final history = await electrumClient.getHistory(addressRecord.getScriptHash(network)); + + if (history.isNotEmpty) { + addressRecord.setAsUsed(); + + await Future.wait(history.map((transaction) async { + txid = transaction['tx_hash'] as String; + final height = transaction['height'] as int; + final storedTx = transactionHistory.transactions[txid]; + + if (storedTx != null) { + if (height > 0) { + storedTx.height = height; + // the tx's block itself is the first confirmation so add 1 + if ((currentHeight ?? 0) > 0) { + storedTx.confirmations = currentHeight! - height + 1; + } + storedTx.isPending = storedTx.confirmations == 0; + } + + historiesWithDetails[txid] = storedTx; + } else { + final tx = await fetchTransactionInfo(hash: txid, height: height, retryOnFailure: true); + + if (tx != null) { + historiesWithDetails[txid] = tx; + + // Got a new transaction fetched, add it to the transaction history + // instead of waiting all to finish, and next time it will be faster + + if (this is LitecoinWallet) { + // if we have a peg out transaction with the same value + // that matches this received transaction, mark it as being from a peg out: + for (final tx2 in transactionHistory.transactions.values) { + final heightDiff = ((tx2.height ?? 0) - (tx.height ?? 0)).abs(); + // this isn't a perfect matching algorithm since we don't have the right input/output information from these transaction models (the addresses are in different formats), but this should be more than good enough for now as it's extremely unlikely a user receives the EXACT same amount from 2 different sources and one of them is a peg out and the other isn't WITHIN 5 blocks of each other + if (tx2.additionalInfo["isPegOut"] == true && + tx2.amount == tx.amount && + heightDiff <= 5) { + tx.additionalInfo["fromPegOut"] = true; + } + } + } + transactionHistory.addOne(tx); + await transactionHistory.save(); + } + } + + return Future.value(null); + })); + } + + return historiesWithDetails; + } catch (e, stacktrace) { + _onError?.call(FlutterErrorDetails( + exception: "$txid - $e", + stack: stacktrace, + library: this.runtimeType.toString(), + )); return {}; } } Future updateTransactions() async { + printV("updateTransactions() called!"); try { if (_isTransactionUpdating) { return; } + currentChainTip = await getUpdatedChainTip(); + + bool updated = false; + transactionHistory.transactions.values.forEach((tx) { + if ((tx.height ?? 0) > 0 && (currentChainTip ?? 0) > 0) { + var confirmations = currentChainTip! - tx.height! + 1; + if (confirmations < 0) { + // if our chain tip is outdated then it could lead to negative confirmations so this is just a failsafe: + confirmations = 0; + } + if (confirmations != tx.confirmations) { + updated = true; + tx.confirmations = confirmations; + transactionHistory.addOne(tx); + } + } + }); + + if (updated) { + await transactionHistory.save(); + } _isTransactionUpdating = true; - final transactions = await fetchTransactions(); - transactionHistory.addMany(transactions); + await fetchTransactions(); walletAddresses.updateReceiveAddresses(); - await transactionHistory.save(); _isTransactionUpdating = false; } catch (e, stacktrace) { - print(stacktrace); - print(e); + printV(stacktrace); + printV(e); _isTransactionUpdating = false; } } - void _subscribeForUpdates() { - scriptHashes.forEach((sh) async { - await _scripthashesUpdateSubject[sh]?.close(); - _scripthashesUpdateSubject[sh] = electrumClient.scripthashUpdate(sh); + Future subscribeForUpdates() async { + final unsubscribedScriptHashes = walletAddresses.allAddresses.where( + (address) => + !_scripthashesUpdateSubject.containsKey(address.getScriptHash(network)) && + address.type != SegwitAddresType.mweb, + ); + + await Future.wait(unsubscribedScriptHashes.map((address) async { + final sh = address.getScriptHash(network); + if (!(_scripthashesUpdateSubject[sh]?.isClosed ?? true)) { + try { + await _scripthashesUpdateSubject[sh]?.close(); + } catch (e) { + printV("failed to close: $e"); + } + } + try { + _scripthashesUpdateSubject[sh] = await electrumClient.scripthashUpdate(sh); + } catch (e) { + printV("failed scripthashUpdate: $e"); + } _scripthashesUpdateSubject[sh]?.listen((event) async { try { - await updateUnspent(); + await updateUnspentsForAddress(address); + await updateBalance(); - await updateTransactions(); + + await _fetchAddressHistory(address, await getCurrentChainTip()); } catch (e, s) { - print(e.toString()); + printV("sub error: $e"); _onError?.call(FlutterErrorDetails( exception: e, stack: s, library: this.runtimeType.toString(), )); } + }, onError: (e, s) { + printV("sub_listen error: $e $s"); }); - }); + })); } - Future _fetchBalances() async { - final addresses = walletAddresses.addresses.toList(); + Future fetchBalances() async { + final addresses = walletAddresses.allAddresses + .where((address) => RegexUtils.addressTypeFromStr(address.address, network) is! MwebAddress) + .toList(); final balanceFutures = >>[]; for (var i = 0; i < addresses.length; i++) { final addressRecord = addresses[i]; - final sh = scriptHash(addressRecord.address, networkType: networkType); + final sh = addressRecord.getScriptHash(network); final balanceFuture = electrumClient.getBalance(sh); balanceFutures.add(balanceFuture); } var totalFrozen = 0; + var totalConfirmed = 0; + var totalUnconfirmed = 0; + + if (hasSilentPaymentsScanning) { + // Add values from unspent coins that are not fetched by the address list + // i.e. scanned silent payments + transactionHistory.transactions.values.forEach((tx) { + if (tx.unspents != null) { + tx.unspents!.forEach((unspent) { + if (unspent.bitcoinAddressRecord is BitcoinSilentPaymentAddressRecord) { + if (unspent.isFrozen) totalFrozen += unspent.value; + totalConfirmed += unspent.value; + } + }); + } + }); + } + unspentCoinsInfo.values.forEach((info) { unspentCoins.forEach((element) { + if (element.bitcoinAddressRecord is BitcoinSilentPaymentAddressRecord) return; + if (element.hash == info.hash && - info.isFrozen && + element.vout == info.vout && element.bitcoinAddressRecord.address == info.address && element.value == info.value) { - totalFrozen += element.value; + if (info.isFrozen) { + totalFrozen += element.value; + } } }); }); final balances = await Future.wait(balanceFutures); - var totalConfirmed = 0; - var totalUnconfirmed = 0; + + if (balances.isNotEmpty && balances.first['confirmed'] == null) { + // if we got null balance responses from the server, set our connection status to lost and return our last known balance: + printV("got null balance responses from the server, setting connection status to lost"); + syncStatus = LostConnectionSyncStatus(); + return balance[currency] ?? ElectrumBalance(confirmed: 0, unconfirmed: 0, frozen: 0); + } for (var i = 0; i < balances.length; i++) { final addressRecord = addresses[i]; @@ -721,41 +2268,570 @@ abstract class ElectrumWalletBase totalConfirmed += confirmed; totalUnconfirmed += unconfirmed; + addressRecord.balance = confirmed + unconfirmed; if (confirmed > 0 || unconfirmed > 0) { addressRecord.setAsUsed(); } } return ElectrumBalance( - confirmed: totalConfirmed, unconfirmed: totalUnconfirmed, frozen: totalFrozen); + confirmed: totalConfirmed, + unconfirmed: totalUnconfirmed, + frozen: totalFrozen, + ); } Future updateBalance() async { - balance[currency] = await _fetchBalances(); + printV("updateBalance() called!"); + balance[currency] = await fetchBalances(); await save(); } - String getChangeAddress() { - const minCountOfHiddenAddresses = 5; - final random = Random(); - var addresses = walletAddresses.addresses.where((addr) => addr.isHidden).toList(); - - if (addresses.length < minCountOfHiddenAddresses) { - addresses = walletAddresses.addresses.toList(); - } - - return addresses[random.nextInt(addresses.length)].address; - } - @override void setExceptionHandler(void Function(FlutterErrorDetails) onError) => _onError = onError; @override - String signMessage(String message, {String? address = null}) { + Future signMessage(String message, {String? address = null}) async { final index = address != null - ? walletAddresses.addresses.firstWhere((element) => element.address == address).index + ? walletAddresses.allAddresses.firstWhere((element) => element.address == address).index : null; - final HD = index == null ? hd : hd.derive(index); - return base64Encode(HD.signMessage(message)); + final HD = index == null ? hd : hd.childKey(Bip32KeyIndex(index)); + final priv = ECPrivate.fromHex(HD.privateKey.privKey.toHex()); + + String messagePrefix = '\x18Bitcoin Signed Message:\n'; + final hexEncoded = priv.signMessage(utf8.encode(message), messagePrefix: messagePrefix); + final decodedSig = hex.decode(hexEncoded); + return base64Encode(decodedSig); + } + + @override + Future verifyMessage(String message, String signature, {String? address = null}) async { + if (address == null) { + return false; + } + + List sigDecodedBytes = []; + + if (signature.endsWith('=')) { + sigDecodedBytes = base64.decode(signature); + } else { + sigDecodedBytes = hex.decode(signature); + } + + if (sigDecodedBytes.length != 64 && sigDecodedBytes.length != 65) { + throw ArgumentException( + "signature must be 64 bytes without recover-id or 65 bytes with recover-id"); + } + + String messagePrefix = '\x18Bitcoin Signed Message:\n'; + final messageHash = QuickCrypto.sha256Hash( + BitcoinSignerUtils.magicMessage(utf8.encode(message), messagePrefix)); + + List correctSignature = + sigDecodedBytes.length == 65 ? sigDecodedBytes.sublist(1) : List.from(sigDecodedBytes); + List rBytes = correctSignature.sublist(0, 32); + List sBytes = correctSignature.sublist(32); + final sig = ECDSASignature(BigintUtils.fromBytes(rBytes), BigintUtils.fromBytes(sBytes)); + + List possibleRecoverIds = [0, 1]; + + final baseAddress = RegexUtils.addressTypeFromStr(address, network); + + for (int recoveryId in possibleRecoverIds) { + final pubKey = sig.recoverPublicKey(messageHash, Curves.generatorSecp256k1, recoveryId); + + final recoveredPub = ECPublic.fromBytes(pubKey!.toBytes()); + + String? recoveredAddress; + + if (baseAddress is P2pkAddress) { + recoveredAddress = recoveredPub.toP2pkAddress().toAddress(network); + } else if (baseAddress is P2pkhAddress) { + recoveredAddress = recoveredPub.toP2pkhAddress().toAddress(network); + } else if (baseAddress is P2wshAddress) { + recoveredAddress = recoveredPub.toP2wshAddress().toAddress(network); + } else if (baseAddress is P2wpkhAddress) { + recoveredAddress = recoveredPub.toP2wpkhAddress().toAddress(network); + } + + if (recoveredAddress == address) { + return true; + } + } + + return false; + } + + Future _setInitialHeight() async { + if (_chainTipUpdateSubject != null) return; + + currentChainTip = await getUpdatedChainTip(); + + if ((currentChainTip == null || currentChainTip! == 0) && walletInfo.restoreHeight == 0) { + await walletInfo.updateRestoreHeight(currentChainTip!); + } + + _chainTipUpdateSubject = electrumClient.chainTipSubscribe(); + _chainTipUpdateSubject?.listen((e) async { + final event = e as Map; + final height = int.tryParse(event['height'].toString()); + + if (height != null) { + currentChainTip = height; + + if (alwaysScan == true && syncStatus is SyncedSyncStatus) { + _setListeners(walletInfo.restoreHeight); + } + } + }); + } + + static String _hardenedDerivationPath(String derivationPath) => + derivationPath.substring(0, derivationPath.lastIndexOf("'") + 1); + + @action + void _onConnectionStatusChange(ConnectionStatus status) { + switch (status) { + case ConnectionStatus.connected: + if (syncStatus is NotConnectedSyncStatus || + syncStatus is LostConnectionSyncStatus || + syncStatus is ConnectingSyncStatus) { + syncStatus = ConnectedSyncStatus(); + } + + break; + case ConnectionStatus.disconnected: + if (syncStatus is! NotConnectedSyncStatus && + syncStatus is! ConnectingSyncStatus && + syncStatus is! SyncronizingSyncStatus) { + syncStatus = NotConnectedSyncStatus(); + } + break; + case ConnectionStatus.failed: + if (syncStatus is! LostConnectionSyncStatus) { + syncStatus = LostConnectionSyncStatus(); + } + break; + case ConnectionStatus.connecting: + if (syncStatus is! ConnectingSyncStatus) { + syncStatus = ConnectingSyncStatus(); + } + break; + default: + } + } + + void _syncStatusReaction(SyncStatus syncStatus) async { + printV("SYNC_STATUS_CHANGE: ${syncStatus}"); + if (syncStatus is SyncingSyncStatus) { + return; + } + + if (syncStatus is NotConnectedSyncStatus || syncStatus is LostConnectionSyncStatus) { + // Needs to re-subscribe to all scripthashes when reconnected + _scripthashesUpdateSubject = {}; + + if (_isTryingToConnect) return; + + _isTryingToConnect = true; + + Timer(Duration(seconds: 5), () { + if (this.syncStatus is NotConnectedSyncStatus || + this.syncStatus is LostConnectionSyncStatus) { + this.electrumClient.connectToUri( + node!.uri, + useSSL: node!.useSSL ?? false, + ); + } + _isTryingToConnect = false; + }); + } + + // Message is shown on the UI for 3 seconds, revert to synced + if (syncStatus is SyncedTipSyncStatus) { + Timer(Duration(seconds: 3), () { + if (this.syncStatus is SyncedTipSyncStatus) this.syncStatus = SyncedSyncStatus(); + }); + } + } + + void _updateInputsAndOutputs(ElectrumTransactionInfo tx, ElectrumTransactionBundle bundle) { + tx.inputAddresses = tx.inputAddresses?.where((address) => address.isNotEmpty).toList(); + + if (tx.inputAddresses == null || + tx.inputAddresses!.isEmpty || + tx.outputAddresses == null || + tx.outputAddresses!.isEmpty) { + List inputAddresses = []; + List outputAddresses = []; + + for (int i = 0; i < bundle.originalTransaction.inputs.length; i++) { + final input = bundle.originalTransaction.inputs[i]; + final inputTransaction = bundle.ins[i]; + final vout = input.txIndex; + final outTransaction = inputTransaction.outputs[vout]; + final address = addressFromOutputScript(outTransaction.scriptPubKey, network); + + if (address.isNotEmpty) inputAddresses.add(address); + } + + for (int i = 0; i < bundle.originalTransaction.outputs.length; i++) { + final out = bundle.originalTransaction.outputs[i]; + final address = addressFromOutputScript(out.scriptPubKey, network); + + if (address.isNotEmpty) outputAddresses.add(address); + + // Check if the script contains OP_RETURN + final script = out.scriptPubKey.script; + if (script.contains('OP_RETURN')) { + final index = script.indexOf('OP_RETURN'); + if (index + 1 <= script.length) { + try { + final opReturnData = script[index + 1].toString(); + final decodedString = utf8.decode(HEX.decode(opReturnData)); + outputAddresses.add('OP_RETURN:$decodedString'); + } catch (_) { + outputAddresses.add('OP_RETURN:'); + } + } + } + } + tx.inputAddresses = inputAddresses; + tx.outputAddresses = outputAddresses; + + transactionHistory.addOne(tx); + } + } + + @override + String formatCryptoAmount(String amount) { + final amountInt = int.parse(amount); + return bitcoinAmountToString(amount: amountInt); } } + +class ScanNode { + final Uri uri; + final bool? useSSL; + + ScanNode(this.uri, this.useSSL); +} + +class ScanData { + final SendPort sendPort; + final SilentPaymentOwner silentAddress; + final int height; + final ScanNode? node; + final BasedUtxoNetwork network; + final int chainTip; + final ElectrumClient electrumClient; + final List transactionHistoryIds; + final Map labels; + final List labelIndexes; + final bool isSingleScan; + + ScanData({ + required this.sendPort, + required this.silentAddress, + required this.height, + required this.node, + required this.network, + required this.chainTip, + required this.electrumClient, + required this.transactionHistoryIds, + required this.labels, + required this.labelIndexes, + required this.isSingleScan, + }); + + factory ScanData.fromHeight(ScanData scanData, int newHeight) { + return ScanData( + sendPort: scanData.sendPort, + silentAddress: scanData.silentAddress, + height: newHeight, + node: scanData.node, + network: scanData.network, + chainTip: scanData.chainTip, + transactionHistoryIds: scanData.transactionHistoryIds, + electrumClient: scanData.electrumClient, + labels: scanData.labels, + labelIndexes: scanData.labelIndexes, + isSingleScan: scanData.isSingleScan, + ); + } +} + +class SyncResponse { + final int height; + final SyncStatus syncStatus; + + SyncResponse(this.height, this.syncStatus); +} + +Future startRefresh(ScanData scanData) async { + int syncHeight = scanData.height; + int initialSyncHeight = syncHeight; + + BehaviorSubject? tweaksSubscription = null; + + final electrumClient = scanData.electrumClient; + await electrumClient.connectToUri( + scanData.node?.uri ?? Uri.parse("tcp://electrs.cakewallet.com:50001"), + useSSL: scanData.node?.useSSL ?? false, + ); + + int getCountPerRequest(int syncHeight) { + if (scanData.isSingleScan) { + return 1; + } + + final amountLeft = scanData.chainTip - syncHeight + 1; + return amountLeft; + } + + if (tweaksSubscription == null) { + final receiver = Receiver( + scanData.silentAddress.b_scan.toHex(), + scanData.silentAddress.B_spend.toHex(), + scanData.network == BitcoinNetwork.testnet, + scanData.labelIndexes, + scanData.labelIndexes.length, + ); + + // Initial status UI update, send how many blocks in total to scan + final initialCount = getCountPerRequest(syncHeight); + scanData.sendPort.send(SyncResponse(syncHeight, StartingScanSyncStatus(syncHeight))); + + tweaksSubscription = await electrumClient.tweaksSubscribe( + height: syncHeight, + count: initialCount, + ); + + Future listenFn(t) async { + final tweaks = t as Map; + final msg = tweaks["message"]; + // success or error msg + final noData = msg != null; + + if (noData) { + // re-subscribe to continue receiving messages, starting from the next unscanned height + final nextHeight = syncHeight + 1; + final nextCount = getCountPerRequest(nextHeight); + + if (nextCount > 0) { + tweaksSubscription?.close(); + + final nextTweaksSubscription = electrumClient.tweaksSubscribe( + height: nextHeight, + count: nextCount, + ); + nextTweaksSubscription?.listen(listenFn); + } + + return; + } + + // Continuous status UI update, send how many blocks left to scan + final syncingStatus = scanData.isSingleScan + ? SyncingSyncStatus(1, 0) + : SyncingSyncStatus.fromHeightValues(scanData.chainTip, initialSyncHeight, syncHeight); + scanData.sendPort.send(SyncResponse(syncHeight, syncingStatus)); + + final blockHeight = tweaks.keys.first; + final tweakHeight = int.parse(blockHeight); + + try { + final blockTweaks = tweaks[blockHeight] as Map; + + for (var j = 0; j < blockTweaks.keys.length; j++) { + final txid = blockTweaks.keys.elementAt(j); + final details = blockTweaks[txid] as Map; + final outputPubkeys = (details["output_pubkeys"] as Map); + final tweak = details["tweak"].toString(); + + try { + // scanOutputs called from rust here + final addToWallet = scanOutputs( + outputPubkeys.values.toList(), + tweak, + receiver, + ); + + if (addToWallet.isEmpty) { + // no results tx, continue to next tx + continue; + } + + // placeholder ElectrumTransactionInfo object to update values based on new scanned unspent(s) + final txInfo = ElectrumTransactionInfo( + WalletType.bitcoin, + id: txid, + height: tweakHeight, + amount: 0, + fee: 0, + direction: TransactionDirection.incoming, + isPending: false, + isReplaced: false, + date: scanData.network == BitcoinNetwork.mainnet + ? getDateByBitcoinHeight(tweakHeight) + : DateTime.now(), + confirmations: scanData.chainTip - tweakHeight + 1, + unspents: [], + isReceivedSilentPayment: true, + ); + + addToWallet.forEach((label, value) { + (value as Map).forEach((output, tweak) { + final t_k = tweak.toString(); + + final receivingOutputAddress = ECPublic.fromHex(output) + .toTaprootAddress(tweak: false) + .toAddress(scanData.network); + + int? amount; + int? pos; + outputPubkeys.entries.firstWhere((k) { + final isMatchingOutput = k.value[0] == output; + if (isMatchingOutput) { + amount = int.parse(k.value[1].toString()); + pos = int.parse(k.key.toString()); + return true; + } + return false; + }); + + final receivedAddressRecord = BitcoinSilentPaymentAddressRecord( + receivingOutputAddress, + index: 0, + isHidden: false, + isUsed: true, + network: scanData.network, + silentPaymentTweak: t_k, + type: SegwitAddresType.p2tr, + txCount: 1, + balance: amount!, + ); + + final unspent = BitcoinSilentPaymentsUnspent( + receivedAddressRecord, + txid, + amount!, + pos!, + silentPaymentTweak: t_k, + silentPaymentLabel: label == "None" ? null : label, + ); + + txInfo.unspents!.add(unspent); + txInfo.amount += unspent.value; + }); + }); + + scanData.sendPort.send({txInfo.id: txInfo}); + } catch (_) {} + } + } catch (_) {} + + syncHeight = tweakHeight; + + if (tweakHeight >= scanData.chainTip || scanData.isSingleScan) { + if (tweakHeight >= scanData.chainTip) + scanData.sendPort.send(SyncResponse( + syncHeight, + SyncedTipSyncStatus(scanData.chainTip), + )); + + if (scanData.isSingleScan) { + scanData.sendPort.send(SyncResponse(syncHeight, SyncedSyncStatus())); + } + + await tweaksSubscription!.close(); + await electrumClient.close(); + } + } + + tweaksSubscription?.listen(listenFn); + } + + if (tweaksSubscription == null) { + return scanData.sendPort.send( + SyncResponse(syncHeight, UnsupportedSyncStatus()), + ); + } +} + +class EstimatedTxResult { + EstimatedTxResult({ + required this.utxos, + required this.inputPrivKeyInfos, + required this.publicKeys, + required this.fee, + required this.amount, + required this.hasChange, + required this.isSendAll, + this.memo, + required this.spendsSilentPayment, + required this.spendsUnconfirmedTX, + }); + + final List utxos; + final List inputPrivKeyInfos; + final Map publicKeys; // PubKey to derivationPath + final int fee; + final int amount; + final bool spendsSilentPayment; + + // final bool sendsToSilentPayment; + final bool hasChange; + final bool isSendAll; + final String? memo; + final bool spendsUnconfirmedTX; +} + +class PublicKeyWithDerivationPath { + const PublicKeyWithDerivationPath(this.publicKey, this.derivationPath); + + final String derivationPath; + final String publicKey; +} + +BitcoinAddressType _getScriptType(BitcoinBaseAddress type) { + if (type is P2pkhAddress) { + return P2pkhAddressType.p2pkh; + } else if (type is P2shAddress) { + return P2shAddressType.p2wpkhInP2sh; + } else if (type is P2wshAddress) { + return SegwitAddresType.p2wsh; + } else if (type is P2trAddress) { + return SegwitAddresType.p2tr; + } else if (type is MwebAddress) { + return SegwitAddresType.mweb; + } else if (type is SilentPaymentsAddresType) { + return SilentPaymentsAddresType.p2sp; + } else { + return SegwitAddresType.p2wpkh; + } +} + +class UtxoDetails { + final List availableInputs; + final List unconfirmedCoins; + final List utxos; + final List vinOutpoints; + final List inputPrivKeyInfos; + final Map publicKeys; // PubKey to derivationPath + final int allInputsAmount; + final bool spendsSilentPayment; + final bool spendsUnconfirmedTX; + + UtxoDetails({ + required this.availableInputs, + required this.unconfirmedCoins, + required this.utxos, + required this.vinOutpoints, + required this.inputPrivKeyInfos, + required this.publicKeys, + required this.allInputsAmount, + required this.spendsSilentPayment, + required this.spendsUnconfirmedTX, + }); +} diff --git a/cw_bitcoin/lib/electrum_wallet_addresses.dart b/cw_bitcoin/lib/electrum_wallet_addresses.dart index 850d58f40..614a06a3b 100644 --- a/cw_bitcoin/lib/electrum_wallet_addresses.dart +++ b/cw_bitcoin/lib/electrum_wallet_addresses.dart @@ -1,8 +1,12 @@ -import 'package:bitbox/bitbox.dart' as bitbox; -import 'package:bitcoin_flutter/bitcoin_flutter.dart' as bitcoin; +import 'dart:io' show Platform; + +import 'package:bitcoin_base/bitcoin_base.dart'; +import 'package:blockchain_utils/blockchain_utils.dart'; import 'package:cw_bitcoin/bitcoin_address_record.dart'; -import 'package:cw_bitcoin/electrum.dart'; -import 'package:cw_bitcoin/script_hash.dart'; +import 'package:cw_bitcoin/electrum_wallet.dart'; +import 'package:cw_core/unspent_coin_type.dart'; +import 'package:cw_core/utils/print_verbose.dart'; +import 'package:cw_bitcoin/bitcoin_unspent.dart'; import 'package:cw_core/wallet_addresses.dart'; import 'package:cw_core/wallet_info.dart'; import 'package:cw_core/wallet_type.dart'; @@ -12,65 +16,160 @@ part 'electrum_wallet_addresses.g.dart'; class ElectrumWalletAddresses = ElectrumWalletAddressesBase with _$ElectrumWalletAddresses; +const List BITCOIN_ADDRESS_TYPES = [ + SegwitAddresType.p2wpkh, + P2pkhAddressType.p2pkh, + SegwitAddresType.p2tr, + SegwitAddresType.p2wsh, + P2shAddressType.p2wpkhInP2sh, +]; + +const List LITECOIN_ADDRESS_TYPES = [ + SegwitAddresType.p2wpkh, + SegwitAddresType.mweb, +]; + +const List BITCOIN_CASH_ADDRESS_TYPES = [ + P2pkhAddressType.p2pkh, +]; + abstract class ElectrumWalletAddressesBase extends WalletAddresses with Store { - ElectrumWalletAddressesBase(WalletInfo walletInfo, - {required this.mainHd, - required this.sideHd, - required this.electrumClient, - required this.networkType, - List? initialAddresses, - int initialRegularAddressIndex = 0, - int initialChangeAddressIndex = 0}) - : addresses = ObservableList.of((initialAddresses ?? []).toSet()), + ElectrumWalletAddressesBase( + WalletInfo walletInfo, { + required this.mainHd, + required this.sideHd, + required this.network, + required this.isHardwareWallet, + List? initialAddresses, + Map? initialRegularAddressIndex, + Map? initialChangeAddressIndex, + List? initialSilentAddresses, + int initialSilentAddressIndex = 0, + List? initialMwebAddresses, + Bip32Slip10Secp256k1? masterHd, + BitcoinAddressType? initialAddressPageType, + }) : _addresses = ObservableList.of((initialAddresses ?? []).toSet()), + addressesByReceiveType = + ObservableList.of(([]).toSet()), receiveAddresses = ObservableList.of((initialAddresses ?? []) .where((addressRecord) => !addressRecord.isHidden && !addressRecord.isUsed) .toSet()), changeAddresses = ObservableList.of((initialAddresses ?? []) .where((addressRecord) => addressRecord.isHidden && !addressRecord.isUsed) .toSet()), - currentReceiveAddressIndex = initialRegularAddressIndex, - currentChangeAddressIndex = initialChangeAddressIndex, - super(walletInfo); + currentReceiveAddressIndexByType = initialRegularAddressIndex ?? {}, + currentChangeAddressIndexByType = initialChangeAddressIndex ?? {}, + _addressPageType = initialAddressPageType ?? + (walletInfo.addressPageType != null + ? BitcoinAddressType.fromValue(walletInfo.addressPageType!) + : SegwitAddresType.p2wpkh), + silentAddresses = ObservableList.of( + (initialSilentAddresses ?? []).toSet()), + currentSilentAddressIndex = initialSilentAddressIndex, + mwebAddresses = + ObservableList.of((initialMwebAddresses ?? []).toSet()), + super(walletInfo) { + if (masterHd != null) { + silentAddress = SilentPaymentOwner.fromPrivateKeys( + b_scan: ECPrivate.fromHex(masterHd.derivePath(SCAN_PATH).privateKey.toHex()), + b_spend: ECPrivate.fromHex(masterHd.derivePath(SPEND_PATH).privateKey.toHex()), + network: network, + ); + + if (silentAddresses.length == 0) { + silentAddresses.add(BitcoinSilentPaymentAddressRecord( + silentAddress.toString(), + index: 0, + isHidden: false, + name: "", + silentPaymentTweak: null, + network: network, + type: SilentPaymentsAddresType.p2sp, + )); + silentAddresses.add(BitcoinSilentPaymentAddressRecord( + silentAddress!.toLabeledSilentPaymentAddress(0).toString(), + index: 0, + isHidden: true, + name: "", + silentPaymentTweak: BytesUtils.toHexString(silentAddress!.generateLabel(0)), + network: network, + type: SilentPaymentsAddresType.p2sp, + )); + } + } + + updateAddressesByMatch(); + } static const defaultReceiveAddressesCount = 22; static const defaultChangeAddressesCount = 17; static const gap = 20; - static String toCashAddr(String address) => bitbox.Address.toCashAddress(address); - - static String toLegacy(String address) => bitbox.Address.toLegacyAddress(address); - - final ObservableList addresses; + final ObservableList _addresses; + final ObservableList addressesByReceiveType; final ObservableList receiveAddresses; final ObservableList changeAddresses; - final ElectrumClient electrumClient; - final bitcoin.NetworkType networkType; - final bitcoin.HDWallet mainHd; - final bitcoin.HDWallet sideHd; + // TODO: add this variable in `bitcoin_wallet_addresses` and just add a cast in cw_bitcoin to use it + final ObservableList silentAddresses; + // TODO: add this variable in `litecoin_wallet_addresses` and just add a cast in cw_bitcoin to use it + final ObservableList mwebAddresses; + final BasedUtxoNetwork network; + final Bip32Slip10Secp256k1 mainHd; + final Bip32Slip10Secp256k1 sideHd; + final bool isHardwareWallet; + + @observable + SilentPaymentOwner? silentAddress; + + @observable + late BitcoinAddressType _addressPageType; + + @computed + BitcoinAddressType get addressPageType => _addressPageType; + + @observable + String? activeSilentAddress; + + @computed + List get allAddresses => _addresses; @override @computed String get address { - if (isEnabledAutoGenerateSubaddress) { - if (receiveAddresses.isEmpty) { - final newAddress = generateNewAddress(hd: mainHd).address; - return walletInfo.type == WalletType.bitcoinCash ? toCashAddr(newAddress) : newAddress; + if (addressPageType == SilentPaymentsAddresType.p2sp) { + if (activeSilentAddress != null) { + return activeSilentAddress!; } - final receiveAddress = receiveAddresses.first.address; - return walletInfo.type == WalletType.bitcoinCash - ? toCashAddr(receiveAddress) - : receiveAddress; - } else { - final receiveAddress = (receiveAddresses.first.address != addresses.first.address && - previousAddressRecord != null) - ? previousAddressRecord!.address - : addresses.first.address; - - return walletInfo.type == WalletType.bitcoinCash - ? toCashAddr(receiveAddress) - : receiveAddress; + return silentAddress.toString(); } + + final typeMatchingAddresses = _addresses.where((addr) => !addr.isHidden && _isAddressPageTypeMatch(addr)).toList(); + final typeMatchingReceiveAddresses = typeMatchingAddresses.where((addr) => !addr.isUsed).toList(); + + if (!isEnabledAutoGenerateSubaddress) { + if (previousAddressRecord != null && + previousAddressRecord!.type == addressPageType) { + return previousAddressRecord!.address; + } + + if (typeMatchingAddresses.isNotEmpty) { + return typeMatchingAddresses.first.address; + } + + return generateNewAddress().address; + } + + if (typeMatchingAddresses.isEmpty || typeMatchingReceiveAddresses.isEmpty) { + return generateNewAddress().address; + } + + final prev = previousAddressRecord; + if (prev != null && prev.type == addressPageType && !prev.isUsed) { + return prev.address; + } + + return typeMatchingReceiveAddresses.first.address; } @observable @@ -78,27 +177,59 @@ abstract class ElectrumWalletAddressesBase extends WalletAddresses with Store { @override set address(String addr) { - if (addr.startsWith('bitcoincash:')) { - addr = toLegacy(addr); + if (addr == "Silent Payments" && SilentPaymentsAddresType.p2sp != addressPageType) { + return; } - final addressRecord = addresses.firstWhere((addressRecord) => addressRecord.address == addr); + if (addressPageType == SilentPaymentsAddresType.p2sp) { + final selected = silentAddresses.firstWhere((addressRecord) => addressRecord.address == addr); - previousAddressRecord = addressRecord; - receiveAddresses.remove(addressRecord); - receiveAddresses.insert(0, addressRecord); + if (selected.silentPaymentTweak != null && silentAddress != null) { + activeSilentAddress = + silentAddress!.toLabeledSilentPaymentAddress(selected.index).toString(); + } else { + activeSilentAddress = silentAddress!.toString(); + } + return; + } + try { + final addressRecord = _addresses.firstWhere( + (addressRecord) => addressRecord.address == addr, + ); + + previousAddressRecord = addressRecord; + receiveAddresses.remove(addressRecord); + receiveAddresses.insert(0, addressRecord); + } catch (e) { + printV("ElectrumWalletAddressBase: set address ($addr): $e"); + } } @override - String get primaryAddress => getAddress(index: 0, hd: mainHd); + String get primaryAddress => getAddress(index: 0, hd: mainHd, addressType: addressPageType); - int currentReceiveAddressIndex; - int currentChangeAddressIndex; + Map currentReceiveAddressIndexByType; + + int get currentReceiveAddressIndex => + currentReceiveAddressIndexByType[_addressPageType.toString()] ?? 0; + + void set currentReceiveAddressIndex(int index) => + currentReceiveAddressIndexByType[_addressPageType.toString()] = index; + + Map currentChangeAddressIndexByType; + + int get currentChangeAddressIndex => + currentChangeAddressIndexByType[_addressPageType.toString()] ?? 0; + + void set currentChangeAddressIndex(int index) => + currentChangeAddressIndexByType[_addressPageType.toString()] = index; + + int currentSilentAddressIndex; @observable BitcoinAddressRecord? previousAddressRecord; @computed - int get totalCountOfReceiveAddresses => addresses.fold(0, (acc, addressRecord) { + int get totalCountOfReceiveAddresses => addressesByReceiveType.fold(0, (acc, addressRecord) { if (!addressRecord.isHidden) { return acc + 1; } @@ -106,24 +237,36 @@ abstract class ElectrumWalletAddressesBase extends WalletAddresses with Store { }); @computed - int get totalCountOfChangeAddresses => addresses.fold(0, (acc, addressRecord) { + int get totalCountOfChangeAddresses => addressesByReceiveType.fold(0, (acc, addressRecord) { if (addressRecord.isHidden) { return acc + 1; } return acc; }); - Future discoverAddresses() async { - await _discoverAddresses(mainHd, false); - await _discoverAddresses(sideHd, true); - await updateAddressesInBox(); - } - @override Future init() async { - await _generateInitialAddresses(); + if (walletInfo.type == WalletType.bitcoinCash) { + await _generateInitialAddresses(type: P2pkhAddressType.p2pkh); + } else if (walletInfo.type == WalletType.litecoin) { + await _generateInitialAddresses(type: SegwitAddresType.p2wpkh); + if ((Platform.isAndroid || Platform.isIOS) && !isHardwareWallet) { + await _generateInitialAddresses(type: SegwitAddresType.mweb); + } + } else if (walletInfo.type == WalletType.bitcoin) { + await _generateInitialAddresses(); + if (!isHardwareWallet) { + await _generateInitialAddresses(type: P2pkhAddressType.p2pkh); + await _generateInitialAddresses(type: P2shAddressType.p2wpkhInP2sh); + await _generateInitialAddresses(type: SegwitAddresType.p2tr); + await _generateInitialAddresses(type: SegwitAddresType.p2wsh); + } + } + + updateAddressesByMatch(); updateReceiveAddresses(); updateChangeAddresses(); + _validateAddresses(); await updateAddressesInBox(); if (currentReceiveAddressIndex >= receiveAddresses.length) { @@ -136,15 +279,17 @@ abstract class ElectrumWalletAddressesBase extends WalletAddresses with Store { } @action - Future getChangeAddress() async { + Future getChangeAddress( + {List? inputs, + List? outputs, + UnspentCoinType coinTypeToSpendFrom = UnspentCoinType.any}) async { updateChangeAddresses(); if (changeAddresses.isEmpty) { final newAddresses = await _createNewAddresses(gap, - hd: sideHd, startIndex: totalCountOfChangeAddresses > 0 ? totalCountOfChangeAddresses - 1 : 0, isHidden: true); - _addAddresses(newAddresses); + addAddresses(newAddresses); } if (currentChangeAddressIndex >= changeAddresses.length) { @@ -152,161 +297,408 @@ abstract class ElectrumWalletAddressesBase extends WalletAddresses with Store { } updateChangeAddresses(); - final address = changeAddresses[currentChangeAddressIndex].address; + final address = changeAddresses[currentChangeAddressIndex]; currentChangeAddressIndex += 1; return address; } - BitcoinAddressRecord generateNewAddress({bitcoin.HDWallet? hd, String? label}) { - final isHidden = hd == sideHd; + Map get labels { + final G = ECPublic.fromBytes(BigintUtils.toBytes(Curves.generatorSecp256k1.x, length: 32)); + final labels = {}; + for (int i = 0; i < silentAddresses.length; i++) { + final silentAddressRecord = silentAddresses[i]; + final silentPaymentTweak = silentAddressRecord.silentPaymentTweak; - final newAddressIndex = addresses.fold( - 0, (int acc, addressRecord) => isHidden == addressRecord.isHidden ? acc + 1 : acc); + if (silentPaymentTweak != null && + SilentPaymentAddress.regex.hasMatch(silentAddressRecord.address)) { + labels[G + .tweakMul(BigintUtils.fromBytes(BytesUtils.fromHexString(silentPaymentTweak))) + .toHex()] = silentPaymentTweak; + } + } + return labels; + } - final address = BitcoinAddressRecord(getAddress(index: newAddressIndex, hd: hd ?? sideHd), - index: newAddressIndex, isHidden: isHidden, name: label ?? ''); - addresses.add(address); + @action + BaseBitcoinAddressRecord generateNewAddress({String label = ''}) { + if (addressPageType == SilentPaymentsAddresType.p2sp && silentAddress != null) { + final currentSilentAddressIndex = silentAddresses + .where((addressRecord) => addressRecord.type != SegwitAddresType.p2tr) + .length - + 1; + + this.currentSilentAddressIndex = currentSilentAddressIndex; + + final address = BitcoinSilentPaymentAddressRecord( + silentAddress!.toLabeledSilentPaymentAddress(currentSilentAddressIndex).toString(), + index: currentSilentAddressIndex, + isHidden: false, + name: label, + silentPaymentTweak: + BytesUtils.toHexString(silentAddress!.generateLabel(currentSilentAddressIndex)), + network: network, + type: SilentPaymentsAddresType.p2sp, + ); + + silentAddresses.add(address); + Future.delayed(Duration.zero, () => updateAddressesByMatch()); + + return address; + } + + final newAddressIndex = addressesByReceiveType.fold( + 0, (int acc, addressRecord) => addressRecord.isHidden == false ? acc + 1 : acc); + + final address = BitcoinAddressRecord( + getAddress(index: newAddressIndex, hd: mainHd, addressType: addressPageType), + index: newAddressIndex, + isHidden: false, + name: label, + type: addressPageType, + network: network, + ); + Future.delayed(Duration.zero, () { + _addresses.add(address); + updateAddressesByMatch(); + }); return address; } - String getAddress({required int index, required bitcoin.HDWallet hd}) => ''; + String getAddress({ + required int index, + required Bip32Slip10Secp256k1 hd, + BitcoinAddressType? addressType, + }) => + ''; + + Future getAddressAsync({ + required int index, + required Bip32Slip10Secp256k1 hd, + BitcoinAddressType? addressType, + }) async => + getAddress(index: index, hd: hd, addressType: addressType); + + void addBitcoinAddressTypes() { + final lastP2wpkh = _addresses + .where((addressRecord) => + _isUnusedReceiveAddressByType(addressRecord, SegwitAddresType.p2wpkh)) + .toList() + .last; + if (lastP2wpkh.address != address) { + addressesMap[lastP2wpkh.address] = 'P2WPKH'; + } else { + addressesMap[address] = 'Active - P2WPKH'; + } + + final lastP2pkh = _addresses.firstWhere( + (addressRecord) => _isUnusedReceiveAddressByType(addressRecord, P2pkhAddressType.p2pkh)); + if (lastP2pkh.address != address) { + addressesMap[lastP2pkh.address] = 'P2PKH'; + } else { + addressesMap[address] = 'Active - P2PKH'; + } + + final lastP2sh = _addresses.firstWhere((addressRecord) => + _isUnusedReceiveAddressByType(addressRecord, P2shAddressType.p2wpkhInP2sh)); + if (lastP2sh.address != address) { + addressesMap[lastP2sh.address] = 'P2SH'; + } else { + addressesMap[address] = 'Active - P2SH'; + } + + final lastP2tr = _addresses.firstWhere( + (addressRecord) => _isUnusedReceiveAddressByType(addressRecord, SegwitAddresType.p2tr)); + if (lastP2tr.address != address) { + addressesMap[lastP2tr.address] = 'P2TR'; + } else { + addressesMap[address] = 'Active - P2TR'; + } + + final lastP2wsh = _addresses.firstWhere( + (addressRecord) => _isUnusedReceiveAddressByType(addressRecord, SegwitAddresType.p2wsh)); + if (lastP2wsh.address != address) { + addressesMap[lastP2wsh.address] = 'P2WSH'; + } else { + addressesMap[address] = 'Active - P2WSH'; + } + + silentAddresses.forEach((addressRecord) { + if (addressRecord.type != SilentPaymentsAddresType.p2sp || addressRecord.isHidden) { + return; + } + + if (addressRecord.address != address) { + addressesMap[addressRecord.address] = addressRecord.name.isEmpty + ? "Silent Payments" + : "Silent Payments - " + addressRecord.name; + } else { + addressesMap[address] = 'Active - Silent Payments'; + } + }); + } + + void addLitecoinAddressTypes() { + final lastP2wpkh = _addresses + .where((addressRecord) => + _isUnusedReceiveAddressByType(addressRecord, SegwitAddresType.p2wpkh)) + .toList() + .last; + if (lastP2wpkh.address != address) { + addressesMap[lastP2wpkh.address] = 'P2WPKH'; + } else { + addressesMap[address] = 'Active - P2WPKH'; + } + + final lastMweb = _addresses.firstWhere( + (addressRecord) => _isUnusedReceiveAddressByType(addressRecord, SegwitAddresType.mweb)); + if (lastMweb.address != address) { + addressesMap[lastMweb.address] = 'MWEB'; + } else { + addressesMap[address] = 'Active - MWEB'; + } + } + + void addBitcoinCashAddressTypes() { + final lastP2pkh = _addresses.firstWhere( + (addressRecord) => _isUnusedReceiveAddressByType(addressRecord, P2pkhAddressType.p2pkh)); + if (lastP2pkh.address != address) { + addressesMap[lastP2pkh.address] = 'P2PKH'; + } else { + addressesMap[address] = 'Active - P2PKH'; + } + } @override Future updateAddressesInBox() async { try { addressesMap.clear(); - addressesMap[address] = ''; + addressesMap[address] = 'Active'; + + allAddressesMap.clear(); + _addresses.forEach((addressRecord) { + allAddressesMap[addressRecord.address] = addressRecord.name; + }); + + switch (walletInfo.type) { + case WalletType.bitcoin: + addBitcoinAddressTypes(); + break; + case WalletType.litecoin: + addLitecoinAddressTypes(); + break; + case WalletType.bitcoinCash: + addBitcoinCashAddressTypes(); + break; + default: + break; + } + await saveAddressesInBox(); } catch (e) { - print(e.toString()); + printV("updateAddresses $e"); } } @action void updateAddress(String address, String label) { - if (address.startsWith('bitcoincash:')) { - address = toLegacy(address); + BaseBitcoinAddressRecord? foundAddress; + _addresses.forEach((addressRecord) { + if (addressRecord.address == address) { + foundAddress = addressRecord; + } + }); + silentAddresses.forEach((addressRecord) { + if (addressRecord.address == address) { + foundAddress = addressRecord; + } + }); + mwebAddresses.forEach((addressRecord) { + if (addressRecord.address == address) { + foundAddress = addressRecord; + } + }); + + if (foundAddress != null) { + foundAddress!.setNewName(label); + + if (foundAddress is BitcoinAddressRecord) { + final index = _addresses.indexOf(foundAddress); + _addresses.remove(foundAddress); + _addresses.insert(index, foundAddress as BitcoinAddressRecord); + } else { + final index = silentAddresses.indexOf(foundAddress as BitcoinSilentPaymentAddressRecord); + silentAddresses.remove(foundAddress); + silentAddresses.insert(index, foundAddress as BitcoinSilentPaymentAddressRecord); + } } - final addressRecord = addresses.firstWhere((addressRecord) => addressRecord.address == address); - addressRecord.setNewName(label); - final index = addresses.indexOf(addressRecord); - addresses.remove(addressRecord); - addresses.insert(index, addressRecord); + } + + @action + void updateAddressesByMatch() { + if (addressPageType == SilentPaymentsAddresType.p2sp) { + addressesByReceiveType.clear(); + addressesByReceiveType.addAll(silentAddresses); + return; + } + + addressesByReceiveType.clear(); + addressesByReceiveType.addAll(_addresses.where(_isAddressPageTypeMatch).toList()); } @action void updateReceiveAddresses() { receiveAddresses.removeRange(0, receiveAddresses.length); final newAddresses = - addresses.where((addressRecord) => !addressRecord.isHidden && !addressRecord.isUsed); + _addresses.where((addressRecord) => !addressRecord.isHidden && !addressRecord.isUsed); receiveAddresses.addAll(newAddresses); } @action void updateChangeAddresses() { changeAddresses.removeRange(0, changeAddresses.length); - final newAddresses = - addresses.where((addressRecord) => addressRecord.isHidden && !addressRecord.isUsed); + final newAddresses = _addresses.where((addressRecord) => + addressRecord.isHidden && + !addressRecord.isUsed && + // TODO: feature to change change address type. For now fixed to p2wpkh, the cheapest type + (walletInfo.type != WalletType.bitcoin || addressRecord.type == SegwitAddresType.p2wpkh)); changeAddresses.addAll(newAddresses); } - Future _discoverAddresses(bitcoin.HDWallet hd, bool isHidden) async { - var hasAddrUse = true; - List addrs; + @action + Future discoverAddresses(List addressList, bool isHidden, + Future Function(BitcoinAddressRecord) getAddressHistory, + {BitcoinAddressType type = SegwitAddresType.p2wpkh}) async { + final newAddresses = await _createNewAddresses(gap, + startIndex: addressList.length, isHidden: isHidden, type: type); + addAddresses(newAddresses); - if (addresses.isNotEmpty) { + final addressesWithHistory = await Future.wait(newAddresses.map(getAddressHistory)); + final isLastAddressUsed = addressesWithHistory.last == addressList.last.address; - - if(!isHidden) { - final receiveAddressesList = addresses.where((addr) => !addr.isHidden).toList(); - validateSideHdAddresses(receiveAddressesList); - } - - addrs = addresses.where((addr) => addr.isHidden == isHidden).toList(); - } else { - addrs = await _createNewAddresses( - isHidden ? defaultChangeAddressesCount : defaultReceiveAddressesCount, - startIndex: 0, - hd: hd, - isHidden: isHidden); - } - - while (hasAddrUse) { - final addr = addrs.last.address; - hasAddrUse = await _hasAddressUsed(addr); - - if (!hasAddrUse) { - break; - } - - final start = addrs.length; - final count = start + gap; - final batch = await _createNewAddresses(count, startIndex: start, hd: hd, isHidden: isHidden); - addrs.addAll(batch); - } - - if (addresses.length < addrs.length) { - _addAddresses(addrs); + if (isLastAddressUsed) { + discoverAddresses(addressList, isHidden, getAddressHistory, type: type); } } - Future _generateInitialAddresses() async { + Future _generateInitialAddresses( + {BitcoinAddressType type = SegwitAddresType.p2wpkh}) async { var countOfReceiveAddresses = 0; var countOfHiddenAddresses = 0; - addresses.forEach((addr) { - if (addr.isHidden) { - countOfHiddenAddresses += 1; - return; - } + _addresses.forEach((addr) { + if (addr.type == type) { + if (addr.isHidden) { + countOfHiddenAddresses += 1; + return; + } - countOfReceiveAddresses += 1; + countOfReceiveAddresses += 1; + } }); if (countOfReceiveAddresses < defaultReceiveAddressesCount) { final addressesCount = defaultReceiveAddressesCount - countOfReceiveAddresses; final newAddresses = await _createNewAddresses(addressesCount, - startIndex: countOfReceiveAddresses, hd: mainHd, isHidden: false); - addresses.addAll(newAddresses); + startIndex: countOfReceiveAddresses, isHidden: false, type: type); + addAddresses(newAddresses); } if (countOfHiddenAddresses < defaultChangeAddressesCount) { final addressesCount = defaultChangeAddressesCount - countOfHiddenAddresses; final newAddresses = await _createNewAddresses(addressesCount, - startIndex: countOfHiddenAddresses, hd: sideHd, isHidden: true); - addresses.addAll(newAddresses); + startIndex: countOfHiddenAddresses, isHidden: true, type: type); + addAddresses(newAddresses); } } Future> _createNewAddresses(int count, - {required bitcoin.HDWallet hd, int startIndex = 0, bool isHidden = false}) async { + {int startIndex = 0, bool isHidden = false, BitcoinAddressType? type}) async { final list = []; for (var i = startIndex; i < count + startIndex; i++) { - final address = - BitcoinAddressRecord(getAddress(index: i, hd: hd), index: i, isHidden: isHidden); + final address = BitcoinAddressRecord( + await getAddressAsync(index: i, hd: _getHd(isHidden), addressType: type ?? addressPageType), + index: i, + isHidden: isHidden, + type: type ?? addressPageType, + network: network, + ); list.add(address); } return list; } - void _addAddresses(Iterable addresses) { - final addressesSet = this.addresses.toSet(); + @action + void addAddresses(Iterable addresses) { + final addressesSet = this._addresses.toSet(); addressesSet.addAll(addresses); - this.addresses.removeRange(0, this.addresses.length); - this.addresses.addAll(addressesSet); + this._addresses.clear(); + this._addresses.addAll(addressesSet); + updateAddressesByMatch(); } - Future _hasAddressUsed(String address) async { - final sh = scriptHash(address, networkType: networkType); - final transactionHistory = await electrumClient.getHistory(sh); - return transactionHistory.isNotEmpty; + @action + void addSilentAddresses(Iterable addresses) { + final addressesSet = this.silentAddresses.toSet(); + addressesSet.addAll(addresses); + this.silentAddresses.clear(); + this.silentAddresses.addAll(addressesSet); + updateAddressesByMatch(); } - void validateSideHdAddresses(List addrWithTransactions) { - addrWithTransactions.forEach((element) { - if (element.address != getAddress(index: element.index, hd: mainHd)) element.isHidden = true; + @action + void addMwebAddresses(Iterable addresses) { + final addressesSet = this.mwebAddresses.toSet(); + addressesSet.addAll(addresses); + this.mwebAddresses.clear(); + this.mwebAddresses.addAll(addressesSet); + updateAddressesByMatch(); + } + + void _validateAddresses() { + _addresses.forEach((element) async { + if (element.type == SegwitAddresType.mweb) { + // this would add a ton of startup lag for mweb addresses since we have 1000 of them + return; + } + if (!element.isHidden && + element.address != + await getAddressAsync(index: element.index, hd: mainHd, addressType: element.type)) { + element.isHidden = true; + } else if (element.isHidden && + element.address != + await getAddressAsync(index: element.index, hd: sideHd, addressType: element.type)) { + element.isHidden = false; + } }); } + + @action + Future setAddressType(BitcoinAddressType type) async { + _addressPageType = type; + updateAddressesByMatch(); + walletInfo.addressPageType = addressPageType.toString(); + await walletInfo.save(); + } + + bool _isAddressPageTypeMatch(BitcoinAddressRecord addressRecord) { + return _isAddressByType(addressRecord, addressPageType); + } + + Bip32Slip10Secp256k1 _getHd(bool isHidden) => isHidden ? sideHd : mainHd; + + bool _isAddressByType(BitcoinAddressRecord addr, BitcoinAddressType type) => addr.type == type; + + bool _isUnusedReceiveAddressByType(BitcoinAddressRecord addr, BitcoinAddressType type) => + !addr.isHidden && !addr.isUsed && addr.type == type; + + @action + void deleteSilentPaymentAddress(String address) { + final addressRecord = silentAddresses.firstWhere((addressRecord) => + addressRecord.type == SilentPaymentsAddresType.p2sp && addressRecord.address == address); + + silentAddresses.remove(addressRecord); + updateAddressesByMatch(); + } } diff --git a/cw_bitcoin/lib/electrum_wallet_snapshot.dart b/cw_bitcoin/lib/electrum_wallet_snapshot.dart index 86d3e2fed..990719089 100644 --- a/cw_bitcoin/lib/electrum_wallet_snapshot.dart +++ b/cw_bitcoin/lib/electrum_wallet_snapshot.dart @@ -1,59 +1,134 @@ import 'dart:convert'; +import 'package:bitcoin_base/bitcoin_base.dart'; import 'package:cw_bitcoin/bitcoin_address_record.dart'; import 'package:cw_bitcoin/electrum_balance.dart'; +import 'package:cw_core/encryption_file_utils.dart'; +import 'package:cw_bitcoin/electrum_derivations.dart'; import 'package:cw_core/pathForWallet.dart'; +import 'package:cw_core/wallet_info.dart'; import 'package:cw_core/utils/file.dart'; import 'package:cw_core/wallet_type.dart'; -class ElectrumWallletSnapshot { - ElectrumWallletSnapshot({ +class ElectrumWalletSnapshot { + ElectrumWalletSnapshot({ required this.name, required this.type, required this.password, required this.mnemonic, + required this.xpub, required this.addresses, required this.balance, required this.regularAddressIndex, - required this.changeAddressIndex}); + required this.changeAddressIndex, + required this.addressPageType, + required this.silentAddresses, + required this.silentAddressIndex, + required this.mwebAddresses, + required this.alwaysScan, + this.passphrase, + this.derivationType, + this.derivationPath, + }); final String name; final String password; final WalletType type; + final String? addressPageType; + + @deprecated + String? mnemonic; + + @deprecated + String? xpub; + + @deprecated + String? passphrase; - String mnemonic; List addresses; - ElectrumBalance balance; - int regularAddressIndex; - int changeAddressIndex; + List silentAddresses; + List mwebAddresses; + bool alwaysScan; - static Future load(String name, WalletType type, String password) async { + ElectrumBalance balance; + Map regularAddressIndex; + Map changeAddressIndex; + int silentAddressIndex; + DerivationType? derivationType; + String? derivationPath; + + static Future load(EncryptionFileUtils encryptionFileUtils, String name, + WalletType type, String password, BasedUtxoNetwork network) async { final path = await pathForWallet(name: name, type: type); - final jsonSource = await read(path: path, password: password); + final jsonSource = await encryptionFileUtils.read(path: path, password: password); final data = json.decode(jsonSource) as Map; + final mnemonic = data['mnemonic'] as String?; + final xpub = data['xpub'] as String?; + final passphrase = data['passphrase'] as String? ?? ''; + final addressesTmp = data['addresses'] as List? ?? []; - final mnemonic = data['mnemonic'] as String; final addresses = addressesTmp .whereType() - .map((addr) => BitcoinAddressRecord.fromJSON(addr)) + .map((addr) => BitcoinAddressRecord.fromJSON(addr, network: network)) .toList(); - final balance = ElectrumBalance.fromJSON(data['balance'] as String) ?? + + final silentAddressesTmp = data['silent_addresses'] as List? ?? []; + final silentAddresses = silentAddressesTmp + .whereType() + .map((addr) => BitcoinSilentPaymentAddressRecord.fromJSON(addr, network: network)) + .toList(); + + final mwebAddressTmp = data['mweb_addresses'] as List? ?? []; + final mwebAddresses = mwebAddressTmp + .whereType() + .map((addr) => BitcoinAddressRecord.fromJSON(addr, network: network)) + .toList(); + + final alwaysScan = data['alwaysScan'] as bool? ?? false; + + final balance = ElectrumBalance.fromJSON(data['balance'] as String?) ?? ElectrumBalance(confirmed: 0, unconfirmed: 0, frozen: 0); - var regularAddressIndex = 0; - var changeAddressIndex = 0; + var regularAddressIndexByType = {SegwitAddresType.p2wpkh.toString(): 0}; + var changeAddressIndexByType = {SegwitAddresType.p2wpkh.toString(): 0}; + var silentAddressIndex = 0; + + final derivationType = DerivationType + .values[(data['derivationTypeIndex'] as int?) ?? DerivationType.electrum.index]; + final derivationPath = data['derivationPath'] as String? ?? electrum_path; try { - regularAddressIndex = int.parse(data['account_index'] as String? ?? '0'); - changeAddressIndex = int.parse(data['change_address_index'] as String? ?? '0'); - } catch (_) {} + regularAddressIndexByType = { + SegwitAddresType.p2wpkh.toString(): int.parse(data['account_index'] as String? ?? '0') + }; + changeAddressIndexByType = { + SegwitAddresType.p2wpkh.toString(): + int.parse(data['change_address_index'] as String? ?? '0') + }; + silentAddressIndex = int.parse(data['silent_address_index'] as String? ?? '0'); + } catch (_) { + try { + regularAddressIndexByType = data["account_index"] as Map? ?? {}; + changeAddressIndexByType = data["change_address_index"] as Map? ?? {}; + } catch (_) {} + } - return ElectrumWallletSnapshot( + return ElectrumWalletSnapshot( name: name, type: type, password: password, + passphrase: passphrase, mnemonic: mnemonic, + xpub: xpub, addresses: addresses, balance: balance, - regularAddressIndex: regularAddressIndex, - changeAddressIndex: changeAddressIndex); + regularAddressIndex: regularAddressIndexByType, + changeAddressIndex: changeAddressIndexByType, + addressPageType: data['address_page_type'] as String?, + derivationType: derivationType, + derivationPath: derivationPath, + silentAddresses: silentAddresses, + silentAddressIndex: silentAddressIndex, + mwebAddresses: mwebAddresses, + alwaysScan: alwaysScan, + ); } } diff --git a/cw_bitcoin/lib/exceptions.dart b/cw_bitcoin/lib/exceptions.dart new file mode 100644 index 000000000..9bdb66eef --- /dev/null +++ b/cw_bitcoin/lib/exceptions.dart @@ -0,0 +1,50 @@ +import 'package:cw_core/crypto_currency.dart'; +import 'package:cw_core/exceptions.dart'; + +class BitcoinTransactionWrongBalanceException extends TransactionWrongBalanceException { + BitcoinTransactionWrongBalanceException({super.amount}) : super(CryptoCurrency.btc); + + @override + String toString() { + return "BitcoinTransactionWrongBalanceException: $amount, $currency"; + } +} + +class BitcoinTransactionNoInputsException extends TransactionNoInputsException {} + +class BitcoinTransactionNoFeeException extends TransactionNoFeeException {} + +class BitcoinTransactionNoDustException extends TransactionNoDustException {} + +class BitcoinTransactionNoDustOnChangeException extends TransactionNoDustOnChangeException { + BitcoinTransactionNoDustOnChangeException(super.max, super.min); + + @override + String toString() { + return "BitcoinTransactionNoDustOnChangeException: max: $max, min: $min"; + } +} + +class BitcoinTransactionCommitFailed extends TransactionCommitFailed { + BitcoinTransactionCommitFailed({super.errorMessage}); + + @override + String toString() { + return errorMessage??"unknown error"; + } +} + +class BitcoinTransactionCommitFailedDustChange extends TransactionCommitFailedDustChange {} + +class BitcoinTransactionCommitFailedDustOutput extends TransactionCommitFailedDustOutput {} + +class BitcoinTransactionCommitFailedDustOutputSendAll + extends TransactionCommitFailedDustOutputSendAll {} + +class BitcoinTransactionCommitFailedVoutNegative extends TransactionCommitFailedVoutNegative {} + +class BitcoinTransactionCommitFailedBIP68Final extends TransactionCommitFailedBIP68Final {} + +class BitcoinTransactionCommitFailedLessThanMin extends TransactionCommitFailedLessThanMin {} + +class BitcoinTransactionSilentPaymentsNotSupported extends TransactionInputNotSupported {} diff --git a/cw_bitcoin/lib/litecoin_hardware_wallet_service.dart b/cw_bitcoin/lib/litecoin_hardware_wallet_service.dart new file mode 100644 index 000000000..62840933c --- /dev/null +++ b/cw_bitcoin/lib/litecoin_hardware_wallet_service.dart @@ -0,0 +1,46 @@ +import 'dart:async'; + +import 'package:bitcoin_base/bitcoin_base.dart'; +import 'package:blockchain_utils/blockchain_utils.dart'; +import 'package:cw_bitcoin/utils.dart'; +import 'package:cw_core/hardware/hardware_account_data.dart'; +import 'package:ledger_flutter_plus/ledger_flutter_plus.dart'; +import 'package:ledger_litecoin/ledger_litecoin.dart'; + +class LitecoinHardwareWalletService { + LitecoinHardwareWalletService(this.ledgerConnection); + + final LedgerConnection ledgerConnection; + + Future> getAvailableAccounts( + {int index = 0, int limit = 5}) async { + final litecoinLedgerApp = LitecoinLedgerApp(ledgerConnection); + + await litecoinLedgerApp.getVersion(); + + final accounts = []; + final indexRange = List.generate(limit, (i) => i + index); + final xpubVersion = Bip44Conf.litecoinMainNet.altKeyNetVer; + + for (final i in indexRange) { + final derivationPath = "m/84'/2'/$i'"; + final xpub = await litecoinLedgerApp.getXPubKey( + accountsDerivationPath: derivationPath, + xPubVersion: int.parse(hex.encode(xpubVersion.public), radix: 16)); + final hd = Bip32Slip10Secp256k1.fromExtendedKey(xpub, xpubVersion) + .childKey(Bip32KeyIndex(0)); + + final address = generateP2WPKHAddress( + hd: hd, index: 0, network: LitecoinNetwork.mainnet); + + accounts.add(HardwareAccountData( + address: address, + accountIndex: i, + derivationPath: derivationPath, + xpub: xpub, + )); + } + + return accounts; + } +} diff --git a/cw_bitcoin/lib/litecoin_network.dart b/cw_bitcoin/lib/litecoin_network.dart deleted file mode 100644 index d7ad2f837..000000000 --- a/cw_bitcoin/lib/litecoin_network.dart +++ /dev/null @@ -1,9 +0,0 @@ -import 'package:bitcoin_flutter/bitcoin_flutter.dart'; - -final litecoinNetwork = NetworkType( - messagePrefix: '\x19Litecoin Signed Message:\n', - bech32: 'ltc', - bip32: Bip32Type(public: 0x0488b21e, private: 0x0488ade4), - pubKeyHash: 0x30, - scriptHash: 0x32, - wif: 0xb0); diff --git a/cw_bitcoin/lib/litecoin_wallet.dart b/cw_bitcoin/lib/litecoin_wallet.dart index 222e95acc..08c56c600 100644 --- a/cw_bitcoin/lib/litecoin_wallet.dart +++ b/cw_bitcoin/lib/litecoin_wallet.dart @@ -1,81 +1,208 @@ +import 'dart:async'; +import 'dart:convert'; + +import 'package:convert/convert.dart' as convert; +import 'dart:math'; +import 'package:collection/collection.dart'; +import 'package:crypto/crypto.dart'; +import 'package:cw_bitcoin/bitcoin_transaction_credentials.dart'; +import 'package:cw_core/cake_hive.dart'; +import 'package:cw_core/mweb_utxo.dart'; +import 'package:cw_core/unspent_coin_type.dart'; +import 'package:cw_core/utils/print_verbose.dart'; +import 'package:cw_core/node.dart'; +import 'package:cw_mweb/mwebd.pbgrpc.dart'; +import 'package:fixnum/fixnum.dart'; +import 'package:bip39/bip39.dart' as bip39; +import 'package:bitcoin_base/bitcoin_base.dart'; +import 'package:blockchain_utils/blockchain_utils.dart'; +import 'package:blockchain_utils/signer/ecdsa_signing_key.dart'; +import 'package:cw_bitcoin/bitcoin_address_record.dart'; import 'package:cw_bitcoin/bitcoin_mnemonic.dart'; import 'package:cw_bitcoin/bitcoin_transaction_priority.dart'; +import 'package:cw_bitcoin/bitcoin_unspent.dart'; +import 'package:cw_bitcoin/electrum_transaction_info.dart'; +import 'package:cw_bitcoin/pending_bitcoin_transaction.dart'; +import 'package:cw_bitcoin/utils.dart'; +import 'package:cw_bitcoin/electrum_derivations.dart'; +import 'package:cw_core/encryption_file_utils.dart'; import 'package:cw_core/crypto_currency.dart'; +import 'package:cw_core/pending_transaction.dart'; +import 'package:cw_core/sync_status.dart'; +import 'package:cw_core/transaction_direction.dart'; import 'package:cw_core/unspent_coins_info.dart'; +import 'package:cw_bitcoin/electrum_balance.dart'; +import 'package:cw_bitcoin/electrum_wallet.dart'; +import 'package:cw_bitcoin/electrum_wallet_snapshot.dart'; import 'package:cw_bitcoin/litecoin_wallet_addresses.dart'; import 'package:cw_core/transaction_priority.dart'; -import 'package:flutter/foundation.dart'; -import 'package:hive/hive.dart'; -import 'package:mobx/mobx.dart'; import 'package:cw_core/wallet_info.dart'; -import 'package:cw_bitcoin/electrum_wallet_snapshot.dart'; -import 'package:cw_bitcoin/electrum_wallet.dart'; -import 'package:cw_bitcoin/bitcoin_address_record.dart'; -import 'package:cw_bitcoin/electrum_balance.dart'; -import 'package:cw_bitcoin/litecoin_network.dart'; -import 'package:bitcoin_flutter/bitcoin_flutter.dart' as bitcoin; +import 'package:cw_core/wallet_keys_file.dart'; +import 'package:flutter/foundation.dart'; +import 'package:grpc/grpc.dart'; +import 'package:hive/hive.dart'; +import 'package:ledger_flutter_plus/ledger_flutter_plus.dart'; +import 'package:ledger_litecoin/ledger_litecoin.dart'; +import 'package:mobx/mobx.dart'; +import 'package:cw_core/wallet_type.dart'; +import 'package:cw_mweb/cw_mweb.dart'; +import 'package:bitcoin_base/src/crypto/keypair/sign_utils.dart'; +import 'package:pointycastle/ecc/api.dart'; +import 'package:pointycastle/ecc/curves/secp256k1.dart'; +import 'package:shared_preferences/shared_preferences.dart'; part 'litecoin_wallet.g.dart'; class LitecoinWallet = LitecoinWalletBase with _$LitecoinWallet; abstract class LitecoinWalletBase extends ElectrumWallet with Store { - LitecoinWalletBase( + LitecoinWalletBase({ + required String password, + required WalletInfo walletInfo, + required Box unspentCoinsInfo, + required EncryptionFileUtils encryptionFileUtils, + Uint8List? seedBytes, + String? mnemonic, + String? xpub, + String? passphrase, + String? addressPageType, + List? initialAddresses, + List? initialMwebAddresses, + ElectrumBalance? initialBalance, + Map? initialRegularAddressIndex, + Map? initialChangeAddressIndex, + int? initialMwebHeight, + bool? alwaysScan, + }) : super( + mnemonic: mnemonic, + password: password, + passphrase: passphrase, + xpub: xpub, + walletInfo: walletInfo, + unspentCoinsInfo: unspentCoinsInfo, + network: LitecoinNetwork.mainnet, + initialAddresses: initialAddresses, + initialBalance: initialBalance, + seedBytes: seedBytes, + encryptionFileUtils: encryptionFileUtils, + currency: CryptoCurrency.ltc, + alwaysScan: alwaysScan, + ) { + if (seedBytes != null) { + mwebHd = + Bip32Slip10Secp256k1.fromSeed(seedBytes).derivePath("m/1000'") as Bip32Slip10Secp256k1; + mwebEnabled = alwaysScan ?? false; + } else { + mwebHd = null; + mwebEnabled = false; + } + walletAddresses = LitecoinWalletAddresses( + walletInfo, + initialAddresses: initialAddresses, + initialRegularAddressIndex: initialRegularAddressIndex, + initialChangeAddressIndex: initialChangeAddressIndex, + initialMwebAddresses: initialMwebAddresses, + mainHd: hd, + sideHd: accountHD.childKey(Bip32KeyIndex(1)), + network: network, + mwebHd: mwebHd, + mwebEnabled: mwebEnabled, + isHardwareWallet: walletInfo.isHardwareWallet, + ); + autorun((_) { + this.walletAddresses.isEnabledAutoGenerateSubaddress = this.isEnabledAutoGenerateSubaddress; + }); + reaction((_) => mwebSyncStatus, (status) async { + if (mwebSyncStatus is FailedSyncStatus) { + // we failed to connect to mweb, check if we are connected to the litecoin node: + late int nodeHeight; + try { + nodeHeight = await electrumClient.getCurrentBlockChainTip() ?? 0; + } catch (_) { + nodeHeight = 0; + } + + if (nodeHeight == 0) { + // we aren't connected to the litecoin node, so the current electrum_wallet reactions will take care of this case for us + } else { + // we're connected to the litecoin node, but we failed to connect to mweb, try again after a few seconds: + await CwMweb.stop(); + await Future.delayed(const Duration(seconds: 5)); + startSync(); + } + } else if (mwebSyncStatus is SyncingSyncStatus) { + syncStatus = mwebSyncStatus; + } else if (mwebSyncStatus is SyncronizingSyncStatus) { + if (syncStatus is! SyncronizingSyncStatus) { + syncStatus = mwebSyncStatus; + } + } else if (mwebSyncStatus is SyncedSyncStatus) { + if (syncStatus is! SyncedSyncStatus) { + syncStatus = mwebSyncStatus; + } + } + }); + } + late final Bip32Slip10Secp256k1? mwebHd; + late final Box mwebUtxosBox; + Timer? _syncTimer; + Timer? _feeRatesTimer; + Timer? _processingTimer; + StreamSubscription? _utxoStream; + late bool mwebEnabled; + bool processingUtxos = false; + + @observable + SyncStatus mwebSyncStatus = NotConnectedSyncStatus(); + + @override + bool get hasRescan => true; + + List get scanSecret => mwebHd!.childKey(Bip32KeyIndex(0x80000000)).privateKey.privKey.raw; + List get spendSecret => mwebHd!.childKey(Bip32KeyIndex(0x80000001)).privateKey.privKey.raw; + + static Future create( {required String mnemonic, required String password, required WalletInfo walletInfo, required Box unspentCoinsInfo, - required Uint8List seedBytes, + required EncryptionFileUtils encryptionFileUtils, + String? passphrase, + String? addressPageType, List? initialAddresses, + List? initialMwebAddresses, ElectrumBalance? initialBalance, - int initialRegularAddressIndex = 0, - int initialChangeAddressIndex = 0}) - : super( - mnemonic: mnemonic, - password: password, - walletInfo: walletInfo, - unspentCoinsInfo: unspentCoinsInfo, - networkType: litecoinNetwork, - initialAddresses: initialAddresses, - initialBalance: initialBalance, - seedBytes: seedBytes, - currency: CryptoCurrency.ltc) { - walletAddresses = LitecoinWalletAddresses( - walletInfo, - electrumClient: electrumClient, - initialAddresses: initialAddresses, - initialRegularAddressIndex: initialRegularAddressIndex, - initialChangeAddressIndex: initialChangeAddressIndex, - mainHd: hd, - sideHd: bitcoin.HDWallet - .fromSeed(seedBytes, network: networkType) - .derivePath("m/0'/1"), - networkType: networkType,); - autorun((_) { - this.walletAddresses.isEnabledAutoGenerateSubaddress = this.isEnabledAutoGenerateSubaddress; - }); - } + Map? initialRegularAddressIndex, + Map? initialChangeAddressIndex}) async { + late Uint8List seedBytes; - static Future create({ - required String mnemonic, - required String password, - required WalletInfo walletInfo, - required Box unspentCoinsInfo, - List? initialAddresses, - ElectrumBalance? initialBalance, - int initialRegularAddressIndex = 0, - int initialChangeAddressIndex = 0 - }) async { + switch (walletInfo.derivationInfo?.derivationType) { + case DerivationType.bip39: + seedBytes = await bip39.mnemonicToSeed( + mnemonic, + passphrase: passphrase ?? "", + ); + break; + case DerivationType.electrum: + default: + seedBytes = await mnemonicToSeedBytes(mnemonic, passphrase: passphrase ?? ""); + break; + } return LitecoinWallet( - mnemonic: mnemonic, - password: password, - walletInfo: walletInfo, - unspentCoinsInfo: unspentCoinsInfo, - initialAddresses: initialAddresses, - initialBalance: initialBalance, - seedBytes: await mnemonicToSeedBytes(mnemonic), - initialRegularAddressIndex: initialRegularAddressIndex, - initialChangeAddressIndex: initialChangeAddressIndex); + mnemonic: mnemonic, + password: password, + walletInfo: walletInfo, + unspentCoinsInfo: unspentCoinsInfo, + initialAddresses: initialAddresses, + initialMwebAddresses: initialMwebAddresses, + initialBalance: initialBalance, + encryptionFileUtils: encryptionFileUtils, + passphrase: passphrase, + seedBytes: seedBytes, + initialRegularAddressIndex: initialRegularAddressIndex, + initialChangeAddressIndex: initialChangeAddressIndex, + addressPageType: addressPageType, + ); } static Future open({ @@ -83,18 +210,739 @@ abstract class LitecoinWalletBase extends ElectrumWallet with Store { required WalletInfo walletInfo, required Box unspentCoinsInfo, required String password, + required bool alwaysScan, + required EncryptionFileUtils encryptionFileUtils, }) async { - final snp = await ElectrumWallletSnapshot.load (name, walletInfo.type, password); + final hasKeysFile = await WalletKeysFile.hasKeysFile(name, walletInfo.type); + + ElectrumWalletSnapshot? snp = null; + + try { + snp = await ElectrumWalletSnapshot.load( + encryptionFileUtils, + name, + walletInfo.type, + password, + LitecoinNetwork.mainnet, + ); + } catch (e) { + if (!hasKeysFile) rethrow; + } + + final WalletKeysData keysData; + // Migrate wallet from the old scheme to then new .keys file scheme + if (!hasKeysFile) { + keysData = + WalletKeysData(mnemonic: snp!.mnemonic, xPub: snp.xpub, passphrase: snp.passphrase); + } else { + keysData = await WalletKeysFile.readKeysFile( + name, + walletInfo.type, + password, + encryptionFileUtils, + ); + } + + walletInfo.derivationInfo ??= DerivationInfo(); + + // set the default if not present: + walletInfo.derivationInfo!.derivationPath ??= snp?.derivationPath ?? electrum_path; + walletInfo.derivationInfo!.derivationType ??= snp?.derivationType ?? DerivationType.electrum; + + Uint8List? seedBytes = null; + final mnemonic = keysData.mnemonic; + final passphrase = keysData.passphrase; + + if (mnemonic != null) { + switch (walletInfo.derivationInfo?.derivationType) { + case DerivationType.bip39: + seedBytes = await bip39.mnemonicToSeed( + mnemonic, + passphrase: passphrase ?? "", + ); + break; + case DerivationType.electrum: + default: + seedBytes = await mnemonicToSeedBytes(mnemonic, passphrase: passphrase ?? ""); + break; + } + } + return LitecoinWallet( - mnemonic: snp.mnemonic, - password: password, - walletInfo: walletInfo, - unspentCoinsInfo: unspentCoinsInfo, - initialAddresses: snp.addresses, - initialBalance: snp.balance, - seedBytes: await mnemonicToSeedBytes(snp.mnemonic), - initialRegularAddressIndex: snp.regularAddressIndex, - initialChangeAddressIndex: snp.changeAddressIndex); + mnemonic: keysData.mnemonic, + xpub: keysData.xPub, + password: password, + walletInfo: walletInfo, + unspentCoinsInfo: unspentCoinsInfo, + initialAddresses: snp?.addresses, + initialMwebAddresses: snp?.mwebAddresses, + initialBalance: snp?.balance, + seedBytes: seedBytes, + passphrase: passphrase, + encryptionFileUtils: encryptionFileUtils, + initialRegularAddressIndex: snp?.regularAddressIndex, + initialChangeAddressIndex: snp?.changeAddressIndex, + addressPageType: snp?.addressPageType, + alwaysScan: snp?.alwaysScan, + ); + } + + Future waitForMwebAddresses() async { + printV("waitForMwebAddresses() called!"); + // ensure that we have the full 1000 mweb addresses generated before continuing: + // should no longer be needed, but leaving here just in case + await (walletAddresses as LitecoinWalletAddresses).ensureMwebAddressUpToIndexExists(1020); + } + + @action + @override + Future connectToNode({required Node node}) async { + await super.connectToNode(node: node); + + final prefs = await SharedPreferences.getInstance(); + final mwebNodeUri = prefs.getString("mwebNodeUri") ?? "ltc-electrum.cakewallet.com:9333"; + await CwMweb.setNodeUriOverride(mwebNodeUri); + } + + @action + @override + Future startSync() async { + printV("startSync() called!"); + printV("STARTING SYNC - MWEB ENABLED: $mwebEnabled"); + if (!mwebEnabled) { + try { + // in case we're switching from a litecoin wallet that had mweb enabled + CwMweb.stop(); + } catch (_) {} + super.startSync(); + return; + } + + if (mwebSyncStatus is SyncronizingSyncStatus) { + return; + } + + printV("STARTING SYNC - MWEB ENABLED: $mwebEnabled"); + _syncTimer?.cancel(); + try { + mwebSyncStatus = SyncronizingSyncStatus(); + try { + await subscribeForUpdates(); + } catch (e) { + printV("failed to subscribe for updates: $e"); + } + updateFeeRates(); + _feeRatesTimer?.cancel(); + _feeRatesTimer = + Timer.periodic(const Duration(minutes: 1), (timer) async => await updateFeeRates()); + + printV("START SYNC FUNCS"); + await waitForMwebAddresses(); + await processMwebUtxos(); + await updateTransactions(); + await updateUnspent(); + await updateBalance(); + } catch (e) { + printV("failed to start mweb sync: $e"); + syncStatus = FailedSyncStatus(); + return; + } + + _syncTimer?.cancel(); + _syncTimer = Timer.periodic(const Duration(milliseconds: 3000), (timer) async { + if (mwebSyncStatus is FailedSyncStatus) { + _syncTimer?.cancel(); + return; + } + + final nodeHeight = + await electrumClient.getCurrentBlockChainTip() ?? 0; // current block height of our node + + if (nodeHeight == 0) { + // we aren't connected to the ltc node yet + if (mwebSyncStatus is! NotConnectedSyncStatus) { + mwebSyncStatus = FailedSyncStatus(error: "litecoin node isn't connected"); + } + return; + } + + // update the current chain tip so that confirmation calculations are accurate: + currentChainTip = nodeHeight; + + final resp = await CwMweb.status(StatusRequest()); + + try { + if (resp.blockHeaderHeight < nodeHeight) { + int h = resp.blockHeaderHeight; + mwebSyncStatus = SyncingSyncStatus(nodeHeight - h, h / nodeHeight); + } else if (resp.mwebHeaderHeight < nodeHeight) { + int h = resp.mwebHeaderHeight; + mwebSyncStatus = SyncingSyncStatus(nodeHeight - h, h / nodeHeight); + } else if (resp.mwebUtxosHeight < nodeHeight) { + mwebSyncStatus = SyncingSyncStatus(1, 0.999); + } else { + bool confirmationsUpdated = false; + if (resp.mwebUtxosHeight > walletInfo.restoreHeight) { + await walletInfo.updateRestoreHeight(resp.mwebUtxosHeight); + await checkMwebUtxosSpent(); + // update the confirmations for each transaction: + for (final tx in transactionHistory.transactions.values) { + if (tx.height == null || tx.height == 0) { + // update with first confirmation on next block since it hasn't been confirmed yet: + tx.height = resp.mwebUtxosHeight; + continue; + } + + final confirmations = (resp.mwebUtxosHeight - tx.height!) + 1; + + // if the confirmations haven't changed, skip updating: + if (tx.confirmations == confirmations) continue; + + // if an outgoing tx is now confirmed, delete the utxo from the box (delete the unspent coin): + if (confirmations >= 2 && + tx.direction == TransactionDirection.outgoing && + tx.unspents != null) { + for (var coin in tx.unspents!) { + final utxo = mwebUtxosBox.get(coin.address); + if (utxo != null) { + printV("deleting utxo ${coin.address} @@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@"); + await mwebUtxosBox.delete(coin.address); + } + } + } + + tx.confirmations = confirmations; + tx.isPending = false; + transactionHistory.addOne(tx); + confirmationsUpdated = true; + } + if (confirmationsUpdated) { + await transactionHistory.save(); + await updateTransactions(); + } + } + + // prevent unnecessary reaction triggers: + if (mwebSyncStatus is! SyncedSyncStatus) { + // mwebd is synced, but we could still be processing incoming utxos: + if (!processingUtxos) { + mwebSyncStatus = SyncedSyncStatus(); + } + } + return; + } + } catch (e) { + printV("error syncing: $e"); + mwebSyncStatus = FailedSyncStatus(error: e.toString()); + } + }); + } + + @action + @override + Future stopSync() async { + printV("stopSync() called!"); + _syncTimer?.cancel(); + _utxoStream?.cancel(); + _feeRatesTimer?.cancel(); + await CwMweb.stop(); + printV("stopped syncing!"); + } + + Future initMwebUtxosBox() async { + final boxName = "${walletInfo.name.replaceAll(" ", "_")}_${MwebUtxo.boxName}"; + + mwebUtxosBox = await CakeHive.openBox(boxName); + } + + @override + Future renameWalletFiles(String newWalletName) async { + // rename the hive box: + final oldBoxName = "${walletInfo.name.replaceAll(" ", "_")}_${MwebUtxo.boxName}"; + final newBoxName = "${newWalletName.replaceAll(" ", "_")}_${MwebUtxo.boxName}"; + + final oldBox = await CakeHive.openBox(oldBoxName); + mwebUtxosBox = await CakeHive.openBox(newBoxName); + for (final key in oldBox.keys) { + final value = oldBox.get(key); + await oldBox.delete(key); + await mwebUtxosBox.put(key, value!); + } + oldBox.deleteFromDisk(); + + await super.renameWalletFiles(newWalletName); + } + + @action + @override + Future rescan({ + required int height, + int? chainTip, + ScanData? scanData, + bool? doSingleScan, + bool? usingElectrs, + }) async { + _syncTimer?.cancel(); + await walletInfo.updateRestoreHeight(height); + + // go through mwebUtxos and clear any that are above the new restore height: + if (height == 0) { + await mwebUtxosBox.clear(); + transactionHistory.clear(); + } else { + for (final utxo in mwebUtxosBox.values) { + if (utxo.height > height) { + await mwebUtxosBox.delete(utxo.outputId); + } + } + // TODO: remove transactions that are above the new restore height! + } + + // reset coin balances and txCount to 0: + unspentCoins.forEach((coin) { + if (coin.bitcoinAddressRecord is! BitcoinSilentPaymentAddressRecord) + coin.bitcoinAddressRecord.balance = 0; + coin.bitcoinAddressRecord.txCount = 0; + }); + + for (var addressRecord in walletAddresses.allAddresses) { + addressRecord.balance = 0; + addressRecord.txCount = 0; + } + + await startSync(); + } + + @override + Future init() async { + await super.init(); + await initMwebUtxosBox(); + } + + Future handleIncoming(MwebUtxo utxo) async { + printV("handleIncoming() called!"); + final status = await CwMweb.status(StatusRequest()); + var date = DateTime.now(); + var confirmations = 0; + if (utxo.height > 0) { + date = DateTime.fromMillisecondsSinceEpoch(utxo.blockTime * 1000); + confirmations = status.blockHeaderHeight - utxo.height + 1; + } + var tx = transactionHistory.transactions.values + .firstWhereOrNull((tx) => tx.outputAddresses?.contains(utxo.outputId) ?? false); + + if (tx == null) { + tx = ElectrumTransactionInfo( + WalletType.litecoin, + id: utxo.outputId, + height: utxo.height, + amount: utxo.value.toInt(), + fee: 0, + direction: TransactionDirection.incoming, + isPending: utxo.height == 0, + date: date, + confirmations: confirmations, + inputAddresses: [], + outputAddresses: [utxo.outputId], + isReplaced: false, + ); + } else { + if (tx.confirmations != confirmations || tx.height != utxo.height) { + tx.height = utxo.height; + tx.confirmations = confirmations; + tx.isPending = utxo.height == 0; + } + } + + bool isNew = transactionHistory.transactions[tx.id] == null; + + if (!(tx.outputAddresses?.contains(utxo.address) ?? false)) { + tx.outputAddresses?.add(utxo.address); + isNew = true; + } + + if (isNew) { + final addressRecord = walletAddresses.allAddresses + .firstWhereOrNull((addressRecord) => addressRecord.address == utxo.address); + if (addressRecord == null) { + printV("we don't have this address in the wallet! ${utxo.address}"); + return; + } + + // update the txCount: + addressRecord.txCount++; + addressRecord.balance += utxo.value.toInt(); + addressRecord.setAsUsed(); + } + + transactionHistory.addOne(tx); + + if (isNew) { + // update the unconfirmed balance when a new tx is added: + // we do this after adding the tx to the history so that sub address balances are updated correctly + // (since that calculation is based on the tx history) + await updateBalance(); + } + } + + Future processMwebUtxos() async { + printV("processMwebUtxos() called!"); + if (!mwebEnabled) { + return; + } + + int restoreHeight = walletInfo.restoreHeight; + printV("SCANNING FROM HEIGHT: $restoreHeight"); + final req = UtxosRequest(scanSecret: scanSecret, fromHeight: restoreHeight); + + // process new utxos as they come in: + await _utxoStream?.cancel(); + ResponseStream? responseStream = await CwMweb.utxos(req); + if (responseStream == null) { + throw Exception("failed to get utxos stream!"); + } + _utxoStream = responseStream.listen( + (Utxo sUtxo) async { + // we're processing utxos, so our balance could still be inaccurate: + if (mwebSyncStatus is! SyncronizingSyncStatus && mwebSyncStatus is! SyncingSyncStatus) { + mwebSyncStatus = SyncronizingSyncStatus(); + processingUtxos = true; + _processingTimer?.cancel(); + _processingTimer = Timer.periodic(const Duration(seconds: 2), (timer) async { + processingUtxos = false; + timer.cancel(); + }); + } + + final utxo = MwebUtxo( + address: sUtxo.address, + blockTime: sUtxo.blockTime, + height: sUtxo.height, + outputId: sUtxo.outputId, + value: sUtxo.value.toInt(), + ); + + if (mwebUtxosBox.containsKey(utxo.outputId)) { + // we've already stored this utxo, skip it: + // but do update the utxo height if it's somehow different: + final existingUtxo = mwebUtxosBox.get(utxo.outputId); + if (existingUtxo!.height != utxo.height) { + printV( + "updating utxo height for $utxo.outputId: ${existingUtxo.height} -> ${utxo.height}"); + existingUtxo.height = utxo.height; + await mwebUtxosBox.put(utxo.outputId, existingUtxo); + } + return; + } + + await updateUnspent(); + await updateBalance(); + + final mwebAddrs = (walletAddresses as LitecoinWalletAddresses).mwebAddrs; + + // don't process utxos with addresses that are not in the mwebAddrs list: + if (utxo.address.isNotEmpty && !mwebAddrs.contains(utxo.address)) { + return; + } + + await mwebUtxosBox.put(utxo.outputId, utxo); + + await handleIncoming(utxo); + }, + onError: (error) { + printV("error in utxo stream: $error"); + mwebSyncStatus = FailedSyncStatus(error: error.toString()); + }, + cancelOnError: true, + ); + } + + Future deleteSpentUtxos() async { + printV("deleteSpentUtxos() called!"); + final chainHeight = await electrumClient.getCurrentBlockChainTip(); + final status = await CwMweb.status(StatusRequest()); + if (chainHeight == null || status.blockHeaderHeight != chainHeight) return; + if (status.mwebUtxosHeight != chainHeight) return; // we aren't synced + + // delete any spent utxos with >= 2 confirmations: + final spentOutputIds = mwebUtxosBox.values + .where((utxo) => utxo.spent && (chainHeight - utxo.height) >= 2) + .map((utxo) => utxo.outputId) + .toList(); + + if (spentOutputIds.isEmpty) return; + + final resp = await CwMweb.spent(SpentRequest(outputId: spentOutputIds)); + final spent = resp.outputId; + if (spent.isEmpty) return; + + for (final outputId in spent) { + await mwebUtxosBox.delete(outputId); + } + } + + Future checkMwebUtxosSpent() async { + printV("checkMwebUtxosSpent() called!"); + if (!mwebEnabled) { + return; + } + + final pendingOutgoingTransactions = transactionHistory.transactions.values + .where((tx) => tx.direction == TransactionDirection.outgoing && tx.isPending); + + // check if any of the pending outgoing transactions are now confirmed: + bool updatedAny = false; + for (final tx in pendingOutgoingTransactions) { + updatedAny = await isConfirmed(tx) || updatedAny; + } + + await deleteSpentUtxos(); + + // get output ids of all the mweb utxos that have > 0 height: + final outputIds = mwebUtxosBox.values + .where((utxo) => utxo.height > 0 && !utxo.spent) + .map((utxo) => utxo.outputId) + .toList(); + + final resp = await CwMweb.spent(SpentRequest(outputId: outputIds)); + final spent = resp.outputId; + if (spent.isEmpty) return; + + final status = await CwMweb.status(StatusRequest()); + final height = await electrumClient.getCurrentBlockChainTip(); + if (height == null || status.blockHeaderHeight != height) return; + if (status.mwebUtxosHeight != height) return; // we aren't synced + int amount = 0; + Set inputAddresses = {}; + var output = convert.AccumulatorSink(); + var input = sha256.startChunkedConversion(output); + + for (final outputId in spent) { + final utxo = mwebUtxosBox.get(outputId); + await mwebUtxosBox.delete(outputId); + if (utxo == null) continue; + final addressRecord = walletAddresses.allAddresses + .firstWhere((addressRecord) => addressRecord.address == utxo.address); + if (!inputAddresses.contains(utxo.address)) { + addressRecord.txCount++; + } + addressRecord.balance -= utxo.value.toInt(); + amount += utxo.value.toInt(); + inputAddresses.add(utxo.address); + input.add(hex.decode(outputId)); + } + + if (inputAddresses.isEmpty) return; + input.close(); + var digest = output.events.single; + final tx = ElectrumTransactionInfo( + WalletType.litecoin, + id: digest.toString(), + height: height, + amount: amount, + fee: 0, + direction: TransactionDirection.outgoing, + isPending: false, + date: DateTime.fromMillisecondsSinceEpoch(status.blockTime * 1000), + confirmations: 1, + inputAddresses: inputAddresses.toList(), + outputAddresses: [], + isReplaced: false, + ); + + transactionHistory.addOne(tx); + await transactionHistory.save(); + + if (updatedAny) { + await updateBalance(); + } + } + + // checks if a pending transaction is now confirmed, and updates the tx info accordingly: + Future isConfirmed(ElectrumTransactionInfo tx) async { + if (!mwebEnabled) return false; + if (!tx.isPending) return false; + + final isMwebTx = (tx.inputAddresses?.any((addr) => addr.contains("mweb")) ?? false) || + (tx.outputAddresses?.any((addr) => addr.contains("mweb")) ?? false); + + if (!isMwebTx) { + return false; + } + + final outputId = [], target = {}; + final isHash = RegExp(r'^[a-f0-9]{64}$').hasMatch; + final spendingOutputIds = tx.inputAddresses?.where(isHash) ?? []; + final payingToOutputIds = tx.outputAddresses?.where(isHash) ?? []; + outputId.addAll(spendingOutputIds); + outputId.addAll(payingToOutputIds); + target.addAll(spendingOutputIds); + + for (final outputId in payingToOutputIds) { + final spendingTx = transactionHistory.transactions.values + .firstWhereOrNull((tx) => tx.inputAddresses?.contains(outputId) ?? false); + if (spendingTx != null && !spendingTx.isPending) { + target.add(outputId); + } + } + + if (outputId.isEmpty) { + return false; + } + + final resp = await CwMweb.spent(SpentRequest(outputId: outputId)); + if (!setEquals(resp.outputId.toSet(), target)) { + return false; + } + + final status = await CwMweb.status(StatusRequest()); + tx.height = status.mwebUtxosHeight; + tx.confirmations = 1; + tx.isPending = false; + await transactionHistory.save(); + return true; + } + + Future updateUnspent() async { + printV("updateUnspent() called!"); + await checkMwebUtxosSpent(); + await updateAllUnspents(); + } + + @override + @action + Future updateAllUnspents() async { + if (!mwebEnabled) { + await super.updateAllUnspents(); + return; + } + + // add the mweb unspents to the list: + List mwebUnspentCoins = []; + // update mweb unspents: + final mwebAddrs = (walletAddresses as LitecoinWalletAddresses).mwebAddrs; + mwebUtxosBox.keys.forEach((dynamic oId) { + final String outputId = oId as String; + final utxo = mwebUtxosBox.get(outputId); + if (utxo == null || utxo.spent) { + return; + } + if (utxo.address.isEmpty) { + // not sure if a bug or a special case but we definitely ignore these + return; + } + final addressRecord = walletAddresses.allAddresses + .firstWhereOrNull((addressRecord) => addressRecord.address == utxo.address); + + if (addressRecord == null) { + printV("utxo contains an address that is not in the wallet: ${utxo.address}"); + return; + } + final unspent = BitcoinUnspent( + addressRecord, + outputId, + utxo.value.toInt(), + mwebAddrs.indexOf(utxo.address), + ); + if (unspent.vout == 0) { + unspent.isChange = true; + } + mwebUnspentCoins.add(unspent); + }); + + // copy coin control attributes to mwebCoins: + await updateCoins(mwebUnspentCoins); + // get regular ltc unspents (this resets unspentCoins): + await super.updateAllUnspents(); + // add the mwebCoins: + unspentCoins.addAll(mwebUnspentCoins); + } + + @override + Future fetchBalances() async { + final balance = await super.fetchBalances(); + if (!mwebEnabled) { + return balance; + } + + // update unspent balances: + await updateUnspent(); + + int confirmed = balance.confirmed; + int unconfirmed = balance.unconfirmed; + int confirmedMweb = 0; + int unconfirmedMweb = 0; + try { + mwebUtxosBox.values.forEach((utxo) { + bool isConfirmed = utxo.height > 0; + + printV( + "utxo: ${isConfirmed ? "confirmed" : "unconfirmed"} ${utxo.spent ? "spent" : "unspent"} ${utxo.outputId} ${utxo.height} ${utxo.value}"); + + if (isConfirmed) { + confirmedMweb += utxo.value.toInt(); + } + + if (isConfirmed && utxo.spent) { + unconfirmedMweb -= utxo.value.toInt(); + } + + if (!isConfirmed && !utxo.spent) { + unconfirmedMweb += utxo.value.toInt(); + } + }); + } catch (_) {} + + for (var addressRecord in walletAddresses.allAddresses) { + addressRecord.balance = 0; + addressRecord.txCount = 0; + } + + unspentCoins.forEach((coin) { + final coinInfoList = unspentCoinsInfo.values.where( + (element) => + element.walletId.contains(id) && + element.hash.contains(coin.hash) && + element.vout == coin.vout, + ); + + if (coinInfoList.isNotEmpty) { + final coinInfo = coinInfoList.first; + + coin.isFrozen = coinInfo.isFrozen; + coin.isSending = coinInfo.isSending; + coin.note = coinInfo.note; + if (coin.bitcoinAddressRecord is! BitcoinSilentPaymentAddressRecord) + coin.bitcoinAddressRecord.balance += coinInfo.value; + } else { + super.addCoinInfo(coin); + } + }); + + // update the txCount for each address using the tx history, since we can't rely on mwebd + // to have an accurate count, we should just keep it in sync with what we know from the tx history: + for (final tx in transactionHistory.transactions.values) { + if (tx.inputAddresses == null || tx.outputAddresses == null) { + continue; + } + final txAddresses = tx.inputAddresses! + tx.outputAddresses!; + for (final address in txAddresses) { + final addressRecord = walletAddresses.allAddresses + .firstWhereOrNull((addressRecord) => addressRecord.address == address); + if (addressRecord == null) { + continue; + } + addressRecord.txCount++; + } + } + + return ElectrumBalance( + confirmed: confirmed, + unconfirmed: unconfirmed, + frozen: balance.frozen, + secondConfirmed: confirmedMweb, + secondUnconfirmed: unconfirmedMweb, + ); } @override @@ -112,4 +960,469 @@ abstract class LitecoinWalletBase extends ElectrumWallet with Store { return 0; } + + @override + Future calcFee({ + required List utxos, + required List outputs, + required BasedUtxoNetwork network, + String? memo, + required int feeRate, + List? inputPrivKeyInfos, + List? vinOutpoints, + }) async { + bool spendsMweb = utxos.any((utxo) => utxo.utxo.scriptType == SegwitAddresType.mweb); + bool paysToMweb = outputs + .any((output) => output.toOutput.scriptPubKey.getAddressType() == SegwitAddresType.mweb); + + bool isRegular = !spendsMweb && !paysToMweb; + bool isMweb = spendsMweb || paysToMweb; + + if (isMweb && !mwebEnabled) { + throw Exception("MWEB is not enabled! can't calculate fee without starting the mweb server!"); + // TODO: likely the change address is mweb and just not updated + } + + if (isRegular) { + return await super.calcFee( + utxos: utxos, + outputs: outputs, + network: network, + memo: memo, + feeRate: feeRate, + inputPrivKeyInfos: inputPrivKeyInfos, + vinOutpoints: vinOutpoints, + ); + } + + if (outputs.length == 1 && outputs[0].toOutput.amount == BigInt.zero) { + outputs = [ + BitcoinScriptOutput( + script: outputs[0].toOutput.scriptPubKey, value: utxos.sumOfUtxosValue()) + ]; + } + + // https://github.com/ltcmweb/mwebd?tab=readme-ov-file#fee-estimation + final preOutputSum = + outputs.fold(BigInt.zero, (acc, output) => acc + output.toOutput.amount); + var fee = utxos.sumOfUtxosValue() - preOutputSum; + + // determines if the fee is correct: + BigInt _sumOutputAmounts(List outputs) { + BigInt sum = BigInt.zero; + for (final e in outputs) { + sum += e.amount; + } + return sum; + } + + final sum1 = _sumOutputAmounts(outputs.map((e) => e.toOutput).toList()) + fee; + final sum2 = utxos.sumOfUtxosValue(); + if (sum1 != sum2) { + printV("@@@@@ WE HAD TO ADJUST THE FEE! @@@@@@@@"); + final diff = sum2 - sum1; + // add the difference to the fee (abs value): + fee += diff.abs(); + } + + final txb = + BitcoinTransactionBuilder(utxos: utxos, outputs: outputs, fee: fee, network: network); + final resp = await CwMweb.create(CreateRequest( + rawTx: txb.buildTransaction((a, b, c, d) => '').toBytes(), + scanSecret: scanSecret, + spendSecret: spendSecret, + feeRatePerKb: Int64(feeRate * 1000), + dryRun: true)); + final tx = BtcTransaction.fromRaw(hex.encode(resp.rawTx)); + final posUtxos = utxos + .where((utxo) => tx.inputs + .any((input) => input.txId == utxo.utxo.txHash && input.txIndex == utxo.utxo.vout)) + .toList(); + final posOutputSum = tx.outputs.fold(0, (acc, output) => acc + output.amount.toInt()); + final mwebInputSum = utxos.sumOfUtxosValue() - posUtxos.sumOfUtxosValue(); + final expectedPegin = max(0, (preOutputSum - mwebInputSum).toInt()); + var feeIncrease = posOutputSum - expectedPegin; + if (expectedPegin > 0 && fee == BigInt.zero) { + feeIncrease += await super.calcFee( + utxos: posUtxos, + outputs: tx.outputs + .map((output) => + BitcoinScriptOutput(script: output.scriptPubKey, value: output.amount)) + .toList(), + network: network, + memo: memo, + feeRate: feeRate) + + feeRate * 41; + } + return fee.toInt() + feeIncrease; + } + + @override + Future createTransaction(Object credentials) async { + try { + var tx = await super.createTransaction(credentials) as PendingBitcoinTransaction; + tx.isMweb = mwebEnabled; + + if (!mwebEnabled) { + tx.changeAddressOverride = + (await (walletAddresses as LitecoinWalletAddresses).getChangeAddress(coinTypeToSpendFrom: UnspentCoinType.nonMweb)) + .address; + return tx; + } + await waitForMwebAddresses(); + + final resp = await CwMweb.create(CreateRequest( + rawTx: hex.decode(tx.hex), + scanSecret: scanSecret, + spendSecret: spendSecret, + feeRatePerKb: Int64.parseInt(tx.feeRate) * 1000, + )); + final tx2 = BtcTransaction.fromRaw(hex.encode(resp.rawTx)); + + // check if the transaction doesn't contain any mweb inputs or outputs: + final transactionCredentials = credentials as BitcoinTransactionCredentials; + + bool hasMwebInput = false; + bool hasMwebOutput = false; + bool hasRegularOutput = false; + + for (final output in transactionCredentials.outputs) { + final address = output.address.toLowerCase(); + final extractedAddress = output.extractedAddress?.toLowerCase(); + + if (address.contains("mweb")) { + hasMwebOutput = true; + } + if (!address.contains("mweb")) { + hasRegularOutput = true; + } + if (extractedAddress != null && extractedAddress.isNotEmpty) { + if (extractedAddress.contains("mweb")) { + hasMwebOutput = true; + } + if (!extractedAddress.contains("mweb")) { + hasRegularOutput = true; + } + } + } + + // check if mweb inputs are used: + for (final utxo in tx.utxos) { + if (utxo.utxo.scriptType == SegwitAddresType.mweb) { + hasMwebInput = true; + } + } + + // could probably be simplified but left for clarity: + bool isPegIn = !hasMwebInput && hasMwebOutput; + bool isPegOut = hasMwebInput && hasRegularOutput; + bool isRegular = !hasMwebInput && !hasMwebOutput; + bool shouldNotUseMwebChange = isPegIn || isRegular || !hasMwebInput; + tx.changeAddressOverride = (await (walletAddresses as LitecoinWalletAddresses) + .getChangeAddress(coinTypeToSpendFrom: shouldNotUseMwebChange ? UnspentCoinType.nonMweb : UnspentCoinType.any)) + .address; + if (isRegular) { + tx.isMweb = false; + return tx; + } + + // check if any of the inputs of this transaction are hog-ex: + // this list is only non-mweb inputs: + tx2.inputs.forEach((txInput) { + bool isHogEx = true; + + final utxo = unspentCoins + .firstWhere((utxo) => utxo.hash == txInput.txId && utxo.vout == txInput.txIndex); + + // TODO: detect actual hog-ex inputs + + if (!isHogEx) { + return; + } + + int confirmations = utxo.confirmations ?? 0; + if (confirmations < 6) { + throw Exception( + "A transaction input has less than 6 confirmations, please try again later."); + } + }); + + tx.hexOverride = tx2 + .copyWith( + witnesses: tx2.inputs.asMap().entries.map((e) { + final utxo = unspentCoins + .firstWhere((utxo) => utxo.hash == e.value.txId && utxo.vout == e.value.txIndex); + final key = generateECPrivate( + hd: utxo.bitcoinAddressRecord.isHidden + ? walletAddresses.sideHd + : walletAddresses.mainHd, + index: utxo.bitcoinAddressRecord.index, + network: network); + final digest = tx2.getTransactionSegwitDigit( + txInIndex: e.key, + script: key.getPublic().toP2pkhAddress().toScriptPubKey(), + amount: BigInt.from(utxo.value), + ); + return TxWitnessInput(stack: [key.signInput(digest), key.getPublic().toHex()]); + }).toList()) + .toHex(); + tx.outputAddresses = resp.outputId; + + return tx + ..addListener((transaction) async { + final addresses = {}; + transaction.inputAddresses?.forEach((id) async { + final utxo = mwebUtxosBox.get(id); + // await mwebUtxosBox.delete(id); // gets deleted in checkMwebUtxosSpent + if (utxo == null) return; + // mark utxo as spent so we add it to the unconfirmed balance (as negative): + utxo.spent = true; + await mwebUtxosBox.put(id, utxo); + final addressRecord = walletAddresses.allAddresses + .firstWhere((addressRecord) => addressRecord.address == utxo.address); + if (!addresses.contains(utxo.address)) { + addresses.add(utxo.address); + } + addressRecord.balance -= utxo.value.toInt(); + }); + transaction.inputAddresses?.addAll(addresses); + printV("isPegIn: $isPegIn, isPegOut: $isPegOut"); + transaction.additionalInfo["isPegIn"] = isPegIn; + transaction.additionalInfo["isPegOut"] = isPegOut; + transactionHistory.addOne(transaction); + await updateUnspent(); + await updateBalance(); + }); + } catch (e, s) { + printV(e); + printV(s); + if (e.toString().contains("commit failed")) { + printV(e); + throw Exception("Transaction commit failed (no peers responded), please try again."); + } + rethrow; + } + } + + @override + Future save() async { + await super.save(); + } + + @override + Future close({bool shouldCleanup = false}) async { + _utxoStream?.cancel(); + _feeRatesTimer?.cancel(); + _syncTimer?.cancel(); + _processingTimer?.cancel(); + if (shouldCleanup) { + try { + await stopSync(); + } catch (_) {} + } + await super.close(shouldCleanup: shouldCleanup); + } + + Future setMwebEnabled(bool enabled) async { + if (mwebEnabled == enabled && + alwaysScan == enabled && + (walletAddresses as LitecoinWalletAddresses).mwebEnabled == enabled) { + return; + } + + alwaysScan = enabled; + mwebEnabled = enabled; + (walletAddresses as LitecoinWalletAddresses).mwebEnabled = enabled; + await save(); + try { + await stopSync(); + } catch (_) {} + await startSync(); + } + + Future getStatusRequest() async { + final resp = await CwMweb.status(StatusRequest()); + return resp; + } + + @override + Future signMessage(String message, {String? address = null}) async { + final index = address != null + ? walletAddresses.allAddresses.firstWhere((element) => element.address == address).index + : null; + final HD = index == null ? hd : hd.childKey(Bip32KeyIndex(index)); + final priv = ECPrivate.fromHex(HD.privateKey.privKey.toHex()); + + final privateKey = ECDSAPrivateKey.fromBytes( + priv.toBytes(), + Curves.generatorSecp256k1, + ); + + final signature = + signLitecoinMessage(utf8.encode(message), privateKey: privateKey, bipPrive: priv.prive); + + return base64Encode(signature); + } + + List _magicPrefix(List message, List messagePrefix) { + final encodeLength = IntUtils.encodeVarint(message.length); + + return [...messagePrefix, ...encodeLength, ...message]; + } + + List signLitecoinMessage(List message, + {required ECDSAPrivateKey privateKey, required Bip32PrivateKey bipPrive}) { + String messagePrefix = '\x19Litecoin Signed Message:\n'; + final messageHash = QuickCrypto.sha256Hash(magicMessage(message, messagePrefix)); + final signingKey = EcdsaSigningKey(privateKey); + ECDSASignature ecdsaSign = + signingKey.signDigestDeterminstic(digest: messageHash, hashFunc: () => SHA256()); + final n = Curves.generatorSecp256k1.order! >> 1; + BigInt newS; + if (ecdsaSign.s.compareTo(n) > 0) { + newS = Curves.generatorSecp256k1.order! - ecdsaSign.s; + } else { + newS = ecdsaSign.s; + } + final rawSig = ECDSASignature(ecdsaSign.r, newS); + final rawSigBytes = rawSig.toBytes(BitcoinSignerUtils.baselen); + + final pub = bipPrive.publicKey; + final ECDomainParameters curve = ECCurve_secp256k1(); + final point = curve.curve.decodePoint(pub.point.toBytes()); + + final rawSigEc = ECSignature(rawSig.r, rawSig.s); + + final recId = SignUtils.findRecoveryId( + SignUtils.getHexString(messageHash, offset: 0, length: messageHash.length), + rawSigEc, + Uint8List.fromList(pub.uncompressed), + ); + + final v = recId + 27 + (point!.isCompressed ? 4 : 0); + + final combined = Uint8List.fromList([v, ...rawSigBytes]); + + return combined; + } + + List magicMessage(List message, String messagePrefix) { + final prefixBytes = StringUtils.encode(messagePrefix); + final magic = _magicPrefix(message, prefixBytes); + return QuickCrypto.sha256Hash(magic); + } + + @override + Future verifyMessage(String message, String signature, {String? address = null}) async { + if (address == null) { + return false; + } + + List sigDecodedBytes = []; + + if (signature.endsWith('=')) { + sigDecodedBytes = base64.decode(signature); + } else { + sigDecodedBytes = hex.decode(signature); + } + + if (sigDecodedBytes.length != 64 && sigDecodedBytes.length != 65) { + throw ArgumentException( + "litecoin signature must be 64 bytes without recover-id or 65 bytes with recover-id"); + } + + String messagePrefix = '\x19Litecoin Signed Message:\n'; + final messageHash = QuickCrypto.sha256Hash(magicMessage(utf8.encode(message), messagePrefix)); + + List correctSignature = + sigDecodedBytes.length == 65 ? sigDecodedBytes.sublist(1) : List.from(sigDecodedBytes); + List rBytes = correctSignature.sublist(0, 32); + List sBytes = correctSignature.sublist(32); + final sig = ECDSASignature(BigintUtils.fromBytes(rBytes), BigintUtils.fromBytes(sBytes)); + + List possibleRecoverIds = [0, 1]; + + final baseAddress = RegexUtils.addressTypeFromStr(address, network); + + for (int recoveryId in possibleRecoverIds) { + final pubKey = sig.recoverPublicKey(messageHash, Curves.generatorSecp256k1, recoveryId); + final recoveredPub = ECPublic.fromBytes(pubKey!.toBytes()); + + String? recoveredAddress; + + if (baseAddress is P2pkAddress) { + recoveredAddress = recoveredPub.toP2pkAddress().toAddress(network); + } else if (baseAddress is P2pkhAddress) { + recoveredAddress = recoveredPub.toP2pkhAddress().toAddress(network); + } else if (baseAddress is P2wshAddress) { + recoveredAddress = recoveredPub.toP2wshAddress().toAddress(network); + } else if (baseAddress is P2wpkhAddress) { + recoveredAddress = recoveredPub.toP2wpkhAddress().toAddress(network); + } + + if (recoveredAddress == address) { + return true; + } + } + + return false; + } + + LedgerConnection? _ledgerConnection; + LitecoinLedgerApp? _litecoinLedgerApp; + + @override + void setLedgerConnection(LedgerConnection connection) { + _ledgerConnection = connection; + _litecoinLedgerApp = LitecoinLedgerApp(_ledgerConnection!, + derivationPath: walletInfo.derivationInfo!.derivationPath!); + } + + @override + Future buildHardwareWalletTransaction({ + required List outputs, + required BigInt fee, + required BasedUtxoNetwork network, + required List utxos, + required Map publicKeys, + String? memo, + bool enableRBF = false, + BitcoinOrdering inputOrdering = BitcoinOrdering.bip69, + BitcoinOrdering outputOrdering = BitcoinOrdering.bip69, + }) async { + final readyInputs = []; + for (final utxo in utxos) { + final rawTx = await electrumClient.getTransactionHex(hash: utxo.utxo.txHash); + final publicKeyAndDerivationPath = publicKeys[utxo.ownerDetails.address.pubKeyHash()]!; + + readyInputs.add(LedgerTransaction( + rawTx: rawTx, + outputIndex: utxo.utxo.vout, + ownerPublicKey: Uint8List.fromList(hex.decode(publicKeyAndDerivationPath.publicKey)), + ownerDerivationPath: publicKeyAndDerivationPath.derivationPath, + // sequence: enableRBF ? 0x1 : 0xffffffff, + sequence: 0xffffffff, + )); + } + + String? changePath; + for (final output in outputs) { + final maybeChangePath = publicKeys[(output as BitcoinOutput).address.pubKeyHash()]; + if (maybeChangePath != null) changePath ??= maybeChangePath.derivationPath; + } + + final rawHex = await _litecoinLedgerApp!.createTransaction( + inputs: readyInputs, + outputs: outputs + .map((e) => TransactionOutput.fromBigInt((e as BitcoinOutput).value, + Uint8List.fromList(e.address.toScriptPubKey().toBytes()))) + .toList(), + changePath: changePath, + sigHashType: 0x01, + additionals: ["bech32"], + isSegWit: true, + useTrustedInputForSegwit: true); + + return BtcTransaction.fromRaw(rawHex); + } } diff --git a/cw_bitcoin/lib/litecoin_wallet_addresses.dart b/cw_bitcoin/lib/litecoin_wallet_addresses.dart index a317fa9f2..bbb987766 100644 --- a/cw_bitcoin/lib/litecoin_wallet_addresses.dart +++ b/cw_bitcoin/lib/litecoin_wallet_addresses.dart @@ -1,39 +1,213 @@ -import 'package:bitcoin_flutter/bitcoin_flutter.dart' as bitcoin; -import 'package:cw_bitcoin/electrum.dart'; -import 'package:cw_bitcoin/utils.dart'; +import 'dart:async'; +import 'dart:io' show Platform; +import 'dart:typed_data'; + +import 'package:bitcoin_base/bitcoin_base.dart'; +import 'package:blockchain_utils/blockchain_utils.dart'; import 'package:cw_bitcoin/bitcoin_address_record.dart'; +import 'package:cw_bitcoin/bitcoin_unspent.dart'; +import 'package:cw_bitcoin/electrum_wallet.dart'; +import 'package:cw_bitcoin/utils.dart'; import 'package:cw_bitcoin/electrum_wallet_addresses.dart'; +import 'package:cw_core/unspent_coin_type.dart'; +import 'package:cw_core/utils/print_verbose.dart'; import 'package:cw_core/wallet_info.dart'; +import 'package:cw_mweb/cw_mweb.dart'; import 'package:flutter/foundation.dart'; import 'package:mobx/mobx.dart'; part 'litecoin_wallet_addresses.g.dart'; -class LitecoinWalletAddresses = LitecoinWalletAddressesBase - with _$LitecoinWalletAddresses; +class LitecoinWalletAddresses = LitecoinWalletAddressesBase with _$LitecoinWalletAddresses; -abstract class LitecoinWalletAddressesBase extends ElectrumWalletAddresses - with Store { +abstract class LitecoinWalletAddressesBase extends ElectrumWalletAddresses with Store { LitecoinWalletAddressesBase( - WalletInfo walletInfo, - {required bitcoin.HDWallet mainHd, - required bitcoin.HDWallet sideHd, - required bitcoin.NetworkType networkType, - required ElectrumClient electrumClient, - List? initialAddresses, - int initialRegularAddressIndex = 0, - int initialChangeAddressIndex = 0}) - : super( - walletInfo, - initialAddresses: initialAddresses, - initialRegularAddressIndex: initialRegularAddressIndex, - initialChangeAddressIndex: initialChangeAddressIndex, - mainHd: mainHd, - sideHd: sideHd, - electrumClient: electrumClient, - networkType: networkType); + WalletInfo walletInfo, { + required super.mainHd, + required super.sideHd, + required super.network, + required super.isHardwareWallet, + required this.mwebHd, + required this.mwebEnabled, + super.initialAddresses, + super.initialMwebAddresses, + super.initialRegularAddressIndex, + super.initialChangeAddressIndex, + }) : super(walletInfo) { + for (int i = 0; i < mwebAddresses.length; i++) { + mwebAddrs.add(mwebAddresses[i].address); + } + printV("initialized with ${mwebAddrs.length} mweb addresses"); + } + + final Bip32Slip10Secp256k1? mwebHd; + bool mwebEnabled; + int mwebTopUpIndex = 1000; + List mwebAddrs = []; + bool generating = false; + + List get scanSecret => mwebHd!.childKey(Bip32KeyIndex(0x80000000)).privateKey.privKey.raw; + List get spendPubkey => + mwebHd!.childKey(Bip32KeyIndex(0x80000001)).publicKey.pubKey.compressed; @override - String getAddress({required int index, required bitcoin.HDWallet hd}) => - generateP2WPKHAddress(hd: hd, index: index, networkType: networkType); -} \ No newline at end of file + Future init() async { + if (!isHardwareWallet) await initMwebAddresses(); + await super.init(); + } + + @computed + @override + List get allAddresses { + return List.from(super.allAddresses)..addAll(mwebAddresses); + } + + Future ensureMwebAddressUpToIndexExists(int index) async { + if (Platform.isLinux || Platform.isMacOS || Platform.isWindows) { + return null; + } + + Uint8List scan = Uint8List.fromList(scanSecret); + Uint8List spend = Uint8List.fromList(spendPubkey); + + if (index < mwebAddresses.length && index < mwebAddrs.length) { + return; + } + + while (generating) { + printV("generating....."); + // this function was called multiple times in multiple places: + await Future.delayed(const Duration(milliseconds: 100)); + } + + printV("Generating MWEB addresses up to index $index"); + generating = true; + try { + while (mwebAddrs.length <= (index + 1)) { + final addresses = + await CwMweb.addresses(scan, spend, mwebAddrs.length, mwebAddrs.length + 50); + printV("generated up to index ${mwebAddrs.length}"); + // sleep for a bit to avoid making the main thread unresponsive: + await Future.delayed(Duration(milliseconds: 200)); + mwebAddrs.addAll(addresses!); + } + } catch (_) {} + generating = false; + printV("Done generating MWEB addresses len: ${mwebAddrs.length}"); + + // ensure mweb addresses are up to date: + // This is the Case if the Litecoin Wallet is a hardware Wallet + if (mwebHd == null) return; + + if (mwebAddresses.length < mwebAddrs.length) { + List addressRecords = mwebAddrs + .asMap() + .entries + .map((e) => BitcoinAddressRecord( + e.value, + index: e.key, + type: SegwitAddresType.mweb, + network: network, + )) + .toList(); + addMwebAddresses(addressRecords); + printV("set ${addressRecords.length} mweb addresses"); + } + } + + Future initMwebAddresses() async { + if (mwebAddrs.length < 1000) { + await ensureMwebAddressUpToIndexExists(20); + return; + } + } + + @override + String getAddress({ + required int index, + required Bip32Slip10Secp256k1 hd, + BitcoinAddressType? addressType, + }) { + if (addressType == SegwitAddresType.mweb) { + return hd == sideHd ? mwebAddrs[0] : mwebAddrs[index + 1]; + } + return generateP2WPKHAddress(hd: hd, index: index, network: network); + } + + @override + Future getAddressAsync({ + required int index, + required Bip32Slip10Secp256k1 hd, + BitcoinAddressType? addressType, + }) async { + if (addressType == SegwitAddresType.mweb) { + await ensureMwebAddressUpToIndexExists(index); + } + return getAddress(index: index, hd: hd, addressType: addressType); + } + + @action + @override + Future getChangeAddress( + {List? inputs, + List? outputs, + UnspentCoinType coinTypeToSpendFrom = UnspentCoinType.any}) async { + // use regular change address on peg in, otherwise use mweb for change address: + + if (!mwebEnabled || coinTypeToSpendFrom == UnspentCoinType.nonMweb) { + return super.getChangeAddress(); + } + + if (inputs != null && outputs != null) { + // check if this is a PEGIN: + bool outputsToMweb = false; + bool comesFromMweb = false; + + for (var i = 0; i < outputs.length; i++) { + // TODO: probably not the best way to tell if this is an mweb address + // (but it doesn't contain the "mweb" text at this stage) + if (outputs[i].address.toAddress(network).length > 110) { + outputsToMweb = true; + } + } + + inputs.forEach((element) { + if (!element.isSending || element.isFrozen) { + return; + } + if (element.address.contains("mweb")) { + comesFromMweb = true; + } + }); + + bool isPegIn = !comesFromMweb && outputsToMweb; + bool isNonMweb = !comesFromMweb && !outputsToMweb; + + // use regular change address if it's not an mweb tx or if it's a peg in: + if (isPegIn || isNonMweb) { + return super.getChangeAddress(); + } + } + + if (mwebEnabled) { + await ensureMwebAddressUpToIndexExists(1); + updateChangeAddresses(); + return BitcoinAddressRecord( + mwebAddrs[0], + index: 0, + type: SegwitAddresType.mweb, + network: network, + ); + } + + return super.getChangeAddress(); + } + + @override + String get addressForExchange { + // don't use mweb addresses for exchange refund address: + final addresses = receiveAddresses + .where((element) => element.type == SegwitAddresType.p2wpkh && !element.isUsed); + return addresses.first.address; + } +} diff --git a/cw_bitcoin/lib/litecoin_wallet_service.dart b/cw_bitcoin/lib/litecoin_wallet_service.dart index 69d1dfc7e..89ae384d4 100644 --- a/cw_bitcoin/lib/litecoin_wallet_service.dart +++ b/cw_bitcoin/lib/litecoin_wallet_service.dart @@ -1,8 +1,11 @@ import 'dart:io'; +import 'package:bitcoin_base/bitcoin_base.dart'; +import 'package:cw_bitcoin/bitcoin_mnemonics_bip39.dart'; +import 'package:cw_bitcoin/mnemonic_is_incorrect_exception.dart'; +import 'package:cw_core/encryption_file_utils.dart'; import 'package:cw_core/unspent_coins_info.dart'; import 'package:hive/hive.dart'; import 'package:cw_bitcoin/bitcoin_mnemonic.dart'; -import 'package:cw_bitcoin/bitcoin_mnemonic_is_incorrect_exception.dart'; import 'package:cw_bitcoin/bitcoin_wallet_creation_credentials.dart'; import 'package:cw_bitcoin/litecoin_wallet.dart'; import 'package:cw_core/wallet_service.dart'; @@ -11,26 +14,48 @@ import 'package:cw_core/wallet_type.dart'; import 'package:cw_core/wallet_info.dart'; import 'package:cw_core/wallet_base.dart'; import 'package:collection/collection.dart'; +import 'package:bip39/bip39.dart' as bip39; +import 'package:path_provider/path_provider.dart'; class LitecoinWalletService extends WalletService< BitcoinNewWalletCredentials, BitcoinRestoreWalletFromSeedCredentials, - BitcoinRestoreWalletFromWIFCredentials> { - LitecoinWalletService(this.walletInfoSource, this.unspentCoinsInfoSource); + BitcoinRestoreWalletFromWIFCredentials, + BitcoinRestoreWalletFromHardware> { + LitecoinWalletService( + this.walletInfoSource, this.unspentCoinsInfoSource, this.alwaysScan, this.isDirect); final Box walletInfoSource; final Box unspentCoinsInfoSource; + final bool alwaysScan; + final bool isDirect; @override WalletType getType() => WalletType.litecoin; @override - Future create(BitcoinNewWalletCredentials credentials) async { + Future create(BitcoinNewWalletCredentials credentials, {bool? isTestnet}) async { + final String mnemonic; + switch (credentials.walletInfo?.derivationInfo?.derivationType) { + case DerivationType.bip39: + final strength = credentials.seedPhraseLength == 24 ? 256 : 128; + + mnemonic = credentials.mnemonic ?? await MnemonicBip39.generate(strength: strength); + break; + case DerivationType.electrum: + default: + mnemonic = await generateElectrumMnemonic(); + break; + } + final wallet = await LitecoinWalletBase.create( - mnemonic: await generateMnemonic(), - password: credentials.password!, - walletInfo: credentials.walletInfo!, - unspentCoinsInfo: unspentCoinsInfoSource); + mnemonic: mnemonic, + password: credentials.password!, + passphrase: credentials.passphrase, + walletInfo: credentials.walletInfo!, + unspentCoinsInfo: unspentCoinsInfoSource, + encryptionFileUtils: encryptionFileUtilsFor(isDirect), + ); await wallet.save(); await wallet.init(); @@ -43,21 +68,32 @@ class LitecoinWalletService extends WalletService< @override Future openWallet(String name, String password) async { - final walletInfo = walletInfoSource.values.firstWhereOrNull( - (info) => info.id == WalletBase.idFor(name, getType()))!; + + final walletInfo = walletInfoSource.values + .firstWhereOrNull((info) => info.id == WalletBase.idFor(name, getType()))!; try { final wallet = await LitecoinWalletBase.open( - password: password, name: name, walletInfo: walletInfo, - unspentCoinsInfo: unspentCoinsInfoSource); + password: password, + name: name, + walletInfo: walletInfo, + unspentCoinsInfo: unspentCoinsInfoSource, + alwaysScan: alwaysScan, + encryptionFileUtils: encryptionFileUtilsFor(isDirect), + ); await wallet.init(); saveBackup(name); return wallet; } catch (_) { await restoreWalletFilesFromBackup(name); final wallet = await LitecoinWalletBase.open( - password: password, name: name, walletInfo: walletInfo, - unspentCoinsInfo: unspentCoinsInfoSource); + password: password, + name: name, + walletInfo: walletInfo, + unspentCoinsInfo: unspentCoinsInfoSource, + alwaysScan: alwaysScan, + encryptionFileUtils: encryptionFileUtilsFor(isDirect), + ); await wallet.init(); return wallet; } @@ -65,22 +101,54 @@ class LitecoinWalletService extends WalletService< @override Future remove(String wallet) async { - File(await pathForWalletDir(name: wallet, type: getType())) - .delete(recursive: true); - final walletInfo = walletInfoSource.values.firstWhereOrNull( - (info) => info.id == WalletBase.idFor(wallet, getType()))!; + File(await pathForWalletDir(name: wallet, type: getType())).delete(recursive: true); + final walletInfo = walletInfoSource.values + .firstWhereOrNull((info) => info.id == WalletBase.idFor(wallet, getType()))!; await walletInfoSource.delete(walletInfo.key); + + // if there are no more litecoin wallets left, cleanup the neutrino db and other files created by mwebd: + if (walletInfoSource.values.where((info) => info.type == WalletType.litecoin).isEmpty) { + final appDirPath = (await getApplicationSupportDirectory()).path; + File neturinoDb = File('$appDirPath/neutrino.db'); + File blockHeaders = File('$appDirPath/block_headers.bin'); + File regFilterHeaders = File('$appDirPath/reg_filter_headers.bin'); + File mwebdLogs = File('$appDirPath/logs/debug.log'); + if (neturinoDb.existsSync()) { + neturinoDb.deleteSync(); + } + if (blockHeaders.existsSync()) { + blockHeaders.deleteSync(); + } + if (regFilterHeaders.existsSync()) { + regFilterHeaders.deleteSync(); + } + if (mwebdLogs.existsSync()) { + mwebdLogs.deleteSync(); + } + } + + final unspentCoinsToDelete = unspentCoinsInfoSource.values.where( + (unspentCoin) => unspentCoin.walletId == walletInfo.id).toList(); + + final keysToDelete = unspentCoinsToDelete.map((unspentCoin) => unspentCoin.key).toList(); + + if (keysToDelete.isNotEmpty) { + await unspentCoinsInfoSource.deleteAll(keysToDelete); + } } @override Future rename(String currentName, String password, String newName) async { - final currentWalletInfo = walletInfoSource.values.firstWhereOrNull( - (info) => info.id == WalletBase.idFor(currentName, getType()))!; + final currentWalletInfo = walletInfoSource.values + .firstWhereOrNull((info) => info.id == WalletBase.idFor(currentName, getType()))!; final currentWallet = await LitecoinWalletBase.open( - password: password, - name: currentName, - walletInfo: currentWalletInfo, - unspentCoinsInfo: unspentCoinsInfoSource); + password: password, + name: currentName, + walletInfo: currentWalletInfo, + unspentCoinsInfo: unspentCoinsInfoSource, + alwaysScan: alwaysScan, + encryptionFileUtils: encryptionFileUtilsFor(isDirect), + ); await currentWallet.renameWalletFiles(newName); await saveBackup(newName); @@ -93,22 +161,45 @@ class LitecoinWalletService extends WalletService< } @override - Future restoreFromKeys( - BitcoinRestoreWalletFromWIFCredentials credentials) async => + Future restoreFromHardwareWallet(BitcoinRestoreWalletFromHardware credentials, + {bool? isTestnet}) async { + final network = isTestnet == true ? LitecoinNetwork.testnet : LitecoinNetwork.mainnet; + credentials.walletInfo?.network = network.value; + credentials.walletInfo?.derivationInfo?.derivationPath = + credentials.hwAccountData.derivationPath; + + final wallet = await LitecoinWallet( + password: credentials.password!, + xpub: credentials.hwAccountData.xpub, + walletInfo: credentials.walletInfo!, + unspentCoinsInfo: unspentCoinsInfoSource, + encryptionFileUtils: encryptionFileUtilsFor(isDirect), + ); + await wallet.save(); + await wallet.init(); + return wallet; + } + + @override + Future restoreFromKeys(BitcoinRestoreWalletFromWIFCredentials credentials, + {bool? isTestnet}) async => throw UnimplementedError(); @override - Future restoreFromSeed( - BitcoinRestoreWalletFromSeedCredentials credentials) async { - if (!validateMnemonic(credentials.mnemonic)) { - throw BitcoinMnemonicIsIncorrectException(); + Future restoreFromSeed(BitcoinRestoreWalletFromSeedCredentials credentials, + {bool? isTestnet}) async { + if (!validateMnemonic(credentials.mnemonic) && !bip39.validateMnemonic(credentials.mnemonic)) { + throw LitecoinMnemonicIsIncorrectException(); } final wallet = await LitecoinWalletBase.create( - password: credentials.password!, - mnemonic: credentials.mnemonic, - walletInfo: credentials.walletInfo!, - unspentCoinsInfo: unspentCoinsInfoSource); + password: credentials.password!, + passphrase: credentials.passphrase, + mnemonic: credentials.mnemonic, + walletInfo: credentials.walletInfo!, + unspentCoinsInfo: unspentCoinsInfoSource, + encryptionFileUtils: encryptionFileUtilsFor(isDirect), + ); await wallet.save(); await wallet.init(); return wallet; diff --git a/cw_bitcoin/lib/bitcoin_mnemonic_is_incorrect_exception.dart b/cw_bitcoin/lib/mnemonic_is_incorrect_exception.dart similarity index 50% rename from cw_bitcoin/lib/bitcoin_mnemonic_is_incorrect_exception.dart rename to cw_bitcoin/lib/mnemonic_is_incorrect_exception.dart index 8d0583ce5..779bd3ea2 100644 --- a/cw_bitcoin/lib/bitcoin_mnemonic_is_incorrect_exception.dart +++ b/cw_bitcoin/lib/mnemonic_is_incorrect_exception.dart @@ -3,3 +3,9 @@ class BitcoinMnemonicIsIncorrectException implements Exception { String toString() => 'Bitcoin mnemonic has incorrect format. Mnemonic should contain 12 or 24 words separated by space.'; } + +class LitecoinMnemonicIsIncorrectException implements Exception { + @override + String toString() => + 'Litecoin mnemonic has incorrect format. Mnemonic should contain 24 words separated by space.'; +} diff --git a/cw_bitcoin/lib/payjoin/manager.dart b/cw_bitcoin/lib/payjoin/manager.dart new file mode 100644 index 000000000..95a523d89 --- /dev/null +++ b/cw_bitcoin/lib/payjoin/manager.dart @@ -0,0 +1,310 @@ +import 'dart:async'; +import 'dart:isolate'; +import 'dart:math'; +import 'dart:typed_data'; + +import 'package:bitcoin_base/bitcoin_base.dart'; +import 'package:cw_bitcoin/bitcoin_wallet.dart'; +import 'package:cw_bitcoin/bitcoin_wallet_addresses.dart'; +import 'package:cw_bitcoin/payjoin/payjoin_persister.dart'; +import 'package:cw_bitcoin/payjoin/payjoin_receive_worker.dart'; +import 'package:cw_bitcoin/payjoin/payjoin_send_worker.dart'; +import 'package:cw_bitcoin/payjoin/payjoin_session_errors.dart'; +import 'package:cw_bitcoin/payjoin/storage.dart'; +import 'package:cw_bitcoin/psbt/signer.dart'; +import 'package:cw_bitcoin/psbt/utils.dart'; +import 'package:cw_core/utils/print_verbose.dart'; +import 'package:payjoin_flutter/common.dart'; +import 'package:payjoin_flutter/receive.dart'; +import 'package:payjoin_flutter/send.dart'; +import 'package:payjoin_flutter/src/config.dart' as pj_config; +import 'package:payjoin_flutter/uri.dart' as PayjoinUri; + +class PayjoinManager { + PayjoinManager(this._payjoinStorage, this._wallet); + + final PayjoinStorage _payjoinStorage; + final BitcoinWalletBase _wallet; + final Map _activePollers = {}; + + static const List ohttpRelayUrls = [ + 'https://pj.bobspacebkk.com', + 'https://ohttp.achow101.com', + 'https://ohttp.cakewallet.com', + ]; + + static String randomOhttpRelayUrl() => + ohttpRelayUrls[Random.secure().nextInt(ohttpRelayUrls.length)]; + + static const payjoinDirectoryUrl = 'https://payjo.in'; + + Future initPayjoin() => pj_config.PConfig.initializeApp(); + + Future resumeSessions() async { + final allSessions = _payjoinStorage.readAllOpenSessions(_wallet.id); + + final spawnedSessions = allSessions.map((session) { + if (session.isSenderSession) { + printV("Resuming Payjoin Sender Session ${session.pjUri!}"); + return _spawnSender( + sender: Sender.fromJson(json: session.sender!), + pjUri: session.pjUri!, + ); + } + final receiver = Receiver.fromJson(json: session.receiver!); + printV("Resuming Payjoin Receiver Session ${receiver.id()}"); + return spawnReceiver(receiver: receiver); + }); + + printV("Resumed ${spawnedSessions.length} Payjoin Sessions"); + await Future.wait(spawnedSessions); + } + + Future initSender( + String pjUriString, String originalPsbt, int networkFeesSatPerVb) async { + try { + final pjUri = + (await PayjoinUri.Uri.fromStr(pjUriString)).checkPjSupported(); + final minFeeRateSatPerKwu = BigInt.from(networkFeesSatPerVb * 250); + final senderBuilder = await SenderBuilder.fromPsbtAndUri( + psbtBase64: originalPsbt, + pjUri: pjUri, + ); + final persister = PayjoinSenderPersister.impl(); + final newSender = + await senderBuilder.buildRecommended(minFeeRate: minFeeRateSatPerKwu); + final senderToken = await newSender.persist(persister: persister); + + return Sender.load(token: senderToken, persister: persister); + } catch (e) { + throw Exception('Error initializing Payjoin Sender: $e'); + } + } + + Future spawnNewSender({ + required Sender sender, + required String pjUrl, + required BigInt amount, + bool isTestnet = false, + }) async { + final pjUri = Uri.parse(pjUrl).queryParameters['pj']!; + await _payjoinStorage.insertSenderSession( + sender, pjUri, _wallet.id, amount); + + return _spawnSender(isTestnet: isTestnet, sender: sender, pjUri: pjUri); + } + + Future _spawnSender({ + required Sender sender, + required String pjUri, + bool isTestnet = false, + }) async { + final completer = Completer(); + final receivePort = ReceivePort(); + + receivePort.listen((message) async { + if (message is Map) { + try { + switch (message['type'] as PayjoinSenderRequestTypes) { + case PayjoinSenderRequestTypes.requestPosted: + return; + case PayjoinSenderRequestTypes.psbtToSign: + final proposalPsbt = message['psbt'] as String; + final utxos = _wallet.getUtxoWithPrivateKeys(); + final finalizedPsbt = await _wallet.signPsbt(proposalPsbt, utxos); + final txId = getTxIdFromPsbtV0(finalizedPsbt); + _wallet.commitPsbt(finalizedPsbt); + + _cleanupSession(pjUri); + await _payjoinStorage.markSenderSessionComplete(pjUri, txId); + completer.complete(); + } + } catch (e) { + _cleanupSession(pjUri); + await _payjoinStorage.markSenderSessionUnrecoverable(pjUri, e.toString()); + completer.complete(); + } + } else if (message is PayjoinSessionError) { + _cleanupSession(pjUri); + if (message is UnrecoverableError) { + await _payjoinStorage.markSenderSessionUnrecoverable(pjUri, message.message); + completer.complete(); + } else if (message is RecoverableError) { + completer.complete(); + } else { + completer.completeError(message); + } + } + }); + + final isolate = await Isolate.spawn( + PayjoinSenderWorker.run, + [receivePort.sendPort, sender.toJson(), pjUri], + ); + + _activePollers[pjUri] = PayjoinPollerSession(isolate, receivePort); + + return completer.future; + } + + Future getUnusedReceiver(String address, + [bool isTestnet = false]) async { + final session = _payjoinStorage.getUnusedActiveReceiverSession(_wallet.id); + + if (session != null) { + await PayjoinUri.Url.fromStr(payjoinDirectoryUrl); + + return Receiver.fromJson(json: session.receiver!); + } + + return initReceiver(address); + } + + Future initReceiver(String address, [bool isTestnet = false]) async { + final ohttpKeys = await PayjoinUri.fetchOhttpKeys( + ohttpRelay: await randomOhttpRelayUrl(), + payjoinDirectory: payjoinDirectoryUrl, + ); + + final newReceiver = await NewReceiver.create( + address: address, + network: isTestnet ? Network.testnet : Network.bitcoin, + directory: payjoinDirectoryUrl, + ohttpKeys: ohttpKeys, + ); + final persister = PayjoinReceiverPersister.impl(); + final receiverToken = await newReceiver.persist(persister: persister); + final receiver = await Receiver.load(persister: persister, token: receiverToken); + + await _payjoinStorage.insertReceiverSession(receiver, _wallet.id); + + return receiver; + } + + Future spawnReceiver({ + required Receiver receiver, + bool isTestnet = false, + }) async { + final completer = Completer(); + final receivePort = ReceivePort(); + + SendPort? mainToIsolateSendPort; + List utxos = []; + String rawAmount = '0'; + + receivePort.listen((message) async { + if (message is Map) { + try { + switch (message['type'] as PayjoinReceiverRequestTypes) { + case PayjoinReceiverRequestTypes.processOriginalTx: + final tx = message['tx'] as String; + rawAmount = getOutputAmountFromTx(tx, _wallet); + break; + case PayjoinReceiverRequestTypes.checkIsOwned: + (_wallet.walletAddresses as BitcoinWalletAddresses) + .newPayjoinReceiver(); + _payjoinStorage.markReceiverSessionInProgress(receiver.id()); + + final inputScript = message['input_script'] as Uint8List; + final isOwned = + _wallet.isMine(Script.fromRaw(byteData: inputScript)); + mainToIsolateSendPort?.send({ + 'requestId': message['requestId'], + 'result': isOwned, + }); + break; + + case PayjoinReceiverRequestTypes.checkIsReceiverOutput: + final outputScript = message['output_script'] as Uint8List; + final isReceiverOutput = + _wallet.isMine(Script.fromRaw(byteData: outputScript)); + mainToIsolateSendPort?.send({ + 'requestId': message['requestId'], + 'result': isReceiverOutput, + }); + break; + + case PayjoinReceiverRequestTypes.getCandidateInputs: + utxos = _wallet.getUtxoWithPrivateKeys(); + if (utxos.isEmpty) { + await _wallet.updateAllUnspents(); + utxos = _wallet.getUtxoWithPrivateKeys(); + } + mainToIsolateSendPort?.send({ + 'requestId': message['requestId'], + 'result': utxos, + }); + break; + + case PayjoinReceiverRequestTypes.processPsbt: + final psbt = message['psbt'] as String; + final signedPsbt = await _wallet.signPsbt(psbt, utxos); + mainToIsolateSendPort?.send({ + 'requestId': message['requestId'], + 'result': signedPsbt, + }); + break; + + case PayjoinReceiverRequestTypes.proposalSent: + _cleanupSession(receiver.id()); + final psbt = message['psbt'] as String; + await _payjoinStorage.markReceiverSessionComplete( + receiver.id(), getTxIdFromPsbtV0(psbt), rawAmount); + completer.complete(); + } + } catch (e) { + _cleanupSession(receiver.id()); + await _payjoinStorage.markReceiverSessionUnrecoverable( + receiver.id(), e.toString()); + completer.completeError(e); + } + } else if (message is PayjoinSessionError) { + _cleanupSession(receiver.id()); + if (message is UnrecoverableError) { + await _payjoinStorage.markReceiverSessionUnrecoverable( + receiver.id(), message.message); + completer.complete(); + } else if (message is RecoverableError) { + completer.complete(); + } else { + completer.completeError(message); + } + } else if (message is SendPort) { + mainToIsolateSendPort = message; + } + }); + + final isolate = await Isolate.spawn( + PayjoinReceiverWorker.run, + [receivePort.sendPort, receiver.toJson()], + ); + + _activePollers[receiver.id()] = PayjoinPollerSession(isolate, receivePort); + + return completer.future; + } + + void cleanupSessions() { + final sessionIds = _activePollers.keys.toList(); + for (final sessionId in sessionIds) { + _cleanupSession(sessionId); + } + } + + void _cleanupSession(String sessionId) { + _activePollers[sessionId]?.close(); + _activePollers.remove(sessionId); + } +} + +class PayjoinPollerSession { + final Isolate isolate; + final ReceivePort port; + + PayjoinPollerSession(this.isolate, this.port); + + void close() { + isolate.kill(); + port.close(); + } +} diff --git a/cw_bitcoin/lib/payjoin/payjoin_persister.dart b/cw_bitcoin/lib/payjoin/payjoin_persister.dart new file mode 100644 index 000000000..4e395e36a --- /dev/null +++ b/cw_bitcoin/lib/payjoin/payjoin_persister.dart @@ -0,0 +1,66 @@ +import 'package:payjoin_flutter/src/generated/api/receive.dart'; +import 'package:payjoin_flutter/src/generated/api/send.dart'; + +class PayjoinSenderPersister implements DartSenderPersister { + static DartSenderPersister impl() { + final impl = PayjoinSenderPersister(); + return DartSenderPersister( + save: (sender) => impl.save(sender: sender), + load: (token) => impl.load(token: token), + ); + } + + final Map _store = {}; + + Future save({required FfiSender sender}) async { + final token = sender.key(); + _store[token.toBytes().toString()] = sender; + return token; + } + + Future load({required SenderToken token}) async { + final sender = _store[token.toBytes().toString()]; + if (sender == null) { + throw Exception('Sender not found for the provided token.'); + } + return sender; + } + + @override + void dispose() => _store.clear(); + + @override + bool get isDisposed => _store.isEmpty; +} + +class PayjoinReceiverPersister implements DartReceiverPersister { + static DartReceiverPersister impl() { + final impl = PayjoinReceiverPersister(); + return DartReceiverPersister( + save: (receiver) => impl.save(receiver: receiver), + load: (token) => impl.load(token: token), + ); + } + + final Map _store = {}; + + Future save({required FfiReceiver receiver}) async { + final token = receiver.key(); + _store[token.toBytes().toString()] = receiver; + return token; + } + + Future load({required ReceiverToken token}) async { + final receiver = _store[token.toBytes().toString()]; + if (receiver == null) { + throw Exception('Receiver not found for the provided token.'); + } + return receiver; + } + + @override + void dispose() => _store.clear(); + + @override + bool get isDisposed => _store.isEmpty; +} diff --git a/cw_bitcoin/lib/payjoin/payjoin_receive_worker.dart b/cw_bitcoin/lib/payjoin/payjoin_receive_worker.dart new file mode 100644 index 000000000..c56148de2 --- /dev/null +++ b/cw_bitcoin/lib/payjoin/payjoin_receive_worker.dart @@ -0,0 +1,222 @@ +import 'dart:async'; +import 'dart:io'; +import 'dart:isolate'; +import 'dart:typed_data'; + +import 'package:blockchain_utils/blockchain_utils.dart'; +import 'package:cw_bitcoin/payjoin/manager.dart'; +import 'package:cw_bitcoin/payjoin/payjoin_session_errors.dart'; +import 'package:cw_bitcoin/psbt/signer.dart'; +import 'package:cw_core/utils/print_verbose.dart'; +import 'package:cw_core/utils/proxy_wrapper.dart'; +import 'package:payjoin_flutter/bitcoin_ffi.dart'; +import 'package:payjoin_flutter/common.dart'; +import 'package:payjoin_flutter/receive.dart'; +import 'package:payjoin_flutter/src/generated/frb_generated.dart' as pj; +import 'package:http/http.dart' as very_insecure_http_do_not_use; // for errors + +enum PayjoinReceiverRequestTypes { + processOriginalTx, + proposalSent, + getCandidateInputs, + checkIsOwned, + checkIsReceiverOutput, + processPsbt; +} + +class PayjoinReceiverWorker { + final SendPort sendPort; + final pendingRequests = >{}; + + PayjoinReceiverWorker._(this.sendPort); + static final client = ProxyWrapper().getHttpIOClient(); + static Future run(List args) async { + await pj.core.init(); + + final sendPort = args[0] as SendPort; + final receiverJson = args[1] as String; + + final worker = PayjoinReceiverWorker._(sendPort); + final receivePort = ReceivePort(); + + sendPort.send(receivePort.sendPort); + receivePort.listen(worker.handleMessage); + + try { + final receiver = Receiver.fromJson(json: receiverJson); + + final uncheckedProposal = + await worker.receiveUncheckedProposal(receiver); + + final originalTx = await uncheckedProposal.extractTxToScheduleBroadcast(); + sendPort.send({ + 'type': PayjoinReceiverRequestTypes.processOriginalTx, + 'tx': BytesUtils.toHexString(originalTx), + }); + + final payjoinProposal = await worker.processPayjoinProposal( + uncheckedProposal, + ); + final psbt = await worker.sendFinalProposal(payjoinProposal); + sendPort.send({ + 'type': PayjoinReceiverRequestTypes.proposalSent, + 'psbt': psbt, + }); + } catch (e) { + if (e is HttpException || + (e is very_insecure_http_do_not_use.ClientException && + e.message.contains("Software caused connection abort"))) { + sendPort.send(PayjoinSessionError.recoverable(e.toString())); + } else { + sendPort.send(PayjoinSessionError.unrecoverable(e.toString())); + } + } + } + + void handleMessage(dynamic message) async { + if (message is Map) { + final requestId = message['requestId'] as String?; + if (requestId != null && pendingRequests.containsKey(requestId)) { + pendingRequests[requestId]!.complete(message['result']); + pendingRequests.remove(requestId); + } + } + } + + Future _sendRequest(PayjoinReceiverRequestTypes type, + [Map data = const {}]) async { + final completer = Completer(); + final requestId = DateTime.now().millisecondsSinceEpoch.toString(); + pendingRequests[requestId] = completer; + + sendPort.send({ + ...data, + 'type': type, + 'requestId': requestId, + }); + + return completer.future; + } + + Future receiveUncheckedProposal(Receiver session) async { + while (true) { + printV("Polling for Proposal (${session.id()})"); + final extractReq = await session.extractReq( + ohttpRelay: await PayjoinManager.randomOhttpRelayUrl(), + ); + final request = extractReq.$1; + + final url = Uri.parse(request.url.asString()); + final httpRequest = await client.post(url, + headers: {'Content-Type': request.contentType}, body: request.body); + + final proposal = await session.processRes( + body: httpRequest.bodyBytes, ctx: extractReq.$2); + if (proposal != null) return proposal; + } + } + + Future sendFinalProposal(PayjoinProposal finalProposal) async { + final req = await finalProposal.extractReq( + ohttpRelay: await PayjoinManager.randomOhttpRelayUrl(), + ); + final proposalReq = req.$1; + final proposalCtx = req.$2; + + final request = await client.post( + Uri.parse(proposalReq.url.asString()), + headers: {"Content-Type": proposalReq.contentType}, + body: proposalReq.body, + ); + + await finalProposal.processRes( + res: request.bodyBytes, + ohttpContext: proposalCtx, + ); + + return await finalProposal.psbt(); + } + + Future processPayjoinProposal( + UncheckedProposal proposal) async { + await proposal.extractTxToScheduleBroadcast(); + // TODO Handle this. send to the main port on a timer? + + try { + // Receive Check 1: can broadcast + final pj1 = await proposal.assumeInteractiveReceiver(); + + // Receive Check 2: original PSBT has no receiver-owned inputs + final pj2 = await pj1.checkInputsNotOwned( + isOwned: (inputScript) async { + final result = await _sendRequest( + PayjoinReceiverRequestTypes.checkIsOwned, + {'input_script': inputScript}, + ); + return result as bool; + }, + ); + // Receive Check 3: sender inputs have not been seen before (prevent probing attacks) + final pj3 = await pj2.checkNoInputsSeenBefore(isKnown: (input) => false); + + // Identify receiver outputs + final pj4 = await pj3.identifyReceiverOutputs( + isReceiverOutput: (outputScript) async { + final result = await _sendRequest( + PayjoinReceiverRequestTypes.checkIsReceiverOutput, + {'output_script': outputScript}, + ); + return result as bool; + }, + ); + final pj5 = await pj4.commitOutputs(); + + final listUnspent = + await _sendRequest(PayjoinReceiverRequestTypes.getCandidateInputs); + final unspent = listUnspent as List; + if (unspent.isEmpty) throw RecoverableError('No unspent outputs available'); + + final selectedUtxo = await _inputPairFromUtxo(unspent[0]); + final pj6 = await pj5.contributeInputs(replacementInputs: [selectedUtxo]); + final pj7 = await pj6.commitInputs(); + + // Finalize proposal + final payjoinProposal = await pj7.finalizeProposal( + processPsbt: (String psbt) async { + final result = await _sendRequest( + PayjoinReceiverRequestTypes.processPsbt, {'psbt': psbt}); + return result as String; + }, + // TODO set maxFeeRateSatPerVb + maxFeeRateSatPerVb: BigInt.from(10000), + ); + return payjoinProposal; + } catch (e) { + printV('Error occurred while finalizing proposal: $e'); + rethrow; + } + } + + Future _inputPairFromUtxo(UtxoWithPrivateKey utxo) async { + final txout = TxOut( + value: utxo.utxo.value, + scriptPubkey: Uint8List.fromList( + utxo.ownerDetails.address.toScriptPubKey().toBytes()), + ); + + final psbtin = + PsbtInput(witnessUtxo: txout, redeemScript: null, witnessScript: null); + + final previousOutput = + OutPoint(txid: utxo.utxo.txHash, vout: utxo.utxo.vout); + + final txin = TxIn( + previousOutput: previousOutput, + scriptSig: await Script.newInstance(rawOutputScript: []), + witness: [], + sequence: 0, + ); + + return InputPair.newInstance(txin: txin, psbtin: psbtin); + } +} diff --git a/cw_bitcoin/lib/payjoin/payjoin_send_worker.dart b/cw_bitcoin/lib/payjoin/payjoin_send_worker.dart new file mode 100644 index 000000000..7e85cc773 --- /dev/null +++ b/cw_bitcoin/lib/payjoin/payjoin_send_worker.dart @@ -0,0 +1,120 @@ +import 'dart:async'; +import 'dart:io'; +import 'dart:isolate'; + +import 'package:cw_bitcoin/payjoin/manager.dart'; +import 'package:cw_bitcoin/payjoin/payjoin_session_errors.dart'; +import 'package:cw_core/utils/print_verbose.dart'; +import 'package:cw_core/utils/proxy_wrapper.dart'; +import 'package:payjoin_flutter/common.dart'; +import 'package:payjoin_flutter/send.dart'; +import 'package:payjoin_flutter/src/generated/frb_generated.dart' as pj; +import 'package:payjoin_flutter/src/generated/api/send/error.dart' as pj_error; +import 'package:payjoin_flutter/uri.dart' as pj_uri; + +enum PayjoinSenderRequestTypes { + requestPosted, + psbtToSign; +} + +class PayjoinSenderWorker { + final SendPort sendPort; + final pendingRequests = >{}; + final String pjUrl; + + PayjoinSenderWorker._(this.sendPort, this.pjUrl); + + static Future run(List args) async { + await pj.core.init(); + + final sendPort = args[0] as SendPort; + final senderJson = args[1] as String; + final pjUrl = args[2] as String; + + final sender = Sender.fromJson(json: senderJson); + final worker = PayjoinSenderWorker._(sendPort, pjUrl); + + try { + final proposalPsbt = await worker.runSender(sender); + sendPort.send({ + 'type': PayjoinSenderRequestTypes.psbtToSign, + 'psbt': proposalPsbt, + }); + } catch (e) { + sendPort.send(e); + } + } + final client = ProxyWrapper().getHttpIOClient(); + + /// Run a payjoin sender (V2 protocol first, fallback to V1). + Future runSender(Sender sender) async { + + try { + return await _runSenderV2(sender); + } catch (e) { + printV(e); + if (e is pj_error.FfiCreateRequestError) { + return await _runSenderV1(sender); + } else if (e is HttpException) { + printV(e); + throw Exception(PayjoinSessionError.recoverable(e.toString())); + } else { + throw Exception(PayjoinSessionError.unrecoverable(e.toString())); + } + } + } + + /// Attempt to send payjoin using the V2 of the protocol. + Future _runSenderV2(Sender sender) async { + try { + final postRequest = await sender.extractV2( + ohttpProxyUrl: + await pj_uri.Url.fromStr(PayjoinManager.randomOhttpRelayUrl()), + ); + + final postResult = await _postRequest(postRequest.$1); + final getContext = + await postRequest.$2.processResponse(response: postResult); + + sendPort.send({'type': PayjoinSenderRequestTypes.requestPosted, "pj": pjUrl}); + + while (true) { + printV('Polling V2 Proposal Request (${pjUrl})'); + + final getRequest = await getContext.extractReq( + ohttpRelay: await PayjoinManager.randomOhttpRelayUrl(), + ); + final getRes = await _postRequest(getRequest.$1); + final proposalPsbt = await getContext.processResponse( + response: getRes, + ohttpCtx: getRequest.$2, + ); + printV("$proposalPsbt"); + if (proposalPsbt != null) return proposalPsbt; + } + } catch (e) { + rethrow; + } + } + + /// Attempt to send payjoin using the V1 of the protocol. + Future _runSenderV1(Sender sender) async { + try { + final postRequest = await sender.extractV1(); + final response = await _postRequest(postRequest.$1); + + sendPort.send({'type': PayjoinSenderRequestTypes.requestPosted}); + + return await postRequest.$2.processResponse(response: response); + } catch (e, stack) { + throw PayjoinSessionError.unrecoverable('Send V1 payjoin error: $e, $stack'); + } + } + + Future> _postRequest(Request req) async { + final httpRequest = await client.post(Uri.parse(req.url.asString()), + headers: {'Content-Type': req.contentType}, body: req.body); + + return httpRequest.bodyBytes; + } +} diff --git a/cw_bitcoin/lib/payjoin/payjoin_session_errors.dart b/cw_bitcoin/lib/payjoin/payjoin_session_errors.dart new file mode 100644 index 000000000..06e0a5431 --- /dev/null +++ b/cw_bitcoin/lib/payjoin/payjoin_session_errors.dart @@ -0,0 +1,16 @@ +class PayjoinSessionError { + final String message; + + const PayjoinSessionError._(this.message); + + factory PayjoinSessionError.recoverable(String message) = RecoverableError; + factory PayjoinSessionError.unrecoverable(String message) = UnrecoverableError; +} + +class RecoverableError extends PayjoinSessionError { + const RecoverableError(super.message) : super._(); +} + +class UnrecoverableError extends PayjoinSessionError { + const UnrecoverableError(super.message) : super._(); +} diff --git a/cw_bitcoin/lib/payjoin/storage.dart b/cw_bitcoin/lib/payjoin/storage.dart new file mode 100644 index 000000000..5fb9d5716 --- /dev/null +++ b/cw_bitcoin/lib/payjoin/storage.dart @@ -0,0 +1,104 @@ +import 'package:cw_core/payjoin_session.dart'; +import 'package:hive/hive.dart'; +import 'package:payjoin_flutter/receive.dart'; +import 'package:payjoin_flutter/send.dart'; + +class PayjoinStorage { + PayjoinStorage(this._payjoinSessionSources); + + final Box _payjoinSessionSources; + + static const String _receiverPrefix = 'pj_recv_'; + static const String _senderPrefix = 'pj_send_'; + + Future insertReceiverSession( + Receiver receiver, + String walletId, + ) => + _payjoinSessionSources.put( + "$_receiverPrefix${receiver.id()}", + PayjoinSession( + walletId: walletId, + receiver: receiver.toJson(), + ), + ); + + PayjoinSession? getUnusedActiveReceiverSession(String walletId) => + _payjoinSessionSources.values + .where((session) => + session.walletId == walletId && + session.status == PayjoinSessionStatus.created.name && + !session.isSenderSession) + .firstOrNull; + + Future markReceiverSessionComplete( + String sessionId, String txId, String amount) async { + final session = _payjoinSessionSources.get("$_receiverPrefix${sessionId}")!; + + session.status = PayjoinSessionStatus.success.name; + session.txId = txId; + session.rawAmount = amount; + await session.save(); + } + + Future markReceiverSessionUnrecoverable( + String sessionId, String reason) async { + final session = _payjoinSessionSources.get("$_receiverPrefix${sessionId}")!; + + session.status = PayjoinSessionStatus.unrecoverable.name; + session.error = reason; + await session.save(); + } + + Future markReceiverSessionInProgress(String sessionId) async { + final session = _payjoinSessionSources.get("$_receiverPrefix${sessionId}")!; + + session.status = PayjoinSessionStatus.inProgress.name; + session.inProgressSince = DateTime.now(); + await session.save(); + } + + Future insertSenderSession( + Sender sender, + String pjUrl, + String walletId, + BigInt amount, + ) => + _payjoinSessionSources.put( + "$_senderPrefix$pjUrl", + PayjoinSession( + walletId: walletId, + pjUri: pjUrl, + sender: sender.toJson(), + status: PayjoinSessionStatus.inProgress.name, + inProgressSince: DateTime.now(), + rawAmount: amount.toString(), + ), + ); + + Future markSenderSessionComplete(String pjUrl, String txId) async { + final session = _payjoinSessionSources.get("$_senderPrefix$pjUrl")!; + + session.status = PayjoinSessionStatus.success.name; + session.txId = txId; + await session.save(); + } + + Future markSenderSessionUnrecoverable(String pjUrl, String reason) async { + final session = _payjoinSessionSources.get("$_senderPrefix$pjUrl")!; + + session.status = PayjoinSessionStatus.unrecoverable.name; + session.error = reason; + await session.save(); + } + + List readAllOpenSessions(String walletId) => + _payjoinSessionSources.values + .where((session) => + session.walletId == walletId && + ![ + PayjoinSessionStatus.success.name, + PayjoinSessionStatus.unrecoverable.name + ].contains(session.status)) + .toList(); +} diff --git a/cw_bitcoin/lib/pending_bitcoin_transaction.dart b/cw_bitcoin/lib/pending_bitcoin_transaction.dart index e2dc10bfb..6930524eb 100644 --- a/cw_bitcoin/lib/pending_bitcoin_transaction.dart +++ b/cw_bitcoin/lib/pending_bitcoin_transaction.dart @@ -1,30 +1,59 @@ -import 'package:cw_bitcoin/bitcoin_commit_transaction_exception.dart'; -import 'package:bitcoin_flutter/bitcoin_flutter.dart' as bitcoin; +import 'package:cw_bitcoin/electrum_wallet.dart'; +import 'package:grpc/grpc.dart'; +import 'package:cw_bitcoin/exceptions.dart'; +import 'package:bitcoin_base/bitcoin_base.dart'; +import 'package:blockchain_utils/blockchain_utils.dart'; import 'package:cw_core/pending_transaction.dart'; import 'package:cw_bitcoin/electrum.dart'; import 'package:cw_bitcoin/bitcoin_amount_format.dart'; import 'package:cw_bitcoin/electrum_transaction_info.dart'; import 'package:cw_core/transaction_direction.dart'; import 'package:cw_core/wallet_type.dart'; +import 'package:cw_mweb/cw_mweb.dart'; +import 'package:cw_mweb/mwebd.pb.dart'; class PendingBitcoinTransaction with PendingTransaction { - PendingBitcoinTransaction(this._tx, this.type, - {required this.electrumClient, - required this.amount, - required this.fee}) - : _listeners = []; + PendingBitcoinTransaction( + this._tx, + this.type, { + required this.electrumClient, + required this.amount, + required this.fee, + required this.feeRate, + this.network, + required this.hasChange, + this.isSendAll = false, + this.hasTaprootInputs = false, + this.isMweb = false, + this.utxos = const [], + this.publicKeys, + this.commitOverride, + }) : _listeners = []; final WalletType type; - final bitcoin.Transaction _tx; + final BtcTransaction _tx; final ElectrumClient electrumClient; final int amount; final int fee; + final String feeRate; + final BasedUtxoNetwork? network; + final bool isSendAll; + final bool hasChange; + final bool hasTaprootInputs; + List utxos; + bool isMweb; + String? changeAddressOverride; + String? idOverride; + String? hexOverride; + List? outputAddresses; + final Map? publicKeys; + Future Function()? commitOverride; @override - String get id => _tx.getId(); + String get id => idOverride ?? _tx.txId(); @override - String get hex => _tx.toHex(); + String get hex => hexOverride ?? _tx.serialize(); @override String get amountFormatted => bitcoinAmountToString(amount: amount); @@ -32,22 +61,93 @@ class PendingBitcoinTransaction with PendingTransaction { @override String get feeFormatted => bitcoinAmountToString(amount: fee); + @override + int? get outputCount => _tx.outputs.length; + + List get outputs => _tx.outputs; + + bool get hasSilentPayment => _tx.hasSilentPayment; + + PendingChange? get change { + try { + final change = _tx.outputs.firstWhere((out) => out.isChange); + if (changeAddressOverride != null) { + return PendingChange(changeAddressOverride!, BtcUtils.fromSatoshi(change.amount)); + } + return PendingChange(change.scriptPubKey.toAddress(), BtcUtils.fromSatoshi(change.amount)); + } catch (_) { + return null; + } + } + final List _listeners; + Future _commit() async { + int? callId; + + final result = await electrumClient.broadcastTransaction( + transactionRaw: hex, network: network, idCallback: (id) => callId = id); + + if (result.isEmpty) { + if (callId != null) { + final error = electrumClient.getErrorMessage(callId!); + + if (error.contains("dust")) { + if (hasChange) { + throw BitcoinTransactionCommitFailedDustChange(); + } else if (!isSendAll) { + throw BitcoinTransactionCommitFailedDustOutput(); + } else { + throw BitcoinTransactionCommitFailedDustOutputSendAll(); + } + } + + if (error.contains("bad-txns-vout-negative")) { + throw BitcoinTransactionCommitFailedVoutNegative(); + } + + if (error.contains("non-BIP68-final")) { + throw BitcoinTransactionCommitFailedBIP68Final(); + } + + if (error.contains("min fee not met")) { + throw BitcoinTransactionCommitFailedLessThanMin(); + } + + throw BitcoinTransactionCommitFailed(errorMessage: error); + } + + throw BitcoinTransactionCommitFailed(); + } + } + + Future _ltcCommit() async { + try { + final resp = await CwMweb.broadcast(BroadcastRequest(rawTx: BytesUtils.fromHexString(hex))); + idOverride = resp.txid; + } on GrpcError catch (e) { + throw BitcoinTransactionCommitFailed(errorMessage: e.message); + } catch (e) { + throw BitcoinTransactionCommitFailed(errorMessage: "Unknown error: ${e.toString()}"); + } + } + @override Future commit() async { - final result = - await electrumClient.broadcastTransaction(transactionRaw: _tx.toHex()); - - if (result.isEmpty) { - throw BitcoinCommitTransactionException(); + if (commitOverride != null) { + return commitOverride?.call(); } - _listeners?.forEach((listener) => listener(transactionInfo())); + if (isMweb) { + await _ltcCommit(); + } else { + await _commit(); + } + + _listeners.forEach((listener) => listener(transactionInfo())); } - void addListener( - void Function(ElectrumTransactionInfo transaction) listener) => + void addListener(void Function(ElectrumTransactionInfo transaction) listener) => _listeners.add(listener); ElectrumTransactionInfo transactionInfo() => ElectrumTransactionInfo(type, @@ -57,6 +157,14 @@ class PendingBitcoinTransaction with PendingTransaction { direction: TransactionDirection.outgoing, date: DateTime.now(), isPending: true, + isReplaced: false, confirmations: 0, + inputAddresses: _tx.inputs.map((input) => input.txId).toList(), + outputAddresses: outputAddresses, fee: fee); + + @override + Future commitUR() { + throw UnimplementedError(); + } } diff --git a/cw_bitcoin/lib/psbt/signer.dart b/cw_bitcoin/lib/psbt/signer.dart new file mode 100644 index 000000000..1d0ceba8b --- /dev/null +++ b/cw_bitcoin/lib/psbt/signer.dart @@ -0,0 +1,263 @@ +import 'dart:typed_data'; + +import 'package:bitcoin_base/bitcoin_base.dart'; +import 'package:blockchain_utils/blockchain_utils.dart'; +import 'package:collection/collection.dart'; +import 'package:cw_bitcoin/bitcoin_address_record.dart'; +import 'package:cw_bitcoin/bitcoin_unspent.dart'; +import 'package:cw_bitcoin/bitcoin_wallet.dart'; +import 'package:cw_bitcoin/utils.dart'; +import 'package:ledger_bitcoin/psbt.dart'; +import 'package:ledger_bitcoin/src/utils/buffer_writer.dart'; + +extension PsbtSigner on PsbtV2 { + Uint8List extractUnsignedTX({bool getSegwit = true}) { + final tx = BufferWriter()..writeUInt32(getGlobalTxVersion()); + + final isSegwit = getInputWitnessUtxo(0) != null; + if (isSegwit && getSegwit) { + tx.writeSlice(Uint8List.fromList([0, 1])); + } + + final inputCount = getGlobalInputCount(); + tx.writeVarInt(inputCount); + + for (var i = 0; i < inputCount; i++) { + tx + ..writeSlice(getInputPreviousTxid(i)) + ..writeUInt32(getInputOutputIndex(i)) + ..writeVarSlice(Uint8List(0)) + ..writeUInt32(getInputSequence(i)); + } + + final outputCount = getGlobalOutputCount(); + tx.writeVarInt(outputCount); + for (var i = 0; i < outputCount; i++) { + tx.writeUInt64(getOutputAmount(i)); + tx.writeVarSlice(getOutputScript(i)); + } + tx.writeUInt32(getGlobalFallbackLocktime() ?? 0); + return tx.buffer(); + } + + Future signWithUTXO( + List utxos, UTXOSignerCallBack signer, + [UTXOGetterCallBack? getTaprootPair]) async { + final raw = BytesUtils.toHexString(extractUnsignedTX(getSegwit: false)); + final tx = BtcTransaction.fromRaw(raw); + + /// when the transaction is taproot and we must use getTaproot transaction + /// digest we need all of inputs amounts and owner script pub keys + List taprootAmounts = []; + List