From 109bba43013b90270bb556d954a34102ec7a4c30 Mon Sep 17 00:00:00 2001 From: Adegoke David <64401859+Blazebrain@users.noreply.github.com> Date: Fri, 23 Feb 2024 14:39:19 +0100 Subject: [PATCH 1/9] CW-555-Add-Solana-Wallet (#1272) * chore: Create cw_solana package and clean up files * feat: Add Solana Wallet - Create, Restore form seed, restore from Key, Restore from QR, Send, Receive, transaction history, spl tokens * fix: Make transactions file specific to solana only for solana transactions * chore: Revert inject app details script * fix: Fix issue with node and switch current node to main beta instead of testnet * fix: Fix merge conflicts and adjust migration version * fix: Fetch spl token error Signed-off-by: Blazebrain * fix: Diplay and activate spl tokens bug * fix: Review and fixes * fix: reverted formatting for cryptocurrency class * fix: Review comments, split sending flow into signing and sending separately, fix issues * fix: Revert throwing unimplenented error * chore: Fix comment * chore: Fix comment * fix: Errors in flow * Update provider_types.dart [skip ci] * fix: Issues with solana wallet * Update solana_wallet.dart [skip ci] * fix: Review comments * fix: Date time config * fix: Revert bash script for app details * fix: Error with balance, displaying fees, fixing sent or received identifier bug, displaying token symbol with token transaction item in transactions list * fix: Issues with address validation when sending spl tokens and walletconnect initial setup * fix: Issues with sending, fetching transactions history, almost wrapping up walletconnect * fix: Adjust imports that would affect monerocom building successfully * fix: Refine transaction direction and continue work on walletconnect * feat: Display SPL token transfers in the transaction history and finally settle the transaction direction * fix: Delay in transactions history dispaly, show native token transactions first, then process spl token transactions * feat: Switch node and revert solana chain id to previous id * fix: Remove print statement * fix: Remove await for transactions, fetch all transaction histories instantly and adjust solana send success message * chore: Code refactoring and streamlined wallet type check for solana send success message * fix: Make timeout error for node silent and add spl token images --------- Signed-off-by: Blazebrain Co-authored-by: Omar Hatem --- .github/workflows/pr_test_build.yml | 31 +- .gitignore | 3 + android/app/src/main/AndroidManifestBase.xml | 1 + assets/images/avdo_icon.png | Bin 0 -> 55363 bytes assets/images/bonk_icon.png | Bin 0 -> 55872 bytes assets/images/gmt_icon.png | Bin 0 -> 16590 bytes assets/images/hnt_icon.png | Bin 0 -> 10492 bytes assets/images/ray_icon.png | Bin 0 -> 15614 bytes assets/solana_node_list.yml | 4 + cw_core/lib/crypto_currency.dart | 7 +- cw_core/lib/currency_for_wallet_type.dart | 2 + cw_core/lib/hive_type_ids.dart | 3 +- cw_core/lib/node.dart | 25 +- cw_core/lib/pathForWallet.dart | 1 - cw_core/lib/wallet_type.dart | 16 +- cw_solana/.gitignore | 30 + cw_solana/.metadata | 10 + cw_solana/CHANGELOG.md | 3 + cw_solana/LICENSE | 1 + cw_solana/README.md | 39 + cw_solana/analysis_options.yaml | 4 + cw_solana/lib/cw_solana.dart | 7 + cw_solana/lib/default_spl_tokens.dart | 109 + cw_solana/lib/file.dart | 39 + cw_solana/lib/pending_solana_transaction.dart | 43 + cw_solana/lib/solana_balance.dart | 39 + cw_solana/lib/solana_client.dart | 477 ++++ cw_solana/lib/solana_exceptions.dart | 21 + cw_solana/lib/solana_mnemonics.dart | 2058 +++++++++++++++++ .../lib/solana_transaction_credentials.dart | 12 + cw_solana/lib/solana_transaction_history.dart | 78 + cw_solana/lib/solana_transaction_info.dart | 78 + cw_solana/lib/solana_transaction_model.dart | 47 + cw_solana/lib/solana_wallet.dart | 510 ++++ cw_solana/lib/solana_wallet_addresses.dart | 33 + .../solana_wallet_creation_credentials.dart | 29 + cw_solana/lib/solana_wallet_service.dart | 118 + cw_solana/lib/spl_token.dart | 146 ++ cw_solana/pubspec.yaml | 37 + cw_solana/test/cw_solana_test.dart | 12 + ios/Runner/InfoBase.plist | 10 + lib/core/address_validator.dart | 17 +- lib/core/seed_validator.dart | 3 + .../{ => chain_service}/chain_service.dart | 0 .../{ => chain_service/eth}/evm_chain_id.dart | 2 +- .../eth}/evm_chain_service.dart | 6 +- .../solana/entities/solana_sign_message.dart | 28 + .../entities/solana_sign_transaction.dart | 106 + .../chain_service/solana/solana_chain_id.dart | 27 + .../solana/solana_chain_service.dart | 177 ++ .../wallet_connect_key_service.dart | 14 +- .../wallet_connect/web3wallet_service.dart | 44 +- lib/di.dart | 5 +- lib/entities/default_settings_migration.dart | 45 +- lib/entities/node_list.dart | 21 +- lib/entities/preferences_key.dart | 1 + lib/entities/priority_for_wallet_type.dart | 9 +- lib/entities/provider_types.dart | 9 + lib/ethereum/cw_ethereum.dart | 11 +- lib/main.dart | 4 +- lib/polygon/cw_polygon.dart | 10 +- lib/reactions/fiat_rate_update.dart | 10 +- lib/reactions/on_current_wallet_change.dart | 10 +- lib/reactions/wallet_connect.dart | 19 +- lib/solana/cw_solana.dart | 118 + .../desktop_wallet_selection_dropdown.dart | 3 + .../screens/dashboard/edit_token_page.dart | 48 +- .../screens/dashboard/home_settings_page.dart | 22 +- .../screens/dashboard/pages/balance_page.dart | 16 +- .../dashboard/pages/nft_details_page.dart | 4 +- .../dashboard/widgets/menu_widget.dart | 36 +- .../dashboard/widgets/nft_tile_widget.dart | 5 +- lib/src/screens/send/send_page.dart | 12 +- lib/src/screens/send/widgets/send_card.dart | 2 +- .../settings/connection_sync_page.dart | 2 +- .../screens/settings/other_settings_page.dart | 2 +- .../widgets/pairing_item_widget.dart | 12 +- .../screens/wallet_list/wallet_list_page.dart | 3 + .../cake_image_widget.dart} | 32 +- lib/store/app_store.dart | 2 +- lib/store/settings_store.dart | 16 +- .../advanced_privacy_settings_view_model.dart | 5 +- .../dashboard/balance_view_model.dart | 120 +- .../dashboard/home_settings_view_model.dart | 78 +- .../dashboard/transaction_list_item.dart | 9 + .../exchange/exchange_view_model.dart | 4 + .../node_list/node_list_view_model.dart | 3 + .../restore/restore_from_qr_vm.dart | 7 + .../restore/wallet_restore_from_qr_code.dart | 9 + lib/view_model/send/output.dart | 9 +- .../send/send_template_view_model.dart | 3 +- lib/view_model/send/send_view_model.dart | 14 +- .../settings/other_settings_view_model.dart | 29 +- .../transaction_details_view_model.dart | 24 + .../wallet_address_list_view_model.dart | 26 + lib/view_model/wallet_keys_view_model.dart | 5 +- lib/view_model/wallet_new_vm.dart | 33 +- lib/view_model/wallet_restore_view_model.dart | 45 +- model_generator.sh | 1 + pubspec_base.yaml | 2 + res/values/strings_ar.arb | 5 +- res/values/strings_bg.arb | 5 +- res/values/strings_cs.arb | 5 +- res/values/strings_de.arb | 5 +- res/values/strings_en.arb | 5 +- res/values/strings_es.arb | 5 +- res/values/strings_fr.arb | 5 +- res/values/strings_ha.arb | 5 +- res/values/strings_hi.arb | 5 +- res/values/strings_hr.arb | 5 +- res/values/strings_id.arb | 5 +- res/values/strings_it.arb | 5 +- res/values/strings_ja.arb | 5 +- res/values/strings_ko.arb | 5 +- res/values/strings_my.arb | 5 +- res/values/strings_nl.arb | 5 +- res/values/strings_pl.arb | 5 +- res/values/strings_pt.arb | 5 +- res/values/strings_ru.arb | 5 +- res/values/strings_th.arb | 5 +- res/values/strings_tl.arb | 5 +- res/values/strings_tr.arb | 5 +- res/values/strings_uk.arb | 5 +- res/values/strings_ur.arb | 5 +- res/values/strings_yo.arb | 5 +- res/values/strings_zh.arb | 5 +- scripts/android/pubspec_gen.sh | 2 +- scripts/ios/app_config.sh | 2 +- scripts/macos/app_config.sh | 2 +- tool/configure.dart | 113 +- tool/generate_secrets_config.dart | 20 +- tool/import_secrets_config.dart | 14 + tool/utils/secret_key.dart | 4 + 133 files changed, 5356 insertions(+), 353 deletions(-) create mode 100644 assets/images/avdo_icon.png create mode 100644 assets/images/bonk_icon.png create mode 100644 assets/images/gmt_icon.png create mode 100644 assets/images/hnt_icon.png create mode 100644 assets/images/ray_icon.png create mode 100644 assets/solana_node_list.yml create mode 100644 cw_solana/.gitignore create mode 100644 cw_solana/.metadata create mode 100644 cw_solana/CHANGELOG.md create mode 100644 cw_solana/LICENSE create mode 100644 cw_solana/README.md create mode 100644 cw_solana/analysis_options.yaml create mode 100644 cw_solana/lib/cw_solana.dart create mode 100644 cw_solana/lib/default_spl_tokens.dart create mode 100644 cw_solana/lib/file.dart create mode 100644 cw_solana/lib/pending_solana_transaction.dart create mode 100644 cw_solana/lib/solana_balance.dart create mode 100644 cw_solana/lib/solana_client.dart create mode 100644 cw_solana/lib/solana_exceptions.dart create mode 100644 cw_solana/lib/solana_mnemonics.dart create mode 100644 cw_solana/lib/solana_transaction_credentials.dart create mode 100644 cw_solana/lib/solana_transaction_history.dart create mode 100644 cw_solana/lib/solana_transaction_info.dart create mode 100644 cw_solana/lib/solana_transaction_model.dart create mode 100644 cw_solana/lib/solana_wallet.dart create mode 100644 cw_solana/lib/solana_wallet_addresses.dart create mode 100644 cw_solana/lib/solana_wallet_creation_credentials.dart create mode 100644 cw_solana/lib/solana_wallet_service.dart create mode 100644 cw_solana/lib/spl_token.dart create mode 100644 cw_solana/pubspec.yaml create mode 100644 cw_solana/test/cw_solana_test.dart rename lib/core/wallet_connect/{ => chain_service}/chain_service.dart (100%) rename lib/core/wallet_connect/{ => chain_service/eth}/evm_chain_id.dart (86%) rename lib/core/wallet_connect/{ => chain_service/eth}/evm_chain_service.dart (98%) create mode 100644 lib/core/wallet_connect/chain_service/solana/entities/solana_sign_message.dart create mode 100644 lib/core/wallet_connect/chain_service/solana/entities/solana_sign_transaction.dart create mode 100644 lib/core/wallet_connect/chain_service/solana/solana_chain_id.dart create mode 100644 lib/core/wallet_connect/chain_service/solana/solana_chain_service.dart create mode 100644 lib/solana/cw_solana.dart rename lib/src/{screens/dashboard/widgets/nft_image_tile_widget.dart => widgets/cake_image_widget.dart} (51%) diff --git a/.github/workflows/pr_test_build.yml b/.github/workflows/pr_test_build.yml index 88dd2c1eb..b5fe24f18 100644 --- a/.github/workflows/pr_test_build.yml +++ b/.github/workflows/pr_test_build.yml @@ -6,9 +6,9 @@ on: workflow_dispatch: inputs: branch: - description: 'Branch name to build' + description: "Branch name to build" required: true - default: 'main' + default: "main" jobs: PR_test_build: @@ -111,6 +111,7 @@ jobs: 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_solana && 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 @@ -120,6 +121,7 @@ jobs: cd /opt/android/cake_wallet touch lib/.secrets.g.dart touch cw_evm/lib/.secrets.g.dart + touch cw_solana/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 @@ -154,6 +156,7 @@ jobs: 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 - name: Rename app run: echo -e "id=com.cakewallet.test\nname=${{ env.BRANCH_NAME }}" > /opt/android/cake_wallet/android/app.properties @@ -163,18 +166,18 @@ jobs: 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: 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: | diff --git a/.gitignore b/.gitignore index f084f8d0d..25edfcfb0 100644 --- a/.gitignore +++ b/.gitignore @@ -92,8 +92,10 @@ android/key.properties **/tool/.secrets-config.json **/tool/.evm-secrets-config.json **/tool/.ethereum-secrets-config.json +**/tool/.solana-secrets-config.json **/lib/.secrets.g.dart **/cw_evm/lib/.secrets.g.dart +**/cw_solana/lib/.secrets.g.dart vendor/ @@ -128,6 +130,7 @@ lib/ethereum/ethereum.dart lib/bitcoin_cash/bitcoin_cash.dart lib/nano/nano.dart lib/polygon/polygon.dart +lib/solana/solana.dart ios/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_180.png ios/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_120.png diff --git a/android/app/src/main/AndroidManifestBase.xml b/android/app/src/main/AndroidManifestBase.xml index 180190914..eea9b5521 100644 --- a/android/app/src/main/AndroidManifestBase.xml +++ b/android/app/src/main/AndroidManifestBase.xml @@ -66,6 +66,7 @@ + 3hD3zX(XYhkdT;8=(e#jwlS7_ zw`JLutYYb@*Vp?kyZ`ylnVFrv_r52|g+RjEtGBy%`_4DtIo~-m6VR`x|FP-5kDarq zl%Yj&IcN_kUTwF>%cj@v^DRueDBag4c}DCmx;O!z<9Kj;o(+7Zc0)J-9E zM@#cHOXH>0*b?7DvX_4J&l zUjytpPU}Cj=IWTDi@6Z5W7us52(!a+DwpxIq zqX%0pmaBb^G}y2bMe({ILu)fZ_|;$gb4Ne@8em5;J@wCPn4ReDAu|-0wbg#XuL9RG zfX%M!s;-ZY7HEWLA1nidV^l0gW>oBg*HJE;ap%bLa%)pvVg^1RV5{67$Ndxr z*HSsU?v-DB;#&IkH0ktffK4)O{MR*$f;haI3)BbsEiI}v+jLp}t?-{{N-!NbJVb>; znfeCC7)TQWs6w$!{S2bXry%vG#xFIehWeb$7IgJkugg(0FK091l)$S6wwlWT8_`!X znO`$ot?AbQtC%+b`=#&TV)J%3|JGnV!BaJmrUDy4wEy5B^)iqS92%mLkqKd@qnmr(Xk%r|s8VwkRq^ zAB^JY9sEMtvCTKpsFI;-fNf}aLIAb@;1KOMfI7NKKS4b+OMP8dRvTnF=RQtg761qU z1M6Tw|2Hg@N@W@{*DaMI8XO*<>~b|X;oKdqjnpo412s41YqFd^_YuGr&{u*W`0ACP zUALD04^RK60mi^u6Gf#Dat(jAha;*K5h}M~nqY?7cVK{a?Czue2Zm_)C;-*mSVwit z5-7O!21G5qO+lld!U9It4UwgO3k)L#y6%?E4nd9Oo`F#ZxUum9jgA$mr*9;=v7ovb zU<>Z6y`_<+cQ)}~FkH48Y!&8k!e+W(RAzA`2tW7AFRc3t{U4nEPXcV`HJ803ipy{3 zg0&_sR>a|= zzhplCp90vjUl&E`WAI}P`$tD3*bG?A7t+_#22|xJY zsa$^nS*!B=Rm)YN)ngAcvmNRgrb7(2!#$G&u35m=Letu(23wU$8Bu>6(&tJK2%ef_dEnIQP9D5f=@(N`N(q-^yxgX-}o_ljtdOj_aqDu@7`ZA~kDKK)ouUMqPwtMdYZQOi-=V~*sj{S<+(^>^w)#e}3{}tA%4c0W^ zx9#kqXS#bO-j|yA_jU%@+!<}u+1}^?HWk={IJh>Bq7VOX$?*SQ1F(JHe#M#~h_B%f zwa9L(AS(wp0PBHAcFJE&{!4%fIE#VR%7R4-C}zk1%dF*t(%r9jufuFMKE7ce?b_2X zfp+rV8-$RBvpZ-;XEW8z#Z0GX^+!SQ@t6O@qo1W;@#$9{V0}Njy1i5=ToWOdg(=Y~ zQgnr-LG~f;xOW?E+uoBpN5=S8^JcV*?@${^M89S&N}Vqa+T{Q*;e~)b$Dx zo;7PF;Pim&9S@-4I}8sO-228flTF6Ob33K7qY8jkfNdScA+y;-zfx@WD+jPc-?P3} zu*hz!WG(#J&3?d7Z`;HQVLBggdV4c1oTVSMCQ#hAO0WeUbI=RMT46Rr`KBg!{tVU| zo7|VG1Zq?$P`NPSwns%?=lOq^6c$=~|cR(kAv-=l%uyXBa=CJs#3)iaP91w0l+p|tDL!Z|TK zWN$GAt^#k*H8je;fp=m2k?om`-I##+hoX^=tIf8_B!w0^_> z)VphH$kTBPrpw=|)GJSM<7{VIDrZZa3Y_ zCZRJ2c>V^hc2GC05V^p3PqtfUImBxl& zP}Zv2X09_dQlQ)Keunn!8%n*`1+&^|{;W1iavG@$Z2Vt$CJ0}3$zT6+n~cBo0Ap~i zj-t}-{81#XRqbdFn%mVz_T$^%o-fvFWl$|R<8%R3LtCqNM`>2Y zRGOHe$G-dD%phZ8($zHGD$XC&S~mMVTVE^8WGP$UK)I%7{oB|~0fP=8D>K-NBZF*q zjz90Ll{#PQUIWSXyzS0s*la9SvzZY=6l94yY{AC6zmq}s75XKeeyIW0_oFM{!M@ft zSWXM>$>J6U)lEO!lwvKg)#5pw%w9QqAzACBmtRhEPCm)pOYGg7cS3Kv>+_$jy2fP7 z^ekAb7NCyC*UHubXib!5u;q-uR~jBtF~f2`He&P;g9$#ihbRvJE}Ss(DDmYjPI&0D?7T*H;vs%IG=9HfIA zH_(m;A2=q~f^P=DiYoT2yjP0K%#h>sd?|#vh9=52w=&DIdC8SjacF?bV?&hT?OkWT zjF*RLbk8$1y7OtqUg24>mc2H4`(fIg>vFVc_B3i_lj8Yetwsjc(hJTLaLK*x z+(nyjzgsRW}HsC;)UriF>ff`QdbY9YGc;JmRH1;ULFSBEhJTdF=PApx zUqO_MMT$y=>PjGlZ`I(wBL1+i$_z81EHpME+e*wrQK?8V&pt?x#@EWQ7?W#hV|Hty zmL(@qwyBjobkNw|Z8Wy)X(|u*9W!hB@18u6fz5utb}Mb)(d)h&l*SVm&yc=Fs8(+4nAVAg?;1=SMU%VVSL1CFzwH6i9-TrPTS zS^*A^Ve+k=)OFq!=7U7$;-LdYvHt)~?B7m>{o9V2wUP($SechMZ9Yi%+}q7;mVE2^ zFd1jI$$eCsic(G@>d$1NOV0l#0ogANz!+SM7+gOh^uI1sT(!?V_wNw1(B3pm!liSk z*H#FR2)3Y#wHg>$%c_7C>0cBk4p8^)+QnkU_uRR1T#&OY%TF-C$YUn}3Sh#VZ>Z-& zU1YUzM8Jd@%YFgQH7Fp!UYVS-u&RIc?*9B|^PdIG1cuU;GIbyBqXn&vq7?d|1jw>= zb;3$nHa#=>dO5Z{!Jsp$A|tyd$XbqDMJ-ELCLaUuB{JVAG+^T?V*hMn4_APDHy;yg z1wq9JG~0W6N9p$4w@Bkp`#C$?8tAzB)8zZ7IvcWJlSZBKJ1_d!ga6}Q%KcIR3`(Ig zmr)8UriqE7u-2Am4!b*sNw{=w7v-OaxK`_|nY8fC(>-8C!YHy!7`noPY533~dg$w4 zuL3E6(ZKO80IQyX3bU>-G9u<-VPsVNBoZJsC@6V<1e7!RoB#^vfFDKWh|9PF#@;KI z!D_zGWB~}!*zH)~Um$wf_wS;wTy;L3$CXda&mwHabIjnua>RhLjFM&f9*Sb7jseHu zQ)U}srW;}KO*oK+e2+~Fms8_{6PYRV$;ZLFl1eE3u122tIh6+w9W!hBzuD)6KX%W( z+iBCL18y7C#S<4#7r(4lM?6+xJpW_&e}(=Br~h$)0bF622AAKY1g7AB|8QO6Z}Bv( zt%(-RnnuqDYhgO;&A&mN3+5YOmGwLoz=8?5{l5EX|5H!7+7<=6p|ypYr%x9KDl#*T z4-Ck@3Xnj8$p9U|gkUlbEppte$Y6?uJtjoLR{%bDh49rkZ=|RDN9j*bUqEj^ai+w- zAXH$6zZT}})I0@{n6aV)m}^{~J!D46Hn4n;;5VC=V6)Q50JDFhP@=PaRy`6p7g zxg+^d(m`q2nbX4$U8gMfELh8JulQ~Arh~Nh=bPR4glt(aRDDZ}FkzAX9)PL<|7SvzUVT9T!DpY1 za=dQu5hk6@&Ir7JTqQwZzA^dx;oH~I_n+P?+dsH+KF{+csEmT{gEq|OBnE~#!#*3< zqmue$0w(x>A;6|U%P>e|_S?$i!~FX&S${CsF_Y@&oj|#1^OBFo@344y2bKHwa$IeI z;_)G2yxi>Lsd!*Z;`7;?pVWK|sEYac|Gj$$=$4x{I=>7cTRf+Ox;h#?aLK=JJ&5CX zp8G!pvi~uF9Tm8K__z}@ke~~H3(Xx;K%rw}t(hkrC)*Bg*}`U8jymVhWgn@5mR)oa z)wL$UV)$CSAAO7tJ+oEL1;46+0n;+0OA7qN@Gy<^_6oqVY(4>0@ZoX|b;3yFEP?=7 zShju)t_OcDhcqb$5teZ-fDpigsU<;ZBI7^V8l7}Ifa0+h%uOs07bE)U{oCl4U59mN z_K7}n)^YR)=d2L5Q;r8?GT36IOfi_^V$r=904yVb!#y<#TfvNk91j^j%;Y3SmSJ{- za#|z?Es~5^zmyf0)Vxd+BId%T_f0 zzP;>^fysIfCtc4SH=jkAj;16K?K7F2gF*IZzj%=Sj{xjQ;Hp##;mjUCZ-$rxGCd2{ zg5R_96_?YDOS3E% zhUw=sh3$#G2kDRRdq&JcDP2Z*_`s^U%wTgA01!^tG?bYu1AvL5NHR8LE{==XA?28_ z16C72v0nzTl{w%XO|Zzo?+a{}X>1i!6M{y>V1s#E)7Rp2<>#G5xvnL8@30Q|_j3PU zn%MD(ocm~4i-@R}Uv|T_>uZ3l(Ua~e4L0`4&i%7{ze2yj=@$;LBY_K<)&Q4foQAqQ zEip;D&z7}XXU?S4UVF7LjQCk__Ddyt;2Ynj(Z0Ua`D{%JP&ah~SRDeM(LOZ;AxboL zcF4H^GN^}RgG1_b@o_No>RXz{jKs2lXl#s4#1Zwij9&t8*UvrHP5=1NCjBk+9+;<(oVJuca{3ZwExu1MR}u#!$=(P7yn3T13Tut= ze&gbo3FtGSiXR*U1IxwCY5*EcNceP_2HsxBtWg*hpv7Lshx}m{Yh3km0T$l5FmRYf zttD($-17vL`gR@_Yt{1CU?$#w_cOHl=>xV@2*8%j>#PMXbpc{}=UKl%ko`gdhB#N4 z2_EAoT_hV~_m50ZZ9G6X{p@LXTj)ai`1xI89zGk^!rx^Vo-eap2_G4?ZAL%xp)FhK z@$0TL-%GCI$un(ko%VsvH z40BX~#Dy1$zEP1?y;510Q2@)Lpu&uI849DpPvc|8 z2US%x0BbnmBL3afrBP&ju+aFreD&eOG_m_J2KLwyS*s?)@pR{Hn;B&L-7A2pxNKg> zq)f&ve=39Q7ZNA?g#k>!6=rUyFj`%!0oPMtuAgnvn+gy@LgbI`PxcyaZ-t#;AkFC=4|u!E68nY!))B$#DQ7SSZW>VneHlA<@K;Y!e1#@YO9ioquc7d$eo@lQ^Tk z^~8?*C>rfKD%NuB=B?k!*3T9TGX7`Yj8>XIqopR3Vc4Z?U2gHIA71-{<}Ut105*8z z>#pHH??^=f&7XsZhUhy#e9ZMnhye03RtvQ$|FOrl5G+20#SA%psUR0o)>7&H(AKTO zTo4&xK8b+Ak~^4blw(-Tvb?QBmzWSNl~J9WFpy9R}=aE>}i^2~lY_NfY^ zV;bjTV99`ZTyX2#+673qZB~)lPTFE2dxB>RBwryMLOvb?>z|+6OM6BN$wO1}J|aPVb1zA}X zmNEc=9~Qv0WKe>+upR4RU90g9ZIns(fv04x zS`2p=gKX=@r0WGR|JllsjRvC|;MzXD@jnL6)T#cpCW08$QI3@(x*1i^2R8 z)4{_-^xYq=6GaGHz+B7bF}T8K-w&a@d%Q^aGN>I*)&cw}Bix&k3P|uECV%BBYTx(@%9>T@M$S@7;-6!Y5_7-LcuvN?egls>2U_|^6 z_*yNq=1?7<3jiyM`8Z7Dy@#cBu?105UK0Om??{opzU2V@XnT)~C@E{~Ojd5VJyVlffxHNKT=?UelhG{m^dqy4;W%tQudzzh|al-0_kgA_0@ z0jRKv&9A7a!UuyYD2@uC!p08SKPn7MH~$bWE(JFEH8nqL(h0-?whuFWa{5=@(+34v@(U(Sj(&BonNTie*&(uc8-d zdO;a%=%(LT!_RZOtoS1<@H8=Ar2n|)9w)^+voH*gnYHwb1d*KvAhYmBdOvrwX9ql4&Km|n1D`qhQ85=MU z@fRWod@le_Ql&aNWyZ3kNDU3hUzerfR|Wy4SnKB;K>hmWgY?kBp$Y&U9c%e!<3C-n zlwN;a7uiA=1W9dgujy~g3J6%1uWwPXA)q|U>MAS%n1V17a}Y^lqau!rMGy)In*_u# z2Zg0%|5o+y45(l>26ZiJt`|oVHtRZpvR%h>PPXYtAOz&iXHM6NVM5n&?bz-*d zdgM{svUaUp7baZWY-KI@T41Oq1`)&!_*s}mU}5lKd#O-l01k*CA;1D7!3-Q3RiBKH zuWxN*P<6;(sE6YNeFjuf!c_L2D?m2P4E6Ob2kE-*9?uLklh0ag@{t*wFE!zHmZN05 z5jZx!H$w9S>9FCBV<%ENo7WG$)yo!$5P zZ#_)Chc(3xF2{;R(>a!!CBKD+16vfo>eN5D>v>NOe!&1XaLZdB3(@Q(oxg$wy6*aQ zwDlRSnZQ`^x!`-*J33m{nz!;~I+?``fM6?|e`PKZ{d*4xFu*(rE{l*6D6_S&zlE3; z{4D^crE8iL%3+q`k?oEw448>Cwk_ zxP7I$2^Z{RVJ-ZfYrrM!h?S`i4z*b0$`4& zy;i`&M}~baFqo)?U^NII%w{khNdnoXYXO*&08m_3bI^!P0HkDYu=%GK9C!|8Jk4^7aIzxfaqCsaIXZOYT~h0{!Vq?C*g z(PP!`-+3xMkJIxGu#um=1i2EFYVpvr6YhY0W%ka}8 zmdqPFJE^&AhMZ%Rfi*rj;3~_hSWB9vuzL8&V1fSNo~LN{2%Ayt%OMy{L*t%_7gT?h z=6Zd9qZ7$`Hk01RPEky6TCtG6_>xlvM56Se6*E$t%ngM`2@DZbhF`@6AO02;K>)}4 zMt(Mi@B4^}*w8K@g3lF=4H#fF$Tl=@5uZ!;ZBjoB%oQoKhX56X3=67k1KPpx`@p&h zcr$}#n&)bXsQ_6tvX6nbfu>?D^1gw+_uj)JbR&bzPP3;q(Sn(+T7D`FEdKl1l^B>3*78Va39H8%g_kL;Hg$cl#KePRqSc@vbWmAcA+Qfd> zqwH_pzE(|*e2&^@bunX&GjImPcWRwJP4WO?BEt5D*$6NJn3$0U)yyU$*bD52_*ESe zd}q#->yNSuIBuA1D%L{4xK4btY0_2NKivBS{li_4vwu=naA8L{+%W%!#;PnonHq8m zCUiNC7YH8=-63FLbH6SSvJ=yabac)I^lFU})!sL&0ni zD%jsr&Z9MC27V|9wP-6Dv3H| zGsC{*bJRn!WDuHlR7%rNrm%UAJ|FI#RoWnLKsL%C>)%LIv6jDXf8VxYAKiP)({9s} zIc+q(twBp@MfN=#4zF7AduyK;f9!b$*znrlzMb!KjjVX4p@r=n!0PXFAFvi{A-*Mo$Mh~~cU2f3q#jlt z-+SwNx_?h^^&7|E<_3LFBNOQ_0pI~(NQHuM0iOYW*jP~kTH{=wyJ#7`;rJO;DQLh< z42>)=svrLqlMbu}Gtn>?GUg$@2Vge%T%!XD9GGN^*-?4=@J+@nZ&T?S>#D$&W`=?YzUFv76{CNpNKW_jVz4P54isRrjak>LL`R(SL z9;FRW?ozXjKi={MGmcF$L~mE{VJ&hYz}Z!dk3_42b5gnZj7ez!*cU zYj}tT_}cGWyP2LI99I=EpHW7FxmWPwTP?=Nnow0uz|@%6^^!LJB< zC=(k=p4{EyW}n%zm#)9|0e3NE#y2jZu$=l57%!$&fdQJVVLMK>ID0-Qh1G^B#G1!v_yYrp08em2YaM zCI(iH*%3m=#9*K4#fjy2c0%8A&sKUG(mOIzikkW)6Pny$cv|aw;dcNC-~pf-OmloI zYQQuEK&`0=->r!Y=E#`%U2iyHCjIlJtF-cx_FAFK#(=2{A5}mn0pU@Xyz459yh&|h zc|JhFg;L1Am9$_+A}?5t1IRGx5E~PJifWi-U`qIB0PMtovRNjtAi^#fm4)c^H^fHv98x6N9W>^B7}eE&^ob!414i&kMFee*$47_r~X z&PG}w=~S_=#p&N5dew^eJ~vbGc>&ny-S5AhZ*Wc8V~lBVV1&N$mD`Np#ShcbOtU&# z=xABXY@*e#x|+IvQfq^yNz|4l4*po2~Xl1F|_W0tTCHhyw2kzk}hdX^3@z%>oo)M1n6fElk4CUwoXLI@dmv ztYRs-h9;pF?@G->O=pU2Cj5l9PWEel$`6yT`@CzBQ?0)7j-F5wXvkRMw^E)^Qo%5QuII#8CO+E;bP)a=7lZ?gAo4@_V#FcO%^ z`cqaJ#6Ix9BBQ?ISSWi%b1nc8;A9}a`;@&#uEcux56Y&2;R1DaAgp1_ofbs?TVKuvCw}pU?cbqiIXx!^ z>wD}&?R68ho!@jjd9pkfAA4vM-Eo_?27upn+`Q>*?PQLcwe+v<)YR4@=HAkaU&O)P z8PfmYlh35dI6QKLMD?WSu2B=Xj`Dx)DV%*RMj8A3*Vn~EXuxyl8q zWs(K?7V*6x%Y(gwFfYL1hm&hS+&~>4U_Ahv=M9r#_$9onI^~?&0AZj7^q3n2ny?GA zaI}9=z#-rwfJ>!x2pqC*z}3{*#tL;&{4FH!Ld-yB228@F7A9Bwp-)5jSt#H`dv-`> z2ju_8*>fGRhIV(0?}e_<{&q9htgVg8QZVZ*i@~hJJfdy01TYAE0$63+iD$*jtz2jS z^LI{~M}M$<8nxHu+-v5jNmY!PGtSUv_LoQan>9ndjnf|>#&~o@i|JWR&)P&Bd+4KU_{Kh)G7Zh&TW)-S z<6ix;shMlxxzpPch9;^o0sVrl>r2i*pHBac*K0$?i7^2P{HC~!J~aBIM_shNYGJ^P zvFwi2sOHW#ITrikTxfyZ96-_DA}Zp<@Q5VwqC0?5meFAnrp!T}p>Vd$nk_ko z1^#{b;C^Af#%a@e`&{`#BM0^}n;lZIW9D4?@xf90`s3SFtN?h*rXeJr3qF(WPsHC2 zMz=v{`x~}tS^<}WPy@&(FQjk1?qYi7j2zX6>3a>h0HgoG2s5N|2pKq*E3^!N)W(V} zn|c8<pu&EU`3<#+D>UqvE#Y~Ah#qoy zre3=vC=Za&rd6h+C&||{m`ZFa_8ruWmf^9I#KuC81r2Mqf8Z5Mf-l^#Z7m(+6wtGr zMj!se+k-eC|C=M;n{C{tDFzuLZJA`8-Bt{ z;32m!x<+g6XyrH;|2{g-U}-h^gBoXZ;9>zI&t{c0H5t~dcd!&nIP#|XNxsoBoHU3;&{;CzTSQHS8Ys>F3+(eC8$$$-G2VADF+s&d)E=W^ zD2B{nU@6QJWG;^;-`o4ZL9WHz>j+s;DIjrPmh=gJRZ-1C!f&B66~G*`DHxX2bi|=e z%Z0UK6Pl2&4A_s0CpIz2`f9S44IjD0bG&A@Xm0!7IvO2TQ*q9;Mw;E(n2I#n(KWNV z(&EMMJT}4OSuxnyV}JS%u0uYQX0MQ?&m!IU{X6)t4q-a8y_wpY>W_-G>*@X|6Ur|*Fpj>SZ(Cc0om3vdyZsefXPMgTeHR zgwdF_nx^UZg0KOZ9qCQ@9(8TT97Og;QB}lD-o(1B9jhNcz&89QzLx<~kTu`lFdsk` z#xiRo*^IE504vU#?SUweL2kOTmar*&L;gL(>>BkZOjyBM6}{kywbDMliFR!{q%1bh zEY{kf4Ss!#ivQt zz80n>=ba}3WvO8yG1qA-3q~lhg$aU3gn;hQ2nUrL*!;^&f-Y_bjU{oaG0R*W_Znv( z3yHw3d|t$bh7KI0s*X4rv?9GTdkyc~E6r2H|6(RXuTX=#cc|3gHA@5!$n%5UyCnsv zzO9q)MxEARSZmbC@5mbj;6^8sS{$ZhzT%aeXK$^`(7RX8ruUt{l3KX1NxBl*G7<~0 z@ycy?)V6Z)c5=ifIyDcob;@kgLRkaK0RSf=MvF@Djh&((M&o@{9zGz)hb&}d z*w2zpmGq8iHR7; zIcWkJgk@9kbFv*EiQ&5eSedd|6<80le9j2L@or|)W!hq0m~29mkL!--Z}H@$tVOAq zS4`7pwbE=BjrQ;ClWjXW5PjU7R;p&d@EzN)x$NVM-}%#SI?Cyo0XFvd$3DhSxyasO zV1$jq{vig~MrRhxpWa55S>?7Y=-(h!LqNP(eBQb0cuTin^>QG_HQuxCF*&Zjxls~z z!B$`=#Idjpa}de7*ayrdX-g&x9+?@4Zy{p?%q0q$$DZhe5V?k!2KVfe>m!Q;A_f%0 z!cf1APy%yM+L7(rE}+Y|v{K`YIdcDJ^bXQD&t53YyV+U3b$1`n{hFmwXMh*fc$oo7 z4&m>fv6O!26b=ED$Hnnv!jsr@!&`^5{VhL(~hf*skXN2jpvqb?qu_6b4O~ z0DXLZHq-0Snwhti(3@|gnuO7)VKZA0@IQjg^$2jGM3D#>01W~${g;}Ir zWqBQ$AF+nV0JCXyCxhjzx4u= zHVOM=SfIwP+0uDufqk#S@Br1dPZJZRIN0kdlZ}0&^al^_pbcztNKn}fCLzoQKa8*S zN9UbD?^@hK(>glkCp6p7wN2AtDhGjctz9aM2P(Oj2M<=U7Bb|a4r&vSdQ}Rgc}Rnr zP7pQ3hZ=Emx`-Ik15}e%XazMNrSdV%g%}o^n^@lqiXj?9T41RJkLE7}34k&Usu;ot zlN`f3DeN&)p{S62cl!YXRHCRrG=ac^ieLstMs?1p${I%*S%%dZmou`D01ol6x)lP} z*sz%NDI)&Z22g%Y)~W;>rp=Gv8A?E9RRHdSa_Em|cVL5@xZrbQIGuF<2p+ z`A}dppV!rzGGQiTE&JP&bI+9qjM_;};BpMN-F1hw9EOio-_od~ zl<+m{TbjfND@k*cyyO>-4`~w!fD8Ursc68({)>VuPOc9VmVwg5u`TqGfmXEN!t^wN19 zbP_wsCvh_1eK<&|Y)@BQih=VmM- z`x2!qHFK~u3jvTudh82UF#^6>9zFG%EVjak^p8;+PYi0p?&BZq_$ z0`{*m%`;UkEDTA5N%&kr<8%QQ5_Pc;GUY|sh_xwBkZqA3ykeMfG0cQ|)h*(p2d+Sw zj6oI$=#zg!8iRtVLeLbLEXoL*0Z8&LA^?j)mgVAJ3S}ShwMwks=9i0DgKi=s0T~3H z6F8jk5KYEfB@h_f7a4bOh?UN!d`Xx z?E!+y_Qni@{opIt)1DpsWOE1C&~vA?9+9;y&|n&_e%VJxUXlI@5MaKvfX)FBCSL(2V(9vzCR@khvkS4gW4Vg{*W!OM}@a z`G?7Xf7VWqNmHpp#JcI=_O3@*GY^sO1L{8(R~wj~M@Gc_;Z>JV_147d>ALbHE>t-ifO zx>+0V>yebGdInlU$8?e6G1sx8*f4FTjG^_(>wZrEwr;0fx0Nf1HfDictP0+LV32xBY0dPe)l2C$GqTjs)yAgfxIBAcSSd)o9~EFiS0N;`fN=OQxtC}P zsn1$!D%PpbWlTXS!~!(5^BI>sf4KP}>qh5X#n82i)FK61w0B&=nO z3bMePyRd_{ZP;&EEEX2)Xv$NXiy+CwAEcvTv7-hU)9>5m;|pIZxS$|Dxb|TOqUp>u zS=-I^6s+YhAK17-GA=S%)15L;02hP=>2r_)Ho>MLf`7>9fZqj{s%z4b9}5gdNbkrk zoM46n7`0DB?UcsWl=zD-s}VdN>+ci43w;$fFrY@+Bt-v&NQXjvYvkZw31&CXnJ*&9 z@ZKF_A~x|l1dp+UyBVBA^nKH>$V(S=(m$TRlsem5=?mM3=x=}a2;H}TP&%@i-%>|E zdc`W~9<9*3m#nE5n5>6DUzg-G#swQhCew+i8Yg5-MEsDOxy&n=r2Q7c*FqdjI$70B zj%c*kAyj}?uUQuXiyUGKvfou6Ij9*N3^XzO#J>Wd4x_6nG5;iP#%u;qA>Jh>BKuwu z#0*vqHU8Bm!iIJkz6Cu&J1*p|!MVQr5Bnvqss{F- znOK1aL|Cq7MRC!Q6vd+gSYh)&z5`@KamIlQfA?-@g>T1RBXtKft+g@nC8l64|F=V% zo~H4``)U5!=TYa9Wz@U<8J@SvZ^BR*0Gtau6hxSM=*JykLPwy8gCULqMG>Ngn3D`v z8LD++JY}ZB7ehR4VgTS8lMDyvX56}`l5Rr+SL7qrv%O$TXwjD2|qa{p+FSIS`6fyp4vfA?CYW1fAex-tcl)(8fc!sL^_p`%nQE%js0V63bxS#mgU!N>(&pI zH{c1AN+z_GjR8v#Rs`I`q(H=hmx%zg&-71dUTpHQBh$7DS@0AmtR;$KUR6P~I*v<5 z2;u$4`&rEC7xsb3LCDrXGZdJEr4ePcdIcKHzbKjz89u-ev*^9FG@w#A*eZgUF{)-8 zwn@`}bZpb4_aQzPz}BGbCCl)&P(f(glq)k5crL4&m?@G98|K|1999fue_?KtN7BGE zDc}G;-`7%0pp&iC#jEN5CB1@07F32JKk| z=e3h&cN{U3qqzMGYxt}#YeQ>c4%o7Cc-xO|aVE_?7B-}nvZ;-enz_$4Z&VTGa6()`%{JS35i3Auf;VlBzQEY#QD&k3&I5kT!89mtU_f4(}5_i zRTySJj==@95HT*au|j}Zz;)w;^ury!3N8eMk%hq^8RYZqKh#4n>d1;}2yo$CWBaz# zh64li%xIAw?c-Xyqyg2BJ=jfy<3(y?)$<=OULkQAn12WfAU!GD*rJ0a1SAb32|eZJ za;EQ$36S7E^%FTBz(bZrPE(*{mQ0DPFr~1U07tt8z;9N_kU|3W zj{yhuPCBVU$Dd#Um!#Y1nw$tb1zBM^EsW!GFK93+Ifk-c5~*HBPa#+krZHup=};O# z&WIGRAhG#|_JfgTeZ-o<5oRM}{ssm}%1XlO)|Io%Wdl$HlpxE&^X8NJRBhy1Pa`-k z&qh?OC??E?Uz)kHJh+!*f$URbGZH5n@?m1ao3fkbosK`boH`D%wqc`-US8mBd0B(`+mBC2Dw7`@|J_C+`}st&Y*mI7ZnaB z`GSG*tMt9;*YsH{{q^*Ze0vNNgpp26lYS|5#WDAOV|xN)CMxi>!y6r>ylArQk;xHi z_Blighys#3$lEaxV79^dRE+{M@v*Rt*-JXD5p%GpGCZ;`AZW0dK@d8^RwytcXau3@ z3mzt+0hzV|!ziA{2SZ*VfE3%bspK6XxEvw49BG;qd~USw6o0Br+SW7FcPHC3#w0W> zCS^6#s&Tlij*X_nEG=MPd*7t2RjD*CTsDoiJas^BY;Y_9pFm*0F%x5oDX}5qxAUKm z(InH93>I(y*F|v{d_{p_M>^pG&gR)2*WGGFgn$-s+$)RWYo=f=`&-+*c{KlwGez=7 zf$v;;oUlmGGnAEE}7M;XDym|iH$ z=+h9xwL;VYI2+nKMT*B@3lKX1&=39OVFedbn9!oQ93|iyGo8+`8Mv0&>WXO@YUF~Q z=fE>^B&TzYcGZG58sy`4vmbz#+w<8W9xRmUi35Ff(b73IFI$ws>q6<1a8z0sBkMx$ zQF_B2OBhPB5KKEVl$?wX|0JL}Z+L+?!3Cu7)5jw_i)j}zMYr+`>HnL*nu zDU@i!UoySz<{Dfdh0)o0=RuAeLA zRuT;GtmqS@JZ>1>2bk}hCE`a@a~3WCTbG{=WAf!=>G>?!|-WpO_w)4f4{w__UHYh{GhnuNK8$y(d^I^!~` z36}Buw;tKV;9{Svj-RjSfeVSAV>%F?Tw(%B)7lBq78M(h+Y&*Z{yN zlQ6iH@kl@!mBeNg_L9f7T7d0|0e~%&d9RTO9x|pGfHYx7zLcJY^<9OHixMEk%38`? zabhmUu~Qo7%xl+H;~`kAoHjI!#l?63_Q^0bCBXQ0KB%_8f=mBq=%JtAZ2+ap;gML2 z%rg3lmq8i83U&ZCo(^r=sGwlhfX`Ga>Tp5u#Q-AY7D73MfFaG|`8Q;0piLMgdIXj& zYuSu@^mhoK4qdVVP?*48xyD9G>Rs2phyM8f?aEvQ(|!vkBEV$?6b&ASnuWn&yRUDQ z{^O}VRN&w+zG|MO_iR%u2zWaqfk@pQz}eD|u&0 z4drdKyOnJgNb)`wD}%=9#I$VuEC69d_6ssi=H6{r*tSeqUaG)6ALUulY1rrc`VU+sz3E(bVG>iWDj0Lj)Pgs@w_T~fRd_#jJ zW6!0Q{jh@D85IWl#gKSallK2EOx3ERnAEo!fuhOyU3jmNk>^KT;1}kc0;}j+L!Hx_q|(Os85%EFk%$sV=4v_?fJM372bHxlIvWOCfA2$1UT$x$KO$?{f)8J4 z#)->?xkNNE>~(na(}ul5VJ{o!l6yeQW!WC+Ab8oVJgAz9b#hC{9||NeF84j z;<$Hh$T%jcRUbWbA+79clNa;RyEikN8P*7>%0#qD$cgh=Ubr zaM_?wfMt9p3nsF_^7jFY4R@Q89*S?Lqn#pjF>6xR^36y5o!-@?WuE7uiKxn?H=pwD zbSwblGhD5#W#~0j^RRovCRae99v+Fc?DFhYtM%QY`8aAuzAZ@VJzF-)jUxrBY?^<= zJS>;Az#u>z8FenhAh5!U_-7LnDM=pp3C2RK3vU}~P)Oc|tPY@I+Hts-J}(1i$7&kF zuFM%T5ic1HC7H=u8GIi(bpc)bnzQMvFI!3Hb?H#Ae|*7-^vQFUNanlr;MCA09d{y+ zP)*dt$NJJ`r%68I04tMkyzwy#JPM1+d>qSUQkW8R(T|T2@&oZ5a*dh-mGfpJaHv^j zfP?^WY|KOm8)!OWm`eZ^X>OvJdZ7^I=fUF=u(zaYBWBnnv39x2#7XPs%VuoNOQ*xiZ50`u4_3?Doa=XXmu?!u_nOc z17E*7Ad}Q9HnW-6{7qQP2g(*xTWz?Ns-Djs}zR^>GK$r4bAuM z$R^V9OxjF5sWC_QzwT7}qjQg=^XIhFOJ_CGkFH!v+urk1`oq=pY5kr9^r9a>K{xH{ zmkwp1)Q5v;NQk{msKWEWXH;qSO~k%tCV2qL$3EDL_R{XEL+O zX`~7wm`mDVH5o943b+Vjh#6KXR6yxo5E50JLSDAq}$P)h#cJ-O2{BhE{7z`dQG=Kv#R}Ii8~-S0zEN4S^zcL=7_+n!+XoS3DelO8j50wr)EZx*i##)trS!YLsGZRNkabh02&%{>`tJTEX z3|6K!nt~IFG>!{TuKc#oy^d#+ouNQ0o?lAkLS0{8XBfaTnNV`ij-Bb%pfqhV+6$~@w4I7nA6Mnz* znMgpT@4RTtzO1&(f*FTpX$WbUi;$qJeycS7mu!y6G?*+qvrkM#VJNMX1Y}m&@N)>w zK8Ct90If?#QS{71N;6Tpe-o7lwo$2P9hDBPm#z_x#9BV!>Ny5h#}zPG4CGmi;>6HM zz{Y0c)r+PCn5c&oEh5KYkrdi}&ukZZ#Eb#+kWn!;gC(Hb`{;wzedle`N|+45dbesG zV>W98fhu%Z_R8c!dB}v}A^{wh%M~5g0GJ`RGppfuh#O^LvaB#?X>3?}fBOD*bY~q& ztt{rC?bbSPdvYpM8!L;Md>!rVS7Ahe$omX%Tns)}JBAP{8&QH;@5W~i(7!&q&AjNq zfsHKK5Q*e-{`Xa9$}<80e|+a=vJ{q*y#ZB8GT2p&NP|iQ3ESBW%sCM<(0rtl$%TheArvMlsGBpyalq;~zSq@P=a4Pz?eF+>OnNIP&e&sDbN+nR%ByJ+Q! ziWnHM*?nTmhU6)VW#l40Bc1qIv+_u+RcSi5wyHHC-A@#~@9llUe71P?lmIKmrK@B6 z@w|_?W20rSz?0@DXRS)m^>5!wTYq*FZM^ooG8aaM)NBBIfw2OOUkQ7OPi9yRewe;N z5j^DHU_*z#LYdbQMfg79YXN9@D~NZ2xv>2}l%b~%m_GBQzd`eS@cD3oqSnYy_Kym< z-ms*T+N@O1`@QG1#j@KCn|C{K*>n8O!)*HX45nDi0?2N-XnrTXm;EHhHZyy zO7b2ZN(xjQsQK}!d$-W19_*I>4rR`nzZoMyS)qhW&TrrFFn#@rU1}zV%2I*rA=jF4 z+Qv!DGnjG0INDV;fCOJj*eQq%lPSm~`$Cw@WbWk3dXv&f(u@WZ2WA=OSw;aPa=YGV z5ZGl!f>jRDH-|F~VM11#i3UKCQ3zAa6&sMr6enh7L^Tw`_^4Pb#c+I{_LjW*VLXqO zjhIzoE5<=^)sz4Wqv$HlUyCUASZv4BTO62LjCnW}Yq@1(w~7SM4}c_Fv=Ub4(exfX z7?xwlWEnL9DFGmyabdDjF?DVUPG_^yxeZiC*SOB#9_VttNf*NzlECXz| z@Apt&$DiM`nL3&qY2Di{r0cFYiSB&ES@hAfm(cqCy&Tgzn1D+Zu8;1Dw1{Wi_n0Gv~NP|S84IcXx^Xn>SWTIvf3#RBzC!P zY2L3{K4CU#6d6hzFO}hiF@=>vH5FsCpZISqD z`+k`KH!FktWjjxx{DL)9x8yPrJSJl;A8>W~P~u`CMxo(Kq!fGDO2D>eQh>$VKhw_6 z?P{y`k@3NL_U)I{D6mXDUqG|wkIY(Nlcs6YWq(uMOnY2~bT`qq;>>B{TZDY)#1HnA0)WBs8a`sh8+H~=K3Um#6SFtu@f?Mp2A zV>_h#-?`Zgr|a#jf0I7>@Xx32+p6b9)s1WfX7-Ebw$XQAxst{{aw&cH%9CkTN4=_{ zfz(jc&#dsp$M%}|Wl2D#2+oVCD9n_x+iYKi%0R+gp%Eoa4^I>-kRY#1a!@uROR=`K zaU_5t0k33tkzu7$#sI=F7hWGsMeAod<otVJ}{T|CK_ltD!x=!R))>+zV*haVN5rJt7IW1G{$6p^fXEk0L>1E-<#43z>ke zR{pX0WYWw6H<~x><&A)084P)d;)n6JA_iv2=TtH;6^wdWrYAAFrr$+%#(=|isb;{X zZ=8LrZ3o4_B2x)CK$&|p@d59*fd1w2-E`&h1$5!u4st%2_XR|{pV3BtfANX*M{76I zUAubdU)JrU_np2--?d{cOKJL^e!TUdo)4E|Gx_4hbLp11U80)ad#?A*T(}5kkTx;8=y6lv!WxmbV?!Jk&U`6-;44gGXByt?6f% z$pe@Lm2wpWMoc*`-_Qh?!CErnA$q74slTL5h9u!~QGtf*^E_C#Hhq+IRb4htdFTPh zfye+YiV^_Xz72yg`QW~j#nX;~Q8xV^H|H^6wxiDk$+!K^Ri`@X{&#MEQcT9c1FB^* zY){!Cd@31T1wm)DKrRe$p*cwL=x$n2KOtapewk@PgiQFj#DJ7svUX7zeNYKIg$5vz zw0@K(`0`khG5c(-UG!g+hAYxaz!m4-Ivp86YB{vIjA{ z#P4KvZGgqb|MEa4>}8abCU4lBBL$gT=FByx#EVvG9wU-i^ea=5Nuo42lL=fw#PtMd zVoGKl80|FxI(^BJ$nxlvEL8OQikm0WY%cP{LU=bKG zLwy}hyjAv)_FE!m5ZT6(>Svb}tn%8*-fJ#Sd ztV|yzW*)i**YA-b7b0q&IVo%Tpv6A*xljk|18Kwo`PPR8Ti=haUXub?am#1c*ub)% z!Eq4mWeP{V?Pp>V*7BEim>)8dx?X1F;6B4xwlu~LNNFZB%Vd}=P|*A0h75+l=N15h z$s%O{Sr#TUou}}!449yAzQT_(ZzJ{qLLPhRgFExW0m?FyGZVdi&4Gw@?HC;Q*oe|! zfR|snY$mv*$FuVB*$d22>=_A#dcZ#b?HdjDAHfHCSj6YAwVgc zcTXyEIDe?@gUPU%1s;{j`940_JP^43%|TPfwVe6L02|WWL<$p21cO^l#agigEC-#l zh9p8sd#2Rnia@kRE@8JvQE82pp26(Y!H7Ua;(CeFq!l)UQ^24k$8ES5@mjD{)5YU;Rb)7L)F zsFW#LVJ23*WSDlpas4B%;)(5f&9y9>efmWw(!w@vR{!pso=8kW16KU|ww3de`yw?% zPx#lh^o2)u(w!`n{K3zjpqGB*4(gxK_@5~~^ww2NOu<4*5SpeSf3TUfPm6J34DIwY z6p+bdTeiw3Y$eNO{f5GDmN^1bz-@mRv9d>O`^G3+Gt;t8*{Od5Ajejb2cX#bR*6rP z056sKPH_qZzVB6SCPr@G2!p&A$C9#%%G-9ZItgp}fXy0`0CALqFjXJhiQnBS{R;MT z#PO;mbHlP&M2Gh7bD$A{V+z)?!C&}a`R0}sW1thy(#UYad_)eoa(%BrS%%ZvG+{Lh zGJs1!DGZlM*e#nepb8|j!*)0mn97lC$1zi}S2!8yEL&ADoG*kV1ZGUX0s8Cvx29OD zlA#dH{?4mUmqP|v(fsY3*1O;Aet&-936c%sf5!faBK^T_8|l(-KS*DAbT{=&!(h|h z02lCu9S!vMQx_6sc~uRu!?3x}MoMxF@xAjZZe-1k!$iykCVAP&@S!JKm?r#ks*G(> zOd7Cut&7P7e)YY8(R8QCuw#_=zwFPXW};X%4tNT(N){snHg}zw6$ls+)h=HXNfrUgjY zi^!OMo_QFO$5guPm@HIP(E`YEYhL&EsCrUVed@k#bl2`a^8te7T%qoR z#Z@y}>CZ3Ftot8t+0W+UcHP$~m$<+Gc*UvdQ%iv=N!QxwB3s2Il#(2^H#X4^-gF`V zwQ_hU(t98dI5OVh1EDP!@;!^B4IxcNd@hMG*|bXlO8_RKax6fyewKay$j@R=wbzQ3 z-9)Icpb5-t#dRxLEUL{Sj+xScaj)M3%VuMEps)goe=LU0HHFZ9%S_XBnvAs)zf9M2 zURf?vNGT3ehFipbSbG96iWkL3IkYT>?y7AjMtv?bC2RSBlhGjUlRNWppYx$?`F21H zEZ7Wd1%}Bid)aNC|3!wq9FxIRlQm`WC(bm! zV$9%bqMKfGhV*lom}?clq>Am7e#gFP`8zj1B^~3q>MgK=dIMO#uRpusIJ#)TH2M8g zcW+MQd2{_MPnb=&zV$q6Z!jHT;$G4DD7tpDUA@V7D^dUWIQ18h)uGQE+N^eIpQVYr zA}-iO-i%3W66qZ#VL+}w!c6RP0t26+La#6f33~*A-A?541r0znIX6)VmCa&j2F8(n zpHUJ#rZi(sZu)tQLsXNse87}3tdsqQ*rzZ;rGdSZvQ`SPvB}Ofi4bLH#RQZ#V`#Pf zGWNtL*7(e2eXxUj_q+0y&t|4%Egx`}!N~JZ#t!ZCfRr$mp5HJS8C5a0t-}8TsNy&c zK(k#;#ZXyInaucSJh#c<*5(C!X51A^yJ+R~Cc`cYFeD^{HU9MMMahodvk=AAKnuJV z>jRCa3m0@6Q0R||iI~i|{(rn;m6(G4?1%mKjgPx?+Dl!uXgY2By_e8Gy!2$cctHoo zKUD_fisj-(GwDBH`yys7?F8suKfIlV5H$5i?8}$sLj$T3X^cskE6iy)LZtVMKBJZ1 zV{0DTO2aTz>@6qTNh|5dE9eF+DOJ!do1ifnut+aau`rwSwLJeUu3)hgyHFZPllHlR zxm2Wy?gyx_=Ls6$dN++ebqf^_K0{NnmTx*{C^DVlkTiy^p5$e%79$`ND|=NIq_N=< z$1GOhAPXj%WSQswZJVim@o|)GFat8c{~8^3pwa8c(w9&a#D=i~ave&#atFmJ-QR}T zy_6`8u}%Dy<&r0g#HIzSnvyGLv`haO2_xxCSUJ5p4H$D>eANr*bTP)Y@2-MvRup$E=#!Rt*TA zb`IG_h*sGQohiA1)xAGCcNy8izfr2zAL{30CM5gf365W_KRnEGyj7%Z?GV#N^E&BE zFF%zP!a7OR{nS0rNbKu@cU+{gEmx_S*$hZS_4?95DvoH3Ahquv{C(dSen&t_M zQe_JJNZ*C%um;p2eQu2MzAf~Da~9L*@83q(J$;bp-E`G*4h6&zJMyMW>L3}hBpXSt zp!SQQz3fI7G`KA+XN?SBj!jRRhOI&$K;~PS2!;UCMnt06cnYzLaHv=iH~^(frw=d- zIysR8W1K4d32;)3Lq0HSY>(XqDnJxZeonC#_1Msu_wrI)YM58Ya$H%K$JhPEj|k13 z4Qeb{uKn5%UAb8{qh(6g3MfbcF04@3EGUr;f*W)SVjMd^&H_XN%mN@S=+;ca?1IWx zs)w)T8funwP=wg{V);7hyAWLzpwZ;X*W5*^$z1cE-#CL-cC{+21g3vJ{_y92`yuva zO3VuNlJvW&^gBOUCsMjKKjCNH z{HAlv^M>Y{L@w8lSNg|Gbk={}K|A_Kh1DK<_Zn);*O|btYUtWP(wMcT$EN@dE75@s zOY*1x>#g+ozCkuQ>*$g9UrLKQ&<|oxE3&LjQG#h`3XK7ke6f=4*Ijc81zcX|pR$i( zF)MtCSs57snK+oOgkvk$<2sRL#K3%lw9p~JXp_IF<=gsWrYwoz7*n;cWq*&8eQU6m zZcYORy(UAZmwQG3Di{s425VW9Q9}icsisDXvpV!`{g`$^o}S`v3$WV>Y4-Mw7_j{K zn>wI+0}C8;I~u2Ct-yjapbCI_6}vGJEr3jA&mSjnsMp`g1enRNogR;70ye-LWK2)C zkx>ne4UhCJ*w^yA?Jd^?nos`9J%+I;1u~3)@^3F$!60fE|7(B+iQm0t1KofInzkx6 zm4U4Z0HOs9IJEOxIkTD9o3$y55iY**X=By=*a(H?GF46rut)!_k9TVKO~jsqJ#_ES@0A@ISfZcX-f%?L!qhZlj;1J; zw1%A+=;2zhXaJI!c!8%N0yyeddBJ9rN=1~IlaAG-O92BaIGbQClgS~2CzQ=#exzz> zfH8ad1-|EJ8|j;y_E$W8QuqNF0LRMe4ivtD?b`q~&arZ)j!wV+;2^nb+x{NJ?tlm1 zb)oo$+BLMX<1@+o2Wx!dj!pE5JGG(fm#;jX-hTQ5lOokDETw%Fs(&RtMa8C%LiDe} zCgJDrf0BOxClAZ<|8@0w^!76s3In0HTpd^;R-emuPhB)X2`CXY%(64_0IW;`n!qZB z#!?Y5I+lw}EX@D{=PR52r10XTHe(|&Y-V4XVKalS>UddP!9>aLN5fi{;hN5Pjk(vN zWZ2_d4Bh|TNmoy~0lVF`#|KyCn1r^V#$~TLh%M|!>C@&T-f`AsvvNFn0H~qRB|a5Vl2uf(FInlFsJ7~to~Mdy zMWbfqp}5(0QL4gLJnc|zAD!zlip|8*ltY1*ky`T%{aVd6C47UZ(BKq^`-IE zNes-!!PJ4bo!n@Ud2-gWg&S?JY8A5Nn4};jz^Vb*&||Hz#x?!@{Nvs5wVuiu8X+On z&Sz{FYI|1ZNdZqJZb}L@*<&g|;8LMts>*yPAE0qf=8OI7rGS{ePW8U}R|E|r z3cRuqhq*~v%b!(CFl9KGO)5uk3MEG^_Dn-F2gdi1R>PU8S+l&lz0$H{N_!UdL5-|86@o)R6Sj#`A;>Q&5aQR>o zV6v(B333k zm6_5Ozi zuLMRVYgO*&zd#?TlkGbd1M<{AUZmH4??LjJU4a#N+XDKp*PLO}qyl=)x9?YV(pD-G zzv6I&6wIuqN3j&&-8&-R^5P|3WU6CxeWSJoDw{ioaA{5CnSMmJYc$tobya~U$2@6J zP-0IqH5F6?CV&LJSL*rfU7!h_4Q&8oKdM-n7{xVld+bY%{#FD#z3|9fn~ot zx+hsf75AH4Z| z+1}khLcf30<0+6M{&vUCy|i}Q0b0BD5UtzaN9zyt%BNV=(P$)kQy5VRMV{gyn_>qb zEsF{8ENdnGz*BA3a%>lq-vHA2JC*yI^V9^Ee-AZTE3N=cT${D3j!%OR19((^Pm%2> zVJ+>c&-5A`X}C%H>$J)66sEq5&6hS0Z2Lh7k}^pcrYP{Xd8|We2Ff)xQ&ld7y#5vz(QJ`4yEb9keX z-g@#px@h4v4`}28CIOWF>ws0m)J(Pq2H=!tC-MO2T_@SM);W{yE*m5EK9H$=GR+;% z$4UXL)?Wx6`6P`n^_gP;EYf%g`KDBCVp5)&;>g{yZ4dOokeoYk_niht@;nI; zgA~J61FpvPD!>_gjE735(0jms5b{4}inTmYRh|Rm+2!iBpPHmBB`+qM4o;W{@XSCh z3zM@};1%GYMqw)s^--nYR0SwX%sIWTj|bn>3q00}$vZ&>0F%P&p0}dFA_4asPwkrm zEEUgE1(3>R4=}Y@E1>ktuRNYNiLd06q4j?6>^?*v|M>>GWBZ}Wuj$yq`#tv09jEpM zNWc1MH!W&!zS2L-nCiUw_UkxCe=)styoWp zEe*Cb;9^QMU*PWN*|1h66O~!$z}VRsC8yXwAOqb;(NVBhOs+?2{k~!PTYz+AKs;Nx zJq8;Y88r+>Qvf0jvLJ;pbDdJ*=vj;GeI&jZnQaL}c?FPs08tHK1u7W0j!BYhyzjJy z^o6U@Rtzu-9Ce$Qq$7f0n9e{kzl zMofySCTmrlH>OD$uEsss_}Q~%txAye-2J08c#gnY3Ff`*BUkDOhT5j3@ueU;e@aTg4>3 z_3h_N-;1?OJUd;qpwoa$e>egn!;YJofWiGfIUuUos1~?VJ5K?w2E$wc7he;@Bfa`t_bIrl&h4{STn&yY zu=xeA@;9nr4R3!!3fBM~|M^>K9R|y=zu63nqy*EQJ9{_`FhG`F)*l#j;KGhboUWG1 zO|4&*Ll}GeCifFp9P1Z)y|3h)a&H>EI${oC7`%0mu`9Mc`R78xJ$QVxLpsNmwxkp`n@w2$?_K-?snIQ z>AevBnK-aivR3jusWNJTMTXg`_Km&$h@LOj>OAcdIc@84E9K>kAKp*ZalDrQdRBW`zRstyXf~T0w&mTDFIC|S@i@fUu zN$v0U^U*<6%cM(9{+F)YH?G(o*Vw1x)?wnk^y~N1ruV)`26F%6L)%3m^e5c&C-2^p z0s+qR_iIih-_)zACfa*;W??0Wq8j^>JMZ)5YiYBlidRXLG8a=mSLM1lNw*aV;^k#{ zCZgZrjn@7)Q@D2Kd!@M;P}9N_spIrZsddG<)VO#hH63?4)9%bFGtiobKKo2&%B2{} z+gJ1TC=JAbs?R~*xye6X_j&6n^R(w&qBMI{F-TBzk!tPc&lUTun%zPFblEDh0rjsv zx`WNdJpJ&k=ezq$fvXl6DocY`n6-3N{2o*uTLX+WfG8mQ#=Z3V?>{WcAeasP=_8<= zK0Z0$zh8EWgh0qE+#VG;ra7h@!m8svpgM5HQ)j;WtmS26WLr9@>La;xQPtn5;`r3$ zdJEIB(!@k`gB!>WrbeMSDa3jlO~zW4epTg!#*i;8YgH?R_HWAc^simPX0JScCVl;h z-Ra{~C#u-w?-CzjpVWRtpSfZck*VoE|G*ad{U5KR0S;Dv@WSKiuU>pI$Gn~-*CEbS zz>;l~0L5M~u5p|X##;9pd;102tOheCba(LYFJCELDE!&E%joM*?xwqT>S!ew&hMnR zteB&{6vq_|W6oELiT)>8s}gXPQ*m@Sb#AJ8ye{W#B@eWzbEJ+9QsuuEU|)Is ztOQ^_8fq|5dfn_>#SoRp=(XOydJzYcXGnh--Mypq|89FqSop&?uA_@NF#A`RoN2ZFDsjBlOmQ2O(ar$?j!1Vuq`6+I@fDAobowL*&A1BRN zYG&F~3T*mw8Zh)i`C75b9h?L#l~ZweXv*{Y_mFS!Oiafd$%_r+I9g{on|xEzsfeK& zq9r)8LRcC&Saq`r06kFnK(dGp1DaYP^!5#opb%QqQM&-t=MIBOkH7MSS(>R{0f^K? zq#h{saJ9F)^8~IC|K%koksV{}_kX%h`pdBQ{hQx=fK9LhUGv(rSWKwX1H1z6rr6sU zSG?)i&m{0ZcI+d{KbQA6_7-XsIwT|~1-ypoHVL@&Pn-fv6~k2&+QbR+3iY#82>rHO z0u75!9hU`Fk zRZ~n=ewbH)5Ip|k!`l-k^6p2Wa(0oI4Vpm!EOlH4}&r&g^s^UuUIY7nq z$$PdY!0^qvqwX8SKVRj(^b2DxKdzNl0`=L)dwZs2EerDIj;8dxvdm>z3?R#vO4;sg zrsVCUA1MVu$bzwLI-JD2@~^rj!a>0DaS(xl+wbN#>DHq!rF`;;rw_`7;eJ2O+W z)bcxrhv|-O2Tk^j+<@6nOW$w);6Zw3(W~gcUVRpw{?9j4cb^%{iwvmz$@E3<0OQ66 zbHkk^`}sHSmSd{vo2Ay1jgaNP!M-ZSG^;Z49N>u3Ol#P!LXIcDaQlGsk8@yqK76gT zIFSOjy$AD+ON9wl2rJJ|M3sJ+ouvEk(0fMnezP5yf2Diq<~Qp`>o4$x(NscH)2I5r zpoATQ8X!X-2Vq^4y9gSuRhnZC1Qh@|DuuAtzLeZg3Ro6|v9}*pUl;$t2eVA%Uk^=M z05r@y`o|;~jAhLsyh!%_{DWJi@LhHCTvh^45_$Xc4{mm4`0b}J)=oOh8D^S}{)>H}XE$J++q{09>Y?kk8gsD>xg z8^_9Ia!|aMAA5~oQ(>?Nn}#2TQ?r(DmQ{Cy#lJJa0*oklG$1%y)~dO01z;VUCHEP7 zK#gOsVj%S3)J9);aGRKk*KbaDPztaiZ*Wn&0&W$XeeLym1((^Uf2<^c7KPaHNqx-PA2lJ5~laV`LYCSF|pT~blA6%7_x9<&G4$}Khoj(a{*#aGq&ydb1 zlg{tU-Zpw(St~h@K1ae@X$G_V5ANDd13S0tzEN~U*78A?Z}b30p)nDSWg^c?hQT63 zX~s-+i&A3;!eBu-Icxa`FRT~(u>k8+8ku|bN*2GT4-&jV|Dz)78#f+`@p;$m(* zro`N}We(+AJNYCJlt0TA%wt{9h&0f?}hW)=)$?i1i$bElA(!$I{MoBUG%OW z-eH)`fW-Q27F<%eqY7YolVhw3_KN+#G2OYVm)^U2ehO5UJse;>;UzZ1w90u+=eEx) zYkAw{w3V#oZC4;wfRgOKM`o?qW8Jz&Kg~+Ja<`@-X-{30&$lNx^Sv{-W z1HCr|-hx?1L&&$EzKDMK=JV;}KYvORc0X|L3AA$NbY{Vg25`)ZvpQTMyyF?mcGmC0 zO4$RM={Aql+}qJRLZ82XyLqOq<~)%M=&)+`bXwTnOpDr@Im9r7-m<)%u3I*pu9vRC ztxsl5M+!Y~RiI1CoY~hdn>SPKhv|m9cJ)$H+K9+6s21F=z?*k38ACc+MEpFnRvKV6 zSc@#^;)GZ$KkEGJ&Rc=7bakUGD>%B-Qf!9 z=kLnV=J#JpU%vW$I`hl7u^Bm>_+)mpm~zIN+*8%{O>8eTEGH8NUML6bFTwmeJSni;`{4=wTEGXhP5mLiuYKmA zvJ)w&Rg=y7Rd<=0|%kS)tzB?HC`zF<=D-Jl1aNRjl?O8xBi{?<=7)AwJ0 zF@5>vtLf6O-LAifW>n8AaG7P(e}5v51eEkQPc^}1Cx{&v%rz!;Qo0QZ2I3y*NzToZOd*#0I|kOuw9lbT>rBeD%#bDl;~Q_G zSO%fe^EePxp0~0O4SP>VVy&P;rnkTQW}?!zmQ5Ll$_%eVI@lk!iyyXrhkJ38fy-VS zajK6ar!Zww#SXX_YY#Pe9Mx46Tss-ROPI#8mjOg*L6o6X49uO!0FTdq{gcno=V#T^ z@14Dz{^CVv&?nYDuIKSnzEj}Jm}AT48Xl8P0jw&~!tQGdql{2ufV6YCNH=WXD-AP| zLUeLxBfXr3oB7RkdgxJr=~!4xMvf_)VjgQ2hQBPy5GE@FO{)Md_a4Knd#=e?%iq@4 z)#AO9th_&MWFMP-_-{5`D`)L^)wTVDH@v2wx3!a3kfb|A!(h_ESq&Dc#aj8<%PGfm zEJF&Z{SUyE`gT?OS_V{3a0sRb%%rR}8N>PK@b-&cHV0~WGeTxYo0CilEdRdE>-GP* z6$5UqEGBpb`Gv-G@^^Lxv_we`n`N&knTe{eV;Mr&Dnp;VZ#!Ll+%o#Ja~IQfPi&S} z#>u|1_iif04;vtL0?5?ha?4(UOTY$`ut?wBxSu|H?>4WUn=;wovRU~lzP~pdH{G#6>4ei@AC2PgznXz1#QV{E?(nM5`E7P4+i;+$G zb!QQ)DdQ4;gEWhn0vxzJ!DJSuA_a}Lzca0~Yk;6K{mI`(HLph%=cqb9OwN%2qCj20 zoYU;<_nIr#kvTpS%f~_aN8GDw@>$E(CgwBRSLPQyuLMR>y@2eF?Yqo2N%=^h-<|{S z3Z@k@Vq&8(!c@%G>5QO#*3Nk}JJ)3ckpAHjde`@EqqsOi|MBvb(snCVhy;kHpQEy6 zF2iJHD}YS$OpZ0RYJ!LB8Ti0+(8lxi&O4LJ=55P+J{u&-|}ADFsj8G{9k!+AJN=X^g@x= zU}4iVs`85hujE##e#yD3a}6u@^{-#WaJ79h`BT;f0a#hpv&GYzlEUQU;7F`hS=-Bm z0U9ut7st*P&Q{P^bI^?BSalLtkO2TMUR9YUMKSq)SekVM>(qfOjXaAtN>u&wB6qE=2NCO%Lp!~uo?KxjjAG3loVdqp{ZfW zKfiAqy>wp3QL`1raaAj0!vhIZ86X64l8fjnc`J07?ai}H5699?yx!x$p4}$RvNmhk zZ4fu=o9fKtWLzcXhJ_M|K3FHO-M+BEHd`_Fqdi&9bZ(DebII2DHCW5fe2+P(92Z8D z!jA{#+7~QlnIx#eIJRM@d=s1b9#(?O?rTw!zK7(Tc3(=ZJLU~cIgib;l8he8{a-A7 z`UPIe@>nbOYIuLHER?n|#sJtjZ)z~FJ2v@)WD0#IIjH7&%yJZYbJ=5@>U;R&!`;%n zYBBRtb3yEK5la+S74|CA&CKN?lL_I1jLKq1%zXC& zn*vbfzLYRg<#wuVdZx|9z*uX7KsNw6K(?r}krK8G$VKKStL2H(@6Fo}&Ftft3!IGQ zWfjA~Fo|I($=A#z6;kCj;?xCbO6D{`c=s(EX(-dl(*1?h20Nu>R6T$i;DU)*E>Cif z>ho3p_HQpffd=1uG2Q+8)970-KZX9}tR-~C!VYG#7Gr^!_le2NzV~OCi$|0BZVPv1 zk52NRH3?mC*_em1n*A13n97V|oX@gXVmb$E9y*3h1FCXc+B~cm0Vi<&bH-L+X$E7V zqnq!vyRur&QEa*gh94GA%375mumIColkvk^X4N>Jl7cl^d#&IIzE*<@<7p9!Wset7MUev?n9Px<(EuWk4O?c< zHIG;(JM`qzg2kDBzL{t5vJx;ZkW3CR)$PT65LTR1w#Q0bQE19_ zKo>2Vm7F)C$qRM*-PPPI-ASu0V{#I06B7d{Co=Fhj8zqt4ZCL4w96VH17e3i11!0m z0tzFGVp(c7K7N83vs9!XZ{A1W*wjmFPFzm!XZc=OtmK)5sr#SeSt_0%y-c;{)PDq86|6X9gR{~FJ3S$IR+rJF(|NBm`y4pfB*#+ zIgO~K%H`U?mVNp60t0G%LXRn%S{VS$=jo5uZlLZ12k1{%O{bMszNTZyLU~=S{C5#73`JtAZ7e8nX_x{J}H3lw^{3y6YnkO zxFG0AetgqGI4Nt9XM#ykZ%lg}f`l0xe$Z*0P)ukSBU&079`7a%(Y z6$01Z<*^Yp$JjcF80=uO-#u+9{l>Ccp16@d$7`2Pr{6qrRstSt?imx$`B~(%M+JM8 zBO_9BnJh(Wg^&?CV$}_FyOrz{@#4vAe?aTM|KGIZf&1ye#trn$op(&hTE1f0H9zU* zEzY(WKxLdCCK(-Jc!%951u*zwd?Ve?<~0`x=l6+WebAe5s^AKEV`Uz&ku?*A*|LcVSZB<@ zoQZ?g)hDxs~4bo{eoEw?XES1j&gKs7R3jWVc8 zVkY9J()buxRCOXc{L?uL>3f&2pqI_>q!lb0ox?T%OSy3V_oXM&r_WvD%r_M&)K{ux zFIWx$OjN;M0dF1*98z>@`*e-2xKJ+m$}P5LcxV!=)Y&|=GOfh+$G{0>y)uH{w(kCiZGN_>ww zj*rE(X32DI+Hc$U*ghiztt|nn@4w+3S-xoDbb84(cXBZKu>acZKa%18duJ|@>%8)~ zS<>b#*~eROh1==assvoC1yjlgI&&yz>)HzKlLNvUvlU zOoK3Z;JFfdu4rAb_`|Mqn#$;l39Bg+ z5yf6vat#_ODO+Tj%-k~eNwP7bbdOE~B;$JpngS(2;_r-O%H@<}B+-^VO1+eL_{qePaewa(PEEIH% zZh#=nknd}yeVs|-T48$K?d$nPZk#1bixYvgL$l^%=orlcSpgX)AoR_{Ffv>J^dpE`s!mlY8Ti*8*8F%msJv9dBUt@ zM_aOLO-BGsH7MeW_4MYQgX8qacWjnsCja)5<>`H^Kp!VeXu#FhR8N<)iBuXLmNFoK z8X0CLD@wNzWo9*)iMBvShMBC{SGg=Epj|exti<$-T-l3E!56>2tN;x8d&9?)TLX|v z%3fG575(LsEazKVX~9`%Q`_9RwDiJ@=&av-7tLI;Vp3nLk~zgZT;TQJk!v;SQNFkV zRWWm|{}!tNrtdboHi>3?wFeqsn#_(!qt!K8E5%Mh0z%m8nT``Fzu;2JcAP-I8W@uY z7_y+j!+9XYmH>uzVvhTv-}cT!9<_HzzJGK(x0DyyjOs!Hfy;bXmS9$-;M@^T3T!l z;T0^nyx9W13Xndy%sXat>~~horl!)E6sl}fyL2&KE{yYXtGw&N@Nfd4vhk_N^Qp)* zCoviCB4)_@*UHNRCN^rIpUL6Oz*1zsXTTbdvKM}bd5H6enLxl6vPfjXh-=PSwc31R zHSx3c4K!={NmUG1$y#<9=3zs#6~5pPH#^qS4o3xS-T8U%Ugy8caFTC!ox6~yzR*O$ z#lw6So0PRY(=pS@z?yqL*L2h63SsjC@y#rYS=O@sFglPKzyUNat;sgy@YqxR%+N#w z0>gT-0i4Yqj~zx_QwB-^C+h}e$RR}I%cQql+dwj5GW0U_wa0f!KO9vBI!G7tuk(K0 zydU7Zaz={|pb*!7`4)(i0wx8>saY#oPN@5Lf3}g?Y>fW)f@QRzrQX}mlq&yPM)IHE zkf)EFJWJHWOmhb_*O0JProLI;^2A`j98ejxInFqXehS@y}~>S*S2RZ!7REiehs`<{BL0#Mbg6`LDhbkgkPJ=J$n z$~+9MiI`?DOf>p=Ta8&Xn>W1Q`mv97GnMze*KTO@N_&7(eK>wOB0XfTr!U zUS#AcSj%U)XmFc(2{~=;3Knae%`d55k28ZyU^%n`hZQ+Ul|liQRSdJCE>{9MYg3Hu z{-kE0ZgQeT;4RBjDLeVNNy}3A7p{PR4qYf-Q*aW$4D3aSbRCK7`lbSDUl6< zCMC&~20?170++Y%(d0@S;}s_^ zWL3I@3Pb%=9v`Rg?-`+Y-ue{nIy~ggy`g7>-gxsyx@A`%IY4>fDo2{Rj%{TpRFDA2 zR*DuB&NXrVR@o?rSz#|s833xFf(k%YE|#6TSTw#D=DDYys;>a$&R9P?A9?hMtYw$y z9ydbnf~7n#HdBb0&otF>6HI51+e>qDj5DOk$~Tr{?i zA8_2zDUoeyt1L#&%%e&c1K_fT#jweMOc{!tZ{-UqVosVH7n9SdNOH>9D1WBZsXt%< z=X@|Rc4Q3VRWsYjITO-Dx=>4ZJ zN(0-UYO)cTnqsp3Y^4#Wf3LCcNAG@y)*l+BKR$f{2aj9z2Ru{WH27q2o!r?>e{<>S zq85(#9u(%vwN0bH#89_fxL0so@82&up1ySPahg5_rha&5ilJ~SQT50Gs#rFrp%E&u zU&h9~{3KsB^%R84SU4|^M+^M2S(Z-$P_fwJ*vkM9>{Z{|>MBF&2$!dw4?l85*0MmG zd;E;#{WZ*_IId7}`5DIshpWs(xi6Yzn)9Zcn8BicU+BO?msmNAS*8Wcpk@KcCS$Fj z0&LOHb~(3Xc9gYiv!H$!vr$8$@=*d3$4~}LBu=dURZ}r^HE#M^T9V?D-n)ROq~MYJ z#50wn)PA;*N80&0SsHrV$_1uSdTqI6M-zW>AdAugV!4+%U8rLnL_tP{G#mc@k`w8o zd7VekMpJ^vt_11d{kdX#@6Vs6zOe#*@x?6Nw=|-ey8wsSjBKmV(>Gpw5w+IW(|FGT zDFgYAF1lw=FAcB{hx7fz1xx8AbKB{~bKB_eIDm|4_t1n6f^NZ8O1dTR)jnZ0d

! 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

  • v*`zg#&ou{TaF`J$%qsv&w)?yO6Zd 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 p`_tU)*f;A%xb3*1aG!`yU3jl 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 literal 0 HcmV?d00001 diff --git a/assets/images/ray_icon.png b/assets/images/ray_icon.png new file mode 100644 index 0000000000000000000000000000000000000000..0d48e54a62a1cdce1a9c4a45e8e85f804562b6f9 GIT binary patch literal 15614 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 VC4BAKA*fs#Ojb%svPRql_&=;Iuj>E+ literal 0 HcmV?d00001 diff --git a/assets/solana_node_list.yml b/assets/solana_node_list.yml new file mode 100644 index 000000000..4a2e12161 --- /dev/null +++ b/assets/solana_node_list.yml @@ -0,0 +1,4 @@ +- + uri: rpc.ankr.com + is_default: true + useSSL: true \ No newline at end of file diff --git a/cw_core/lib/crypto_currency.dart b/cw_core/lib/crypto_currency.dart index 162ffb1d2..62c0ad437 100644 --- a/cw_core/lib/crypto_currency.dart +++ b/cw_core/lib/crypto_currency.dart @@ -9,7 +9,8 @@ class CryptoCurrency extends EnumerableItem with Serializable implemen required this.decimals, this.fullName, this.iconPath, - this.tag}) + this.tag, this.enabled = false, + }) : super(title: title, raw: raw); final String name; @@ -17,6 +18,9 @@ class CryptoCurrency extends EnumerableItem with Serializable implemen final String? fullName; final String? iconPath; final int decimals; + final bool enabled; + + set enabled(bool value) => this.enabled = value; static const all = [ CryptoCurrency.xmr, @@ -208,6 +212,7 @@ class CryptoCurrency extends EnumerableItem with Serializable implemen static const usdtPoly = CryptoCurrency(title: 'USDT', tag: 'POLY', fullName: 'Tether USD (PoS)', raw: 87, name: 'usdtpoly', iconPath: 'assets/images/usdt_icon.png', decimals: 6); static const usdcEPoly = CryptoCurrency(title: 'USDC.E', tag: 'POLY', fullName: 'USD Coin (PoS)', raw: 88, name: 'usdcepoly', iconPath: 'assets/images/usdc_icon.png', decimals: 6); static const kaspa = CryptoCurrency(title: 'KAS', fullName: 'Kaspa', raw: 89, name: 'kaspa', iconPath: 'assets/images/kaspa_icon.png', decimals: 8); + static const usdtSol = CryptoCurrency(title: 'USDT', tag: 'SOL', fullName: 'USDT Tether', raw: 90, name: 'usdtsol', iconPath: 'assets/images/usdt_icon.png', decimals: 6); static final Map _rawCurrencyMap = diff --git a/cw_core/lib/currency_for_wallet_type.dart b/cw_core/lib/currency_for_wallet_type.dart index ce0219f1f..58ee37669 100644 --- a/cw_core/lib/currency_for_wallet_type.dart +++ b/cw_core/lib/currency_for_wallet_type.dart @@ -21,6 +21,8 @@ CryptoCurrency currencyForWalletType(WalletType type) { return CryptoCurrency.banano; case WalletType.polygon: return CryptoCurrency.maticpoly; + case WalletType.solana: + return CryptoCurrency.sol; default: throw Exception('Unexpected wallet type: ${type.toString()} for CryptoCurrency currencyForWalletType'); } diff --git a/cw_core/lib/hive_type_ids.dart b/cw_core/lib/hive_type_ids.dart index 4d4d1a6a8..3fa2eb647 100644 --- a/cw_core/lib/hive_type_ids.dart +++ b/cw_core/lib/hive_type_ids.dart @@ -13,4 +13,5 @@ const ADDRESS_INFO_TYPE_ID = 11; const ERC20_TOKEN_TYPE_ID = 12; const NANO_ACCOUNT_TYPE_ID = 13; const POW_NODE_TYPE_ID = 14; -const DERIVATION_TYPE_TYPE_ID = 15; \ No newline at end of file +const DERIVATION_TYPE_TYPE_ID = 15; +const SPL_TOKEN_TYPE_ID = 16; diff --git a/cw_core/lib/node.dart b/cw_core/lib/node.dart index 2c43dd21a..585bc3c38 100644 --- a/cw_core/lib/node.dart +++ b/cw_core/lib/node.dart @@ -70,15 +70,10 @@ class Node extends HiveObject with Keyable { Uri get uri { switch (type) { case WalletType.monero: - return Uri.http(uriRaw, ''); - case WalletType.bitcoin: - return createUriFromElectrumAddress(uriRaw); - case WalletType.litecoin: - return createUriFromElectrumAddress(uriRaw); case WalletType.haven: return Uri.http(uriRaw, ''); - case WalletType.ethereum: - return Uri.https(uriRaw, ''); + case WalletType.bitcoin: + case WalletType.litecoin: case WalletType.bitcoinCash: return createUriFromElectrumAddress(uriRaw); case WalletType.nano: @@ -88,7 +83,9 @@ class Node extends HiveObject with Keyable { } else { return Uri.http(uriRaw, ''); } + case WalletType.ethereum: case WalletType.polygon: + case WalletType.solana: return Uri.https(uriRaw, ''); default: throw Exception('Unexpected type ${type.toString()} for Node uri'); @@ -134,21 +131,17 @@ class Node extends HiveObject with Keyable { try { switch (type) { case WalletType.monero: - return requestMoneroNode(); - case WalletType.bitcoin: - return requestElectrumServer(); - case WalletType.litecoin: - return requestElectrumServer(); case WalletType.haven: return requestMoneroNode(); - case WalletType.ethereum: - return requestElectrumServer(); - case WalletType.bitcoinCash: - return requestElectrumServer(); case WalletType.nano: case WalletType.banano: return requestNanoNode(); + case WalletType.bitcoin: + case WalletType.litecoin: + case WalletType.bitcoinCash: + case WalletType.ethereum: case WalletType.polygon: + case WalletType.solana: return requestElectrumServer(); default: return false; diff --git a/cw_core/lib/pathForWallet.dart b/cw_core/lib/pathForWallet.dart index af4838ffa..cfc33ef21 100644 --- a/cw_core/lib/pathForWallet.dart +++ b/cw_core/lib/pathForWallet.dart @@ -1,6 +1,5 @@ import 'dart:io'; import 'package:cw_core/wallet_type.dart'; -import 'package:flutter/foundation.dart'; import 'package:path_provider/path_provider.dart'; Future pathForWalletDir({required String name, required WalletType type}) async { diff --git a/cw_core/lib/wallet_type.dart b/cw_core/lib/wallet_type.dart index 20f0bdb19..a63ddf37c 100644 --- a/cw_core/lib/wallet_type.dart +++ b/cw_core/lib/wallet_type.dart @@ -14,6 +14,7 @@ const walletTypes = [ WalletType.nano, WalletType.banano, WalletType.polygon, + WalletType.solana, ]; @HiveType(typeId: WALLET_TYPE_TYPE_ID) @@ -46,7 +47,10 @@ enum WalletType { bitcoinCash, @HiveField(9) - polygon + polygon, + + @HiveField(10) + solana } int serializeToInt(WalletType type) { @@ -69,6 +73,8 @@ int serializeToInt(WalletType type) { return 7; case WalletType.polygon: return 8; + case WalletType.solana: + return 9; default: return -1; } @@ -94,6 +100,8 @@ WalletType deserializeFromInt(int raw) { return WalletType.bitcoinCash; case 8: return WalletType.polygon; + case 9: + return WalletType.solana; default: throw Exception('Unexpected token: $raw for WalletType deserializeFromInt'); } @@ -119,6 +127,8 @@ String walletTypeToString(WalletType type) { return 'Banano'; case WalletType.polygon: return 'Polygon'; + case WalletType.solana: + return 'Solana'; default: return ''; } @@ -144,6 +154,8 @@ String walletTypeToDisplayName(WalletType type) { return 'Banano (BAN)'; case WalletType.polygon: return 'Polygon (MATIC)'; + case WalletType.solana: + return 'Solana (SOL)'; default: return ''; } @@ -169,6 +181,8 @@ CryptoCurrency walletTypeToCryptoCurrency(WalletType type) { return CryptoCurrency.banano; case WalletType.polygon: return CryptoCurrency.maticpoly; + case WalletType.solana: + return CryptoCurrency.sol; default: throw Exception( 'Unexpected wallet type: ${type.toString()} for CryptoCurrency walletTypeToCryptoCurrency'); diff --git a/cw_solana/.gitignore b/cw_solana/.gitignore new file mode 100644 index 000000000..96486fd93 --- /dev/null +++ b/cw_solana/.gitignore @@ -0,0 +1,30 @@ +# Miscellaneous +*.class +*.log +*.pyc +*.swp +.DS_Store +.atom/ +.buildlog/ +.history +.svn/ +migrate_working_dir/ + +# IntelliJ related +*.iml +*.ipr +*.iws +.idea/ + +# The .vscode folder contains launch configuration and tasks you configure in +# VS Code which you may wish to be included in version control, so this line +# is commented out by default. +#.vscode/ + +# Flutter/Dart/Pub related +# Libraries should not include pubspec.lock, per https://dart.dev/guides/libraries/private-files#pubspeclock. +/pubspec.lock +**/doc/api/ +.dart_tool/ +.packages +build/ diff --git a/cw_solana/.metadata b/cw_solana/.metadata new file mode 100644 index 000000000..fa347fc6a --- /dev/null +++ b/cw_solana/.metadata @@ -0,0 +1,10 @@ +# 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 and should not be manually edited. + +version: + revision: f468f3366c26a5092eb964a230ce7892fda8f2f8 + channel: stable + +project_type: package diff --git a/cw_solana/CHANGELOG.md b/cw_solana/CHANGELOG.md new file mode 100644 index 000000000..41cc7d819 --- /dev/null +++ b/cw_solana/CHANGELOG.md @@ -0,0 +1,3 @@ +## 0.0.1 + +* TODO: Describe initial release. diff --git a/cw_solana/LICENSE b/cw_solana/LICENSE new file mode 100644 index 000000000..ba75c69f7 --- /dev/null +++ b/cw_solana/LICENSE @@ -0,0 +1 @@ +TODO: Add your license here. diff --git a/cw_solana/README.md b/cw_solana/README.md new file mode 100644 index 000000000..02fe8ecab --- /dev/null +++ b/cw_solana/README.md @@ -0,0 +1,39 @@ + + +TODO: Put a short description of the package here that helps potential users +know whether this package might be useful for them. + +## Features + +TODO: List what your package can do. Maybe include images, gifs, or videos. + +## Getting started + +TODO: List prerequisites and provide or point to information on how to +start using the package. + +## Usage + +TODO: Include short and useful examples for package users. Add longer examples +to `/example` folder. + +```dart +const like = 'sample'; +``` + +## Additional information + +TODO: Tell users more about the package: where to find more information, how to +contribute to the package, how to file issues, what response they can expect +from the package authors, and more. diff --git a/cw_solana/analysis_options.yaml b/cw_solana/analysis_options.yaml new file mode 100644 index 000000000..a5744c1cf --- /dev/null +++ b/cw_solana/analysis_options.yaml @@ -0,0 +1,4 @@ +include: package:flutter_lints/flutter.yaml + +# Additional information about this file can be found at +# https://dart.dev/guides/language/analysis-options diff --git a/cw_solana/lib/cw_solana.dart b/cw_solana/lib/cw_solana.dart new file mode 100644 index 000000000..d04069b3b --- /dev/null +++ b/cw_solana/lib/cw_solana.dart @@ -0,0 +1,7 @@ +library cw_solana; + +/// A Calculator. +class Calculator { + /// Returns [value] plus 1. + int addOne(int value) => value + 1; +} diff --git a/cw_solana/lib/default_spl_tokens.dart b/cw_solana/lib/default_spl_tokens.dart new file mode 100644 index 000000000..f96d62d86 --- /dev/null +++ b/cw_solana/lib/default_spl_tokens.dart @@ -0,0 +1,109 @@ +import 'package:cw_core/crypto_currency.dart'; +import 'package:cw_solana/spl_token.dart'; + +class DefaultSPLTokens { + final List _defaultTokens = [ + SPLToken( + name: 'USDT Tether', + symbol: 'USDT', + mintAddress: 'Es9vMFrzaCERmJfrF4H2FYD4KCoNkY11McCe8BenwNYB', + decimal: 6, + mint: 'usdtsol', + enabled: true, + ), + SPLToken( + name: 'USD Coin', + symbol: 'USDC', + mintAddress: 'EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v', + decimal: 6, + mint: 'usdcsol', + enabled: true, + ), + SPLToken( + name: 'Wrapped Ethereum (Sollet)', + symbol: 'soETH', + mintAddress: '2FPyTwcZLUg1MDrwsyoP4D6s1tM7hAkHYRjkNb5w6Pxk', + decimal: 6, + mint: 'soEth', + enabled: true, + iconPath: 'assets/images/eth_icon.png', + ), + SPLToken( + name: 'Wrapped SOL', + symbol: 'WSOL', + mintAddress: 'So11111111111111111111111111111111111111112', + decimal: 9, + mint: 'WSOL', + enabled: true, + iconPath: 'assets/images/sol_icon.png', + ), + SPLToken( + name: 'Wrapped Bitcoin (Sollet)', + symbol: 'BTC', + mintAddress: '9n4nbM75f5Ui33ZbPYXn59EwSgE8CGsHtAeTH5YFeJ9E', + decimal: 6, + mint: 'btcsol', + iconPath: 'assets/images/btc.png', + ), + SPLToken( + name: 'Bonk', + symbol: 'Bonk', + mintAddress: 'DezXAZ8z7PnrnRJjz3wXBoRgixCa6xjnB7YaB1pPB263', + decimal: 5, + mint: 'Bonk', + iconPath: 'assets/images/bonk_icon.png', + ), + SPLToken( + name: 'Helium Network Token', + symbol: 'HNT', + mintAddress: 'hntyVP6YFm1Hg25TN9WGLqM12b8TQmcknKrdu1oxWux', + decimal: 8, + mint: 'hnt', + iconPath: 'assets/images/hnt_icon.png', + ), + SPLToken( + name: 'Pyth Network', + symbol: 'PYTH', + mintAddress: 'HZ1JovNiVvGrGNiiYvEozEVgZ58xaU3RKwX8eACQBCt3', + decimal: 6, + mint: 'pyth', + ), + SPLToken( + name: 'Raydium', + symbol: 'RAY', + mintAddress: '4k3Dyjzvzp8eMZWUXbBCjEvwSkkk59S5iCNLY3QrkX6R', + decimal: 6, + mint: 'ray', + iconPath: 'assets/images/ray_icon.png', + ), + SPLToken( + name: 'GMT', + symbol: 'GMT', + mintAddress: '7i5KKsX2weiTkry7jA4ZwSuXGhs5eJBEjY8vVxR4pfRx', + decimal: 6, + mint: 'ray', + iconPath: 'assets/images/gmt_icon.png', + ), + SPLToken( + name: 'AvocadoCoin', + symbol: 'AVDO', + mintAddress: 'EE5L8cMU4itTsCSuor7NLK6RZx6JhsBe8GGV3oaAHm3P', + decimal: 8, + mint: 'avdo', + iconPath: 'assets/images/avdo_icon.png', + ), + ]; + + List get initialSPLTokens => _defaultTokens.map((token) { + String? iconPath; + if (token.iconPath != null) return token; + + try { + iconPath = CryptoCurrency.all + .firstWhere((element) => element.title.toUpperCase() == token.symbol.toUpperCase()) + .iconPath; + } catch (_) {} + + return SPLToken.copyWith(token, iconPath, 'SOL'); + }).toList(); +} diff --git a/cw_solana/lib/file.dart b/cw_solana/lib/file.dart new file mode 100644 index 000000000..8fd236ec3 --- /dev/null +++ b/cw_solana/lib/file.dart @@ -0,0 +1,39 @@ +import 'dart:io'; +import 'package:cw_core/key.dart'; +import 'package:encrypt/encrypt.dart' as encrypt; + +Future write( + {required String path, + required String password, + required String data}) async { + final keys = extractKeys(password); + final key = encrypt.Key.fromBase64(keys.first); + final iv = encrypt.IV.fromBase64(keys.last); + final encrypted = await encode(key: key, iv: iv, data: data); + final f = File(path); + f.writeAsStringSync(encrypted); +} + +Future writeData( + {required String path, + required String password, + required String data}) async { + final keys = extractKeys(password); + final key = encrypt.Key.fromBase64(keys.first); + final iv = encrypt.IV.fromBase64(keys.last); + final encrypted = await encode(key: key, iv: iv, data: data); + final f = File(path); + f.writeAsStringSync(encrypted); +} + +Future read({required String path, required String password}) async { + final file = File(path); + + if (!file.existsSync()) { + file.createSync(); + } + + final encrypted = file.readAsStringSync(); + + return decode(password: password, data: encrypted); +} diff --git a/cw_solana/lib/pending_solana_transaction.dart b/cw_solana/lib/pending_solana_transaction.dart new file mode 100644 index 000000000..38347ed13 --- /dev/null +++ b/cw_solana/lib/pending_solana_transaction.dart @@ -0,0 +1,43 @@ +import 'package:cw_core/pending_transaction.dart'; +import 'package:solana/encoder.dart'; + +class PendingSolanaTransaction with PendingTransaction { + final double amount; + final SignedTx signedTransaction; + final String destinationAddress; + final Function sendTransaction; + final double fee; + + PendingSolanaTransaction({ + required this.fee, + required this.amount, + required this.signedTransaction, + required this.destinationAddress, + required this.sendTransaction, + }); + + @override + String get amountFormatted { + String stringifiedAmount = amount.toString(); + + if (stringifiedAmount.toString().length >= 6) { + stringifiedAmount = stringifiedAmount.substring(0, 6); + } + + return stringifiedAmount; + } + + @override + Future commit() async { + return await sendTransaction(); + } + + @override + String get feeFormatted => fee.toString(); + + @override + String get hex => signedTransaction.encode(); + + @override + String get id => ''; +} diff --git a/cw_solana/lib/solana_balance.dart b/cw_solana/lib/solana_balance.dart new file mode 100644 index 000000000..b1f0ef153 --- /dev/null +++ b/cw_solana/lib/solana_balance.dart @@ -0,0 +1,39 @@ +import 'dart:convert'; + +import 'package:cw_core/balance.dart'; + +class SolanaBalance extends Balance { + SolanaBalance(this.balance) : super(balance.toInt(), balance.toInt()); + + final double balance; + + @override + String get formattedAdditionalBalance => _balanceFormatted(); + + @override + String get formattedAvailableBalance => _balanceFormatted(); + + String _balanceFormatted() { + String stringBalance = balance.toString(); + if (stringBalance.toString().length >= 6) { + stringBalance = stringBalance.substring(0, 6); + } + return stringBalance; + } + + static SolanaBalance? fromJSON(String? jsonSource) { + if (jsonSource == null) { + return null; + } + + final decoded = json.decode(jsonSource) as Map; + + try { + return SolanaBalance(decoded['balance']); + } catch (e) { + return SolanaBalance(0.0); + } + } + + String toJSON() => json.encode({'balance': balance.toString()}); +} diff --git a/cw_solana/lib/solana_client.dart b/cw_solana/lib/solana_client.dart new file mode 100644 index 000000000..ececc56ba --- /dev/null +++ b/cw_solana/lib/solana_client.dart @@ -0,0 +1,477 @@ +import 'dart:async'; +import 'dart:convert'; +import 'dart:math'; + +import 'package:cw_core/crypto_currency.dart'; +import 'package:cw_core/node.dart'; +import 'package:cw_solana/pending_solana_transaction.dart'; +import 'package:cw_solana/solana_balance.dart'; +import 'package:cw_solana/solana_transaction_model.dart'; +import 'package:http/http.dart' as http; +import 'package:solana/dto.dart'; +import 'package:solana/encoder.dart'; +import 'package:solana/solana.dart'; +import '.secrets.g.dart' as secrets; + +class SolanaWalletClient { + final httpClient = http.Client(); + SolanaClient? _client; + + bool connect(Node node) { + try { + Uri? rpcUri; + String webSocketUrl; + bool isModifiedNodeUri = false; + + if (node.uriRaw == 'rpc.ankr.com') { + isModifiedNodeUri = true; + String ankrApiKey = secrets.ankrApiKey; + + rpcUri = Uri.https(node.uriRaw, '/solana/$ankrApiKey'); + webSocketUrl = 'wss://${node.uriRaw}/solana/ws/$ankrApiKey'; + } else { + webSocketUrl = 'wss://${node.uriRaw}'; + } + + _client = SolanaClient( + rpcUrl: isModifiedNodeUri ? rpcUri! : node.uri, + websocketUrl: Uri.parse(webSocketUrl), + timeout: const Duration(minutes: 2), + ); + return true; + } catch (e) { + return false; + } + } + + Future getBalance(String address) async { + try { + final balance = await _client!.rpcClient.getBalance(address); + + final solBalance = balance.value / lamportsPerSol; + + return solBalance; + } catch (_) { + return 0.0; + } + } + + Future getSPLTokenAccounts(String mintAddress, String publicKey) async { + try { + final tokenAccounts = await _client!.rpcClient.getTokenAccountsByOwner( + publicKey, + TokenAccountsFilter.byMint(mintAddress), + commitment: Commitment.confirmed, + encoding: Encoding.jsonParsed, + ); + return tokenAccounts; + } catch (e) { + return null; + } + } + + Future getSplTokenBalance(String mintAddress, String publicKey) async { + // Fetch the token accounts (a token can have multiple accounts for various uses) + final tokenAccounts = await getSPLTokenAccounts(mintAddress, publicKey); + + // Handle scenario where there is no token account + if (tokenAccounts == null || tokenAccounts.value.isEmpty) { + return null; + } + + // Sum the balances of all accounts with the specified mint address + double totalBalance = 0.0; + + for (var programAccount in tokenAccounts.value) { + final tokenAmountResult = + await _client!.rpcClient.getTokenAccountBalance(programAccount.pubkey); + + final balance = tokenAmountResult.value.uiAmountString; + + final balanceAsDouble = double.tryParse(balance ?? '0.0') ?? 0.0; + + totalBalance += balanceAsDouble; + } + + return SolanaBalance(totalBalance); + } + + Future getGasForMessage(String message) async { + try { + final gasPrice = await _client!.rpcClient.getFeeForMessage(message) ?? 0; + final fee = gasPrice / lamportsPerSol; + return fee; + } catch (_) { + return 0; + } + } + + /// Load the Address's transactions into the account + Future> fetchTransactions( + Ed25519HDPublicKey publicKey, { + String? splTokenSymbol, + int? splTokenDecimal, + }) async { + List transactions = []; + + try { + final response = await _client!.rpcClient.getTransactionsList( + publicKey, + commitment: Commitment.confirmed, + limit: 1000, + ); + + for (final tx in response) { + if (tx.transaction is ParsedTransaction) { + final parsedTx = (tx.transaction as ParsedTransaction); + final message = parsedTx.message; + + final fee = (tx.meta?.fee ?? 0) / lamportsPerSol; + + for (final instruction in message.instructions) { + if (instruction is ParsedInstruction) { + instruction.map( + system: (systemData) { + systemData.parsed.map( + transfer: (transferData) { + ParsedSystemTransferInformation transfer = transferData.info; + bool isOutgoingTx = transfer.source == publicKey.toBase58(); + + double amount = transfer.lamports.toDouble() / lamportsPerSol; + + transactions.add( + SolanaTransactionModel( + id: parsedTx.signatures.first, + from: transfer.source, + to: transfer.destination, + amount: amount, + isOutgoingTx: isOutgoingTx, + blockTimeInInt: tx.blockTime!, + fee: fee, + programId: SystemProgram.programId, + tokenSymbol: 'SOL', + ), + ); + }, + transferChecked: (_) {}, + unsupported: (_) {}, + ); + }, + splToken: (splTokenData) { + if (splTokenSymbol != null) { + splTokenData.parsed.map( + transfer: (transferData) { + SplTokenTransferInfo transfer = transferData.info; + bool isOutgoingTx = transfer.source == publicKey.toBase58(); + + double amount = (double.tryParse(transfer.amount) ?? 0.0) / + pow(10, splTokenDecimal ?? 9); + + transactions.add( + SolanaTransactionModel( + id: parsedTx.signatures.first, + fee: fee, + from: transfer.source, + to: transfer.destination, + amount: amount, + isOutgoingTx: isOutgoingTx, + programId: TokenProgram.programId, + blockTimeInInt: tx.blockTime!, + tokenSymbol: splTokenSymbol, + ), + ); + }, + transferChecked: (transferCheckedData) { + SplTokenTransferCheckedInfo transfer = transferCheckedData.info; + bool isOutgoingTx = transfer.source == publicKey.toBase58(); + double amount = + double.tryParse(transfer.tokenAmount.uiAmountString ?? '0.0') ?? 0.0; + + transactions.add( + SolanaTransactionModel( + id: parsedTx.signatures.first, + fee: fee, + from: transfer.source, + to: transfer.destination, + amount: amount, + isOutgoingTx: isOutgoingTx, + programId: TokenProgram.programId, + blockTimeInInt: tx.blockTime!, + tokenSymbol: splTokenSymbol, + ), + ); + }, + generic: (genericData) {}, + ); + } + }, + memo: (_) {}, + unsupported: (a) {}, + ); + } + } + } + } + + return transactions; + } catch (err) { + return []; + } + } + + Future> getSPLTokenTransfers( + String address, + String splTokenSymbol, + int splTokenDecimal, + Ed25519HDKeyPair ownerKeypair, + ) async { + final tokenMint = Ed25519HDPublicKey.fromBase58(address); + + ProgramAccount? associatedTokenAccount; + + try { + associatedTokenAccount = await _client!.getAssociatedTokenAccount( + mint: tokenMint, + owner: ownerKeypair.publicKey, + commitment: Commitment.confirmed, + ); + } catch (_) {} + + if (associatedTokenAccount == null) return []; + + final accountPublicKey = Ed25519HDPublicKey.fromBase58(associatedTokenAccount.pubkey); + + final tokenTransactions = await fetchTransactions( + accountPublicKey, + splTokenSymbol: splTokenSymbol, + splTokenDecimal: splTokenDecimal, + ); + + return tokenTransactions; + } + + void stop() {} + + SolanaClient? get getSolanaClient => _client; + + Future signSolanaTransaction({ + required String tokenTitle, + required int tokenDecimals, + String? tokenMint, + required double inputAmount, + required String destinationAddress, + required Ed25519HDKeyPair ownerKeypair, + List references = const [], + }) async { + const commitment = Commitment.finalized; + + final latestBlockhash = + await _client!.rpcClient.getLatestBlockhash(commitment: commitment).value; + + final recentBlockhash = RecentBlockhash( + blockhash: latestBlockhash.blockhash, + feeCalculator: const FeeCalculator( + lamportsPerSignature: 500, + ), + ); + + if (tokenTitle == CryptoCurrency.sol.title) { + final pendingNativeTokenTransaction = await _signNativeTokenTransaction( + tokenTitle: tokenTitle, + tokenDecimals: tokenDecimals, + inputAmount: inputAmount, + destinationAddress: destinationAddress, + ownerKeypair: ownerKeypair, + recentBlockhash: recentBlockhash, + commitment: commitment, + ); + return pendingNativeTokenTransaction; + } else { + final pendingSPLTokenTransaction = _signSPLTokenTransaction( + tokenTitle: tokenTitle, + tokenDecimals: tokenDecimals, + tokenMint: tokenMint!, + inputAmount: inputAmount, + destinationAddress: destinationAddress, + ownerKeypair: ownerKeypair, + recentBlockhash: recentBlockhash, + commitment: commitment, + ); + return pendingSPLTokenTransaction; + } + } + + Future _signNativeTokenTransaction({ + required String tokenTitle, + required int tokenDecimals, + required double inputAmount, + required String destinationAddress, + required Ed25519HDKeyPair ownerKeypair, + required RecentBlockhash recentBlockhash, + required Commitment commitment, + }) async { + // Convert SOL to lamport + int lamports = (inputAmount * lamportsPerSol).toInt(); + + final instructions = [ + SystemInstruction.transfer( + fundingAccount: ownerKeypair.publicKey, + recipientAccount: Ed25519HDPublicKey.fromBase58(destinationAddress), + lamports: lamports, + ), + ]; + + final message = Message(instructions: instructions); + final signers = [ownerKeypair]; + + final signedTx = await _signTransactionInternal( + message: message, + signers: signers, + commitment: commitment, + recentBlockhash: recentBlockhash, + ); + + final fee = await _getFeeFromCompiledMessage( + message, + recentBlockhash, + signers.first.publicKey, + ); + + sendTx() async => await sendTransaction( + signedTransaction: signedTx, + commitment: commitment, + ); + + final pendingTransaction = PendingSolanaTransaction( + amount: inputAmount, + signedTransaction: signedTx, + destinationAddress: destinationAddress, + sendTransaction: sendTx, + fee: fee, + ); + + return pendingTransaction; + } + + Future _signSPLTokenTransaction({ + required String tokenTitle, + required int tokenDecimals, + required String tokenMint, + required double inputAmount, + required String destinationAddress, + required Ed25519HDKeyPair ownerKeypair, + required RecentBlockhash recentBlockhash, + required Commitment commitment, + }) async { + final destinationOwner = Ed25519HDPublicKey.fromBase58(destinationAddress); + final mint = Ed25519HDPublicKey.fromBase58(tokenMint); + + ProgramAccount? associatedRecipientAccount; + ProgramAccount? associatedSenderAccount; + + associatedRecipientAccount = await _client!.getAssociatedTokenAccount( + mint: mint, + owner: destinationOwner, + commitment: commitment, + ); + + associatedSenderAccount = await _client!.getAssociatedTokenAccount( + owner: ownerKeypair.publicKey, + mint: mint, + commitment: commitment, + ); + + // Throw an appropriate exception if the sender has no associated + // token account + if (associatedSenderAccount == null) { + throw NoAssociatedTokenAccountException(ownerKeypair.address, mint.toBase58()); + } + + try { + associatedRecipientAccount ??= await _client!.createAssociatedTokenAccount( + mint: mint, + owner: destinationOwner, + funder: ownerKeypair, + ); + } catch (e) { + throw Exception( + 'Error while creating an associated token account for the recipient: ${e.toString()}', + ); + } + + // Input by the user + final amount = (inputAmount * pow(10, tokenDecimals)).toInt(); + + final instruction = TokenInstruction.transfer( + source: Ed25519HDPublicKey.fromBase58(associatedSenderAccount.pubkey), + destination: Ed25519HDPublicKey.fromBase58(associatedRecipientAccount.pubkey), + owner: ownerKeypair.publicKey, + amount: amount, + ); + + final message = Message(instructions: [instruction]); + final signers = [ownerKeypair]; + + final signedTx = await _signTransactionInternal( + message: message, + signers: signers, + commitment: commitment, + recentBlockhash: recentBlockhash, + ); + + final fee = await _getFeeFromCompiledMessage( + message, + recentBlockhash, + signers.first.publicKey, + ); + + sendTx() async => await sendTransaction( + signedTransaction: signedTx, + commitment: commitment, + ); + + final pendingTransaction = PendingSolanaTransaction( + amount: inputAmount, + signedTransaction: signedTx, + destinationAddress: destinationAddress, + sendTransaction: sendTx, + fee: fee, + ); + return pendingTransaction; + } + + Future _getFeeFromCompiledMessage( + Message message, RecentBlockhash recentBlockhash, Ed25519HDPublicKey feePayer) async { + final compile = message.compile( + recentBlockhash: recentBlockhash.blockhash, + feePayer: feePayer, + ); + + final base64Message = base64Encode(compile.toByteArray().toList()); + + final fee = await getGasForMessage(base64Message); + return fee; + } + + Future _signTransactionInternal({ + required Message message, + required List signers, + required Commitment commitment, + required RecentBlockhash recentBlockhash, + }) async { + final signedTx = await signTransaction(recentBlockhash, message, signers); + + return signedTx; + } + + Future sendTransaction({ + required SignedTx signedTransaction, + required Commitment commitment, + }) async { + final signature = await _client!.rpcClient.sendTransaction(signedTransaction.encode()); + + _client!.waitForSignatureStatus(signature, status: commitment); + + return signature; + } +} diff --git a/cw_solana/lib/solana_exceptions.dart b/cw_solana/lib/solana_exceptions.dart new file mode 100644 index 000000000..7409b0500 --- /dev/null +++ b/cw_solana/lib/solana_exceptions.dart @@ -0,0 +1,21 @@ +import 'package:cw_core/crypto_currency.dart'; + +class SolanaTransactionCreationException implements Exception { + final String exceptionMessage; + + SolanaTransactionCreationException(CryptoCurrency currency) + : exceptionMessage = 'Error creating ${currency.title} transaction.'; + + @override + String toString() => exceptionMessage; +} + +class SolanaTransactionWrongBalanceException implements Exception { + final String exceptionMessage; + + SolanaTransactionWrongBalanceException(CryptoCurrency currency) + : exceptionMessage = 'Wrong balance. Not enough ${currency.title} on your balance.'; + + @override + String toString() => exceptionMessage; +} diff --git a/cw_solana/lib/solana_mnemonics.dart b/cw_solana/lib/solana_mnemonics.dart new file mode 100644 index 000000000..21cbb613a --- /dev/null +++ b/cw_solana/lib/solana_mnemonics.dart @@ -0,0 +1,2058 @@ +class SolanaMnemonicIsIncorrectException implements Exception { + @override + String toString() => + 'Solana mnemonic has incorrect format. Mnemonic should contain 12 or 24 words separated by space.'; +} + +class SolanaMnemonics { + static const englishWordlist = [ + 'abandon', + 'ability', + 'able', + 'about', + 'above', + 'absent', + 'absorb', + 'abstract', + 'absurd', + 'abuse', + 'access', + 'accident', + 'account', + 'accuse', + 'achieve', + 'acid', + 'acoustic', + 'acquire', + 'across', + 'act', + 'action', + 'actor', + 'actress', + 'actual', + 'adapt', + 'add', + 'addict', + 'address', + 'adjust', + 'admit', + 'adult', + 'advance', + 'advice', + 'aerobic', + 'affair', + 'afford', + 'afraid', + 'again', + 'age', + 'agent', + 'agree', + 'ahead', + 'aim', + 'air', + 'airport', + 'aisle', + 'alarm', + 'album', + 'alcohol', + 'alert', + 'alien', + 'all', + 'alley', + 'allow', + 'almost', + 'alone', + 'alpha', + 'already', + 'also', + 'alter', + 'always', + 'amateur', + 'amazing', + 'among', + 'amount', + 'amused', + 'analyst', + 'anchor', + 'ancient', + 'anger', + 'angle', + 'angry', + 'animal', + 'ankle', + 'announce', + 'annual', + 'another', + 'answer', + 'antenna', + 'antique', + 'anxiety', + 'any', + 'apart', + 'apology', + 'appear', + 'apple', + 'approve', + 'april', + 'arch', + 'arctic', + 'area', + 'arena', + 'argue', + 'arm', + 'armed', + 'armor', + 'army', + 'around', + 'arrange', + 'arrest', + 'arrive', + 'arrow', + 'art', + 'artefact', + 'artist', + 'artwork', + 'ask', + 'aspect', + 'assault', + 'asset', + 'assist', + 'assume', + 'asthma', + 'athlete', + 'atom', + 'attack', + 'attend', + 'attitude', + 'attract', + 'auction', + 'audit', + 'august', + 'aunt', + 'author', + 'auto', + 'autumn', + 'average', + 'avocado', + 'avoid', + 'awake', + 'aware', + 'away', + 'awesome', + 'awful', + 'awkward', + 'axis', + 'baby', + 'bachelor', + 'bacon', + 'badge', + 'bag', + 'balance', + 'balcony', + 'ball', + 'bamboo', + 'banana', + 'banner', + 'bar', + 'barely', + 'bargain', + 'barrel', + 'base', + 'basic', + 'basket', + 'battle', + 'beach', + 'bean', + 'beauty', + 'because', + 'become', + 'beef', + 'before', + 'begin', + 'behave', + 'behind', + 'believe', + 'below', + 'belt', + 'bench', + 'benefit', + 'best', + 'betray', + 'better', + 'between', + 'beyond', + 'bicycle', + 'bid', + 'bike', + 'bind', + 'biology', + 'bird', + 'birth', + 'bitter', + 'black', + 'blade', + 'blame', + 'blanket', + 'blast', + 'bleak', + 'bless', + 'blind', + 'blood', + 'blossom', + 'blouse', + 'blue', + 'blur', + 'blush', + 'board', + 'boat', + 'body', + 'boil', + 'bomb', + 'bone', + 'bonus', + 'book', + 'boost', + 'border', + 'boring', + 'borrow', + 'boss', + 'bottom', + 'bounce', + 'box', + 'boy', + 'bracket', + 'brain', + 'brand', + 'brass', + 'brave', + 'bread', + 'breeze', + 'brick', + 'bridge', + 'brief', + 'bright', + 'bring', + 'brisk', + 'broccoli', + 'broken', + 'bronze', + 'broom', + 'brother', + 'brown', + 'brush', + 'bubble', + 'buddy', + 'budget', + 'buffalo', + 'build', + 'bulb', + 'bulk', + 'bullet', + 'bundle', + 'bunker', + 'burden', + 'burger', + 'burst', + 'bus', + 'business', + 'busy', + 'butter', + 'buyer', + 'buzz', + 'cabbage', + 'cabin', + 'cable', + 'cactus', + 'cage', + 'cake', + 'call', + 'calm', + 'camera', + 'camp', + 'can', + 'canal', + 'cancel', + 'candy', + 'cannon', + 'canoe', + 'canvas', + 'canyon', + 'capable', + 'capital', + 'captain', + 'car', + 'carbon', + 'card', + 'cargo', + 'carpet', + 'carry', + 'cart', + 'case', + 'cash', + 'casino', + 'castle', + 'casual', + 'cat', + 'catalog', + 'catch', + 'category', + 'cattle', + 'caught', + 'cause', + 'caution', + 'cave', + 'ceiling', + 'celery', + 'cement', + 'census', + 'century', + 'cereal', + 'certain', + 'chair', + 'chalk', + 'champion', + 'change', + 'chaos', + 'chapter', + 'charge', + 'chase', + 'chat', + 'cheap', + 'check', + 'cheese', + 'chef', + 'cherry', + 'chest', + 'chicken', + 'chief', + 'child', + 'chimney', + 'choice', + 'choose', + 'chronic', + 'chuckle', + 'chunk', + 'churn', + 'cigar', + 'cinnamon', + 'circle', + 'citizen', + 'city', + 'civil', + 'claim', + 'clap', + 'clarify', + 'claw', + 'clay', + 'clean', + 'clerk', + 'clever', + 'click', + 'client', + 'cliff', + 'climb', + 'clinic', + 'clip', + 'clock', + 'clog', + 'close', + 'cloth', + 'cloud', + 'clown', + 'club', + 'clump', + 'cluster', + 'clutch', + 'coach', + 'coast', + 'coconut', + 'code', + 'coffee', + 'coil', + 'coin', + 'collect', + 'color', + 'column', + 'combine', + 'come', + 'comfort', + 'comic', + 'common', + 'company', + 'concert', + 'conduct', + 'confirm', + 'congress', + 'connect', + 'consider', + 'control', + 'convince', + 'cook', + 'cool', + 'copper', + 'copy', + 'coral', + 'core', + 'corn', + 'correct', + 'cost', + 'cotton', + 'couch', + 'country', + 'couple', + 'course', + 'cousin', + 'cover', + 'coyote', + 'crack', + 'cradle', + 'craft', + 'cram', + 'crane', + 'crash', + 'crater', + 'crawl', + 'crazy', + 'cream', + 'credit', + 'creek', + 'crew', + 'cricket', + 'crime', + 'crisp', + 'critic', + 'crop', + 'cross', + 'crouch', + 'crowd', + 'crucial', + 'cruel', + 'cruise', + 'crumble', + 'crunch', + 'crush', + 'cry', + 'crystal', + 'cube', + 'culture', + 'cup', + 'cupboard', + 'curious', + 'current', + 'curtain', + 'curve', + 'cushion', + 'custom', + 'cute', + 'cycle', + 'dad', + 'damage', + 'damp', + 'dance', + 'danger', + 'daring', + 'dash', + 'daughter', + 'dawn', + 'day', + 'deal', + 'debate', + 'debris', + 'decade', + 'december', + 'decide', + 'decline', + 'decorate', + 'decrease', + 'deer', + 'defense', + 'define', + 'defy', + 'degree', + 'delay', + 'deliver', + 'demand', + 'demise', + 'denial', + 'dentist', + 'deny', + 'depart', + 'depend', + 'deposit', + 'depth', + 'deputy', + 'derive', + 'describe', + 'desert', + 'design', + 'desk', + 'despair', + 'destroy', + 'detail', + 'detect', + 'develop', + 'device', + 'devote', + 'diagram', + 'dial', + 'diamond', + 'diary', + 'dice', + 'diesel', + 'diet', + 'differ', + 'digital', + 'dignity', + 'dilemma', + 'dinner', + 'dinosaur', + 'direct', + 'dirt', + 'disagree', + 'discover', + 'disease', + 'dish', + 'dismiss', + 'disorder', + 'display', + 'distance', + 'divert', + 'divide', + 'divorce', + 'dizzy', + 'doctor', + 'document', + 'dog', + 'doll', + 'dolphin', + 'domain', + 'donate', + 'donkey', + 'donor', + 'door', + 'dose', + 'double', + 'dove', + 'draft', + 'dragon', + 'drama', + 'drastic', + 'draw', + 'dream', + 'dress', + 'drift', + 'drill', + 'drink', + 'drip', + 'drive', + 'drop', + 'drum', + 'dry', + 'duck', + 'dumb', + 'dune', + 'during', + 'dust', + 'dutch', + 'duty', + 'dwarf', + 'dynamic', + 'eager', + 'eagle', + 'early', + 'earn', + 'earth', + 'easily', + 'east', + 'easy', + 'echo', + 'ecology', + 'economy', + 'edge', + 'edit', + 'educate', + 'effort', + 'egg', + 'eight', + 'either', + 'elbow', + 'elder', + 'electric', + 'elegant', + 'element', + 'elephant', + 'elevator', + 'elite', + 'else', + 'embark', + 'embody', + 'embrace', + 'emerge', + 'emotion', + 'employ', + 'empower', + 'empty', + 'enable', + 'enact', + 'end', + 'endless', + 'endorse', + 'enemy', + 'energy', + 'enforce', + 'engage', + 'engine', + 'enhance', + 'enjoy', + 'enlist', + 'enough', + 'enrich', + 'enroll', + 'ensure', + 'enter', + 'entire', + 'entry', + 'envelope', + 'episode', + 'equal', + 'equip', + 'era', + 'erase', + 'erode', + 'erosion', + 'error', + 'erupt', + 'escape', + 'essay', + 'essence', + 'estate', + 'eternal', + 'ethics', + 'evidence', + 'evil', + 'evoke', + 'evolve', + 'exact', + 'example', + 'excess', + 'exchange', + 'excite', + 'exclude', + 'excuse', + 'execute', + 'exercise', + 'exhaust', + 'exhibit', + 'exile', + 'exist', + 'exit', + 'exotic', + 'expand', + 'expect', + 'expire', + 'explain', + 'expose', + 'express', + 'extend', + 'extra', + 'eye', + 'eyebrow', + 'fabric', + 'face', + 'faculty', + 'fade', + 'faint', + 'faith', + 'fall', + 'false', + 'fame', + 'family', + 'famous', + 'fan', + 'fancy', + 'fantasy', + 'farm', + 'fashion', + 'fat', + 'fatal', + 'father', + 'fatigue', + 'fault', + 'favorite', + 'feature', + 'february', + 'federal', + 'fee', + 'feed', + 'feel', + 'female', + 'fence', + 'festival', + 'fetch', + 'fever', + 'few', + 'fiber', + 'fiction', + 'field', + 'figure', + 'file', + 'film', + 'filter', + 'final', + 'find', + 'fine', + 'finger', + 'finish', + 'fire', + 'firm', + 'first', + 'fiscal', + 'fish', + 'fit', + 'fitness', + 'fix', + 'flag', + 'flame', + 'flash', + 'flat', + 'flavor', + 'flee', + 'flight', + 'flip', + 'float', + 'flock', + 'floor', + 'flower', + 'fluid', + 'flush', + 'fly', + 'foam', + 'focus', + 'fog', + 'foil', + 'fold', + 'follow', + 'food', + 'foot', + 'force', + 'forest', + 'forget', + 'fork', + 'fortune', + 'forum', + 'forward', + 'fossil', + 'foster', + 'found', + 'fox', + 'fragile', + 'frame', + 'frequent', + 'fresh', + 'friend', + 'fringe', + 'frog', + 'front', + 'frost', + 'frown', + 'frozen', + 'fruit', + 'fuel', + 'fun', + 'funny', + 'furnace', + 'fury', + 'future', + 'gadget', + 'gain', + 'galaxy', + 'gallery', + 'game', + 'gap', + 'garage', + 'garbage', + 'garden', + 'garlic', + 'garment', + 'gas', + 'gasp', + 'gate', + 'gather', + 'gauge', + 'gaze', + 'general', + 'genius', + 'genre', + 'gentle', + 'genuine', + 'gesture', + 'ghost', + 'giant', + 'gift', + 'giggle', + 'ginger', + 'giraffe', + 'girl', + 'give', + 'glad', + 'glance', + 'glare', + 'glass', + 'glide', + 'glimpse', + 'globe', + 'gloom', + 'glory', + 'glove', + 'glow', + 'glue', + 'goat', + 'goddess', + 'gold', + 'good', + 'goose', + 'gorilla', + 'gospel', + 'gossip', + 'govern', + 'gown', + 'grab', + 'grace', + 'grain', + 'grant', + 'grape', + 'grass', + 'gravity', + 'great', + 'green', + 'grid', + 'grief', + 'grit', + 'grocery', + 'group', + 'grow', + 'grunt', + 'guard', + 'guess', + 'guide', + 'guilt', + 'guitar', + 'gun', + 'gym', + 'habit', + 'hair', + 'half', + 'hammer', + 'hamster', + 'hand', + 'happy', + 'harbor', + 'hard', + 'harsh', + 'harvest', + 'hat', + 'have', + 'hawk', + 'hazard', + 'head', + 'health', + 'heart', + 'heavy', + 'hedgehog', + 'height', + 'hello', + 'helmet', + 'help', + 'hen', + 'hero', + 'hidden', + 'high', + 'hill', + 'hint', + 'hip', + 'hire', + 'history', + 'hobby', + 'hockey', + 'hold', + 'hole', + 'holiday', + 'hollow', + 'home', + 'honey', + 'hood', + 'hope', + 'horn', + 'horror', + 'horse', + 'hospital', + 'host', + 'hotel', + 'hour', + 'hover', + 'hub', + 'huge', + 'human', + 'humble', + 'humor', + 'hundred', + 'hungry', + 'hunt', + 'hurdle', + 'hurry', + 'hurt', + 'husband', + 'hybrid', + 'ice', + 'icon', + 'idea', + 'identify', + 'idle', + 'ignore', + 'ill', + 'illegal', + 'illness', + 'image', + 'imitate', + 'immense', + 'immune', + 'impact', + 'impose', + 'improve', + 'impulse', + 'inch', + 'include', + 'income', + 'increase', + 'index', + 'indicate', + 'indoor', + 'industry', + 'infant', + 'inflict', + 'inform', + 'inhale', + 'inherit', + 'initial', + 'inject', + 'injury', + 'inmate', + 'inner', + 'innocent', + 'input', + 'inquiry', + 'insane', + 'insect', + 'inside', + 'inspire', + 'install', + 'intact', + 'interest', + 'into', + 'invest', + 'invite', + 'involve', + 'iron', + 'island', + 'isolate', + 'issue', + 'item', + 'ivory', + 'jacket', + 'jaguar', + 'jar', + 'jazz', + 'jealous', + 'jeans', + 'jelly', + 'jewel', + 'job', + 'join', + 'joke', + 'journey', + 'joy', + 'judge', + 'juice', + 'jump', + 'jungle', + 'junior', + 'junk', + 'just', + 'kangaroo', + 'keen', + 'keep', + 'ketchup', + 'key', + 'kick', + 'kid', + 'kidney', + 'kind', + 'kingdom', + 'kiss', + 'kit', + 'kitchen', + 'kite', + 'kitten', + 'kiwi', + 'knee', + 'knife', + 'knock', + 'know', + 'lab', + 'label', + 'labor', + 'ladder', + 'lady', + 'lake', + 'lamp', + 'language', + 'laptop', + 'large', + 'later', + 'latin', + 'laugh', + 'laundry', + 'lava', + 'law', + 'lawn', + 'lawsuit', + 'layer', + 'lazy', + 'leader', + 'leaf', + 'learn', + 'leave', + 'lecture', + 'left', + 'leg', + 'legal', + 'legend', + 'leisure', + 'lemon', + 'lend', + 'length', + 'lens', + 'leopard', + 'lesson', + 'letter', + 'level', + 'liar', + 'liberty', + 'library', + 'license', + 'life', + 'lift', + 'light', + 'like', + 'limb', + 'limit', + 'link', + 'lion', + 'liquid', + 'list', + 'little', + 'live', + 'lizard', + 'load', + 'loan', + 'lobster', + 'local', + 'lock', + 'logic', + 'lonely', + 'long', + 'loop', + 'lottery', + 'loud', + 'lounge', + 'love', + 'loyal', + 'lucky', + 'luggage', + 'lumber', + 'lunar', + 'lunch', + 'luxury', + 'lyrics', + 'machine', + 'mad', + 'magic', + 'magnet', + 'maid', + 'mail', + 'main', + 'major', + 'make', + 'mammal', + 'man', + 'manage', + 'mandate', + 'mango', + 'mansion', + 'manual', + 'maple', + 'marble', + 'march', + 'margin', + 'marine', + 'market', + 'marriage', + 'mask', + 'mass', + 'master', + 'match', + 'material', + 'math', + 'matrix', + 'matter', + 'maximum', + 'maze', + 'meadow', + 'mean', + 'measure', + 'meat', + 'mechanic', + 'medal', + 'media', + 'melody', + 'melt', + 'member', + 'memory', + 'mention', + 'menu', + 'mercy', + 'merge', + 'merit', + 'merry', + 'mesh', + 'message', + 'metal', + 'method', + 'middle', + 'midnight', + 'milk', + 'million', + 'mimic', + 'mind', + 'minimum', + 'minor', + 'minute', + 'miracle', + 'mirror', + 'misery', + 'miss', + 'mistake', + 'mix', + 'mixed', + 'mixture', + 'mobile', + 'model', + 'modify', + 'mom', + 'moment', + 'monitor', + 'monkey', + 'monster', + 'month', + 'moon', + 'moral', + 'more', + 'morning', + 'mosquito', + 'mother', + 'motion', + 'motor', + 'mountain', + 'mouse', + 'move', + 'movie', + 'much', + 'muffin', + 'mule', + 'multiply', + 'muscle', + 'museum', + 'mushroom', + 'music', + 'must', + 'mutual', + 'myself', + 'mystery', + 'myth', + 'naive', + 'name', + 'napkin', + 'narrow', + 'nasty', + 'nation', + 'nature', + 'near', + 'neck', + 'need', + 'negative', + 'neglect', + 'neither', + 'nephew', + 'nerve', + 'nest', + 'net', + 'network', + 'neutral', + 'never', + 'news', + 'next', + 'nice', + 'night', + 'noble', + 'noise', + 'nominee', + 'noodle', + 'normal', + 'north', + 'nose', + 'notable', + 'note', + 'nothing', + 'notice', + 'novel', + 'now', + 'nuclear', + 'number', + 'nurse', + 'nut', + 'oak', + 'obey', + 'object', + 'oblige', + 'obscure', + 'observe', + 'obtain', + 'obvious', + 'occur', + 'ocean', + 'october', + 'odor', + 'off', + 'offer', + 'office', + 'often', + 'oil', + 'okay', + 'old', + 'olive', + 'olympic', + 'omit', + 'once', + 'one', + 'onion', + 'online', + 'only', + 'open', + 'opera', + 'opinion', + 'oppose', + 'option', + 'orange', + 'orbit', + 'orchard', + 'order', + 'ordinary', + 'organ', + 'orient', + 'original', + 'orphan', + 'ostrich', + 'other', + 'outdoor', + 'outer', + 'output', + 'outside', + 'oval', + 'oven', + 'over', + 'own', + 'owner', + 'oxygen', + 'oyster', + 'ozone', + 'pact', + 'paddle', + 'page', + 'pair', + 'palace', + 'palm', + 'panda', + 'panel', + 'panic', + 'panther', + 'paper', + 'parade', + 'parent', + 'park', + 'parrot', + 'party', + 'pass', + 'patch', + 'path', + 'patient', + 'patrol', + 'pattern', + 'pause', + 'pave', + 'payment', + 'peace', + 'peanut', + 'pear', + 'peasant', + 'pelican', + 'pen', + 'penalty', + 'pencil', + 'people', + 'pepper', + 'perfect', + 'permit', + 'person', + 'pet', + 'phone', + 'photo', + 'phrase', + 'physical', + 'piano', + 'picnic', + 'picture', + 'piece', + 'pig', + 'pigeon', + 'pill', + 'pilot', + 'pink', + 'pioneer', + 'pipe', + 'pistol', + 'pitch', + 'pizza', + 'place', + 'planet', + 'plastic', + 'plate', + 'play', + 'please', + 'pledge', + 'pluck', + 'plug', + 'plunge', + 'poem', + 'poet', + 'point', + 'polar', + 'pole', + 'police', + 'pond', + 'pony', + 'pool', + 'popular', + 'portion', + 'position', + 'possible', + 'post', + 'potato', + 'pottery', + 'poverty', + 'powder', + 'power', + 'practice', + 'praise', + 'predict', + 'prefer', + 'prepare', + 'present', + 'pretty', + 'prevent', + 'price', + 'pride', + 'primary', + 'print', + 'priority', + 'prison', + 'private', + 'prize', + 'problem', + 'process', + 'produce', + 'profit', + 'program', + 'project', + 'promote', + 'proof', + 'property', + 'prosper', + 'protect', + 'proud', + 'provide', + 'public', + 'pudding', + 'pull', + 'pulp', + 'pulse', + 'pumpkin', + 'punch', + 'pupil', + 'puppy', + 'purchase', + 'purity', + 'purpose', + 'purse', + 'push', + 'put', + 'puzzle', + 'pyramid', + 'quality', + 'quantum', + 'quarter', + 'question', + 'quick', + 'quit', + 'quiz', + 'quote', + 'rabbit', + 'raccoon', + 'race', + 'rack', + 'radar', + 'radio', + 'rail', + 'rain', + 'raise', + 'rally', + 'ramp', + 'ranch', + 'random', + 'range', + 'rapid', + 'rare', + 'rate', + 'rather', + 'raven', + 'raw', + 'razor', + 'ready', + 'real', + 'reason', + 'rebel', + 'rebuild', + 'recall', + 'receive', + 'recipe', + 'record', + 'recycle', + 'reduce', + 'reflect', + 'reform', + 'refuse', + 'region', + 'regret', + 'regular', + 'reject', + 'relax', + 'release', + 'relief', + 'rely', + 'remain', + 'remember', + 'remind', + 'remove', + 'render', + 'renew', + 'rent', + 'reopen', + 'repair', + 'repeat', + 'replace', + 'report', + 'require', + 'rescue', + 'resemble', + 'resist', + 'resource', + 'response', + 'result', + 'retire', + 'retreat', + 'return', + 'reunion', + 'reveal', + 'review', + 'reward', + 'rhythm', + 'rib', + 'ribbon', + 'rice', + 'rich', + 'ride', + 'ridge', + 'rifle', + 'right', + 'rigid', + 'ring', + 'riot', + 'ripple', + 'risk', + 'ritual', + 'rival', + 'river', + 'road', + 'roast', + 'robot', + 'robust', + 'rocket', + 'romance', + 'roof', + 'rookie', + 'room', + 'rose', + 'rotate', + 'rough', + 'round', + 'route', + 'royal', + 'rubber', + 'rude', + 'rug', + 'rule', + 'run', + 'runway', + 'rural', + 'sad', + 'saddle', + 'sadness', + 'safe', + 'sail', + 'salad', + 'salmon', + 'salon', + 'salt', + 'salute', + 'same', + 'sample', + 'sand', + 'satisfy', + 'satoshi', + 'sauce', + 'sausage', + 'save', + 'say', + 'scale', + 'scan', + 'scare', + 'scatter', + 'scene', + 'scheme', + 'school', + 'science', + 'scissors', + 'scorpion', + 'scout', + 'scrap', + 'screen', + 'script', + 'scrub', + 'sea', + 'search', + 'season', + 'seat', + 'second', + 'secret', + 'section', + 'security', + 'seed', + 'seek', + 'segment', + 'select', + 'sell', + 'seminar', + 'senior', + 'sense', + 'sentence', + 'series', + 'service', + 'session', + 'settle', + 'setup', + 'seven', + 'shadow', + 'shaft', + 'shallow', + 'share', + 'shed', + 'shell', + 'sheriff', + 'shield', + 'shift', + 'shine', + 'ship', + 'shiver', + 'shock', + 'shoe', + 'shoot', + 'shop', + 'short', + 'shoulder', + 'shove', + 'shrimp', + 'shrug', + 'shuffle', + 'shy', + 'sibling', + 'sick', + 'side', + 'siege', + 'sight', + 'sign', + 'silent', + 'silk', + 'silly', + 'silver', + 'similar', + 'simple', + 'since', + 'sing', + 'siren', + 'sister', + 'situate', + 'six', + 'size', + 'skate', + 'sketch', + 'ski', + 'skill', + 'skin', + 'skirt', + 'skull', + 'slab', + 'slam', + 'sleep', + 'slender', + 'slice', + 'slide', + 'slight', + 'slim', + 'slogan', + 'slot', + 'slow', + 'slush', + 'small', + 'smart', + 'smile', + 'smoke', + 'smooth', + 'snack', + 'snake', + 'snap', + 'sniff', + 'snow', + 'soap', + 'soccer', + 'social', + 'sock', + 'soda', + 'soft', + 'solar', + 'soldier', + 'solid', + 'solution', + 'solve', + 'someone', + 'song', + 'soon', + 'sorry', + 'sort', + 'soul', + 'sound', + 'soup', + 'source', + 'south', + 'space', + 'spare', + 'spatial', + 'spawn', + 'speak', + 'special', + 'speed', + 'spell', + 'spend', + 'sphere', + 'spice', + 'spider', + 'spike', + 'spin', + 'spirit', + 'split', + 'spoil', + 'sponsor', + 'spoon', + 'sport', + 'spot', + 'spray', + 'spread', + 'spring', + 'spy', + 'square', + 'squeeze', + 'squirrel', + 'stable', + 'stadium', + 'staff', + 'stage', + 'stairs', + 'stamp', + 'stand', + 'start', + 'state', + 'stay', + 'steak', + 'steel', + 'stem', + 'step', + 'stereo', + 'stick', + 'still', + 'sting', + 'stock', + 'stomach', + 'stone', + 'stool', + 'story', + 'stove', + 'strategy', + 'street', + 'strike', + 'strong', + 'struggle', + 'student', + 'stuff', + 'stumble', + 'style', + 'subject', + 'submit', + 'subway', + 'success', + 'such', + 'sudden', + 'suffer', + 'sugar', + 'suggest', + 'suit', + 'summer', + 'sun', + 'sunny', + 'sunset', + 'super', + 'supply', + 'supreme', + 'sure', + 'surface', + 'surge', + 'surprise', + 'surround', + 'survey', + 'suspect', + 'sustain', + 'swallow', + 'swamp', + 'swap', + 'swarm', + 'swear', + 'sweet', + 'swift', + 'swim', + 'swing', + 'switch', + 'sword', + 'symbol', + 'symptom', + 'syrup', + 'system', + 'table', + 'tackle', + 'tag', + 'tail', + 'talent', + 'talk', + 'tank', + 'tape', + 'target', + 'task', + 'taste', + 'tattoo', + 'taxi', + 'teach', + 'team', + 'tell', + 'ten', + 'tenant', + 'tennis', + 'tent', + 'term', + 'test', + 'text', + 'thank', + 'that', + 'theme', + 'then', + 'theory', + 'there', + 'they', + 'thing', + 'this', + 'thought', + 'three', + 'thrive', + 'throw', + 'thumb', + 'thunder', + 'ticket', + 'tide', + 'tiger', + 'tilt', + 'timber', + 'time', + 'tiny', + 'tip', + 'tired', + 'tissue', + 'title', + 'toast', + 'tobacco', + 'today', + 'toddler', + 'toe', + 'together', + 'toilet', + 'token', + 'tomato', + 'tomorrow', + 'tone', + 'tongue', + 'tonight', + 'tool', + 'tooth', + 'top', + 'topic', + 'topple', + 'torch', + 'tornado', + 'tortoise', + 'toss', + 'total', + 'tourist', + 'toward', + 'tower', + 'town', + 'toy', + 'track', + 'trade', + 'traffic', + 'tragic', + 'train', + 'transfer', + 'trap', + 'trash', + 'travel', + 'tray', + 'treat', + 'tree', + 'trend', + 'trial', + 'tribe', + 'trick', + 'trigger', + 'trim', + 'trip', + 'trophy', + 'trouble', + 'truck', + 'true', + 'truly', + 'trumpet', + 'trust', + 'truth', + 'try', + 'tube', + 'tuition', + 'tumble', + 'tuna', + 'tunnel', + 'turkey', + 'turn', + 'turtle', + 'twelve', + 'twenty', + 'twice', + 'twin', + 'twist', + 'two', + 'type', + 'typical', + 'ugly', + 'umbrella', + 'unable', + 'unaware', + 'uncle', + 'uncover', + 'under', + 'undo', + 'unfair', + 'unfold', + 'unhappy', + 'uniform', + 'unique', + 'unit', + 'universe', + 'unknown', + 'unlock', + 'until', + 'unusual', + 'unveil', + 'update', + 'upgrade', + 'uphold', + 'upon', + 'upper', + 'upset', + 'urban', + 'urge', + 'usage', + 'use', + 'used', + 'useful', + 'useless', + 'usual', + 'utility', + 'vacant', + 'vacuum', + 'vague', + 'valid', + 'valley', + 'valve', + 'van', + 'vanish', + 'vapor', + 'various', + 'vast', + 'vault', + 'vehicle', + 'velvet', + 'vendor', + 'venture', + 'venue', + 'verb', + 'verify', + 'version', + 'very', + 'vessel', + 'veteran', + 'viable', + 'vibrant', + 'vicious', + 'victory', + 'video', + 'view', + 'village', + 'vintage', + 'violin', + 'virtual', + 'virus', + 'visa', + 'visit', + 'visual', + 'vital', + 'vivid', + 'vocal', + 'voice', + 'void', + 'volcano', + 'volume', + 'vote', + 'voyage', + 'wage', + 'wagon', + 'wait', + 'walk', + 'wall', + 'walnut', + 'want', + 'warfare', + 'warm', + 'warrior', + 'wash', + 'wasp', + 'waste', + 'water', + 'wave', + 'way', + 'wealth', + 'weapon', + 'wear', + 'weasel', + 'weather', + 'web', + 'wedding', + 'weekend', + 'weird', + 'welcome', + 'west', + 'wet', + 'whale', + 'what', + 'wheat', + 'wheel', + 'when', + 'where', + 'whip', + 'whisper', + 'wide', + 'width', + 'wife', + 'wild', + 'will', + 'win', + 'window', + 'wine', + 'wing', + 'wink', + 'winner', + 'winter', + 'wire', + 'wisdom', + 'wise', + 'wish', + 'witness', + 'wolf', + 'woman', + 'wonder', + 'wood', + 'wool', + 'word', + 'work', + 'world', + 'worry', + 'worth', + 'wrap', + 'wreck', + 'wrestle', + 'wrist', + 'write', + 'wrong', + 'yard', + 'year', + 'yellow', + 'you', + 'young', + 'youth', + 'zebra', + 'zero', + 'zone', + 'zoo' + ]; +} diff --git a/cw_solana/lib/solana_transaction_credentials.dart b/cw_solana/lib/solana_transaction_credentials.dart new file mode 100644 index 000000000..bd0c97f0b --- /dev/null +++ b/cw_solana/lib/solana_transaction_credentials.dart @@ -0,0 +1,12 @@ +import 'package:cw_core/crypto_currency.dart'; +import 'package:cw_core/output_info.dart'; + +class SolanaTransactionCredentials { + SolanaTransactionCredentials( + this.outputs, { + required this.currency, + }); + + final List outputs; + final CryptoCurrency currency; +} diff --git a/cw_solana/lib/solana_transaction_history.dart b/cw_solana/lib/solana_transaction_history.dart new file mode 100644 index 000000000..c03de19ad --- /dev/null +++ b/cw_solana/lib/solana_transaction_history.dart @@ -0,0 +1,78 @@ +import 'dart:convert'; +import 'dart:core'; +import 'package:cw_core/pathForWallet.dart'; +import 'package:cw_core/wallet_info.dart'; +import 'package:cw_solana/file.dart'; +import 'package:cw_solana/solana_transaction_info.dart'; +import 'package:mobx/mobx.dart'; +import 'package:cw_core/transaction_history.dart'; + +part 'solana_transaction_history.g.dart'; + +const transactionsHistoryFileName = 'solana_transactions.json'; + +class SolanaTransactionHistory = SolanaTransactionHistoryBase with _$SolanaTransactionHistory; + +abstract class SolanaTransactionHistoryBase extends TransactionHistoryBase + with Store { + SolanaTransactionHistoryBase({required this.walletInfo, required String password}) + : _password = password { + transactions = ObservableMap(); + } + + final WalletInfo walletInfo; + String _password; + + Future init() async => await _load(); + + @override + Future save() async { + try { + final dirPath = await pathForWalletDir(name: walletInfo.name, type: walletInfo.type); + final path = '$dirPath/$transactionsHistoryFileName'; + final transactionMaps = transactions.map((key, value) => MapEntry(key, value.toJson())); + final data = json.encode({'transactions': transactionMaps}); + await writeData(path: path, password: _password, data: data); + } catch (e, s) { + print('Error while saving solana transaction history: ${e.toString()}'); + print(s); + } + } + + @override + void addOne(SolanaTransactionInfo transaction) => transactions[transaction.id] = transaction; + + @override + void addMany(Map transactions) => + this.transactions.addAll(transactions); + + Future> _read() async { + final dirPath = await pathForWalletDir(name: walletInfo.name, type: walletInfo.type); + final path = '$dirPath/$transactionsHistoryFileName'; + final content = await read(path: path, password: _password); + if (content.isEmpty) { + return {}; + } + return json.decode(content) as Map; + } + + Future _load() async { + try { + final content = await _read(); + final txs = content['transactions'] as Map? ?? {}; + + txs.entries.forEach((entry) { + final val = entry.value; + + if (val is Map) { + final tx = SolanaTransactionInfo.fromJson(val); + _update(tx); + } + }); + } catch (e) { + print(e); + } + } + + void _update(SolanaTransactionInfo transaction) => transactions[transaction.id] = transaction; +} diff --git a/cw_solana/lib/solana_transaction_info.dart b/cw_solana/lib/solana_transaction_info.dart new file mode 100644 index 000000000..1b7610e34 --- /dev/null +++ b/cw_solana/lib/solana_transaction_info.dart @@ -0,0 +1,78 @@ +import 'package:cw_core/format_amount.dart'; +import 'package:cw_core/transaction_direction.dart'; +import 'package:cw_core/transaction_info.dart'; + +class SolanaTransactionInfo extends TransactionInfo { + SolanaTransactionInfo({ + required this.id, + required this.blockTime, + required this.to, + required this.from, + required this.direction, + required this.solAmount, + this.tokenSymbol = "SOL", + required this.isPending, + required this.txFee, + }) : amount = solAmount.toInt(); + + final String id; + final String? to; + final String? from; + final int amount; + final bool isPending; + final double solAmount; + final String tokenSymbol; + final DateTime blockTime; + final double txFee; + final TransactionDirection direction; + + String? _fiatAmount; + + @override + DateTime get date => blockTime; + + @override + String amountFormatted() { + String stringBalance = solAmount.toString(); + + if (stringBalance.toString().length >= 6) { + stringBalance = stringBalance.substring(0, 6); + } + return '$stringBalance $tokenSymbol'; + } + + @override + String fiatAmount() => _fiatAmount ?? ''; + + @override + void changeFiatAmount(String amount) => _fiatAmount = formatAmount(amount); + + @override + String feeFormatted() => '${txFee.toString()} SOL'; + + factory SolanaTransactionInfo.fromJson(Map data) { + return SolanaTransactionInfo( + id: data['id'] as String, + solAmount: data['solAmount'], + direction: parseTransactionDirectionFromInt(data['direction'] as int), + blockTime: DateTime.fromMillisecondsSinceEpoch(data['blockTime'] as int), + isPending: data['isPending'] as bool, + tokenSymbol: data['tokenSymbol'] as String, + to: data['to'], + from: data['from'], + txFee: data['txFee'], + ); + } + + Map toJson() => { + 'id': id, + 'solAmount': solAmount, + 'direction': direction.index, + 'blockTime': blockTime.millisecondsSinceEpoch, + 'isPending': isPending, + 'tokenSymbol': tokenSymbol, + 'to': to, + 'from': from, + 'txFee': txFee, + }; +} diff --git a/cw_solana/lib/solana_transaction_model.dart b/cw_solana/lib/solana_transaction_model.dart new file mode 100644 index 000000000..c16c49258 --- /dev/null +++ b/cw_solana/lib/solana_transaction_model.dart @@ -0,0 +1,47 @@ +class SolanaTransactionModel { + final String id; + + final String from; + + final String to; + + final double amount; + + // If this is an outgoing transaction + final bool isOutgoingTx; + + // The Program ID of this transaction, e.g, System Program, Token Program... + final String programId; + + // The DateTime from the UNIX timestamp of the block where the transaction was included + final DateTime blockTime; + + // The Transaction fee + final double fee; + + // The token symbol + final String tokenSymbol; + + SolanaTransactionModel({ + required this.id, + required this.to, + required this.from, + required this.amount, + required this.programId, + required int blockTimeInInt, + this.isOutgoingTx = false, + required this.tokenSymbol, + required this.fee, + }) : blockTime = DateTime.fromMillisecondsSinceEpoch(blockTimeInInt * 1000); + + factory SolanaTransactionModel.fromJson(Map json) => SolanaTransactionModel( + id: json['id'], + blockTimeInInt: int.parse(json["timeStamp"]) * 1000, + from: json["from"], + to: json["to"], + amount: double.parse(json["value"]), + programId: json["programId"], + fee: json['fee'], + tokenSymbol: json['tokenSymbol'], + ); +} diff --git a/cw_solana/lib/solana_wallet.dart b/cw_solana/lib/solana_wallet.dart new file mode 100644 index 000000000..f901a64be --- /dev/null +++ b/cw_solana/lib/solana_wallet.dart @@ -0,0 +1,510 @@ +import 'dart:async'; +import 'dart:convert'; +import 'dart:developer'; +import 'dart:io'; +import 'package:cw_core/cake_hive.dart'; +import 'package:cw_core/crypto_currency.dart'; +import 'package:cw_core/node.dart'; +import 'package:cw_core/pathForWallet.dart'; +import 'package:cw_core/pending_transaction.dart'; +import 'package:cw_core/sync_status.dart'; +import 'package:cw_core/transaction_direction.dart'; +import 'package:cw_core/transaction_priority.dart'; +import 'package:cw_core/wallet_addresses.dart'; +import 'package:cw_core/wallet_base.dart'; +import 'package:cw_core/wallet_info.dart'; +import 'package:cw_solana/default_spl_tokens.dart'; +import 'package:cw_solana/file.dart'; +import 'package:cw_solana/solana_balance.dart'; +import 'package:cw_solana/solana_client.dart'; +import 'package:cw_solana/solana_exceptions.dart'; +import 'package:cw_solana/solana_transaction_credentials.dart'; +import 'package:cw_solana/solana_transaction_history.dart'; +import 'package:cw_solana/solana_transaction_info.dart'; +import 'package:cw_solana/solana_transaction_model.dart'; +import 'package:cw_solana/solana_wallet_addresses.dart'; +import 'package:cw_solana/spl_token.dart'; +import 'package:hex/hex.dart'; +import 'package:hive/hive.dart'; +import 'package:mobx/mobx.dart'; +import 'package:shared_preferences/shared_preferences.dart'; +import 'package:solana/metaplex.dart' as metaplex; +import 'package:solana/solana.dart'; +import 'package:web3dart/crypto.dart'; + +part 'solana_wallet.g.dart'; + +class SolanaWallet = SolanaWalletBase with _$SolanaWallet; + +abstract class SolanaWalletBase + extends WalletBase with Store { + SolanaWalletBase({ + required WalletInfo walletInfo, + String? mnemonic, + String? privateKey, + required String password, + SolanaBalance? initialBalance, + }) : syncStatus = const NotConnectedSyncStatus(), + _password = password, + _mnemonic = mnemonic, + _hexPrivateKey = privateKey, + _client = SolanaWalletClient(), + walletAddresses = SolanaWalletAddresses(walletInfo), + balance = ObservableMap.of( + {CryptoCurrency.sol: initialBalance ?? SolanaBalance(BigInt.zero.toDouble())}), + super(walletInfo) { + this.walletInfo = walletInfo; + transactionHistory = SolanaTransactionHistory(walletInfo: walletInfo, password: password); + + if (!CakeHive.isAdapterRegistered(SPLToken.typeId)) { + CakeHive.registerAdapter(SPLTokenAdapter()); + } + + _sharedPrefs.complete(SharedPreferences.getInstance()); + } + + final String _password; + final String? _mnemonic; + final String? _hexPrivateKey; + + // The Solana WalletPair + Ed25519HDKeyPair? _walletKeyPair; + + Ed25519HDKeyPair? get walletKeyPair => _walletKeyPair; + + // To access the privateKey bytes. + Ed25519HDKeyPairData? _keyPairData; + + late SolanaWalletClient _client; + + Timer? _transactionsUpdateTimer; + + late final Box splTokensBox; + + @override + WalletAddresses walletAddresses; + + @override + @observable + SyncStatus syncStatus; + + @override + @observable + late ObservableMap balance; + + Completer _sharedPrefs = Completer(); + + @override + Ed25519HDKeyPairData get keys { + if (_keyPairData == null) { + return Ed25519HDKeyPairData([], publicKey: const Ed25519HDPublicKey([])); + } + + return _keyPairData!; + } + + @override + String? get seed => _mnemonic; + + @override + String get privateKey => HEX.encode(_keyPairData!.bytes); + + Future init() async { + final boxName = "${walletInfo.name.replaceAll(" ", "_")}_${SPLToken.boxName}"; + + splTokensBox = await CakeHive.openBox(boxName); + + // Create WalletPair using either the mnemonic or the privateKey + _walletKeyPair = await getWalletPair( + mnemonic: _mnemonic, + privateKey: _hexPrivateKey, + ); + + // Extract the keyPairData containing both the privateKey bytes and the publicKey hex. + _keyPairData = await _walletKeyPair!.extract(); + + walletInfo.address = _walletKeyPair!.address; + + await walletAddresses.init(); + await transactionHistory.init(); + await save(); + } + + Future getWalletPair({String? mnemonic, String? privateKey}) async { + assert(mnemonic != null || privateKey != null); + + if (privateKey != null) { + final privateKeyBytes = hexToBytes(privateKey); + return await Wallet.fromPrivateKeyBytes(privateKey: privateKeyBytes); + } + + return Wallet.fromMnemonic(mnemonic!, account: 0, change: 0); + } + + @override + int calculateEstimatedFee(TransactionPriority priority, int? amount) => 0; + + @override + Future changePassword(String password) => throw UnimplementedError("changePassword"); + + @override + void close() { + _client.stop(); + _transactionsUpdateTimer?.cancel(); + } + + @action + @override + Future connectToNode({required Node node}) async { + try { + syncStatus = ConnectingSyncStatus(); + + final isConnected = _client.connect(node); + + if (!isConnected) { + throw Exception("Solana Node connection failed"); + } + + try { + await Future.wait([ + _updateBalance(), + _updateNativeSOLTransactions(), + _updateSPLTokenTransactions(), + ]); + } catch (e) { + log(e.toString()); + } + + _setTransactionUpdateTimer(); + + syncStatus = ConnectedSyncStatus(); + } catch (e) { + syncStatus = FailedSyncStatus(); + } + } + + @override + Future createTransaction(Object credentials) async { + final solCredentials = credentials as SolanaTransactionCredentials; + + final outputs = solCredentials.outputs; + + final hasMultiDestination = outputs.length > 1; + + await _updateBalance(); + + final CryptoCurrency transactionCurrency = + balance.keys.firstWhere((element) => element.title == solCredentials.currency.title); + + final walletBalanceForCurrency = balance[transactionCurrency]!.balance; + + double totalAmount = 0.0; + + if (hasMultiDestination) { + if (outputs.any((item) => item.sendAll || (item.formattedCryptoAmount ?? 0) <= 0)) { + throw SolanaTransactionWrongBalanceException(transactionCurrency); + } + + final totalAmountFromCredentials = + outputs.fold(0, (acc, value) => acc + (value.formattedCryptoAmount ?? 0)); + + totalAmount = totalAmountFromCredentials.toDouble(); + + if (walletBalanceForCurrency < totalAmount) { + throw SolanaTransactionWrongBalanceException(transactionCurrency); + } + } else { + final output = outputs.first; + + final totalOriginalAmount = double.parse(output.cryptoAmount ?? '0.0'); + + totalAmount = output.sendAll ? walletBalanceForCurrency : totalOriginalAmount; + + if (walletBalanceForCurrency < totalAmount) { + throw SolanaTransactionWrongBalanceException(transactionCurrency); + } + } + + String? tokenMint; + // Token Mint is only needed for transactions that are not native tokens(non-SOL transactions) + if (transactionCurrency.title != CryptoCurrency.sol.title) { + tokenMint = (transactionCurrency as SPLToken).mintAddress; + } + + final pendingSolanaTransaction = await _client.signSolanaTransaction( + tokenMint: tokenMint, + tokenTitle: transactionCurrency.title, + inputAmount: totalAmount, + ownerKeypair: _walletKeyPair!, + tokenDecimals: transactionCurrency.decimals, + destinationAddress: solCredentials.outputs.first.isParsedAddress + ? solCredentials.outputs.first.extractedAddress! + : solCredentials.outputs.first.address, + ); + + return pendingSolanaTransaction; + } + + @override + Future> fetchTransactions() async => {}; + + /// Fetches the native SOL transactions linked to the wallet Public Key + Future _updateNativeSOLTransactions() async { + final address = Ed25519HDPublicKey.fromBase58(_walletKeyPair!.address); + + final transactions = await _client.fetchTransactions(address); + + final Map result = {}; + + for (var transactionModel in transactions) { + result[transactionModel.id] = SolanaTransactionInfo( + id: transactionModel.id, + to: transactionModel.to, + from: transactionModel.from, + blockTime: transactionModel.blockTime, + direction: transactionModel.isOutgoingTx + ? TransactionDirection.outgoing + : TransactionDirection.incoming, + solAmount: transactionModel.amount, + isPending: false, + txFee: transactionModel.fee, + tokenSymbol: transactionModel.tokenSymbol, + ); + } + + transactionHistory.addMany(result); + + await transactionHistory.save(); + } + + /// Fetches the SPL Tokens transactions linked to the token account Public Key + Future _updateSPLTokenTransactions() async { + List splTokenTransactions = []; + + for (var token in balance.keys) { + if (token is SPLToken) { + final tokenTxs = await _client.getSPLTokenTransfers( + token.mintAddress, + token.symbol, + token.decimal, + _walletKeyPair!, + ); + + splTokenTransactions.addAll(tokenTxs); + } + } + + final Map result = {}; + + for (var transactionModel in splTokenTransactions) { + result[transactionModel.id] = SolanaTransactionInfo( + id: transactionModel.id, + to: transactionModel.to, + from: transactionModel.from, + blockTime: transactionModel.blockTime, + direction: transactionModel.isOutgoingTx + ? TransactionDirection.outgoing + : TransactionDirection.incoming, + solAmount: transactionModel.amount, + isPending: false, + txFee: transactionModel.fee, + tokenSymbol: transactionModel.tokenSymbol, + ); + } + + transactionHistory.addMany(result); + + await transactionHistory.save(); + } + + @override + Future rescan({required int height}) => throw UnimplementedError("rescan"); + + @override + Future save() async { + await walletAddresses.updateAddressesInBox(); + final path = await makePath(); + await write(path: path, password: _password, data: toJSON()); + await transactionHistory.save(); + } + + @action + @override + Future startSync() async { + try { + syncStatus = AttemptingSyncStatus(); + + await Future.wait([ + _updateBalance(), + _updateNativeSOLTransactions(), + _updateSPLTokenTransactions(), + ]); + + syncStatus = SyncedSyncStatus(); + } catch (e) { + syncStatus = FailedSyncStatus(); + } + } + + Future makePath() async => pathForWallet(name: walletInfo.name, type: walletInfo.type); + + String toJSON() => json.encode({ + 'mnemonic': _mnemonic, + 'private_key': privateKey, + 'balance': balance[currency]!.toJSON(), + }); + + static Future open({ + required String name, + required String password, + required WalletInfo walletInfo, + }) async { + final path = await pathForWallet(name: name, type: walletInfo.type); + final jsonSource = await read(path: path, password: password); + final data = json.decode(jsonSource) as Map; + final mnemonic = data['mnemonic'] as String?; + final privateKey = data['private_key'] as String?; + final balance = SolanaBalance.fromJSON(data['balance'] as String) ?? SolanaBalance(0.0); + + return SolanaWallet( + walletInfo: walletInfo, + password: password, + mnemonic: mnemonic, + privateKey: privateKey, + initialBalance: balance, + ); + } + + Future _updateBalance() async { + balance[currency] = await _fetchSOLBalance(); + await _fetchSPLTokensBalances(); + await save(); + } + + Future _fetchSOLBalance() async { + final balance = await _client.getBalance(_walletKeyPair!.address); + + return SolanaBalance(balance); + } + + Future _fetchSPLTokensBalances() async { + for (var token in splTokensBox.values) { + if (token.enabled) { + try { + final tokenBalance = + await _client.getSplTokenBalance(token.mintAddress, _walletKeyPair!.address) ?? + balance[token] ?? + SolanaBalance(0.0); + balance[token] = tokenBalance; + } catch (e) { + print('Error fetching spl token (${token.symbol}) balance ${e.toString()}'); + } + } else { + balance.remove(token); + } + } + } + + @override + Future? updateBalance() async => await _updateBalance(); + + List get splTokenCurrencies => splTokensBox.values.toList(); + + void addInitialTokens() { + final initialSPLTokens = DefaultSPLTokens().initialSPLTokens; + + for (var token in initialSPLTokens) { + splTokensBox.put(token.mintAddress, token); + } + } + + Future addSPLToken(SPLToken token) async { + await splTokensBox.put(token.mintAddress, token); + + if (token.enabled) { + final tokenBalance = + await _client.getSplTokenBalance(token.mintAddress, _walletKeyPair!.address) ?? + balance[token] ?? + SolanaBalance(0.0); + + balance[token] = tokenBalance; + } else { + balance.remove(token); + } + } + + Future deleteSPLToken(SPLToken token) async { + await token.delete(); + + balance.remove(token); + _updateBalance(); + } + + Future getSPLToken(String mintAddress) async { + // Convert SPL token mint address to public key + final mintPublicKey = Ed25519HDPublicKey.fromBase58(mintAddress); + + // Fetch token's metadata account + final token = await solanaClient!.rpcClient.getMetadata(mint: mintPublicKey); + + if (token == null) { + return null; + } + + return SPLToken.fromMetadata( + name: token.name, + mint: token.mint, + symbol: token.symbol, + mintAddress: mintAddress, + ); + } + + @override + Future renameWalletFiles(String newWalletName) async { + final currentWalletPath = await pathForWallet(name: walletInfo.name, type: type); + final currentWalletFile = File(currentWalletPath); + + final currentDirPath = await pathForWalletDir(name: walletInfo.name, type: type); + final currentTransactionsFile = File('$currentDirPath/$transactionsHistoryFileName'); + + // Copies current wallet files into new wallet name's dir and files + if (currentWalletFile.existsSync()) { + final newWalletPath = await pathForWallet(name: newWalletName, type: type); + await currentWalletFile.copy(newWalletPath); + } + if (currentTransactionsFile.existsSync()) { + final newDirPath = await pathForWalletDir(name: newWalletName, type: type); + await currentTransactionsFile.copy('$newDirPath/$transactionsHistoryFileName'); + } + + // Delete old name's dir and files + await Directory(currentDirPath).delete(recursive: true); + } + + void _setTransactionUpdateTimer() { + if (_transactionsUpdateTimer?.isActive ?? false) { + _transactionsUpdateTimer!.cancel(); + } + + _transactionsUpdateTimer = Timer.periodic(const Duration(seconds: 20), (_) { + _updateSPLTokenTransactions(); + _updateNativeSOLTransactions(); + _updateBalance(); + }); + } + + Future signSolanaMessage(String message) async { + // Convert the message to bytes + final messageBytes = utf8.encode(message); + + // Sign the message bytes with the wallet's private key + final signature = await _walletKeyPair!.sign(messageBytes); + + // Convert the signature to a hexadecimal string + final hex = bytesToHex(signature.bytes); + + return hex; + } + + SolanaClient? get solanaClient => _client.getSolanaClient; +} diff --git a/cw_solana/lib/solana_wallet_addresses.dart b/cw_solana/lib/solana_wallet_addresses.dart new file mode 100644 index 000000000..97a76fb99 --- /dev/null +++ b/cw_solana/lib/solana_wallet_addresses.dart @@ -0,0 +1,33 @@ +import 'package:cw_core/wallet_addresses.dart'; +import 'package:cw_core/wallet_info.dart'; +import 'package:mobx/mobx.dart'; + +part 'solana_wallet_addresses.g.dart'; + +class SolanaWalletAddresses = SolanaWalletAddressesBase with _$SolanaWalletAddresses; + +abstract class SolanaWalletAddressesBase extends WalletAddresses with Store { + SolanaWalletAddressesBase(WalletInfo walletInfo) + : address = '', + super(walletInfo); + + @override + String address; + + @override + Future init() async { + address = walletInfo.address; + await updateAddressesInBox(); + } + + @override + Future updateAddressesInBox() async { + try { + addressesMap.clear(); + addressesMap[address] = ''; + await saveAddressesInBox(); + } catch (e) { + print(e.toString()); + } + } +} diff --git a/cw_solana/lib/solana_wallet_creation_credentials.dart b/cw_solana/lib/solana_wallet_creation_credentials.dart new file mode 100644 index 000000000..881c30abd --- /dev/null +++ b/cw_solana/lib/solana_wallet_creation_credentials.dart @@ -0,0 +1,29 @@ +import 'package:cw_core/wallet_credentials.dart'; +import 'package:cw_core/wallet_info.dart'; + +class SolanaNewWalletCredentials extends WalletCredentials { + SolanaNewWalletCredentials({required String name, WalletInfo? walletInfo}) + : super(name: name, walletInfo: walletInfo); +} + +class SolanaRestoreWalletFromSeedCredentials extends WalletCredentials { + SolanaRestoreWalletFromSeedCredentials( + {required String name, + required String password, + required this.mnemonic, + WalletInfo? walletInfo}) + : super(name: name, password: password, walletInfo: walletInfo); + + final String mnemonic; +} + +class SolanaRestoreWalletFromPrivateKey extends WalletCredentials { + SolanaRestoreWalletFromPrivateKey( + {required String name, + required String password, + required this.privateKey, + WalletInfo? walletInfo}) + : super(name: name, password: password, walletInfo: walletInfo); + + final String privateKey; +} diff --git a/cw_solana/lib/solana_wallet_service.dart b/cw_solana/lib/solana_wallet_service.dart new file mode 100644 index 000000000..8abf1ffbb --- /dev/null +++ b/cw_solana/lib/solana_wallet_service.dart @@ -0,0 +1,118 @@ +import 'dart:io'; + +import 'package:bip39/bip39.dart' as bip39; +import 'package:collection/collection.dart'; +import 'package:cw_core/pathForWallet.dart'; +import 'package:cw_core/wallet_base.dart'; +import 'package:cw_core/wallet_info.dart'; +import 'package:cw_core/wallet_service.dart'; +import 'package:cw_core/wallet_type.dart'; +import 'package:cw_solana/solana_mnemonics.dart'; +import 'package:cw_solana/solana_wallet.dart'; +import 'package:cw_solana/solana_wallet_creation_credentials.dart'; +import 'package:hive/hive.dart'; + +class SolanaWalletService extends WalletService { + SolanaWalletService(this.walletInfoSource); + + final Box walletInfoSource; + + @override + Future create(SolanaNewWalletCredentials credentials) async { + final strength = credentials.seedPhraseLength == 24 ? 256 : 128; + + final mnemonic = bip39.generateMnemonic(strength: strength); + + final wallet = SolanaWallet( + walletInfo: credentials.walletInfo!, + mnemonic: mnemonic, + password: credentials.password!, + ); + + await wallet.init(); + wallet.addInitialTokens(); + return wallet; + } + + @override + WalletType getType() => WalletType.solana; + + @override + Future isWalletExit(String name) async => + File(await pathForWallet(name: name, type: getType())).existsSync(); + + @override + Future openWallet(String name, String password) async { + final walletInfo = + walletInfoSource.values.firstWhere((info) => info.id == WalletBase.idFor(name, getType())); + final wallet = await SolanaWalletBase.open( + name: name, + password: password, + walletInfo: walletInfo, + ); + + await wallet.init(); + await wallet.save(); + + return wallet; + } + + @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()))!; + await walletInfoSource.delete(walletInfo.key); + } + + @override + Future restoreFromKeys(SolanaRestoreWalletFromPrivateKey credentials) async { + final wallet = SolanaWallet( + password: credentials.password!, + privateKey: credentials.privateKey, + walletInfo: credentials.walletInfo!, + ); + + await wallet.init(); + wallet.addInitialTokens(); + await wallet.save(); + + return wallet; + } + + @override + Future restoreFromSeed(SolanaRestoreWalletFromSeedCredentials credentials) async { + if (!bip39.validateMnemonic(credentials.mnemonic)) { + throw SolanaMnemonicIsIncorrectException(); + } + + final wallet = SolanaWallet( + password: credentials.password!, + mnemonic: credentials.mnemonic, + walletInfo: credentials.walletInfo!, + ); + + await wallet.init(); + wallet.addInitialTokens(); + await wallet.save(); + + return wallet; + } + + @override + Future rename(String currentName, String password, String newName) async { + final currentWalletInfo = walletInfoSource.values + .firstWhere((info) => info.id == WalletBase.idFor(currentName, getType())); + final currentWallet = await SolanaWalletBase.open( + password: password, name: currentName, walletInfo: currentWalletInfo); + + await currentWallet.renameWalletFiles(newName); + + final newWalletInfo = currentWalletInfo; + newWalletInfo.id = WalletBase.idFor(newName, getType()); + newWalletInfo.name = newName; + + await walletInfoSource.put(currentWalletInfo.key, newWalletInfo); + } +} diff --git a/cw_solana/lib/spl_token.dart b/cw_solana/lib/spl_token.dart new file mode 100644 index 000000000..0413990b1 --- /dev/null +++ b/cw_solana/lib/spl_token.dart @@ -0,0 +1,146 @@ +import 'package:cw_core/crypto_currency.dart'; +import 'package:cw_core/hive_type_ids.dart'; +import 'package:hive/hive.dart'; +import 'package:solana/metaplex.dart'; + +part 'spl_token.g.dart'; + +@HiveType(typeId: SPLToken.typeId) +class SPLToken extends CryptoCurrency with HiveObjectMixin { + @HiveField(0) + final String name; + + @HiveField(1) + final String symbol; + + @HiveField(2) + final String mintAddress; + + @HiveField(3) + final int decimal; + + @HiveField(4, defaultValue: false) + bool _enabled; + + @HiveField(5) + final String mint; + + @HiveField(6) + final String? iconPath; + + @HiveField(7) + final String? tag; + + SPLToken({ + required this.name, + required this.symbol, + required this.mintAddress, + required this.decimal, + required this.mint, + this.iconPath, + this.tag = 'SOL', + bool enabled = false, + }) : _enabled = enabled, + super( + name: mint.toLowerCase(), + title: symbol.toUpperCase(), + fullName: name, + tag: tag, + iconPath: iconPath, + decimals: decimal, + ); + + factory SPLToken.fromMetadata({ + required String name, + required String mint, + required String symbol, + required String mintAddress, + }) { + return SPLToken( + name: name, + symbol: symbol, + mintAddress: mintAddress, + decimal: 0, + mint: mint, + iconPath: '', + ); + } + + factory SPLToken.cryptoCurrency({ + required String name, + required String symbol, + required int decimals, + required String iconPath, + required String mint, + }) { + return SPLToken( + name: name, + symbol: symbol, + decimal: decimals, + mint: mint, + iconPath: iconPath, + mintAddress: '', + ); + } + + bool get enabled => _enabled; + + set enabled(bool value) => _enabled = value; + + SPLToken.copyWith(SPLToken other, String? icon, String? tag) + : name = other.name, + symbol = other.symbol, + mintAddress = other.mintAddress, + decimal = other.decimal, + _enabled = other.enabled, + mint = other.mint, + tag = other.tag, + iconPath = icon, + super( + title: other.symbol.toUpperCase(), + name: other.symbol.toLowerCase(), + decimals: other.decimal, + fullName: other.name, + tag: other.tag, + iconPath: icon, + ); + + static const typeId = SPL_TOKEN_TYPE_ID; + static const boxName = 'SPLTokens'; + + @override + bool operator ==(other) => + (other is SPLToken && other.mintAddress == mintAddress) || + (other is CryptoCurrency && other.title == title); + + @override + int get hashCode => mintAddress.hashCode; +} + +class NFT extends SPLToken { + final ImageInfo? imageInfo; + + NFT( + String mint, + String name, + String symbol, + String mintAddress, + int decimal, + String iconPath, + this.imageInfo, + ) : super( + name: name, + symbol: symbol, + mintAddress: mintAddress, + decimal: decimal, + mint: mint, + iconPath: iconPath, + ); +} + +class ImageInfo { + final String uri; + final OffChainMetadata? data; + + const ImageInfo(this.uri, this.data); +} diff --git a/cw_solana/pubspec.yaml b/cw_solana/pubspec.yaml new file mode 100644 index 000000000..c98b7492e --- /dev/null +++ b/cw_solana/pubspec.yaml @@ -0,0 +1,37 @@ +name: cw_solana +description: A new Flutter package project. +version: 0.0.1 +publish_to: none +homepage: https://cakewallet.com + +environment: + sdk: '>=3.0.6 <4.0.0' + flutter: ">=1.17.0" + +dependencies: + flutter: + sdk: flutter + solana: ^0.30.1 + cw_core: + path: ../cw_core + http: ^1.1.0 + hive: ^2.2.3 + bip39: ^1.0.6 + mobx: ^2.3.0+1 + shared_preferences: ^2.0.15 + web3dart: ^2.7.1 + bip32: ^2.0.0 + hex: ^0.2.0 + +dev_dependencies: + flutter_test: + sdk: flutter + flutter_lints: ^2.0.0 + build_runner: ^2.1.11 + mobx_codegen: ^2.0.7 + hive_generator: ^1.1.3 + +flutter: + # assets: + # - images/a_dot_burr.jpeg + # - images/a_dot_ham.jpeg \ No newline at end of file diff --git a/cw_solana/test/cw_solana_test.dart b/cw_solana/test/cw_solana_test.dart new file mode 100644 index 000000000..42a5d8bdf --- /dev/null +++ b/cw_solana/test/cw_solana_test.dart @@ -0,0 +1,12 @@ +import 'package:flutter_test/flutter_test.dart'; + +import 'package:cw_solana/cw_solana.dart'; + +void main() { + test('adds one to input values', () { + final calculator = Calculator(); + expect(calculator.addOne(2), 3); + expect(calculator.addOne(-7), -6); + expect(calculator.addOne(0), 1); + }); +} diff --git a/ios/Runner/InfoBase.plist b/ios/Runner/InfoBase.plist index 2292d0b66..a7f208870 100644 --- a/ios/Runner/InfoBase.plist +++ b/ios/Runner/InfoBase.plist @@ -180,6 +180,16 @@ polygon-wallet + + CFBundleTypeRole + Viewer + CFBundleURLName + solana-wallet + CFBundleURLSchemes + + solana-wallet + + CFBundleVersion $(CURRENT_PROJECT_VERSION) diff --git a/lib/core/address_validator.dart b/lib/core/address_validator.dart index 2ae9e3297..21f3c3557 100644 --- a/lib/core/address_validator.dart +++ b/lib/core/address_validator.dart @@ -1,6 +1,7 @@ import 'package:bitcoin_flutter/bitcoin_flutter.dart' as bitcoin; import 'package:cake_wallet/generated/i18n.dart'; import 'package:cake_wallet/core/validator.dart'; +import 'package:cake_wallet/solana/solana.dart'; import 'package:cw_core/crypto_currency.dart'; import 'package:cw_core/erc20_token.dart'; @@ -8,9 +9,7 @@ class AddressValidator extends TextValidator { AddressValidator({required CryptoCurrency type}) : super( errorMessage: S.current.error_text_address, - useAdditionalValidation: type == CryptoCurrency.btc - ? bitcoin.Address.validateAddress - : null, + useAdditionalValidation: type == CryptoCurrency.btc ? bitcoin.Address.validateAddress : null, pattern: getPattern(type), length: getLength(type)); @@ -130,6 +129,12 @@ class AddressValidator extends TextValidator { if (type is Erc20Token) { return [42]; } + + if (solana != null) { + final length = solana!.getValidationLength(type); + if (length != null) return length; + } + switch (type) { case CryptoCurrency.xmr: return null; @@ -192,11 +197,11 @@ class AddressValidator extends TextValidator { case CryptoCurrency.sc: return [76]; case CryptoCurrency.sol: + case CryptoCurrency.usdtSol: + case CryptoCurrency.usdcsol: return [32, 33, 34, 35, 36, 37, 38, 39, 40, 41, 42, 43, 44]; case CryptoCurrency.trx: return [34]; - case CryptoCurrency.usdcsol: - return [32, 33, 34, 35, 36, 37, 38, 39, 40, 41, 42, 43, 44]; case CryptoCurrency.usdt: return [34]; case CryptoCurrency.usdttrc20: @@ -286,6 +291,8 @@ class AddressValidator extends TextValidator { '|bitcoincash:q[0-9a-zA-Z]{42}([^0-9a-zA-Z]|\$)' '|([^0-9a-zA-Z]|^)q[0-9a-zA-Z]{41}([^0-9a-zA-Z]|\$)' '|([^0-9a-zA-Z]|^)q[0-9a-zA-Z]{42}([^0-9a-zA-Z]|\$)'; + case CryptoCurrency.sol: + return '([^0-9a-zA-Z]|^)[1-9A-HJ-NP-Za-km-z]{43,44}([^0-9a-zA-Z]|\$)'; default: return null; } diff --git a/lib/core/seed_validator.dart b/lib/core/seed_validator.dart index 8f65159e1..6d04055ba 100644 --- a/lib/core/seed_validator.dart +++ b/lib/core/seed_validator.dart @@ -4,6 +4,7 @@ import 'package:cake_wallet/haven/haven.dart'; import 'package:cake_wallet/core/validator.dart'; import 'package:cake_wallet/entities/mnemonic_item.dart'; import 'package:cake_wallet/polygon/polygon.dart'; +import 'package:cake_wallet/solana/solana.dart'; import 'package:cw_core/wallet_type.dart'; import 'package:cake_wallet/monero/monero.dart'; import 'package:cake_wallet/nano/nano.dart'; @@ -37,6 +38,8 @@ class SeedValidator extends Validator { return nano!.getNanoWordList(language); case WalletType.polygon: return polygon!.getPolygonWordList(language); + case WalletType.solana: + return solana!.getSolanaWordList(language); default: return []; } diff --git a/lib/core/wallet_connect/chain_service.dart b/lib/core/wallet_connect/chain_service/chain_service.dart similarity index 100% rename from lib/core/wallet_connect/chain_service.dart rename to lib/core/wallet_connect/chain_service/chain_service.dart diff --git a/lib/core/wallet_connect/evm_chain_id.dart b/lib/core/wallet_connect/chain_service/eth/evm_chain_id.dart similarity index 86% rename from lib/core/wallet_connect/evm_chain_id.dart rename to lib/core/wallet_connect/chain_service/eth/evm_chain_id.dart index b71fb562e..0be21b1b2 100644 --- a/lib/core/wallet_connect/evm_chain_id.dart +++ b/lib/core/wallet_connect/chain_service/eth/evm_chain_id.dart @@ -1,4 +1,4 @@ -import 'package:cake_wallet/core/wallet_connect/evm_chain_service.dart'; +import 'package:cake_wallet/core/wallet_connect/chain_service/eth/evm_chain_service.dart'; enum EVMChainId { ethereum, diff --git a/lib/core/wallet_connect/evm_chain_service.dart b/lib/core/wallet_connect/chain_service/eth/evm_chain_service.dart similarity index 98% rename from lib/core/wallet_connect/evm_chain_service.dart rename to lib/core/wallet_connect/chain_service/eth/evm_chain_service.dart index 74bff7479..6f3c8fa98 100644 --- a/lib/core/wallet_connect/evm_chain_service.dart +++ b/lib/core/wallet_connect/chain_service/eth/evm_chain_service.dart @@ -3,7 +3,7 @@ import 'dart:developer'; import 'dart:typed_data'; import 'package:cake_wallet/core/wallet_connect/eth_transaction_model.dart'; -import 'package:cake_wallet/core/wallet_connect/evm_chain_id.dart'; +import 'package:cake_wallet/core/wallet_connect/chain_service/eth/evm_chain_id.dart'; import 'package:cake_wallet/core/wallet_connect/wc_bottom_sheet_service.dart'; import 'package:cake_wallet/generated/i18n.dart'; import 'package:cake_wallet/reactions/wallet_connect.dart'; @@ -20,8 +20,8 @@ import 'package:eth_sig_util/util/utils.dart'; import 'package:http/http.dart' as http; import 'package:walletconnect_flutter_v2/walletconnect_flutter_v2.dart'; import 'package:web3dart/web3dart.dart'; -import 'chain_service.dart'; -import 'wallet_connect_key_service.dart'; +import '../chain_service.dart'; +import '../../wallet_connect_key_service.dart'; class EvmChainServiceImpl implements ChainService { final AppStore appStore; diff --git a/lib/core/wallet_connect/chain_service/solana/entities/solana_sign_message.dart b/lib/core/wallet_connect/chain_service/solana/entities/solana_sign_message.dart new file mode 100644 index 000000000..e462adbb5 --- /dev/null +++ b/lib/core/wallet_connect/chain_service/solana/entities/solana_sign_message.dart @@ -0,0 +1,28 @@ +class SolanaSignMessage { + final String pubkey; + final String message; + + SolanaSignMessage({ + required this.pubkey, + required this.message, + }); + + factory SolanaSignMessage.fromJson(Map json) { + return SolanaSignMessage( + pubkey: json['pubkey'] as String, + message: json['message'] as String, + ); + } + + Map toJson() { + return { + 'pubkey': pubkey, + 'message': message, + }; + } + + @override + String toString() { + return 'SolanaSignMessage(pubkey: $pubkey, message: $message)'; + } +} diff --git a/lib/core/wallet_connect/chain_service/solana/entities/solana_sign_transaction.dart b/lib/core/wallet_connect/chain_service/solana/entities/solana_sign_transaction.dart new file mode 100644 index 000000000..2cdf4697e --- /dev/null +++ b/lib/core/wallet_connect/chain_service/solana/entities/solana_sign_transaction.dart @@ -0,0 +1,106 @@ +class SolanaSignTransaction { + final String? feePayer; + final String? recentBlockhash; + final String transaction; + final List? instructions; + + SolanaSignTransaction({ + required this.feePayer, + required this.recentBlockhash, + required this.instructions, + required this.transaction, + }); + + factory SolanaSignTransaction.fromJson(Map json) { + return SolanaSignTransaction( + feePayer:json['feePayer'] !=null ? json['feePayer'] as String: null, + recentBlockhash: json['recentBlockhash']!=null? json['recentBlockhash'] as String: null, + instructions:json['instructions']!=null? (json['instructions'] as List) + .map((e) => SolanaInstruction.fromJson(e as Map)) + .toList(): null, + transaction: json['transaction'] as String, + ); + } + + Map toJson() { + return { + 'feePayer': feePayer, + 'recentBlockhash': recentBlockhash, + 'instructions': instructions, + 'transaction': transaction, + }; + } + + @override + String toString() { + return 'SolanaSignTransaction(feePayer: $feePayer, recentBlockhash: $recentBlockhash, instructions: $instructions, transaction: $transaction)'; + } +} + +class SolanaInstruction { + final String programId; + final List keys; + final List data; + + SolanaInstruction({ + required this.programId, + required this.keys, + required this.data, + }); + + factory SolanaInstruction.fromJson(Map json) { + return SolanaInstruction( + programId: json['programId'] as String, + keys: (json['keys'] as List) + .map((e) => SolanaKeyMetadata.fromJson(e as Map)) + .toList(), + data: (json['data'] as List).map((e) => e as int).toList(), + ); + } + + Map toJson() { + return { + 'programId': programId, + 'keys': keys, + 'data': data, + }; + } + + @override + String toString() { + return 'SolanaInstruction(programId: $programId, keys: $keys, data: $data)'; + } +} + +class SolanaKeyMetadata { + final String pubkey; + final bool isSigner; + final bool isWritable; + + SolanaKeyMetadata({ + required this.pubkey, + required this.isSigner, + required this.isWritable, + }); + + factory SolanaKeyMetadata.fromJson(Map json) { + return SolanaKeyMetadata( + pubkey: json['pubkey'] as String, + isSigner: json['isSigner'] as bool, + isWritable: json['isWritable'] as bool, + ); + } + + Map toJson() { + return { + 'pubkey': pubkey, + 'isSigner': isSigner, + 'isWritable': isWritable, + }; + } + + @override + String toString() { + return 'SolanaKeyMetadata(pubkey: $pubkey, isSigner: $isSigner, isWritable: $isWritable)'; + } +} diff --git a/lib/core/wallet_connect/chain_service/solana/solana_chain_id.dart b/lib/core/wallet_connect/chain_service/solana/solana_chain_id.dart new file mode 100644 index 000000000..bdc8a7d20 --- /dev/null +++ b/lib/core/wallet_connect/chain_service/solana/solana_chain_id.dart @@ -0,0 +1,27 @@ +import 'solana_chain_service.dart'; + +enum SolanaChainId { + mainnet, + testnet, + devnet, +} + +extension SolanaChainIdX on SolanaChainId { + String chain() { + String name = ''; + + switch (this) { + case SolanaChainId.mainnet: + name = '4sGjMW1sUnHzSxGspuhpqLDx6wiyjNtZ'; + break; + case SolanaChainId.testnet: + name = '8E9rvCKLFQia2Y35HXjjpWzj8weVo44K'; + break; + case SolanaChainId.devnet: + name = ''; + break; + } + + return '${SolanaChainServiceImpl.namespace}:$name'; + } +} diff --git a/lib/core/wallet_connect/chain_service/solana/solana_chain_service.dart b/lib/core/wallet_connect/chain_service/solana/solana_chain_service.dart new file mode 100644 index 000000000..f5c696be6 --- /dev/null +++ b/lib/core/wallet_connect/chain_service/solana/solana_chain_service.dart @@ -0,0 +1,177 @@ +import 'dart:developer'; + +import 'package:cake_wallet/core/wallet_connect/chain_service/solana/entities/solana_sign_message.dart'; +import 'package:cake_wallet/core/wallet_connect/chain_service/solana/solana_chain_id.dart'; +import 'package:cake_wallet/core/wallet_connect/wc_bottom_sheet_service.dart'; +import 'package:cake_wallet/generated/i18n.dart'; +import 'package:cake_wallet/src/screens/wallet_connect/widgets/message_display_widget.dart'; +import 'package:cake_wallet/core/wallet_connect/models/connection_model.dart'; +import 'package:cake_wallet/src/screens/wallet_connect/widgets/connection_widget.dart'; +import 'package:cake_wallet/src/screens/wallet_connect/widgets/modals/web3_request_modal.dart'; +import 'package:solana/base58.dart'; +import 'package:solana/solana.dart'; +import 'package:walletconnect_flutter_v2/walletconnect_flutter_v2.dart'; +import '../chain_service.dart'; +import '../../wallet_connect_key_service.dart'; +import 'entities/solana_sign_transaction.dart'; + +class SolanaChainServiceImpl implements ChainService { + final BottomSheetService bottomSheetService; + final Web3Wallet wallet; + final WalletConnectKeyService wcKeyService; + + static const namespace = 'solana'; + static const solSignTransaction = 'solana_signTransaction'; + static const solSignMessage = 'solana_signMessage'; + + final SolanaChainId reference; + + final SolanaClient solanaClient; + + final Ed25519HDKeyPair? ownerKeyPair; + + SolanaChainServiceImpl({ + required this.reference, + required this.wcKeyService, + required this.bottomSheetService, + required this.wallet, + required this.ownerKeyPair, + required String webSocketUrl, + required Uri rpcUrl, + SolanaClient? solanaClient, + }) : solanaClient = solanaClient ?? + SolanaClient( + rpcUrl: rpcUrl, + websocketUrl: Uri.parse(webSocketUrl), + timeout: const Duration(minutes: 2), + ) { + for (final String event in getEvents()) { + wallet.registerEventEmitter(chainId: getChainId(), event: event); + } + wallet.registerRequestHandler( + chainId: getChainId(), + method: solSignTransaction, + handler: solanaSignTransaction, + ); + wallet.registerRequestHandler( + chainId: getChainId(), + method: solSignMessage, + handler: solanaSignMessage, + ); + } + + @override + String getNamespace() { + return namespace; + } + + @override + String getChainId() { + return reference.chain(); + } + + @override + List getEvents() { + return ['']; + } + + Future requestAuthorization(String? text) async { + // Show the bottom sheet + final bool? isApproved = await bottomSheetService.queueBottomSheet( + widget: Web3RequestModal( + child: ConnectionWidget( + title: S.current.signTransaction, + info: [ + ConnectionModel( + text: text, + ), + ], + ), + ), + ) as bool?; + + if (isApproved != null && isApproved == false) { + return 'User rejected signature'; + } + + return null; + } + + Future solanaSignTransaction(String topic, dynamic parameters) async { + log('received solana sign transaction request $parameters'); + + final solanaSignTx = + SolanaSignTransaction.fromJson(parameters as Map); + + final String? authError = await requestAuthorization('Confirm request to sign transaction?'); + + if (authError != null) { + return authError; + } + + try { + final message = + await solanaClient.rpcClient.getMessageFromEncodedTx(solanaSignTx.transaction); + + final sign = await ownerKeyPair?.signMessage( + message: message, + recentBlockhash: solanaSignTx.recentBlockhash ?? '', + ); + + if (sign == null) { + return ''; + } + + String signature = sign.signatures.first.toBase58(); + + print(signature); + print(signature.runtimeType); + + bottomSheetService.queueBottomSheet( + isModalDismissible: true, + widget: BottomSheetMessageDisplayWidget( + message: S.current.awaitDAppProcessing, + isError: false, + ), + ); + + return signature; + } catch (e) { + log('An error has occurred while signing transaction: ${e.toString()}'); + bottomSheetService.queueBottomSheet( + isModalDismissible: true, + widget: BottomSheetMessageDisplayWidget( + message: '${S.current.errorSigningTransaction}: ${e.toString()}', + ), + ); + return 'Failed'; + } + } + + Future solanaSignMessage(String topic, dynamic parameters) async { + log('received solana sign message request: $parameters'); + + final solanaSignMessage = SolanaSignMessage.fromJson(parameters as Map); + + final String? authError = await requestAuthorization('Confirm request to sign message?'); + + if (authError != null) { + return authError; + } + Signature? sign; + + try { + sign = await ownerKeyPair?.sign(base58decode(solanaSignMessage.message)); + } catch (e) { + print(e); + } + + if (sign == null) { + return ''; + } + + String signature = sign.toBase58(); + + return signature; + } +} diff --git a/lib/core/wallet_connect/wallet_connect_key_service.dart b/lib/core/wallet_connect/wallet_connect_key_service.dart index 33d721073..f05adad97 100644 --- a/lib/core/wallet_connect/wallet_connect_key_service.dart +++ b/lib/core/wallet_connect/wallet_connect_key_service.dart @@ -2,6 +2,7 @@ import 'package:cake_wallet/ethereum/ethereum.dart'; import 'package:cake_wallet/core/wallet_connect/models/chain_key_model.dart'; import 'package:cake_wallet/polygon/polygon.dart'; import 'package:cake_wallet/reactions/wallet_connect.dart'; +import 'package:cake_wallet/solana/solana.dart'; import 'package:cw_core/wallet_base.dart'; import 'package:cw_core/wallet_type.dart'; @@ -13,7 +14,6 @@ abstract class WalletConnectKeyService { /// If the chain is not found, returns an empty list. /// - [chain]: The chain to get the keys for. List getKeysForChain(WalletBase wallet); - } class KeyServiceImpl implements WalletConnectKeyService { @@ -23,6 +23,8 @@ class KeyServiceImpl implements WalletConnectKeyService { return ethereum!.getPrivateKey(wallet); case WalletType.polygon: return polygon!.getPrivateKey(wallet); + case WalletType.solana: + return solana!.getPrivateKey(wallet); default: return ''; } @@ -34,6 +36,8 @@ class KeyServiceImpl implements WalletConnectKeyService { return ethereum!.getPublicKey(wallet); case WalletType.polygon: return polygon!.getPublicKey(wallet); + case WalletType.solana: + return solana!.getPublicKey(wallet); default: return ''; } @@ -53,6 +57,14 @@ class KeyServiceImpl implements WalletConnectKeyService { privateKey: _getPrivateKeyForWallet(wallet), publicKey: _getPublicKeyForWallet(wallet), ), + ChainKeyModel( + chains: [ + 'solana:4sGjMW1sUnHzSxGspuhpqLDx6wiyjNtZ', // main-net + 'solana:8E9rvCKLFQia2Y35HXjjpWzj8weVo44K', // test-net + ], + privateKey: _getPrivateKeyForWallet(wallet), + publicKey: _getPublicKeyForWallet(wallet), + ), ]; return keys; } diff --git a/lib/core/wallet_connect/web3wallet_service.dart b/lib/core/wallet_connect/web3wallet_service.dart index ee560a0e0..4c71abe48 100644 --- a/lib/core/wallet_connect/web3wallet_service.dart +++ b/lib/core/wallet_connect/web3wallet_service.dart @@ -2,23 +2,27 @@ import 'dart:async'; import 'dart:developer'; import 'dart:typed_data'; -import 'package:cake_wallet/core/wallet_connect/evm_chain_id.dart'; -import 'package:cake_wallet/core/wallet_connect/evm_chain_service.dart'; +import 'package:cake_wallet/core/wallet_connect/chain_service/eth/evm_chain_id.dart'; +import 'package:cake_wallet/core/wallet_connect/chain_service/eth/evm_chain_service.dart'; import 'package:cake_wallet/core/wallet_connect/wallet_connect_key_service.dart'; import 'package:cake_wallet/generated/i18n.dart'; import 'package:cake_wallet/core/wallet_connect/models/auth_request_model.dart'; import 'package:cake_wallet/core/wallet_connect/models/chain_key_model.dart'; import 'package:cake_wallet/core/wallet_connect/models/session_request_model.dart'; import 'package:cake_wallet/reactions/wallet_connect.dart'; +import 'package:cake_wallet/solana/solana.dart'; import 'package:cake_wallet/src/screens/wallet_connect/widgets/connection_request_widget.dart'; import 'package:cake_wallet/src/screens/wallet_connect/widgets/message_display_widget.dart'; import 'package:cake_wallet/src/screens/wallet_connect/widgets/modals/web3_request_modal.dart'; import 'package:cake_wallet/store/app_store.dart'; +import 'package:cw_core/wallet_type.dart'; import 'package:eth_sig_util/eth_sig_util.dart'; import 'package:flutter/material.dart'; import 'package:mobx/mobx.dart'; import 'package:walletconnect_flutter_v2/walletconnect_flutter_v2.dart'; +import 'chain_service/solana/solana_chain_id.dart'; +import 'chain_service/solana/solana_chain_service.dart'; import 'wc_bottom_sheet_service.dart'; import 'package:cake_wallet/.secrets.g.dart' as secrets; @@ -114,14 +118,34 @@ abstract class Web3WalletServiceBase with Store { final newAuthRequests = _web3Wallet.completeRequests.getAll(); auth.addAll(newAuthRequests); - for (final cId in EVMChainId.values) { - EvmChainServiceImpl( - reference: cId, - appStore: appStore, - wcKeyService: walletKeyService, - bottomSheetService: _bottomSheetHandler, - wallet: _web3Wallet, - ); + if (isEVMCompatibleChain(appStore.wallet!.type)) { + for (final cId in EVMChainId.values) { + EvmChainServiceImpl( + reference: cId, + appStore: appStore, + wcKeyService: walletKeyService, + bottomSheetService: _bottomSheetHandler, + wallet: _web3Wallet, + ); + } + } + + if (appStore.wallet!.type == WalletType.solana) { + for (final cId in SolanaChainId.values) { + final node = appStore.settingsStore.getCurrentNode(appStore.wallet!.type); + final rpcUri = node.uri; + final webSocketUri = 'wss://${node.uriRaw}/ws${node.uri.path}'; + + SolanaChainServiceImpl( + reference: cId, + rpcUrl: rpcUri, + webSocketUrl: webSocketUri, + wcKeyService: walletKeyService, + bottomSheetService: _bottomSheetHandler, + wallet: _web3Wallet, + ownerKeyPair: solana!.getWalletKeyPair(appStore.wallet!), + ); + } } } diff --git a/lib/di.dart b/lib/di.dart index 05019a562..4a005a4de 100644 --- a/lib/di.dart +++ b/lib/di.dart @@ -21,6 +21,7 @@ import 'package:cake_wallet/ionia/ionia_gift_card.dart'; import 'package:cake_wallet/ionia/ionia_tip.dart'; import 'package:cake_wallet/polygon/polygon.dart'; import 'package:cake_wallet/routes.dart'; +import 'package:cake_wallet/solana/solana.dart'; import 'package:cake_wallet/src/screens/anonpay_details/anonpay_details_page.dart'; import 'package:cake_wallet/src/screens/buy/buy_options_page.dart'; import 'package:cake_wallet/src/screens/buy/webview_page.dart'; @@ -863,6 +864,8 @@ Future setup({ return nano!.createNanoWalletService(_walletInfoSource); case WalletType.polygon: return polygon!.createPolygonWalletService(_walletInfoSource); + case WalletType.solana: + return solana!.createSolanaWalletService(_walletInfoSource); default: throw Exception('Unexpected token: ${param1.toString()} for generating of WalletService'); } @@ -1174,7 +1177,7 @@ Future setup({ getIt.registerFactoryParam>( (homeSettingsViewModel, arguments) => EditTokenPage( homeSettingsViewModel: homeSettingsViewModel, - erc20token: arguments['token'] as Erc20Token?, + token: arguments['token'] as CryptoCurrency?, initialContractAddress: arguments['contractAddress'] as String?, ), ); diff --git a/lib/entities/default_settings_migration.dart b/lib/entities/default_settings_migration.dart index 68e76d423..019276227 100644 --- a/lib/entities/default_settings_migration.dart +++ b/lib/entities/default_settings_migration.dart @@ -30,6 +30,7 @@ const polygonDefaultNodeUri = 'polygon-bor.publicnode.com'; const cakeWalletBitcoinCashDefaultNodeUri = 'bitcoincash.stackwallet.com:50002'; const nanoDefaultNodeUri = 'rpc.nano.to'; const nanoDefaultPowNodeUri = 'rpc.nano.to'; +const solanaDefaultNodeUri = 'rpc.ankr.com'; Future defaultSettingsMigration( {required int version, @@ -186,10 +187,15 @@ Future defaultSettingsMigration( await rewriteSecureStoragePin(secureStorage: secureStorage); break; case 26: - /// commented out as it was a probable cause for some users to have white screen issues - /// maybe due to multiple access on Secure Storage at once - /// or long await time on start of the app - // await insecureStorageMigration(secureStorage: secureStorage, sharedPreferences: sharedPreferences); + + /// commented out as it was a probable cause for some users to have white screen issues + /// maybe due to multiple access on Secure Storage at once + /// or long await time on start of the app + // await insecureStorageMigration(secureStorage: secureStorage, sharedPreferences: sharedPreferences); + case 27: + await addSolanaNodeList(nodes: nodes); + await changeSolanaCurrentNodeToDefault( + sharedPreferences: sharedPreferences, nodes: nodes); break; default: break; @@ -384,6 +390,11 @@ Node getMoneroDefaultNode({required Box nodes}) { } } +Node? getSolanaDefaultNode({required Box nodes}) { + return nodes.values.firstWhereOrNull((Node node) => node.uriRaw == solanaDefaultNodeUri) ?? + nodes.values.firstWhereOrNull((node) => node.type == WalletType.solana); +} + Future insecureStorageMigration({ required SharedPreferences sharedPreferences, required FlutterSecureStorage secureStorage, @@ -673,6 +684,7 @@ Future checkCurrentNodes( final currentNanoPowNodeId = sharedPreferences.getInt(PreferencesKey.currentNanoPowNodeIdKey); final currentBitcoinCashNodeId = sharedPreferences.getInt(PreferencesKey.currentBitcoinCashNodeIdKey); + final currentSolanaNodeId = sharedPreferences.getInt(PreferencesKey.currentSolanaNodeIdKey); final currentMoneroNode = nodeSource.values.firstWhereOrNull((node) => node.key == currentMoneroNodeId); final currentBitcoinElectrumServer = @@ -691,6 +703,8 @@ Future checkCurrentNodes( powNodeSource.values.firstWhereOrNull((node) => node.key == currentNanoPowNodeId); final currentBitcoinCashNodeServer = nodeSource.values.firstWhereOrNull((node) => node.key == currentBitcoinCashNodeId); + final currentSolanaNodeServer = + nodeSource.values.firstWhereOrNull((node) => node.key == currentSolanaNodeId); if (currentMoneroNode == null) { final newCakeWalletNode = Node(uri: newCakeWalletMoneroUri, type: WalletType.monero); await nodeSource.add(newCakeWalletNode); @@ -750,6 +764,12 @@ Future checkCurrentNodes( await nodeSource.add(node); await sharedPreferences.setInt(PreferencesKey.currentPolygonNodeIdKey, node.key as int); } + + if (currentSolanaNodeServer == null) { + final node = Node(uri: solanaDefaultNodeUri, type: WalletType.solana); + await nodeSource.add(node); + await sharedPreferences.setInt(PreferencesKey.currentSolanaNodeIdKey, node.key as int); + } } Future resetBitcoinElectrumServer( @@ -861,3 +881,20 @@ Future changePolygonCurrentNodeToDefault( await sharedPreferences.setInt(PreferencesKey.currentPolygonNodeIdKey, nodeId); } + +Future addSolanaNodeList({required Box nodes}) async { + final nodeList = await loadDefaultSolanaNodes(); + for (var node in nodeList) { + if (nodes.values.firstWhereOrNull((element) => element.uriRaw == node.uriRaw) == null) { + await nodes.add(node); + } + } +} + +Future changeSolanaCurrentNodeToDefault( + {required SharedPreferences sharedPreferences, required Box nodes}) async { + final node = getSolanaDefaultNode(nodes: nodes); + final nodeId = node?.key as int? ?? 0; + + await sharedPreferences.setInt(PreferencesKey.currentSolanaNodeIdKey, nodeId); +} diff --git a/lib/entities/node_list.dart b/lib/entities/node_list.dart index aaac8a5c2..3c82a3f6c 100644 --- a/lib/entities/node_list.dart +++ b/lib/entities/node_list.dart @@ -149,6 +149,23 @@ Future> loadDefaultPolygonNodes() async { return nodes; } +Future> loadDefaultSolanaNodes() async { + final nodesRaw = await rootBundle.loadString('assets/solana_node_list.yml'); + final loadedNodes = loadYaml(nodesRaw) as YamlList; + final nodes = []; + + for (final raw in loadedNodes) { + if (raw is Map) { + final node = Node.fromMap(Map.from(raw)); + + node.type = WalletType.solana; + nodes.add(node); + } + } + + return nodes; +} + Future resetToDefault(Box nodeSource) async { final moneroNodes = await loadDefaultNodes(); final bitcoinElectrumServerList = await loadBitcoinElectrumServerList(); @@ -158,6 +175,7 @@ Future resetToDefault(Box nodeSource) async { final ethereumNodes = await loadDefaultEthereumNodes(); final nanoNodes = await loadDefaultNanoNodes(); final polygonNodes = await loadDefaultPolygonNodes(); + final solanaNodes = await loadDefaultSolanaNodes(); final nodes = moneroNodes + bitcoinElectrumServerList + @@ -166,7 +184,8 @@ Future resetToDefault(Box nodeSource) async { ethereumNodes + bitcoinCashElectrumServerList + nanoNodes + - polygonNodes; + polygonNodes + + solanaNodes; await nodeSource.clear(); await nodeSource.addAll(nodes); diff --git a/lib/entities/preferences_key.dart b/lib/entities/preferences_key.dart index 75e61b5e8..7adb2df7f 100644 --- a/lib/entities/preferences_key.dart +++ b/lib/entities/preferences_key.dart @@ -13,6 +13,7 @@ class PreferencesKey { static const currentBananoPowNodeIdKey = 'current_node_id_banano_pow'; static const currentFiatCurrencyKey = 'current_fiat_currency'; static const currentBitcoinCashNodeIdKey = 'current_node_id_bch'; + static const currentSolanaNodeIdKey = 'current_node_id_sol'; static const currentTransactionPriorityKeyLegacy = 'current_fee_priority'; static const currentBalanceDisplayModeKey = 'current_balance_display_mode'; static const shouldSaveRecipientAddressKey = 'save_recipient_address'; diff --git a/lib/entities/priority_for_wallet_type.dart b/lib/entities/priority_for_wallet_type.dart index 70b072c55..5fc0b5566 100644 --- a/lib/entities/priority_for_wallet_type.dart +++ b/lib/entities/priority_for_wallet_type.dart @@ -21,12 +21,13 @@ List priorityForWalletType(WalletType type) { return ethereum!.getTransactionPriorities(); case WalletType.bitcoinCash: return bitcoinCash!.getTransactionPriorities(); - // no such thing for nano/banano: - case WalletType.nano: - case WalletType.banano: - return []; case WalletType.polygon: return polygon!.getTransactionPriorities(); + // no such thing for nano/banano/solana: + case WalletType.nano: + case WalletType.banano: + case WalletType.solana: + return []; default: return []; } diff --git a/lib/entities/provider_types.dart b/lib/entities/provider_types.dart index ed688590c..9a4b68dd7 100644 --- a/lib/entities/provider_types.dart +++ b/lib/entities/provider_types.dart @@ -67,6 +67,8 @@ class ProvidersHelper { return [ProviderType.askEachTime, ProviderType.onramper, ProviderType.robinhood]; case WalletType.polygon: return [ProviderType.askEachTime, ProviderType.dfx]; + case WalletType.solana: + return [ProviderType.askEachTime, ProviderType.onramper, ProviderType.robinhood]; case WalletType.none: case WalletType.haven: return []; @@ -88,6 +90,13 @@ class ProvidersHelper { return [ProviderType.askEachTime, ProviderType.moonpaySell]; case WalletType.polygon: return [ProviderType.askEachTime, ProviderType.dfx]; + case WalletType.solana: + return [ + ProviderType.askEachTime, + ProviderType.onramper, + ProviderType.robinhood, + ProviderType.moonpaySell, + ]; case WalletType.monero: case WalletType.nano: case WalletType.banano: diff --git a/lib/ethereum/cw_ethereum.dart b/lib/ethereum/cw_ethereum.dart index d7c174e1a..6e658788e 100644 --- a/lib/ethereum/cw_ethereum.dart +++ b/lib/ethereum/cw_ethereum.dart @@ -119,12 +119,13 @@ class CWEthereum extends Ethereum { } @override - Future addErc20Token(WalletBase wallet, Erc20Token token) async => - await (wallet as EthereumWallet).addErc20Token(token); + Future addErc20Token(WalletBase wallet, CryptoCurrency token) async { + await (wallet as EthereumWallet).addErc20Token(token as Erc20Token); + } @override - Future deleteErc20Token(WalletBase wallet, Erc20Token token) async => - await (wallet as EthereumWallet).deleteErc20Token(token); + Future deleteErc20Token(WalletBase wallet, CryptoCurrency token) async => + await (wallet as EthereumWallet).deleteErc20Token(token as Erc20Token); @override Future getErc20Token(WalletBase wallet, String contractAddress) async { @@ -153,4 +154,6 @@ class CWEthereum extends Ethereum { Web3Client? getWeb3Client(WalletBase wallet) { return (wallet as EthereumWallet).getWeb3Client(); } + + String getTokenAddress(CryptoCurrency asset) => (asset as Erc20Token).contractAddress; } diff --git a/lib/main.dart b/lib/main.dart index 306b109a0..0c8a4c094 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -163,11 +163,11 @@ Future initializeAppConfigs() async { transactionDescriptions: transactionDescriptions, secureStorage: secureStorage, anonpayInvoiceInfo: anonpayInvoiceInfo, - initialMigrationVersion: 26); + initialMigrationVersion: 27); } Future initialSetup( - {required SharedPreferences sharedPreferences, + {required SharedPreferences sharedPreferences, required Box nodes, required Box powNodes, required Box walletInfoSource, diff --git a/lib/polygon/cw_polygon.dart b/lib/polygon/cw_polygon.dart index 6e5fbe2c6..0ee7457eb 100644 --- a/lib/polygon/cw_polygon.dart +++ b/lib/polygon/cw_polygon.dart @@ -119,12 +119,12 @@ class CWPolygon extends Polygon { } @override - Future addErc20Token(WalletBase wallet, Erc20Token token) async => - await (wallet as PolygonWallet).addErc20Token(token); + Future addErc20Token(WalletBase wallet, CryptoCurrency token) async => + await (wallet as PolygonWallet).addErc20Token(token as Erc20Token); @override - Future deleteErc20Token(WalletBase wallet, Erc20Token token) async => - await (wallet as PolygonWallet).deleteErc20Token(token); + Future deleteErc20Token(WalletBase wallet, CryptoCurrency token) async => + await (wallet as PolygonWallet).deleteErc20Token(token as Erc20Token); @override Future getErc20Token(WalletBase wallet, String contractAddress) async { @@ -153,4 +153,6 @@ class CWPolygon extends Polygon { Web3Client? getWeb3Client(WalletBase wallet) { return (wallet as PolygonWallet).getWeb3Client(); } + + String getTokenAddress(CryptoCurrency asset) => (asset as Erc20Token).contractAddress; } diff --git a/lib/reactions/fiat_rate_update.dart b/lib/reactions/fiat_rate_update.dart index 2b757ad44..fb1d4cd1a 100644 --- a/lib/reactions/fiat_rate_update.dart +++ b/lib/reactions/fiat_rate_update.dart @@ -4,9 +4,11 @@ import 'package:cake_wallet/entities/fiat_api_mode.dart'; import 'package:cake_wallet/entities/update_haven_rate.dart'; import 'package:cake_wallet/ethereum/ethereum.dart'; import 'package:cake_wallet/polygon/polygon.dart'; +import 'package:cake_wallet/solana/solana.dart'; import 'package:cake_wallet/store/app_store.dart'; import 'package:cake_wallet/store/dashboard/fiat_conversion_store.dart'; import 'package:cake_wallet/store/settings_store.dart'; +import 'package:cw_core/crypto_currency.dart'; import 'package:cw_core/erc20_token.dart'; import 'package:cw_core/wallet_type.dart'; import 'package:mobx/mobx.dart'; @@ -35,7 +37,7 @@ Future startFiatRateUpdate( torOnly: settingsStore.fiatApiMode == FiatApiMode.torOnly); } - Iterable? currencies; + Iterable? currencies; if (appStore.wallet!.type == WalletType.ethereum) { currencies = ethereum!.getERC20Currencies(appStore.wallet!).where((element) => element.enabled); @@ -46,6 +48,12 @@ Future startFiatRateUpdate( polygon!.getERC20Currencies(appStore.wallet!).where((element) => element.enabled); } + if (appStore.wallet!.type == WalletType.solana) { + currencies = + solana!.getSPLTokenCurrencies(appStore.wallet!).where((element) => element.enabled); + } + + if (currencies != null) { for (final currency in currencies) { () async { diff --git a/lib/reactions/on_current_wallet_change.dart b/lib/reactions/on_current_wallet_change.dart index ade9927ff..a2f2491f1 100644 --- a/lib/reactions/on_current_wallet_change.dart +++ b/lib/reactions/on_current_wallet_change.dart @@ -3,6 +3,8 @@ import 'package:cake_wallet/entities/fiat_api_mode.dart'; import 'package:cake_wallet/entities/update_haven_rate.dart'; import 'package:cake_wallet/ethereum/ethereum.dart'; import 'package:cake_wallet/polygon/polygon.dart'; +import 'package:cake_wallet/solana/solana.dart'; +import 'package:cw_core/crypto_currency.dart'; import 'package:cw_core/erc20_token.dart'; import 'package:cw_core/transaction_history.dart'; import 'package:cw_core/balance.dart'; @@ -109,7 +111,7 @@ void startCurrentWalletChangeReaction( fiat: settingsStore.fiatCurrency, torOnly: settingsStore.fiatApiMode == FiatApiMode.torOnly); - Iterable? currencies; + Iterable? currencies; if (wallet.type == WalletType.ethereum) { currencies = ethereum!.getERC20Currencies(appStore.wallet!).where((element) => element.enabled); @@ -118,7 +120,11 @@ void startCurrentWalletChangeReaction( currencies = polygon!.getERC20Currencies(appStore.wallet!).where((element) => element.enabled); } - + if (wallet.type == WalletType.solana) { + currencies = + solana!.getSPLTokenCurrencies(appStore.wallet!).where((element) => element.enabled); + } + if (currencies != null) { for (final currency in currencies) { () async { diff --git a/lib/reactions/wallet_connect.dart b/lib/reactions/wallet_connect.dart index 4f5923e26..f4487123e 100644 --- a/lib/reactions/wallet_connect.dart +++ b/lib/reactions/wallet_connect.dart @@ -1,4 +1,5 @@ -import 'package:cake_wallet/core/wallet_connect/evm_chain_id.dart'; +import 'package:cake_wallet/core/wallet_connect/chain_service/eth/evm_chain_id.dart'; +import 'package:cake_wallet/core/wallet_connect/chain_service/solana/solana_chain_id.dart'; import 'package:cw_core/wallet_type.dart'; bool isEVMCompatibleChain(WalletType walletType) { @@ -11,12 +12,24 @@ bool isEVMCompatibleChain(WalletType walletType) { } } +bool isWalletConnectCompatibleChain(WalletType walletType) { + switch (walletType) { + case WalletType.polygon: + case WalletType.ethereum: + return true; + default: + return false; + } +} + String getChainNameSpaceAndIdBasedOnWalletType(WalletType walletType) { switch (walletType) { case WalletType.ethereum: return EVMChainId.ethereum.chain(); case WalletType.polygon: return EVMChainId.polygon.chain(); + case WalletType.solana: + return SolanaChainId.mainnet.chain(); default: return ''; } @@ -40,6 +53,8 @@ String getChainNameBasedOnWalletType(WalletType walletType) { return 'eth'; case WalletType.polygon: return 'polygon'; + case WalletType.solana: + return 'solana'; default: return ''; } @@ -51,6 +66,8 @@ String getTokenNameBasedOnWalletType(WalletType walletType) { return 'ETH'; case WalletType.polygon: return 'MATIC'; + case WalletType.solana: + return 'SOL'; default: return ''; } diff --git a/lib/solana/cw_solana.dart b/lib/solana/cw_solana.dart new file mode 100644 index 000000000..a86d6b0c6 --- /dev/null +++ b/lib/solana/cw_solana.dart @@ -0,0 +1,118 @@ +part of 'solana.dart'; + +class CWSolana extends Solana { + @override + List getSolanaWordList(String language) => SolanaMnemonics.englishWordlist; + + WalletService createSolanaWalletService(Box walletInfoSource) => + SolanaWalletService(walletInfoSource); + + @override + WalletCredentials createSolanaNewWalletCredentials({ + required String name, + WalletInfo? walletInfo, + }) => + SolanaNewWalletCredentials(name: name, walletInfo: walletInfo); + + @override + WalletCredentials createSolanaRestoreWalletFromSeedCredentials({ + required String name, + required String mnemonic, + required String password, + }) => + SolanaRestoreWalletFromSeedCredentials(name: name, password: password, mnemonic: mnemonic); + + @override + WalletCredentials createSolanaRestoreWalletFromPrivateKey({ + required String name, + required String privateKey, + required String password, + }) => + SolanaRestoreWalletFromPrivateKey(name: name, password: password, privateKey: privateKey); + + @override + String getAddress(WalletBase wallet) => (wallet as SolanaWallet).walletAddresses.address; + + @override + String getPrivateKey(WalletBase wallet) => (wallet as SolanaWallet).privateKey; + + @override + String getPublicKey(WalletBase wallet) => (wallet as SolanaWallet).keys.publicKey.toBase58(); + + @override + Ed25519HDKeyPair? getWalletKeyPair(WalletBase wallet) => (wallet as SolanaWallet).walletKeyPair; + + Object createSolanaTransactionCredentials( + List outputs, { + required CryptoCurrency currency, + }) => + SolanaTransactionCredentials( + outputs + .map((out) => OutputInfo( + fiatAmount: out.fiatAmount, + cryptoAmount: out.cryptoAmount, + address: out.address, + note: out.note, + sendAll: out.sendAll, + extractedAddress: out.extractedAddress, + isParsedAddress: out.isParsedAddress, + formattedCryptoAmount: out.formattedCryptoAmount)) + .toList(), + currency: currency, + ); + + Object createSolanaTransactionCredentialsRaw( + List outputs, { + required CryptoCurrency currency, + }) => + SolanaTransactionCredentials(outputs, currency: currency); + + @override + List getSPLTokenCurrencies(WalletBase wallet) { + final solanaWallet = wallet as SolanaWallet; + return solanaWallet.splTokenCurrencies; + } + + @override + Future addSPLToken(WalletBase wallet, CryptoCurrency token) async => + await (wallet as SolanaWallet).addSPLToken(token as SPLToken); + + @override + Future deleteSPLToken(WalletBase wallet, CryptoCurrency token) async => + await (wallet as SolanaWallet).deleteSPLToken(token as SPLToken); + + @override + Future getSPLToken(WalletBase wallet, String mintAddress) async { + final solanaWallet = wallet as SolanaWallet; + return await solanaWallet.getSPLToken(mintAddress); + } + + @override + CryptoCurrency assetOfTransaction(WalletBase wallet, TransactionInfo transaction) { + transaction as SolanaTransactionInfo; + if (transaction.tokenSymbol == CryptoCurrency.sol.title) { + return CryptoCurrency.sol; + } + + wallet as SolanaWallet; + return wallet.splTokenCurrencies + .firstWhere((element) => transaction.tokenSymbol == element.symbol); + } + + @override + double getTransactionAmountRaw(TransactionInfo transactionInfo) { + return (transactionInfo as SolanaTransactionInfo).solAmount.toDouble(); + } + + @override + String getTokenAddress(CryptoCurrency asset) => (asset as SPLToken).mintAddress; + + @override + List? getValidationLength(CryptoCurrency type) { + if (type is SPLToken) { + return [44]; + } + + return null; + } +} diff --git a/lib/src/screens/dashboard/desktop_widgets/desktop_wallet_selection_dropdown.dart b/lib/src/screens/dashboard/desktop_widgets/desktop_wallet_selection_dropdown.dart index a81a3f6e4..17a22a88f 100644 --- a/lib/src/screens/dashboard/desktop_widgets/desktop_wallet_selection_dropdown.dart +++ b/lib/src/screens/dashboard/desktop_widgets/desktop_wallet_selection_dropdown.dart @@ -37,6 +37,7 @@ class _DesktopWalletSelectionDropDownState extends State Image.asset( @@ -153,6 +154,8 @@ class _DesktopWalletSelectionDropDownState extends State { void initState() { super.initState(); - if (widget.erc20token != null) { - _contractAddressController.text = widget.erc20token!.contractAddress; - _tokenNameController.text = widget.erc20token!.name; - _tokenSymbolController.text = widget.erc20token!.symbol; - _tokenDecimalController.text = widget.erc20token!.decimal.toString(); + String? address; + + if (widget.token != null) { + address = widget.homeSettingsViewModel.getTokenAddressBasedOnWallet(widget.token!); + + _contractAddressController.text = address ?? ''; + _tokenNameController.text = widget.token!.name; + _tokenSymbolController.text = widget.token!.title; + _tokenDecimalController.text = widget.token!.decimals.toString(); } if (widget.initialContractAddress != null) { @@ -91,7 +96,7 @@ class _EditTokenPageBodyState extends State { } final contractAddress = _contractAddressController.text; - if (contractAddress.isNotEmpty && contractAddress != widget.erc20token?.contractAddress) { + if (contractAddress.isNotEmpty && contractAddress != address) { setState(() { _showDisclaimer = true; }); @@ -139,7 +144,9 @@ class _EditTokenPageBodyState extends State { style: TextStyle( fontSize: 14, fontWeight: FontWeight.normal, - color: Theme.of(context).extension()!.detailsTitlesColor, + color: Theme.of(context) + .extension()! + .detailsTitlesColor, ), ), ), @@ -172,12 +179,12 @@ class _EditTokenPageBodyState extends State { Expanded( child: PrimaryButton( onPressed: () async { - if (widget.erc20token != null) { - await widget.homeSettingsViewModel.deleteErc20Token(widget.erc20token!); + if (widget.token != null) { + await widget.homeSettingsViewModel.deleteToken(widget.token!); } Navigator.pop(context); }, - text: widget.erc20token != null ? S.of(context).delete : S.of(context).cancel, + text: widget.token != null ? S.of(context).delete : S.of(context).cancel, color: Colors.red, textColor: Colors.white, ), @@ -188,7 +195,7 @@ class _EditTokenPageBodyState extends State { onPressed: () async { if (_formKey.currentState!.validate() && (!_showDisclaimer || _disclaimerChecked)) { - await widget.homeSettingsViewModel.addErc20Token(Erc20Token( + await widget.homeSettingsViewModel.addToken(Erc20Token( name: _tokenNameController.text, symbol: _tokenSymbolController.text, contractAddress: _contractAddressController.text, @@ -214,14 +221,13 @@ class _EditTokenPageBodyState extends State { void _getTokenInfo() async { if (_contractAddressController.text.isNotEmpty) { - final token = - await widget.homeSettingsViewModel.getErc20Token(_contractAddressController.text); + final token = await widget.homeSettingsViewModel.getToken(_contractAddressController.text); if (token != null) { if (_tokenNameController.text.isEmpty) _tokenNameController.text = token.name; - if (_tokenSymbolController.text.isEmpty) _tokenSymbolController.text = token.symbol; + if (_tokenSymbolController.text.isEmpty) _tokenSymbolController.text = token.title; if (_tokenDecimalController.text.isEmpty) - _tokenDecimalController.text = token.decimal.toString(); + _tokenDecimalController.text = token.decimals.toString(); } } } diff --git a/lib/src/screens/dashboard/home_settings_page.dart b/lib/src/screens/dashboard/home_settings_page.dart index 618ba49ff..e841423c1 100644 --- a/lib/src/screens/dashboard/home_settings_page.dart +++ b/lib/src/screens/dashboard/home_settings_page.dart @@ -5,6 +5,7 @@ import 'package:cake_wallet/entities/sort_balance_types.dart'; import 'package:cake_wallet/generated/i18n.dart'; import 'package:cake_wallet/routes.dart'; import 'package:cake_wallet/src/screens/base_page.dart'; +import 'package:cake_wallet/src/widgets/cake_image_widget.dart'; import 'package:cake_wallet/src/screens/settings/widgets/settings_picker_cell.dart'; import 'package:cake_wallet/src/screens/settings/widgets/settings_switcher_cell.dart'; import 'package:cake_wallet/themes/extensions/address_theme.dart'; @@ -117,7 +118,7 @@ class HomeSettingsPage extends BasePage { return SettingsSwitcherCell( title: "${token.name} " - "(${token.symbol})", + "(${token.title})", value: token.enabled, onValueChange: (_, bool value) { _homeSettingsViewModel.changeTokenAvailability(token, value); @@ -128,20 +129,16 @@ class HomeSettingsPage extends BasePage { 'token': token, }); }, - leading: token.iconPath != null - ? Container( - child: Image.asset( - token.iconPath!, - height: 30.0, - width: 30.0, - ), - ) - : Container( + leading: CakeImageWidget( + imageUrl: token.iconPath, + height: 40, + width: 40, + displayOnError: Container( height: 30.0, width: 30.0, child: Center( child: Text( - token.symbol.substring(0, min(token.symbol.length, 2)), + token.title.substring(0, min(token.title.length, 2)), style: TextStyle(fontSize: 11), ), ), @@ -149,7 +146,8 @@ class HomeSettingsPage extends BasePage { shape: BoxShape.circle, color: Colors.grey.shade400, ), - ), + ), + ), decoration: BoxDecoration( color: Theme.of(context).cardColor, borderRadius: BorderRadius.circular(30), diff --git a/lib/src/screens/dashboard/pages/balance_page.dart b/lib/src/screens/dashboard/pages/balance_page.dart index 453adccf5..bb3ec70dc 100644 --- a/lib/src/screens/dashboard/pages/balance_page.dart +++ b/lib/src/screens/dashboard/pages/balance_page.dart @@ -6,6 +6,7 @@ import 'package:cake_wallet/reactions/wallet_connect.dart'; import 'package:cake_wallet/routes.dart'; import 'package:cake_wallet/src/screens/dashboard/pages/nft_listing_page.dart'; import 'package:cake_wallet/src/screens/dashboard/widgets/home_screen_account_widget.dart'; +import 'package:cake_wallet/src/widgets/cake_image_widget.dart'; import 'package:cake_wallet/src/screens/exchange_trade/information_page.dart'; import 'package:cake_wallet/src/widgets/introducing_card.dart'; import 'package:cake_wallet/store/settings_store.dart'; @@ -333,15 +334,11 @@ class BalanceRowWidget extends StatelessWidget { child: Center( child: Column( children: [ - currency.iconPath != null - ? Container( - child: Image.asset( - currency.iconPath!, - height: 40.0, - width: 40.0, - ), - ) - : Container( + CakeImageWidget( + imageUrl: currency.iconPath, + height: 40, + width: 40, + displayOnError: Container( height: 30.0, width: 30.0, child: Center( @@ -355,6 +352,7 @@ class BalanceRowWidget extends StatelessWidget { color: Colors.grey.shade400, ), ), + ), const SizedBox(height: 10), Text( currency.title, diff --git a/lib/src/screens/dashboard/pages/nft_details_page.dart b/lib/src/screens/dashboard/pages/nft_details_page.dart index bb642fd4b..15d2a2b5c 100644 --- a/lib/src/screens/dashboard/pages/nft_details_page.dart +++ b/lib/src/screens/dashboard/pages/nft_details_page.dart @@ -2,7 +2,7 @@ import 'package:cake_wallet/entities/wallet_nft_response.dart'; import 'package:cake_wallet/generated/i18n.dart'; import 'package:cake_wallet/src/screens/base_page.dart'; import 'package:cake_wallet/src/screens/dashboard/widgets/menu_widget.dart'; -import 'package:cake_wallet/src/screens/dashboard/widgets/nft_image_tile_widget.dart'; +import 'package:cake_wallet/src/widgets/cake_image_widget.dart'; import 'package:cake_wallet/src/widgets/gradient_background.dart'; import 'package:cake_wallet/themes/extensions/balance_page_theme.dart'; import 'package:cake_wallet/themes/extensions/dashboard_page_theme.dart'; @@ -94,7 +94,7 @@ class NFTDetailsPage extends BasePage { .syncedBackgroundColor, ), - child: NFTImageWidget( + child: CakeImageWidget( imageUrl: nftAsset.normalizedMetadata?.imageUrl, ), ), diff --git a/lib/src/screens/dashboard/widgets/menu_widget.dart b/lib/src/screens/dashboard/widgets/menu_widget.dart index ed9b823ad..acd666025 100644 --- a/lib/src/screens/dashboard/widgets/menu_widget.dart +++ b/lib/src/screens/dashboard/widgets/menu_widget.dart @@ -18,23 +18,23 @@ class MenuWidget extends StatefulWidget { class MenuWidgetState extends State { MenuWidgetState() - : this.menuWidth = 0, - this.screenWidth = 0, - this.screenHeight = 0, - this.headerHeight = 120, - this.tileHeight = 60, - this.fromTopEdge = 50, - this.fromBottomEdge = 25, - this.moneroIcon = Image.asset('assets/images/monero_menu.png'), - this.bitcoinIcon = Image.asset('assets/images/bitcoin_menu.png'), - this.litecoinIcon = Image.asset('assets/images/litecoin_menu.png'), - this.havenIcon = Image.asset('assets/images/haven_menu.png'), - this.ethereumIcon = Image.asset('assets/images/eth_icon.png'), - this.nanoIcon = Image.asset('assets/images/nano_icon.png'), - this.bananoIcon = Image.asset('assets/images/nano_icon.png'), + : this.menuWidth = 0, + this.screenWidth = 0, + this.screenHeight = 0, + this.headerHeight = 120, + this.tileHeight = 60, + this.fromTopEdge = 50, + this.fromBottomEdge = 25, + this.moneroIcon = Image.asset('assets/images/monero_menu.png'), + this.bitcoinIcon = Image.asset('assets/images/bitcoin_menu.png'), + this.litecoinIcon = Image.asset('assets/images/litecoin_menu.png'), + this.havenIcon = Image.asset('assets/images/haven_menu.png'), + this.ethereumIcon = Image.asset('assets/images/eth_icon.png'), + this.nanoIcon = Image.asset('assets/images/nano_icon.png'), + this.bananoIcon = Image.asset('assets/images/nano_icon.png'), this.bitcoinCashIcon = Image.asset('assets/images/bch_icon.png'), - this.polygonIcon = Image.asset('assets/images/matic_icon.png'); - + this.polygonIcon = Image.asset('assets/images/matic_icon.png'), + this.solanaIcon = Image.asset('assets/images/sol_icon.png'); final largeScreen = 731; @@ -56,7 +56,7 @@ class MenuWidgetState extends State { Image nanoIcon; Image bananoIcon; Image polygonIcon; - + Image solanaIcon; @override void initState() { @@ -224,6 +224,8 @@ class MenuWidgetState extends State { return bananoIcon; case WalletType.polygon: return polygonIcon; + case WalletType.solana: + return solanaIcon; default: throw Exception('No icon for ${type.toString()}'); } diff --git a/lib/src/screens/dashboard/widgets/nft_tile_widget.dart b/lib/src/screens/dashboard/widgets/nft_tile_widget.dart index e7391b970..4c4d214e7 100644 --- a/lib/src/screens/dashboard/widgets/nft_tile_widget.dart +++ b/lib/src/screens/dashboard/widgets/nft_tile_widget.dart @@ -1,9 +1,8 @@ import 'package:cake_wallet/entities/wallet_nft_response.dart'; import 'package:cake_wallet/routes.dart'; -import 'package:cake_wallet/src/screens/dashboard/widgets/nft_image_tile_widget.dart'; +import 'package:cake_wallet/src/widgets/cake_image_widget.dart'; import 'package:cake_wallet/themes/extensions/balance_page_theme.dart'; import 'package:cake_wallet/themes/extensions/sync_indicator_theme.dart'; -import 'package:flutter/cupertino.dart'; import 'package:flutter/material.dart'; class NFTTileWidget extends StatelessWidget { @@ -38,7 +37,7 @@ class NFTTileWidget extends StatelessWidget { ), color: Theme.of(context).extension()!.syncedBackgroundColor, ), - child: NFTImageWidget( + child: CakeImageWidget( imageUrl: nftAsset.normalizedMetadata?.imageUrl, ), ), diff --git a/lib/src/screens/send/send_page.dart b/lib/src/screens/send/send_page.dart index 3746118d8..5870b1c4d 100644 --- a/lib/src/screens/send/send_page.dart +++ b/lib/src/screens/send/send_page.dart @@ -14,6 +14,7 @@ import 'package:cake_wallet/utils/payment_request.dart'; import 'package:cake_wallet/utils/request_review_handler.dart'; import 'package:cake_wallet/utils/responsive_layout_util.dart'; import 'package:cake_wallet/view_model/send/output.dart'; +import 'package:cw_core/wallet_type.dart'; import 'package:flutter/material.dart'; import 'package:flutter_mobx/flutter_mobx.dart'; import 'package:mobx/mobx.dart'; @@ -439,10 +440,17 @@ class SendPage extends BasePage { } if (state is TransactionCommitted) { + String alertContent; + if (sendViewModel.walletType == WalletType.solana) { + alertContent = + '${S.of(_dialogContext).send_success(sendViewModel.selectedCryptoCurrency.toString())}. ${S.of(_dialogContext).waitFewSecondForTxUpdate}'; + } else { + alertContent = S.of(_dialogContext).send_success( + sendViewModel.selectedCryptoCurrency.toString()); + } return AlertWithOneAction( alertTitle: '', - alertContent: S.of(_dialogContext).send_success( - sendViewModel.selectedCryptoCurrency.toString()), + alertContent: alertContent, buttonText: S.of(_dialogContext).ok, buttonAction: () { Navigator.of(_dialogContext).pop(); diff --git a/lib/src/screens/send/widgets/send_card.dart b/lib/src/screens/send/widgets/send_card.dart index 5f15c9c4d..6bd2d81e9 100644 --- a/lib/src/screens/send/widgets/send_card.dart +++ b/lib/src/screens/send/widgets/send_card.dart @@ -321,7 +321,7 @@ class SendCardState extends State with AutomaticKeepAliveClientMixin Navigator.of(context).pushNamed(Routes.walletConnectConnectionsListing), ), diff --git a/lib/src/screens/settings/other_settings_page.dart b/lib/src/screens/settings/other_settings_page.dart index c88804147..fcf683050 100644 --- a/lib/src/screens/settings/other_settings_page.dart +++ b/lib/src/screens/settings/other_settings_page.dart @@ -26,7 +26,7 @@ class OtherSettingsPage extends BasePage { padding: EdgeInsets.only(top: 10), child: Column( children: [ - if (!_otherSettingsViewModel.changeRepresentativeEnabled) + if (_otherSettingsViewModel.displayTransactionPriority) SettingsPickerCell( title: S.current.settings_fee_priority, items: priorityForWalletType(_otherSettingsViewModel.walletType), diff --git a/lib/src/screens/wallet_connect/widgets/pairing_item_widget.dart b/lib/src/screens/wallet_connect/widgets/pairing_item_widget.dart index 063de8ec3..0d425f904 100644 --- a/lib/src/screens/wallet_connect/widgets/pairing_item_widget.dart +++ b/lib/src/screens/wallet_connect/widgets/pairing_item_widget.dart @@ -1,4 +1,5 @@ import 'package:cake_wallet/generated/i18n.dart'; +import 'package:cake_wallet/src/widgets/cake_image_widget.dart'; import 'package:cake_wallet/themes/extensions/cake_text_theme.dart'; import 'package:cake_wallet/themes/extensions/receive_page_theme.dart'; import 'package:flutter/material.dart'; @@ -26,12 +27,11 @@ class PairingItemWidget extends StatelessWidget { '$year-${month.toString().padLeft(2, '0')}-${day.toString().padLeft(2, '0')}'; return ListTile( - leading: CircleAvatar( - backgroundImage: (metadata.icons.isNotEmpty - ? NetworkImage(metadata.icons[0]) - : const AssetImage( - 'assets/images/default_icon.png', - )) as ImageProvider, + leading: CakeImageWidget( + imageUrl: metadata.icons.isNotEmpty ? metadata.icons[0]: null, + displayOnError: CircleAvatar( + backgroundImage: AssetImage('assets/images/default_icon.png'), + ), ), title: Text( metadata.name, diff --git a/lib/src/screens/wallet_list/wallet_list_page.dart b/lib/src/screens/wallet_list/wallet_list_page.dart index 717bb0a94..b57473cba 100644 --- a/lib/src/screens/wallet_list/wallet_list_page.dart +++ b/lib/src/screens/wallet_list/wallet_list_page.dart @@ -103,6 +103,7 @@ class WalletListBodyState extends State { final bitcoinCashIcon = Image.asset('assets/images/bch_icon.png', height: 24, width: 24); final nanoIcon = Image.asset('assets/images/nano_icon.png', height: 24, width: 24); final polygonIcon = Image.asset('assets/images/matic_icon.png', height: 24, width: 24); + final solanaIcon = Image.asset('assets/images/sol_icon.png', height: 24, width: 24); final scrollController = ScrollController(); final double tileHeight = 60; Flushbar? _progressBar; @@ -313,6 +314,8 @@ class WalletListBodyState extends State { return nanoIcon; case WalletType.polygon: return polygonIcon; + case WalletType.solana: + return solanaIcon; default: return nonWalletTypeIcon; } diff --git a/lib/src/screens/dashboard/widgets/nft_image_tile_widget.dart b/lib/src/widgets/cake_image_widget.dart similarity index 51% rename from lib/src/screens/dashboard/widgets/nft_image_tile_widget.dart rename to lib/src/widgets/cake_image_widget.dart index d34ff02cb..14c62ad34 100644 --- a/lib/src/screens/dashboard/widgets/nft_image_tile_widget.dart +++ b/lib/src/widgets/cake_image_widget.dart @@ -2,25 +2,45 @@ import 'package:flutter/cupertino.dart'; import 'package:flutter/material.dart'; import 'package:flutter_svg/flutter_svg.dart'; -class NFTImageWidget extends StatelessWidget { - const NFTImageWidget({ +class CakeImageWidget extends StatelessWidget { + CakeImageWidget({ required this.imageUrl, - }); + Widget? displayOnError, + this.height, + this.width, + }) : _displayOnError = displayOnError ?? Icon(Icons.error); final String? imageUrl; + final double? height; + final double? width; + final Widget? _displayOnError; @override Widget build(BuildContext context) { try { - if (imageUrl == null) return Icon(Icons.error); + if (imageUrl == null) return _displayOnError!; + + if (imageUrl!.contains('assets/images')) { + return Image.asset( + imageUrl!, + height: height, + width: width, + ); + } if (imageUrl!.contains('.svg')) { - return SvgPicture.network(imageUrl!); + return SvgPicture.network( + imageUrl!, + height: height, + width: width, + ); } return Image.network( imageUrl!, fit: BoxFit.cover, + height: height, + width: width, loadingBuilder: (BuildContext _, Widget child, ImageChunkEvent? loadingProgress) { if (loadingProgress == null) { return child; @@ -31,7 +51,7 @@ class NFTImageWidget extends StatelessWidget { errorBuilder: (_, __, ___) => Icon(Icons.error), ); } catch (_) { - return Icon(Icons.error); + return _displayOnError!; } } } diff --git a/lib/store/app_store.dart b/lib/store/app_store.dart index a5a2b95e0..7d61abfc5 100644 --- a/lib/store/app_store.dart +++ b/lib/store/app_store.dart @@ -41,7 +41,7 @@ abstract class AppStoreBase with Store { this.wallet = wallet; this.wallet!.setExceptionHandler(ExceptionHandler.onError); - if (isEVMCompatibleChain(wallet.type)) { + if (isWalletConnectCompatibleChain(wallet.type)) { await getIt.get().onDispose(); getIt.get().create(); await getIt.get().init(); diff --git a/lib/store/settings_store.dart b/lib/store/settings_store.dart index 253adf3ea..672b29269 100644 --- a/lib/store/settings_store.dart +++ b/lib/store/settings_store.dart @@ -838,6 +838,7 @@ abstract class SettingsStoreBase with Store { final polygonNodeId = sharedPreferences.getInt(PreferencesKey.currentPolygonNodeIdKey); final nanoNodeId = sharedPreferences.getInt(PreferencesKey.currentNanoNodeIdKey); final nanoPowNodeId = sharedPreferences.getInt(PreferencesKey.currentNanoPowNodeIdKey); + final solanaNodeId = sharedPreferences.getInt(PreferencesKey.currentSolanaNodeIdKey); final moneroNode = nodeSource.get(nodeId); final bitcoinElectrumServer = nodeSource.get(bitcoinElectrumServerId); final litecoinElectrumServer = nodeSource.get(litecoinElectrumServerId); @@ -847,6 +848,7 @@ abstract class SettingsStoreBase with Store { final bitcoinCashElectrumServer = nodeSource.get(bitcoinCashElectrumServerId); final nanoNode = nodeSource.get(nanoNodeId); final nanoPowNode = powNodeSource.get(nanoPowNodeId); + final solanaNode = nodeSource.get(solanaNodeId); final packageInfo = await PackageInfo.fromPlatform(); final deviceName = await _getDeviceName() ?? ''; final shouldShowYatPopup = sharedPreferences.getBool(PreferencesKey.shouldShowYatPopup) ?? true; @@ -903,6 +905,10 @@ abstract class SettingsStoreBase with Store { powNodes[WalletType.nano] = nanoPowNode; } + if (solanaNode != null) { + nodes[WalletType.solana] = solanaNode; + } + final savedSyncMode = SyncMode.all.firstWhere((element) { return element.type.index == (sharedPreferences.getInt(PreferencesKey.syncModeKey) ?? 0); }); @@ -1190,6 +1196,7 @@ abstract class SettingsStoreBase with Store { final ethereumNodeId = sharedPreferences.getInt(PreferencesKey.currentEthereumNodeIdKey); final polygonNodeId = sharedPreferences.getInt(PreferencesKey.currentPolygonNodeIdKey); final nanoNodeId = sharedPreferences.getInt(PreferencesKey.currentNanoNodeIdKey); + final solanaNodeId = sharedPreferences.getInt(PreferencesKey.currentSolanaNodeIdKey); final moneroNode = nodeSource.get(nodeId); final bitcoinElectrumServer = nodeSource.get(bitcoinElectrumServerId); final litecoinElectrumServer = nodeSource.get(litecoinElectrumServerId); @@ -1198,7 +1205,7 @@ abstract class SettingsStoreBase with Store { final polygonNode = nodeSource.get(polygonNodeId); final bitcoinCashNode = nodeSource.get(bitcoinCashElectrumServerId); final nanoNode = nodeSource.get(nanoNodeId); - + final solanaNode = nodeSource.get(solanaNodeId); if (moneroNode != null) { nodes[WalletType.monero] = moneroNode; } @@ -1231,6 +1238,10 @@ abstract class SettingsStoreBase with Store { nodes[WalletType.nano] = nanoNode; } + if (solanaNode != null) { + nodes[WalletType.solana] = solanaNode; + } + // MIGRATED: useTOTP2FA = await SecureKey.getBool( @@ -1358,6 +1369,9 @@ abstract class SettingsStoreBase with Store { case WalletType.polygon: await _sharedPreferences.setInt(PreferencesKey.currentPolygonNodeIdKey, node.key as int); break; + case WalletType.solana: + await _sharedPreferences.setInt(PreferencesKey.currentSolanaNodeIdKey, node.key as int); + break; default: break; } diff --git a/lib/view_model/advanced_privacy_settings_view_model.dart b/lib/view_model/advanced_privacy_settings_view_model.dart index 75d7a9eb4..14033b368 100644 --- a/lib/view_model/advanced_privacy_settings_view_model.dart +++ b/lib/view_model/advanced_privacy_settings_view_model.dart @@ -28,7 +28,10 @@ abstract class AdvancedPrivacySettingsViewModelBase with Store { final SettingsStore _settingsStore; bool get hasSeedPhraseLengthOption => - type == WalletType.bitcoinCash || type == WalletType.ethereum; + type == WalletType.bitcoinCash || + type == WalletType.ethereum || + type == WalletType.polygon || + type == WalletType.solana; bool get hasSeedTypeOption => type == WalletType.monero; diff --git a/lib/view_model/dashboard/balance_view_model.dart b/lib/view_model/dashboard/balance_view_model.dart index e2c0382b0..eee53516e 100644 --- a/lib/view_model/dashboard/balance_view_model.dart +++ b/lib/view_model/dashboard/balance_view_model.dart @@ -18,15 +18,15 @@ import 'package:mobx/mobx.dart'; part 'balance_view_model.g.dart'; class BalanceRecord { - const BalanceRecord({ - required this.availableBalance, - required this.additionalBalance, - required this.frozenBalance, - required this.fiatAvailableBalance, - required this.fiatAdditionalBalance, - required this.fiatFrozenBalance, - required this.asset, - required this.formattedAssetTitle}); + const BalanceRecord( + {required this.availableBalance, + required this.additionalBalance, + required this.frozenBalance, + required this.fiatAvailableBalance, + required this.fiatAdditionalBalance, + required this.fiatFrozenBalance, + required this.asset, + required this.formattedAssetTitle}); final String fiatAdditionalBalance; final String fiatAvailableBalance; final String fiatFrozenBalance; @@ -41,12 +41,10 @@ class BalanceViewModel = BalanceViewModelBase with _$BalanceViewModel; abstract class BalanceViewModelBase with Store { BalanceViewModelBase( - {required this.appStore, - required this.settingsStore, - required this.fiatConvertationStore}) - : isReversing = false, - isShowCard = appStore.wallet!.walletInfo.isShowIntroCakePayCard, - wallet = appStore.wallet! { + {required this.appStore, required this.settingsStore, required this.fiatConvertationStore}) + : isReversing = false, + isShowCard = appStore.wallet!.walletInfo.isShowIntroCakePayCard, + wallet = appStore.wallet! { reaction((_) => appStore.wallet, _onWalletChange); } @@ -60,8 +58,7 @@ abstract class BalanceViewModelBase with Store { bool isReversing; @observable - WalletBase, TransactionInfo> - wallet; + WalletBase, TransactionInfo> wallet; @computed double get price { @@ -82,7 +79,8 @@ abstract class BalanceViewModelBase with Store { bool get isFiatDisabled => settingsStore.fiatApiMode == FiatApiMode.disabled; @computed - bool get isHomeScreenSettingsEnabled => isEVMCompatibleChain(wallet.type); + bool get isHomeScreenSettingsEnabled => + isEVMCompatibleChain(wallet.type) || wallet.type == WalletType.solana; @computed bool get hasAccounts => wallet.type == WalletType.monero; @@ -97,7 +95,7 @@ abstract class BalanceViewModelBase with Store { String get asset { final typeFormatted = walletTypeToString(appStore.wallet!.type); - switch(wallet.type) { + switch (wallet.type) { case WalletType.haven: return '$typeFormatted Assets'; default: @@ -120,13 +118,14 @@ abstract class BalanceViewModelBase with Store { @computed String get availableBalanceLabel { - switch(wallet.type) { + switch (wallet.type) { case WalletType.monero: case WalletType.haven: case WalletType.ethereum: case WalletType.polygon: case WalletType.nano: case WalletType.banano: + case WalletType.solana: return S.current.xmr_available_balance; default: return S.current.confirmed; @@ -135,11 +134,12 @@ abstract class BalanceViewModelBase with Store { @computed String get additionalBalanceLabel { - switch(wallet.type) { + switch (wallet.type) { case WalletType.monero: case WalletType.haven: case WalletType.ethereum: case WalletType.polygon: + case WalletType.solana: return S.current.xmr_full_balance; case WalletType.nano: case WalletType.banano: @@ -228,15 +228,17 @@ abstract class BalanceViewModelBase with Store { Map get balances { return wallet.balance.map((key, value) { if (displayMode == BalanceDisplayMode.hiddenBalance) { - return MapEntry(key, BalanceRecord( - availableBalance: '---', - additionalBalance: '---', - frozenBalance: '---', - fiatAdditionalBalance: isFiatDisabled ? '' : '---', - fiatAvailableBalance: isFiatDisabled ? '' : '---', - fiatFrozenBalance: isFiatDisabled ? '' : '---', - asset: key, - formattedAssetTitle: _formatterAsset(key))); + return MapEntry( + key, + BalanceRecord( + availableBalance: '---', + additionalBalance: '---', + frozenBalance: '---', + fiatAdditionalBalance: isFiatDisabled ? '' : '---', + fiatAvailableBalance: isFiatDisabled ? '' : '---', + fiatFrozenBalance: isFiatDisabled ? '' : '---', + asset: key, + formattedAssetTitle: _formatterAsset(key))); } final fiatCurrency = settingsStore.fiatCurrency; final price = fiatConvertationStore.prices[key] ?? 0; @@ -245,25 +247,23 @@ abstract class BalanceViewModelBase with Store { // throw Exception('Price is null for: $key'); // } - final additionalFiatBalance = isFiatDisabled ? '' : (fiatCurrency.toString() - + ' ' - + _getFiatBalance( - price: price, - cryptoAmount: value.formattedAdditionalBalance)); + final additionalFiatBalance = isFiatDisabled + ? '' + : (fiatCurrency.toString() + + ' ' + + _getFiatBalance(price: price, cryptoAmount: value.formattedAdditionalBalance)); - final availableFiatBalance = isFiatDisabled ? '' : (fiatCurrency.toString() - + ' ' - + _getFiatBalance( - price: price, - cryptoAmount: value.formattedAvailableBalance)); - - - final frozenFiatBalance = isFiatDisabled ? '' : (fiatCurrency.toString() - + ' ' - + _getFiatBalance( - price: price, - cryptoAmount: getFormattedFrozenBalance(value))); + final availableFiatBalance = isFiatDisabled + ? '' + : (fiatCurrency.toString() + + ' ' + + _getFiatBalance(price: price, cryptoAmount: value.formattedAvailableBalance)); + final frozenFiatBalance = isFiatDisabled + ? '' + : (fiatCurrency.toString() + + ' ' + + _getFiatBalance(price: price, cryptoAmount: getFormattedFrozenBalance(value))); return MapEntry( key, @@ -276,12 +276,22 @@ abstract class BalanceViewModelBase with Store { fiatFrozenBalance: frozenFiatBalance, asset: key, formattedAssetTitle: _formatterAsset(key))); - }); + }); } @computed - bool get hasAdditionalBalance => !isEVMCompatibleChain(wallet.type); - + bool get hasAdditionalBalance => _hasAdditionBalanceForWalletType(wallet.type); + + bool _hasAdditionBalanceForWalletType(WalletType type) { + switch (type) { + case WalletType.ethereum: + case WalletType.polygon: + case WalletType.solana: + return false; + default: + return true; + } + } @computed List get formattedBalances { @@ -358,9 +368,7 @@ abstract class BalanceViewModelBase with Store { @action void _onWalletChange( - WalletBase, - TransactionInfo>? - wallet) { + WalletBase, TransactionInfo>? wallet) { if (wallet == null) { return; } @@ -371,7 +379,7 @@ abstract class BalanceViewModelBase with Store { } @action - Future disableIntroCakePayCard () async { + Future disableIntroCakePayCard() async { const cardDisplayStatus = false; wallet.walletInfo.showIntroCakePayCard = cardDisplayStatus; await wallet.walletInfo.save(); @@ -401,6 +409,6 @@ abstract class BalanceViewModelBase with Store { } } - String getFormattedFrozenBalance(Balance walletBalance) => walletBalance.formattedUnAvailableBalance; + String getFormattedFrozenBalance(Balance walletBalance) => + walletBalance.formattedUnAvailableBalance; } - diff --git a/lib/view_model/dashboard/home_settings_view_model.dart b/lib/view_model/dashboard/home_settings_view_model.dart index fc2c27a7c..6d31a5af8 100644 --- a/lib/view_model/dashboard/home_settings_view_model.dart +++ b/lib/view_model/dashboard/home_settings_view_model.dart @@ -3,6 +3,7 @@ import 'package:cake_wallet/entities/fiat_api_mode.dart'; import 'package:cake_wallet/entities/sort_balance_types.dart'; import 'package:cake_wallet/ethereum/ethereum.dart'; import 'package:cake_wallet/polygon/polygon.dart'; +import 'package:cake_wallet/solana/solana.dart'; import 'package:cake_wallet/store/settings_store.dart'; import 'package:cake_wallet/view_model/dashboard/balance_view_model.dart'; import 'package:cw_core/crypto_currency.dart'; @@ -16,14 +17,14 @@ class HomeSettingsViewModel = HomeSettingsViewModelBase with _$HomeSettingsViewM abstract class HomeSettingsViewModelBase with Store { HomeSettingsViewModelBase(this._settingsStore, this._balanceViewModel) - : tokens = ObservableSet() { + : tokens = ObservableSet() { _updateTokensList(); } final SettingsStore _settingsStore; final BalanceViewModel _balanceViewModel; - final ObservableSet tokens; + final ObservableSet tokens; @observable String searchText = ''; @@ -43,7 +44,7 @@ abstract class HomeSettingsViewModelBase with Store { @action void setPinNativeToken(bool value) => _settingsStore.pinNativeTokenAtTop = value; - Future addErc20Token(Erc20Token token) async { + Future addToken(CryptoCurrency token) async { if (_balanceViewModel.wallet.type == WalletType.ethereum) { await ethereum!.addErc20Token(_balanceViewModel.wallet, token); } @@ -52,23 +53,31 @@ abstract class HomeSettingsViewModelBase with Store { await polygon!.addErc20Token(_balanceViewModel.wallet, token); } + if (_balanceViewModel.wallet.type == WalletType.solana) { + await solana!.addSPLToken(_balanceViewModel.wallet, token); + } + _updateTokensList(); _updateFiatPrices(token); } - Future deleteErc20Token(Erc20Token token) async { + Future deleteToken(CryptoCurrency token) async { if (_balanceViewModel.wallet.type == WalletType.ethereum) { - await ethereum!.deleteErc20Token(_balanceViewModel.wallet, token); + await ethereum!.deleteErc20Token(_balanceViewModel.wallet, token as Erc20Token); } if (_balanceViewModel.wallet.type == WalletType.polygon) { - await polygon!.deleteErc20Token(_balanceViewModel.wallet, token); + await polygon!.deleteErc20Token(_balanceViewModel.wallet, token as Erc20Token); + } + + if (_balanceViewModel.wallet.type == WalletType.solana) { + await solana!.deleteSPLToken(_balanceViewModel.wallet, token); } _updateTokensList(); } - Future getErc20Token(String contractAddress) async { + Future getToken(String contractAddress) async { if (_balanceViewModel.wallet.type == WalletType.ethereum) { return await ethereum!.getErc20Token(_balanceViewModel.wallet, contractAddress); } @@ -77,12 +86,16 @@ abstract class HomeSettingsViewModelBase with Store { return await polygon!.getErc20Token(_balanceViewModel.wallet, contractAddress); } + if (_balanceViewModel.wallet.type == WalletType.solana) { + return await solana!.getSPLToken(_balanceViewModel.wallet, contractAddress); + } + return null; } CryptoCurrency get nativeToken => _balanceViewModel.wallet.currency; - void _updateFiatPrices(Erc20Token token) async { + void _updateFiatPrices(CryptoCurrency token) async { try { _balanceViewModel.fiatConvertationStore.prices[token] = await FiatConversionService.fetchPrice( @@ -92,20 +105,27 @@ abstract class HomeSettingsViewModelBase with Store { } catch (_) {} } - void changeTokenAvailability(Erc20Token token, bool value) async { + void changeTokenAvailability(CryptoCurrency token, bool value) async { token.enabled = value; + if (_balanceViewModel.wallet.type == WalletType.ethereum) { - ethereum!.addErc20Token(_balanceViewModel.wallet, token); + ethereum!.addErc20Token(_balanceViewModel.wallet, token as Erc20Token); } + if (_balanceViewModel.wallet.type == WalletType.polygon) { - polygon!.addErc20Token(_balanceViewModel.wallet, token); + polygon!.addErc20Token(_balanceViewModel.wallet, token as Erc20Token); } + + if (_balanceViewModel.wallet.type == WalletType.solana) { + solana!.addSPLToken(_balanceViewModel.wallet, token); + } + _refreshTokensList(); } @action void _updateTokensList() { - int _sortFunc(Erc20Token e1, Erc20Token e2) { + int _sortFunc(CryptoCurrency e1, CryptoCurrency e2) { int index1 = _balanceViewModel.formattedBalances.indexWhere((element) => element.asset == e1); int index2 = _balanceViewModel.formattedBalances.indexWhere((element) => element.asset == e2); @@ -138,6 +158,14 @@ abstract class HomeSettingsViewModelBase with Store { .toList() ..sort(_sortFunc)); } + + if (_balanceViewModel.wallet.type == WalletType.solana) { + tokens.addAll(solana! + .getSPLTokenCurrencies(_balanceViewModel.wallet) + .where((element) => _matchesSearchText(element)) + .toList() + ..sort(_sortFunc)); + } } @action @@ -153,10 +181,32 @@ abstract class HomeSettingsViewModelBase with Store { _updateTokensList(); } - bool _matchesSearchText(Erc20Token asset) { + bool _matchesSearchText(CryptoCurrency asset) { + final address = getTokenAddressBasedOnWallet(asset); + + // The homes settings would only be displayed for either of Ethereum, Polygon or Solana Wallets. + if (address == null) return false; + return searchText.isEmpty || asset.fullName!.toLowerCase().contains(searchText.toLowerCase()) || asset.title.toLowerCase().contains(searchText.toLowerCase()) || - asset.contractAddress == searchText; + address == searchText; + } + + String? getTokenAddressBasedOnWallet(CryptoCurrency asset) { + if (_balanceViewModel.wallet.type == WalletType.solana) { + return solana!.getTokenAddress(asset); + } + + if (_balanceViewModel.wallet.type == WalletType.ethereum) { + return ethereum!.getTokenAddress(asset); + } + + if (_balanceViewModel.wallet.type == WalletType.polygon) { + return polygon!.getTokenAddress(asset); + } + + // We return null if it's neither Polygin, Ethereum or Solana wallet (which is actually impossible because we only display home settings for either of these three wallets). + return null; } } diff --git a/lib/view_model/dashboard/transaction_list_item.dart b/lib/view_model/dashboard/transaction_list_item.dart index d8c4776b7..99de14a18 100644 --- a/lib/view_model/dashboard/transaction_list_item.dart +++ b/lib/view_model/dashboard/transaction_list_item.dart @@ -4,6 +4,7 @@ import 'package:cake_wallet/ethereum/ethereum.dart'; import 'package:cake_wallet/generated/i18n.dart'; import 'package:cake_wallet/nano/nano.dart'; import 'package:cake_wallet/polygon/polygon.dart'; +import 'package:cake_wallet/solana/solana.dart'; import 'package:cw_core/transaction_direction.dart'; import 'package:cw_core/transaction_info.dart'; import 'package:cake_wallet/store/settings_store.dart'; @@ -105,6 +106,14 @@ class TransactionListItem extends ActionListItem with Keyable { nano!.getTransactionAmountRaw(transaction).toString(), nanoUtil!.rawPerNano)), price: price); break; + case WalletType.solana: + final asset = solana!.assetOfTransaction(balanceViewModel.wallet, transaction); + final price = balanceViewModel.fiatConvertationStore.prices[asset]; + amount = calculateFiatAmountRaw( + cryptoAmount: solana!.getTransactionAmountRaw(transaction), + price: price, + ); + break; default: break; } diff --git a/lib/view_model/exchange/exchange_view_model.dart b/lib/view_model/exchange/exchange_view_model.dart index afe617803..1540ebef3 100644 --- a/lib/view_model/exchange/exchange_view_model.dart +++ b/lib/view_model/exchange/exchange_view_model.dart @@ -625,6 +625,10 @@ abstract class ExchangeViewModelBase extends WalletChangeListenerViewModel with depositCurrency = CryptoCurrency.maticpoly; receiveCurrency = CryptoCurrency.xmr; break; + case WalletType.solana: + depositCurrency = CryptoCurrency.sol; + receiveCurrency = CryptoCurrency.xmr; + break; default: break; } diff --git a/lib/view_model/node_list/node_list_view_model.dart b/lib/view_model/node_list/node_list_view_model.dart index 0cd4d7491..5526cc6d2 100644 --- a/lib/view_model/node_list/node_list_view_model.dart +++ b/lib/view_model/node_list/node_list_view_model.dart @@ -75,6 +75,9 @@ abstract class NodeListViewModelBase with Store { case WalletType.polygon: node = getPolygonDefaultNode(nodes: _nodeSource)!; break; + case WalletType.solana: + node = getSolanaDefaultNode(nodes: _nodeSource)!; + break; default: throw Exception('Unexpected wallet type: ${_appStore.wallet!.type}'); } diff --git a/lib/view_model/restore/restore_from_qr_vm.dart b/lib/view_model/restore/restore_from_qr_vm.dart index c8637c4be..31f0bfdd2 100644 --- a/lib/view_model/restore/restore_from_qr_vm.dart +++ b/lib/view_model/restore/restore_from_qr_vm.dart @@ -3,6 +3,7 @@ import 'package:cake_wallet/bitcoin_cash/bitcoin_cash.dart'; import 'package:cake_wallet/ethereum/ethereum.dart'; import 'package:cake_wallet/nano/nano.dart'; import 'package:cake_wallet/polygon/polygon.dart'; +import 'package:cake_wallet/solana/solana.dart'; import 'package:cake_wallet/view_model/restore/restore_mode.dart'; import 'package:cake_wallet/view_model/restore/restore_wallet.dart'; import 'package:hive/hive.dart'; @@ -75,6 +76,9 @@ abstract class WalletRestorationFromQRVMBase extends WalletCreationVM with Store case WalletType.polygon: return polygon!.createPolygonRestoreWalletFromPrivateKey( name: name, password: password, privateKey: restoreWallet.privateKey!); + case WalletType.solana: + return solana!.createSolanaRestoreWalletFromPrivateKey( + name: name, password: password, privateKey: restoreWallet.privateKey!); default: throw Exception('Unexpected type: ${restoreWallet.type.toString()}'); } @@ -102,6 +106,9 @@ abstract class WalletRestorationFromQRVMBase extends WalletCreationVM with Store case WalletType.polygon: return polygon!.createPolygonRestoreWalletFromSeedCredentials( name: name, mnemonic: restoreWallet.mnemonicSeed ?? '', password: password); + case WalletType.solana: + return solana!.createSolanaRestoreWalletFromSeedCredentials( + name: name, mnemonic: restoreWallet.mnemonicSeed ?? '', password: password); default: throw Exception('Unexpected type: ${type.toString()}'); } diff --git a/lib/view_model/restore/wallet_restore_from_qr_code.dart b/lib/view_model/restore/wallet_restore_from_qr_code.dart index bfc9b7980..925c08cca 100644 --- a/lib/view_model/restore/wallet_restore_from_qr_code.dart +++ b/lib/view_model/restore/wallet_restore_from_qr_code.dart @@ -32,6 +32,7 @@ class WalletRestoreFromQRCode { 'bitcoincash': WalletType.bitcoinCash, 'bitcoincash-wallet': WalletType.bitcoinCash, 'bitcoincash_wallet': WalletType.bitcoinCash, + 'solana-wallet': WalletType.solana, }; static bool _containsAssetSpecifier(String code) => _extractWalletType(code) != null; @@ -175,6 +176,14 @@ class WalletRestoreFromQRCode { return WalletRestoreMode.seed; } + if (type == WalletType.solana && credentials.containsKey('private_key')) { + final privateKey = credentials['private_key'] as String; + if (privateKey.isEmpty) { + throw Exception('Unexpected restore mode: private_key'); + } + return WalletRestoreMode.keys; + } + throw Exception('Unexpected restore mode: restore params are invalid'); } } diff --git a/lib/view_model/send/output.dart b/lib/view_model/send/output.dart index e40000139..cc39aca8b 100644 --- a/lib/view_model/send/output.dart +++ b/lib/view_model/send/output.dart @@ -148,9 +148,8 @@ abstract class OutputBase with Store { @computed String get estimatedFeeFiatAmount { try { - final currency = isEVMCompatibleChain(_wallet.type) - ? _wallet.currency - : cryptoCurrencyHandler(); + final currency = + isEVMCompatibleChain(_wallet.type) ? _wallet.currency : cryptoCurrencyHandler(); final fiat = calculateFiatAmountRaw( price: _fiatConversationStore.prices[currency]!, cryptoAmount: estimatedFee); return fiat; @@ -220,7 +219,6 @@ abstract class OutputBase with Store { final crypto = double.parse(fiatAmount.replaceAll(',', '.')) / _fiatConversationStore.prices[cryptoCurrencyHandler()]!; final cryptoAmountTmp = _cryptoNumberFormat.format(crypto); - if (cryptoAmount != cryptoAmountTmp) { cryptoAmount = cryptoAmountTmp; } @@ -252,6 +250,9 @@ abstract class OutputBase with Store { case WalletType.polygon: maximumFractionDigits = 12; break; + case WalletType.solana: + maximumFractionDigits = 12; + break; default: break; } diff --git a/lib/view_model/send/send_template_view_model.dart b/lib/view_model/send/send_template_view_model.dart index 007c4b8c0..f79fbddc7 100644 --- a/lib/view_model/send/send_template_view_model.dart +++ b/lib/view_model/send/send_template_view_model.dart @@ -52,7 +52,8 @@ abstract class SendTemplateViewModelBase with Store { bool get hasMultiRecipient => _wallet.type != WalletType.haven && _wallet.type != WalletType.ethereum && - _wallet.type != WalletType.polygon; + _wallet.type != WalletType.polygon && + _wallet.type != WalletType.solana; @computed CryptoCurrency get cryptoCurrency => _wallet.currency; diff --git a/lib/view_model/send/send_view_model.dart b/lib/view_model/send/send_view_model.dart index 885e2efe0..772150368 100644 --- a/lib/view_model/send/send_view_model.dart +++ b/lib/view_model/send/send_view_model.dart @@ -7,6 +7,7 @@ import 'package:cake_wallet/entities/contact_record.dart'; import 'package:cake_wallet/entities/wallet_contact.dart'; import 'package:cake_wallet/polygon/polygon.dart'; import 'package:cake_wallet/reactions/wallet_connect.dart'; +import 'package:cake_wallet/solana/solana.dart'; import 'package:cake_wallet/store/app_store.dart'; import 'package:cake_wallet/view_model/contact_list/contact_list_view_model.dart'; import 'package:cake_wallet/view_model/dashboard/balance_view_model.dart'; @@ -44,7 +45,7 @@ abstract class SendViewModelBase extends WalletChangeListenerViewModel with Stor void onWalletChange(wallet) { currencies = wallet.balance.keys.toList(); selectedCryptoCurrency = wallet.currency; - hasMultipleTokens = isEVMCompatibleChain(wallet.type); + hasMultipleTokens = isEVMCompatibleChain(wallet.type) || wallet.type == WalletType.solana; } SendViewModelBase( @@ -57,7 +58,8 @@ abstract class SendViewModelBase extends WalletChangeListenerViewModel with Stor ) : state = InitialExecutionState(), currencies = appStore.wallet!.balance.keys.toList(), selectedCryptoCurrency = appStore.wallet!.currency, - hasMultipleTokens = isEVMCompatibleChain(appStore.wallet!.type), + hasMultipleTokens = isEVMCompatibleChain(appStore.wallet!.type) || + appStore.wallet!.type == WalletType.solana, outputs = ObservableList(), _settingsStore = appStore.settingsStore, fiatFromSettings = appStore.settingsStore.fiatCurrency, @@ -100,6 +102,8 @@ abstract class SendViewModelBase extends WalletChangeListenerViewModel with Stor @computed bool get isBatchSending => outputs.length > 1; + bool get shouldDisplaySendALL => walletType != WalletType.solana; + @computed String get pendingTransactionFiatAmount { if (pendingTransaction == null) { @@ -297,6 +301,7 @@ abstract class SendViewModelBase extends WalletChangeListenerViewModel with Stor pendingTransaction = await wallet.createTransaction(_credentials()); state = ExecutedSuccessfullyState(); } catch (e) { + print('Failed with ${e.toString()}'); state = FailureState(e.toString()); } } @@ -351,7 +356,7 @@ abstract class SendViewModelBase extends WalletChangeListenerViewModel with Stor Object _credentials() { final priority = _settingsStore.priority[wallet.type]; - if (priority == null && wallet.type != WalletType.nano) { + if (priority == null && wallet.type != WalletType.nano && wallet.type != WalletType.solana) { throw Exception('Priority is null for wallet type: ${wallet.type}'); } @@ -377,6 +382,9 @@ abstract class SendViewModelBase extends WalletChangeListenerViewModel with Stor case WalletType.polygon: return polygon!.createPolygonTransactionCredentials(outputs, priority: priority!, currency: selectedCryptoCurrency); + case WalletType.solana: + return solana! + .createSolanaTransactionCredentials(outputs, currency: selectedCryptoCurrency); default: throw Exception('Unexpected wallet type: ${wallet.type}'); } diff --git a/lib/view_model/settings/other_settings_view_model.dart b/lib/view_model/settings/other_settings_view_model.dart index e44eb8fc7..263532d29 100644 --- a/lib/view_model/settings/other_settings_view_model.dart +++ b/lib/view_model/settings/other_settings_view_model.dart @@ -14,15 +14,14 @@ import 'package:package_info/package_info.dart'; part 'other_settings_view_model.g.dart'; -class OtherSettingsViewModel = OtherSettingsViewModelBase - with _$OtherSettingsViewModel; +class OtherSettingsViewModel = OtherSettingsViewModelBase with _$OtherSettingsViewModel; abstract class OtherSettingsViewModelBase with Store { OtherSettingsViewModelBase(this._settingsStore, this._wallet) : walletType = _wallet.type, currentVersion = '' { - PackageInfo.fromPlatform().then( - (PackageInfo packageInfo) => currentVersion = packageInfo.version); + PackageInfo.fromPlatform() + .then((PackageInfo packageInfo) => currentVersion = packageInfo.version); final priority = _settingsStore.priority[_wallet.type]; final priorities = priorityForWalletType(_wallet.type); @@ -33,8 +32,7 @@ abstract class OtherSettingsViewModelBase with Store { } final WalletType walletType; - final WalletBase, - TransactionInfo> _wallet; + final WalletBase, TransactionInfo> _wallet; @observable String currentVersion; @@ -57,12 +55,14 @@ abstract class OtherSettingsViewModelBase with Store { _wallet.type == WalletType.nano || _wallet.type == WalletType.banano; @computed - bool get isEnabledBuyAction => - !_settingsStore.disableBuy && _wallet.type != WalletType.haven; + bool get displayTransactionPriority => + !(changeRepresentativeEnabled || _wallet.type == WalletType.solana); @computed - bool get isEnabledSellAction => - !_settingsStore.disableSell && _wallet.type != WalletType.haven; + bool get isEnabledBuyAction => !_settingsStore.disableBuy && _wallet.type != WalletType.haven; + + @computed + bool get isEnabledSellAction => !_settingsStore.disableSell && _wallet.type != WalletType.haven; List get availableBuyProvidersTypes { return ProvidersHelper.getAvailableBuyProviderTypes(walletType); @@ -72,12 +72,10 @@ abstract class OtherSettingsViewModelBase with Store { ProvidersHelper.getAvailableSellProviderTypes(walletType); ProviderType get buyProviderType => - _settingsStore.defaultBuyProviders[walletType] ?? - ProviderType.askEachTime; + _settingsStore.defaultBuyProviders[walletType] ?? ProviderType.askEachTime; ProviderType get sellProviderType => - _settingsStore.defaultSellProviders[walletType] ?? - ProviderType.askEachTime; + _settingsStore.defaultSellProviders[walletType] ?? ProviderType.askEachTime; String getDisplayPriority(dynamic priority) { final _priority = priority as TransactionPriority; @@ -114,7 +112,6 @@ abstract class OtherSettingsViewModelBase with Store { _settingsStore.defaultBuyProviders[walletType] = buyProviderType; @action - ProviderType onSellProviderTypeSelected( - ProviderType sellProviderType) => + ProviderType onSellProviderTypeSelected(ProviderType sellProviderType) => _settingsStore.defaultSellProviders[walletType] = sellProviderType; } diff --git a/lib/view_model/transaction_details_view_model.dart b/lib/view_model/transaction_details_view_model.dart index a3bf281ca..04eaf25e4 100644 --- a/lib/view_model/transaction_details_view_model.dart +++ b/lib/view_model/transaction_details_view_model.dart @@ -54,6 +54,9 @@ abstract class TransactionDetailsViewModelBase with Store { case WalletType.polygon: _addPolygonListItems(tx, dateFormat); break; + case WalletType.solana: + _addSolanaListItems(tx, dateFormat); + break; default: break; } @@ -131,6 +134,8 @@ abstract class TransactionDetailsViewModelBase with Store { return 'https://bananolooker.com/block/${txId}'; case WalletType.polygon: return 'https://polygonscan.com/tx/${txId}'; + case WalletType.solana: + return 'https://solscan.io/tx/${txId}'; default: return ''; } @@ -155,6 +160,8 @@ abstract class TransactionDetailsViewModelBase with Store { return S.current.view_transaction_on + 'bananolooker.com'; case WalletType.polygon: return S.current.view_transaction_on + 'polygonscan.com'; + case WalletType.solana: + return S.current.view_transaction_on + 'solscan.io'; default: return ''; } @@ -281,4 +288,21 @@ abstract class TransactionDetailsViewModelBase with Store { items.addAll(_items); } + + void _addSolanaListItems(TransactionInfo tx, DateFormat dateFormat) { + final _items = [ + StandartListItem(title: S.current.transaction_details_transaction_id, value: tx.id), + StandartListItem( + title: S.current.transaction_details_date, value: dateFormat.format(tx.date)), + StandartListItem(title: S.current.transaction_details_amount, value: tx.amountFormatted()), + if (tx.feeFormatted()?.isNotEmpty ?? false) + StandartListItem(title: S.current.transaction_details_fee, value: tx.feeFormatted()!), + if (showRecipientAddress && tx.to != null) + StandartListItem(title: S.current.transaction_details_recipient_address, value: tx.to!), + if (tx.from != null) + StandartListItem(title: S.current.transaction_details_source_address, value: tx.from!), + ]; + + items.addAll(_items); + } } diff --git a/lib/view_model/wallet_address_list/wallet_address_list_view_model.dart b/lib/view_model/wallet_address_list/wallet_address_list_view_model.dart index 9270d1d44..bde535a23 100644 --- a/lib/view_model/wallet_address_list/wallet_address_list_view_model.dart +++ b/lib/view_model/wallet_address_list/wallet_address_list_view_model.dart @@ -7,6 +7,7 @@ import 'package:cake_wallet/generated/i18n.dart'; import 'package:cake_wallet/haven/haven.dart'; import 'package:cake_wallet/monero/monero.dart'; import 'package:cake_wallet/polygon/polygon.dart'; +import 'package:cake_wallet/solana/solana.dart'; import 'package:cake_wallet/store/app_store.dart'; import 'package:cake_wallet/store/dashboard/fiat_conversion_store.dart'; import 'package:cake_wallet/store/settings_store.dart'; @@ -159,6 +160,21 @@ class PolygonURI extends PaymentURI { } } +class SolanaURI extends PaymentURI { + SolanaURI({required String amount, required String address}) + : super(amount: amount, address: address); + + @override + String toString() { + var base = 'solana:' + address; + if (amount.isNotEmpty) { + base += '?amount=${amount.replaceAll(',', '.')}'; + } + + return base; + } +} + abstract class WalletAddressListViewModelBase extends WalletChangeListenerViewModel with Store { WalletAddressListViewModelBase({ required AppStore appStore, @@ -257,6 +273,10 @@ abstract class WalletAddressListViewModelBase extends WalletChangeListenerViewMo return PolygonURI(amount: amount, address: address.address); } + if (wallet.type == WalletType.solana) { + return SolanaURI(amount: amount, address: address.address); + } + throw Exception('Unexpected type: ${type.toString()}'); } @@ -326,6 +346,12 @@ abstract class WalletAddressListViewModelBase extends WalletChangeListenerViewMo addressList.add(WalletAddressListItem(isPrimary: true, name: null, address: primaryAddress)); } + if (wallet.type == WalletType.solana) { + final primaryAddress = solana!.getAddress(wallet); + + addressList.add(WalletAddressListItem(isPrimary: true, name: null, address: primaryAddress)); + } + if (searchText.isNotEmpty) { return ObservableList.of(addressList.where((item) { if (item is WalletAddressListItem) { diff --git a/lib/view_model/wallet_keys_view_model.dart b/lib/view_model/wallet_keys_view_model.dart index f931fec19..a84f1a4c4 100644 --- a/lib/view_model/wallet_keys_view_model.dart +++ b/lib/view_model/wallet_keys_view_model.dart @@ -110,7 +110,8 @@ abstract class WalletKeysViewModelBase with Store { ]); } - if (isEVMCompatibleChain(_appStore.wallet!.type)) { + if (isEVMCompatibleChain(_appStore.wallet!.type) || + _appStore.wallet!.type == WalletType.solana) { items.addAll([ if (_appStore.wallet!.privateKey != null) StandartListItem(title: S.current.private_key, value: _appStore.wallet!.privateKey!), @@ -165,6 +166,8 @@ abstract class WalletKeysViewModelBase with Store { return 'banano-wallet'; case WalletType.polygon: return 'polygon-wallet'; + case WalletType.solana: + return 'solana-wallet'; default: throw Exception('Unexpected wallet type: ${_appStore.wallet!.toString()}'); } diff --git a/lib/view_model/wallet_new_vm.dart b/lib/view_model/wallet_new_vm.dart index d4a0c4c00..6f3e0280e 100644 --- a/lib/view_model/wallet_new_vm.dart +++ b/lib/view_model/wallet_new_vm.dart @@ -1,5 +1,6 @@ import 'package:cake_wallet/ethereum/ethereum.dart'; import 'package:cake_wallet/bitcoin_cash/bitcoin_cash.dart'; +import 'package:cake_wallet/solana/solana.dart'; import 'package:hive/hive.dart'; import 'package:mobx/mobx.dart'; import 'package:cake_wallet/monero/monero.dart'; @@ -36,19 +37,21 @@ abstract class WalletNewVMBase extends WalletCreationVM with Store { bool get hasLanguageSelector => type == WalletType.monero || type == WalletType.haven; int get seedPhraseWordsLength { - switch (type) { - case WalletType.monero: - if(advancedPrivacySettingsViewModel.isPolySeed) { - return 16; - } - return 25; - case WalletType.ethereum: - case WalletType.bitcoinCash: - return advancedPrivacySettingsViewModel.seedPhraseLength.value; - default: - return 24; - } + switch (type) { + case WalletType.monero: + if (advancedPrivacySettingsViewModel.isPolySeed) { + return 16; + } + return 25; + case WalletType.solana: + case WalletType.polygon: + case WalletType.ethereum: + case WalletType.bitcoinCash: + return advancedPrivacySettingsViewModel.seedPhraseLength.value; + default: + return 24; } + } bool get hasSeedType => type == WalletType.monero; @@ -64,8 +67,8 @@ abstract class WalletNewVMBase extends WalletCreationVM with Store { case WalletType.litecoin: return bitcoin!.createBitcoinNewWalletCredentials(name: name); case WalletType.haven: - return haven!.createHavenNewWalletCredentials( - name: name, language: options!.first as String); + return haven! + .createHavenNewWalletCredentials(name: name, language: options!.first as String); case WalletType.ethereum: return ethereum!.createEthereumNewWalletCredentials(name: name); case WalletType.bitcoinCash: @@ -74,6 +77,8 @@ abstract class WalletNewVMBase extends WalletCreationVM with Store { return nano!.createNanoNewWalletCredentials(name: name); case WalletType.polygon: return polygon!.createPolygonNewWalletCredentials(name: name); + case WalletType.solana: + return solana!.createSolanaNewWalletCredentials(name: name); default: throw Exception('Unexpected type: ${type.toString()}'); } diff --git a/lib/view_model/wallet_restore_view_model.dart b/lib/view_model/wallet_restore_view_model.dart index 8d1e3b223..98dce3d92 100644 --- a/lib/view_model/wallet_restore_view_model.dart +++ b/lib/view_model/wallet_restore_view_model.dart @@ -4,6 +4,7 @@ import 'package:cake_wallet/nano/nano.dart'; import 'package:cake_wallet/ethereum/ethereum.dart'; import 'package:cake_wallet/bitcoin_cash/bitcoin_cash.dart'; import 'package:cake_wallet/polygon/polygon.dart'; +import 'package:cake_wallet/solana/solana.dart'; import 'package:hive/hive.dart'; import 'package:mobx/mobx.dart'; import 'package:cake_wallet/store/app_store.dart'; @@ -28,11 +29,11 @@ abstract class WalletRestoreViewModelBase extends WalletCreationVM with Store { {required WalletType type}) : hasSeedLanguageSelector = type == WalletType.monero || type == WalletType.haven, hasBlockchainHeightLanguageSelector = type == WalletType.monero || type == WalletType.haven, - hasRestoreFromPrivateKey = - type == WalletType.ethereum || + hasRestoreFromPrivateKey = type == WalletType.ethereum || type == WalletType.polygon || type == WalletType.nano || - type == WalletType.banano, + type == WalletType.banano || + type == WalletType.solana, isButtonEnabled = false, mode = WalletRestoreMode.seed, super(appStore, walletInfoSource, walletCreationService, type: type, isRecovery: true) { @@ -45,6 +46,7 @@ abstract class WalletRestoreViewModelBase extends WalletCreationVM with Store { break; case WalletType.nano: case WalletType.banano: + case WalletType.solana: availableModes = [WalletRestoreMode.seed, WalletRestoreMode.keys]; break; default: @@ -98,22 +100,21 @@ abstract class WalletRestoreViewModelBase extends WalletCreationVM with Store { name: name, height: height, mnemonic: seed, password: password); case WalletType.ethereum: return ethereum!.createEthereumRestoreWalletFromSeedCredentials( - name: name, - mnemonic: seed, - password: password); + name: name, mnemonic: seed, password: password); case WalletType.bitcoinCash: return bitcoinCash!.createBitcoinCashRestoreWalletFromSeedCredentials( - name: name, - mnemonic: seed, - password: password); + name: name, mnemonic: seed, password: password); case WalletType.nano: return nano!.createNanoRestoreWalletFromSeedCredentials( + name: name, mnemonic: seed, password: password, derivationType: derivationType); + case WalletType.polygon: + return polygon!.createPolygonRestoreWalletFromSeedCredentials( name: name, mnemonic: seed, password: password, - derivationType: derivationType); - case WalletType.polygon: - return polygon!.createPolygonRestoreWalletFromSeedCredentials( + ); + case WalletType.solana: + return solana!.createSolanaRestoreWalletFromSeedCredentials( name: name, mnemonic: seed, password: password, @@ -160,16 +161,22 @@ abstract class WalletRestoreViewModelBase extends WalletCreationVM with Store { case WalletType.nano: return nano!.createNanoRestoreWalletFromKeysCredentials( - name: name, - password: password, - seedKey: options['private_key'] as String, - derivationType: options["derivationType"] as DerivationType); + name: name, + password: password, + seedKey: options['private_key'] as String, + derivationType: options["derivationType"] as DerivationType); case WalletType.polygon: return polygon!.createPolygonRestoreWalletFromPrivateKey( name: name, password: password, privateKey: options['private_key'] as String, ); + case WalletType.solana: + return solana!.createSolanaRestoreWalletFromPrivateKey( + name: name, + password: password, + privateKey: options['private_key'] as String, + ); default: break; } @@ -187,10 +194,8 @@ abstract class WalletRestoreViewModelBase extends WalletCreationVM with Store { switch (type) { case WalletType.nano: - return nanoUtil!.compareDerivationMethods( - mnemonic: mnemonic, - privateKey: seedKey, - node: node); + return nanoUtil! + .compareDerivationMethods(mnemonic: mnemonic, privateKey: seedKey, node: node); default: break; } diff --git a/model_generator.sh b/model_generator.sh index a2b016bb0..8a6098621 100755 --- a/model_generator.sh +++ b/model_generator.sh @@ -5,6 +5,7 @@ cd cw_bitcoin && flutter pub get && flutter packages pub run build_runner build 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_solana && 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 diff --git a/pubspec_base.yaml b/pubspec_base.yaml index f38482ec3..758287601 100644 --- a/pubspec_base.yaml +++ b/pubspec_base.yaml @@ -106,6 +106,7 @@ dependencies: flutter_svg: ^2.0.9 polyseed: ^0.0.2 nostr_tools: ^1.0.9 + solana: ^0.30.1 dev_dependencies: flutter_test: @@ -152,6 +153,7 @@ flutter: - assets/nano_node_list.yml - assets/nano_pow_node_list.yml - assets/polygon_node_list.yml + - assets/solana_node_list.yml - assets/text/ - assets/faq/ - assets/animation/ diff --git a/res/values/strings_ar.arb b/res/values/strings_ar.arb index 553262f79..3e3f595be 100644 --- a/res/values/strings_ar.arb +++ b/res/values/strings_ar.arb @@ -778,5 +778,6 @@ "you_pay": "انت تدفع", "you_will_get": "حول الى", "you_will_send": "تحويل من", - "yy": "YY" -} \ No newline at end of file + "yy": "YY", + "waitFewSecondForTxUpdate": "ﺕﻼﻣﺎﻌﻤﻟﺍ ﻞﺠﺳ ﻲﻓ ﺔﻠﻣﺎﻌﻤﻟﺍ ﺲﻜﻌﻨﺗ ﻰﺘﺣ ﻥﺍﻮﺛ ﻊﻀﺒﻟ ﺭﺎﻈﺘﻧﻻﺍ ﻰﺟﺮﻳ" +} diff --git a/res/values/strings_bg.arb b/res/values/strings_bg.arb index 8d89f463b..9d64e36ae 100644 --- a/res/values/strings_bg.arb +++ b/res/values/strings_bg.arb @@ -778,5 +778,6 @@ "you_pay": "Вие плащате", "you_will_get": "Обръщане в", "you_will_send": "Обръщане от", - "yy": "гг" -} \ No newline at end of file + "yy": "гг", + "waitFewSecondForTxUpdate": "Моля, изчакайте няколко секунди, докато транзакцията се отрази в историята на транзакциите" +} diff --git a/res/values/strings_cs.arb b/res/values/strings_cs.arb index e81eab570..8665efc2c 100644 --- a/res/values/strings_cs.arb +++ b/res/values/strings_cs.arb @@ -778,5 +778,6 @@ "you_pay": "Zaplatíte", "you_will_get": "Směnit na", "you_will_send": "Směnit z", - "yy": "YY" -} \ No newline at end of file + "yy": "YY", + "waitFewSecondForTxUpdate": "Počkejte několik sekund, než se transakce projeví v historii transakcí" +} diff --git a/res/values/strings_de.arb b/res/values/strings_de.arb index 3ca972af4..28e3d4996 100644 --- a/res/values/strings_de.arb +++ b/res/values/strings_de.arb @@ -781,5 +781,6 @@ "you_pay": "Sie bezahlen", "you_will_get": "Konvertieren zu", "you_will_send": "Konvertieren von", - "yy": "YY" -} \ No newline at end of file + "yy": "YY", + "waitFewSecondForTxUpdate": "Bitte warten Sie einige Sekunden, bis die Transaktion im Transaktionsverlauf angezeigt wird" +} diff --git a/res/values/strings_en.arb b/res/values/strings_en.arb index 2db7602c1..aae06f6b0 100644 --- a/res/values/strings_en.arb +++ b/res/values/strings_en.arb @@ -778,5 +778,6 @@ "you_pay": "You Pay", "you_will_get": "Convert to", "you_will_send": "Convert from", - "yy": "YY" -} \ No newline at end of file + "yy": "YY", + "waitFewSecondForTxUpdate": "Kindly wait for a few seconds for transaction to reflect in transactions history" +} diff --git a/res/values/strings_es.arb b/res/values/strings_es.arb index 5ca5ff4d3..d9e786467 100644 --- a/res/values/strings_es.arb +++ b/res/values/strings_es.arb @@ -779,5 +779,6 @@ "you_pay": "Tú pagas", "you_will_get": "Convertir a", "you_will_send": "Convertir de", - "yy": "YY" -} \ No newline at end of file + "yy": "YY", + "waitFewSecondForTxUpdate": "Espere unos segundos para que la transacción se refleje en el historial de transacciones." +} diff --git a/res/values/strings_fr.arb b/res/values/strings_fr.arb index aee967d21..8b4fc10d1 100644 --- a/res/values/strings_fr.arb +++ b/res/values/strings_fr.arb @@ -778,5 +778,6 @@ "you_pay": "Vous payez", "you_will_get": "Convertir vers", "you_will_send": "Convertir depuis", - "yy": "AA" -} \ No newline at end of file + "yy": "AA", + "waitFewSecondForTxUpdate": "Veuillez attendre quelques secondes pour que la transaction soit reflétée dans l'historique des transactions." +} diff --git a/res/values/strings_ha.arb b/res/values/strings_ha.arb index dfffa8a8b..6d1a5db0c 100644 --- a/res/values/strings_ha.arb +++ b/res/values/strings_ha.arb @@ -780,5 +780,6 @@ "you_pay": "Ka Bayar", "you_will_get": "Maida zuwa", "you_will_send": "Maida daga", - "yy": "YY" -} \ No newline at end of file + "yy": "YY", + "waitFewSecondForTxUpdate": "Da fatan za a jira ƴan daƙiƙa don ciniki don yin tunani a tarihin ma'amala" +} diff --git a/res/values/strings_hi.arb b/res/values/strings_hi.arb index e67b00726..eaeffab92 100644 --- a/res/values/strings_hi.arb +++ b/res/values/strings_hi.arb @@ -780,5 +780,6 @@ "you_pay": "आप भुगतान करते हैं", "you_will_get": "में बदलें", "you_will_send": "से रूपांतरित करें", - "yy": "वाईवाई" -} \ No newline at end of file + "yy": "वाईवाई", + "waitFewSecondForTxUpdate": "लेन-देन इतिहास में लेन-देन प्रतिबिंबित होने के लिए कृपया कुछ सेकंड प्रतीक्षा करें" +} diff --git a/res/values/strings_hr.arb b/res/values/strings_hr.arb index c2fee8420..a915030f4 100644 --- a/res/values/strings_hr.arb +++ b/res/values/strings_hr.arb @@ -778,5 +778,6 @@ "you_pay": "Vi plaćate", "you_will_get": "Razmijeni u", "you_will_send": "Razmijeni iz", - "yy": "GG" -} \ No newline at end of file + "yy": "GG", + "waitFewSecondForTxUpdate": "Pričekajte nekoliko sekundi da se transakcija prikaže u povijesti transakcija" +} diff --git a/res/values/strings_id.arb b/res/values/strings_id.arb index 28a7df3c9..ed53b7e62 100644 --- a/res/values/strings_id.arb +++ b/res/values/strings_id.arb @@ -781,5 +781,6 @@ "you_pay": "Anda Membayar", "you_will_get": "Konversi ke", "you_will_send": "Konversi dari", - "yy": "YY" -} \ No newline at end of file + "yy": "YY", + "waitFewSecondForTxUpdate": "Mohon tunggu beberapa detik hingga transaksi terlihat di riwayat transaksi" +} diff --git a/res/values/strings_it.arb b/res/values/strings_it.arb index 42f0b8d86..d83619855 100644 --- a/res/values/strings_it.arb +++ b/res/values/strings_it.arb @@ -781,5 +781,6 @@ "you_pay": "Tu paghi", "you_will_get": "Converti a", "you_will_send": "Conveti da", - "yy": "YY" -} \ No newline at end of file + "yy": "YY", + "waitFewSecondForTxUpdate": "Attendi qualche secondo affinché la transazione venga riflessa nella cronologia delle transazioni" +} diff --git a/res/values/strings_ja.arb b/res/values/strings_ja.arb index f4b014909..e04d8c000 100644 --- a/res/values/strings_ja.arb +++ b/res/values/strings_ja.arb @@ -779,5 +779,6 @@ "you_pay": "あなたが支払う", "you_will_get": "に変換", "you_will_send": "から変換", - "yy": "YY" -} \ No newline at end of file + "yy": "YY", + "waitFewSecondForTxUpdate": "取引履歴に取引が反映されるまで数秒お待ちください。" +} diff --git a/res/values/strings_ko.arb b/res/values/strings_ko.arb index 7af7376ce..cfd3df6c9 100644 --- a/res/values/strings_ko.arb +++ b/res/values/strings_ko.arb @@ -780,5 +780,6 @@ "you_will_get": "로 변환하다", "you_will_send": "다음에서 변환", "YY": "YY", - "yy": "YY" -} \ No newline at end of file + "yy": "YY", + "waitFewSecondForTxUpdate": "거래 내역에 거래가 반영될 때까지 몇 초 정도 기다려 주세요." +} diff --git a/res/values/strings_my.arb b/res/values/strings_my.arb index 6cba70ab2..281bb6cea 100644 --- a/res/values/strings_my.arb +++ b/res/values/strings_my.arb @@ -778,5 +778,6 @@ "you_pay": "သင်ပေးချေပါ။", "you_will_get": "သို့ပြောင်းပါ။", "you_will_send": "မှပြောင်းပါ။", - "yy": "YY" -} \ No newline at end of file + "yy": "YY", + "waitFewSecondForTxUpdate": "ငွေပေးငွေယူ မှတ်တမ်းတွင် ရောင်ပြန်ဟပ်ရန် စက္ကန့်အနည်းငယ်စောင့်ပါ။" +} diff --git a/res/values/strings_nl.arb b/res/values/strings_nl.arb index 19451da27..19573b116 100644 --- a/res/values/strings_nl.arb +++ b/res/values/strings_nl.arb @@ -779,5 +779,6 @@ "you_pay": "U betaalt", "you_will_get": "Converteren naar", "you_will_send": "Converteren van", - "yy": "JJ" -} \ No newline at end of file + "yy": "JJ", + "waitFewSecondForTxUpdate": "Wacht een paar seconden totdat de transactie wordt weergegeven in de transactiegeschiedenis" +} diff --git a/res/values/strings_pl.arb b/res/values/strings_pl.arb index 74aafd014..33fd1408a 100644 --- a/res/values/strings_pl.arb +++ b/res/values/strings_pl.arb @@ -778,5 +778,6 @@ "you_pay": "Płacisz", "you_will_get": "Konwertuj na", "you_will_send": "Konwertuj z", - "yy": "RR" -} \ No newline at end of file + "yy": "RR", + "waitFewSecondForTxUpdate": "Poczekaj kilka sekund, aż transakcja zostanie odzwierciedlona w historii transakcji" +} diff --git a/res/values/strings_pt.arb b/res/values/strings_pt.arb index 53d93fe75..649551d01 100644 --- a/res/values/strings_pt.arb +++ b/res/values/strings_pt.arb @@ -781,5 +781,6 @@ "you_pay": "Você paga", "you_will_get": "Converter para", "you_will_send": "Converter de", - "yy": "aa" -} \ No newline at end of file + "yy": "aa", + "waitFewSecondForTxUpdate": "Aguarde alguns segundos para que a transação seja refletida no histórico de transações" +} diff --git a/res/values/strings_ru.arb b/res/values/strings_ru.arb index 89a25db06..16c294ef7 100644 --- a/res/values/strings_ru.arb +++ b/res/values/strings_ru.arb @@ -779,5 +779,6 @@ "you_pay": "Вы платите", "you_will_get": "Конвертировать в", "you_will_send": "Конвертировать из", - "yy": "ГГ" -} \ No newline at end of file + "yy": "ГГ", + "waitFewSecondForTxUpdate": "Пожалуйста, подождите несколько секунд, чтобы транзакция отразилась в истории транзакций." +} diff --git a/res/values/strings_th.arb b/res/values/strings_th.arb index ef8992329..dc72090d4 100644 --- a/res/values/strings_th.arb +++ b/res/values/strings_th.arb @@ -778,5 +778,6 @@ "you_pay": "คุณจ่าย", "you_will_get": "แปลงเป็น", "you_will_send": "แปลงจาก", - "yy": "ปี" -} \ No newline at end of file + "yy": "ปี", + "waitFewSecondForTxUpdate": "กรุณารอสักครู่เพื่อให้ธุรกรรมปรากฏในประวัติการทำธุรกรรม" +} diff --git a/res/values/strings_tl.arb b/res/values/strings_tl.arb index 55b6adb51..4090a4669 100644 --- a/res/values/strings_tl.arb +++ b/res/values/strings_tl.arb @@ -778,5 +778,6 @@ "you_pay": "Magbabayad ka", "you_will_get": "Mag -convert sa", "you_will_send": "I -convert mula sa", - "yy": "YY" -} \ No newline at end of file + "yy": "YY", + "waitFewSecondForTxUpdate": "Mangyaring maghintay ng ilang segundo para makita ang transaksyon sa history ng mga transaksyon" +} diff --git a/res/values/strings_tr.arb b/res/values/strings_tr.arb index e6cab5027..39eab9f86 100644 --- a/res/values/strings_tr.arb +++ b/res/values/strings_tr.arb @@ -778,5 +778,6 @@ "you_pay": "Şu kadar ödeyeceksin: ", "you_will_get": "Biçimine dönüştür:", "you_will_send": "Biçiminden dönüştür:", - "yy": "YY" -} \ No newline at end of file + "yy": "YY", + "waitFewSecondForTxUpdate": "İşlemin işlem geçmişine yansıması için lütfen birkaç saniye bekleyin" +} diff --git a/res/values/strings_uk.arb b/res/values/strings_uk.arb index e81d97021..b655a902d 100644 --- a/res/values/strings_uk.arb +++ b/res/values/strings_uk.arb @@ -779,5 +779,6 @@ "you_pay": "Ви платите", "you_will_get": "Конвертувати в", "you_will_send": "Конвертувати з", - "yy": "YY" -} \ No newline at end of file + "yy": "YY", + "waitFewSecondForTxUpdate": "Будь ласка, зачекайте кілька секунд, поки транзакція відобразиться в історії транзакцій" +} diff --git a/res/values/strings_ur.arb b/res/values/strings_ur.arb index 465fac003..0dcd31069 100644 --- a/res/values/strings_ur.arb +++ b/res/values/strings_ur.arb @@ -780,5 +780,6 @@ "you_pay": "تم ادا کرو", "you_will_get": "میں تبدیل کریں۔", "you_will_send": "سے تبدیل کریں۔", - "yy": "YY" -} \ No newline at end of file + "yy": "YY", + "waitFewSecondForTxUpdate": "۔ﮟﯾﺮﮐ ﺭﺎﻈﺘﻧﺍ ﺎﮐ ﮉﻨﮑﯿﺳ ﺪﻨﭼ ﻡﺮﮐ ﮦﺍﺮﺑ ﮯﯿﻟ ﮯﮐ ﮯﻧﺮﮐ ﯽﺳﺎﮑﻋ ﯽﮐ ﻦﯾﺩ ﻦﯿﻟ ﮟﯿﻣ ﺦﯾﺭﺎﺗ ﯽﮐ ﻦ" +} diff --git a/res/values/strings_yo.arb b/res/values/strings_yo.arb index c88f488cd..af21c001b 100644 --- a/res/values/strings_yo.arb +++ b/res/values/strings_yo.arb @@ -779,5 +779,6 @@ "you_pay": "Ẹ sàn", "you_will_get": "Ṣe pàṣípààrọ̀ sí", "you_will_send": "Ṣe pàṣípààrọ̀ láti", - "yy": "Ọd" -} \ No newline at end of file + "yy": "Ọd", + "waitFewSecondForTxUpdate": "Fi inurere duro fun awọn iṣeju diẹ fun idunadura lati ṣe afihan ninu itan-akọọlẹ iṣowo" +} diff --git a/res/values/strings_zh.arb b/res/values/strings_zh.arb index 7e05d4471..6c245d30f 100644 --- a/res/values/strings_zh.arb +++ b/res/values/strings_zh.arb @@ -778,5 +778,6 @@ "you_pay": "你付钱", "you_will_get": "转换到", "you_will_send": "转换自", - "yy": "YY" -} \ No newline at end of file + "yy": "YY", + "waitFewSecondForTxUpdate": "请等待几秒钟,交易才会反映在交易历史记录中" +} diff --git a/scripts/android/pubspec_gen.sh b/scripts/android/pubspec_gen.sh index 4b89c4afa..d238052fe 100755 --- a/scripts/android/pubspec_gen.sh +++ b/scripts/android/pubspec_gen.sh @@ -10,7 +10,7 @@ case $APP_ANDROID_TYPE in CONFIG_ARGS="--monero" ;; $CAKEWALLET) - CONFIG_ARGS="--monero --bitcoin --haven --ethereum --polygon --nano --bitcoinCash" + CONFIG_ARGS="--monero --bitcoin --haven --ethereum --polygon --nano --bitcoinCash --solana" ;; $HAVEN) CONFIG_ARGS="--haven" diff --git a/scripts/ios/app_config.sh b/scripts/ios/app_config.sh index 81752a015..9f59d6632 100755 --- a/scripts/ios/app_config.sh +++ b/scripts/ios/app_config.sh @@ -28,7 +28,7 @@ case $APP_IOS_TYPE in CONFIG_ARGS="--monero" ;; $CAKEWALLET) - CONFIG_ARGS="--monero --bitcoin --haven --ethereum --polygon --nano --bitcoinCash" + CONFIG_ARGS="--monero --bitcoin --haven --ethereum --polygon --nano --bitcoinCash --solana" ;; $HAVEN) diff --git a/scripts/macos/app_config.sh b/scripts/macos/app_config.sh index cda367b9c..bd1417c4b 100755 --- a/scripts/macos/app_config.sh +++ b/scripts/macos/app_config.sh @@ -31,7 +31,7 @@ case $APP_MACOS_TYPE in $MONERO_COM) CONFIG_ARGS="--monero";; $CAKEWALLET) - CONFIG_ARGS="--monero --bitcoin --ethereum --polygon --nano --bitcoinCash";; #--haven + CONFIG_ARGS="--monero --bitcoin --ethereum --polygon --nano --bitcoinCash --solana";; #--haven esac cp -rf pubspec_description.yaml pubspec.yaml diff --git a/tool/configure.dart b/tool/configure.dart index 408a6f6b1..bd2e4227c 100644 --- a/tool/configure.dart +++ b/tool/configure.dart @@ -7,6 +7,7 @@ const ethereumOutputPath = 'lib/ethereum/ethereum.dart'; const bitcoinCashOutputPath = 'lib/bitcoin_cash/bitcoin_cash.dart'; const nanoOutputPath = 'lib/nano/nano.dart'; const polygonOutputPath = 'lib/polygon/polygon.dart'; +const solanaOutputPath = 'lib/solana/solana.dart'; const walletTypesPath = 'lib/wallet_types.g.dart'; const pubspecDefaultPath = 'pubspec_default.yaml'; const pubspecOutputPath = 'pubspec.yaml'; @@ -21,6 +22,7 @@ Future main(List args) async { final hasNano = args.contains('${prefix}nano'); final hasBanano = args.contains('${prefix}banano'); final hasPolygon = args.contains('${prefix}polygon'); + final hasSolana = args.contains('${prefix}solana'); await generateBitcoin(hasBitcoin); await generateMonero(hasMonero); @@ -29,6 +31,7 @@ Future main(List args) async { await generateBitcoinCash(hasBitcoinCash); await generateNano(hasNano); await generatePolygon(hasPolygon); + await generateSolana(hasSolana); // await generateBanano(hasEthereum); await generatePubspec( @@ -40,6 +43,7 @@ Future main(List args) async { hasBanano: hasBanano, hasBitcoinCash: hasBitcoinCash, hasPolygon: hasPolygon, + hasSolana: hasSolana, ); await generateWalletTypes( hasMonero: hasMonero, @@ -50,6 +54,7 @@ Future main(List args) async { hasBanano: hasBanano, hasBitcoinCash: hasBitcoinCash, hasPolygon: hasPolygon, + hasSolana: hasSolana, ); } @@ -577,13 +582,14 @@ abstract class Ethereum { int formatterEthereumParseAmount(String amount); double formatterEthereumAmountToDouble({TransactionInfo? transaction, BigInt? amount, int exponent = 18}); List getERC20Currencies(WalletBase wallet); - Future addErc20Token(WalletBase wallet, Erc20Token token); - Future deleteErc20Token(WalletBase wallet, Erc20Token token); + Future addErc20Token(WalletBase wallet, CryptoCurrency token); + Future deleteErc20Token(WalletBase wallet, CryptoCurrency token); Future getErc20Token(WalletBase wallet, String contractAddress); CryptoCurrency assetOfTransaction(WalletBase wallet, TransactionInfo transaction); void updateEtherscanUsageState(WalletBase wallet, bool isEnabled); Web3Client? getWeb3Client(WalletBase wallet); + String getTokenAddress(CryptoCurrency asset); } """; @@ -669,13 +675,14 @@ abstract class Polygon { int formatterPolygonParseAmount(String amount); double formatterPolygonAmountToDouble({TransactionInfo? transaction, BigInt? amount, int exponent = 18}); List getERC20Currencies(WalletBase wallet); - Future addErc20Token(WalletBase wallet, Erc20Token token); - Future deleteErc20Token(WalletBase wallet, Erc20Token token); + Future addErc20Token(WalletBase wallet, CryptoCurrency token); + Future deleteErc20Token(WalletBase wallet, CryptoCurrency token); Future getErc20Token(WalletBase wallet, String contractAddress); CryptoCurrency assetOfTransaction(WalletBase wallet, TransactionInfo transaction); void updatePolygonScanUsageState(WalletBase wallet, bool isEnabled); Web3Client? getWeb3Client(WalletBase wallet); + String getTokenAddress(CryptoCurrency asset); } """; @@ -885,6 +892,86 @@ abstract class NanoUtil { await outputFile.writeAsString(output); } +Future generateSolana(bool hasImplementation) async { + final outputFile = File(solanaOutputPath); + const solanaCommonHeaders = """ +import 'package:cake_wallet/view_model/send/output.dart'; +import 'package:cw_core/crypto_currency.dart'; +import 'package:cw_core/output_info.dart'; +import 'package:cw_core/transaction_info.dart'; +import 'package:cw_core/wallet_base.dart'; +import 'package:cw_core/wallet_credentials.dart'; +import 'package:cw_core/wallet_info.dart'; +import 'package:cw_core/wallet_service.dart'; +import 'package:hive/hive.dart'; + +"""; + const solanaCWHeaders = """ +import 'package:cw_solana/spl_token.dart'; +import 'package:cw_solana/solana_wallet.dart'; +import 'package:cw_solana/solana_mnemonics.dart'; +import 'package:cw_solana/solana_wallet_service.dart'; +import 'package:cw_solana/solana_transaction_info.dart'; +import 'package:cw_solana/solana_transaction_credentials.dart'; +import 'package:cw_solana/solana_wallet_creation_credentials.dart'; +import 'package:solana/solana.dart'; +"""; + const solanaCwPart = "part 'cw_solana.dart';"; + const solanaContent = """ +abstract class Solana { + List getSolanaWordList(String language); + WalletService createSolanaWalletService(Box walletInfoSource); + WalletCredentials createSolanaNewWalletCredentials( + {required String name, WalletInfo? walletInfo}); + WalletCredentials createSolanaRestoreWalletFromSeedCredentials( + {required String name, required String mnemonic, required String password}); + WalletCredentials createSolanaRestoreWalletFromPrivateKey( + {required String name, required String privateKey, required String password}); + + String getAddress(WalletBase wallet); + String getPrivateKey(WalletBase wallet); + String getPublicKey(WalletBase wallet); + Ed25519HDKeyPair? getWalletKeyPair(WalletBase wallet); + + Object createSolanaTransactionCredentials( + List outputs, { + required CryptoCurrency currency, + }); + + Object createSolanaTransactionCredentialsRaw( + List outputs, { + required CryptoCurrency currency, + }); + List getSPLTokenCurrencies(WalletBase wallet); + Future addSPLToken(WalletBase wallet, CryptoCurrency token); + Future deleteSPLToken(WalletBase wallet, CryptoCurrency token); + Future getSPLToken(WalletBase wallet, String contractAddress); + + CryptoCurrency assetOfTransaction(WalletBase wallet, TransactionInfo transaction); + double getTransactionAmountRaw(TransactionInfo transactionInfo); + String getTokenAddress(CryptoCurrency asset); + List? getValidationLength(CryptoCurrency type); +} + + """; + + const solanaEmptyDefinition = 'Solana? solana;\n'; + const solanaCWDefinition = 'Solana? solana = CWSolana();\n'; + + final output = '$solanaCommonHeaders\n' + + (hasImplementation ? '$solanaCWHeaders\n' : '\n') + + (hasImplementation ? '$solanaCwPart\n\n' : '\n') + + (hasImplementation ? solanaCWDefinition : solanaEmptyDefinition) + + '\n' + + solanaContent; + + if (outputFile.existsSync()) { + await outputFile.delete(); + } + + await outputFile.writeAsString(output); +} + Future generatePubspec( {required bool hasMonero, required bool hasBitcoin, @@ -893,7 +980,8 @@ Future generatePubspec( required bool hasNano, required bool hasBanano, required bool hasBitcoinCash, - required bool hasPolygon}) async { + required bool hasPolygon, + required bool hasSolana}) async { const cwCore = """ cw_core: path: ./cw_core @@ -934,6 +1022,10 @@ Future generatePubspec( cw_polygon: path: ./cw_polygon """; + const cwSolana = """ + cw_solana: + path: ./cw_solana + """; const cwEVM = """ cw_evm: path: ./cw_evm @@ -972,6 +1064,10 @@ Future generatePubspec( output += '\n$cwPolygon'; } + if (hasSolana) { + output += '\n$cwSolana'; + } + if (hasHaven && !hasMonero) { output += '\n$cwSharedExternal\n$cwHaven'; } else if (hasHaven) { @@ -1002,7 +1098,8 @@ Future generateWalletTypes( required bool hasNano, required bool hasBanano, required bool hasBitcoinCash, - required bool hasPolygon}) async { + required bool hasPolygon, + required bool hasSolana}) async { final walletTypesFile = File(walletTypesPath); if (walletTypesFile.existsSync()) { @@ -1037,6 +1134,10 @@ Future generateWalletTypes( outputContent += '\tWalletType.polygon,\n'; } + if (hasSolana) { + outputContent += '\tWalletType.solana,\n'; + } + if (hasNano) { outputContent += '\tWalletType.nano,\n'; } diff --git a/tool/generate_secrets_config.dart b/tool/generate_secrets_config.dart index 58e7b8839..8745c2933 100644 --- a/tool/generate_secrets_config.dart +++ b/tool/generate_secrets_config.dart @@ -5,6 +5,7 @@ import 'utils/utils.dart'; const configPath = 'tool/.secrets-config.json'; const evmChainsConfigPath = 'tool/.evm-secrets-config.json'; +const solanaConfigPath = 'tool/.solana-secrets-config.json'; Future main(List args) async => generateSecretsConfig(args); @@ -18,8 +19,10 @@ Future generateSecretsConfig(List args) async { final configFile = File(configPath); final evmChainsConfigFile = File(evmChainsConfigPath); - final secrets = {}; + final solanaConfigFile = File(solanaConfigPath); + final secrets = {}; + secrets.addAll(extraInfo); secrets.removeWhere((key, dynamic value) { if (key.contains('--')) { @@ -49,6 +52,7 @@ Future generateSecretsConfig(List args) async { await configFile.writeAsString(secretsJson); secrets.clear(); + SecretKey.evmChainsSecrets.forEach((sec) { if (secrets[sec.name] != null) { return; @@ -60,4 +64,18 @@ Future generateSecretsConfig(List args) async { secretsJson = JsonEncoder.withIndent(' ').convert(secrets); await evmChainsConfigFile.writeAsString(secretsJson); + + secrets.clear(); + + SecretKey.solanaSecrets.forEach((sec) { + if (secrets[sec.name] != null) { + return; + } + + secrets[sec.name] = sec.generate(); + }); + + secretsJson = JsonEncoder.withIndent(' ').convert(secrets); + + await solanaConfigFile.writeAsString(secretsJson); } diff --git a/tool/import_secrets_config.dart b/tool/import_secrets_config.dart index 83e345f78..02061669b 100644 --- a/tool/import_secrets_config.dart +++ b/tool/import_secrets_config.dart @@ -8,6 +8,8 @@ const outputPath = 'lib/.secrets.g.dart'; const evmChainsConfigPath = 'tool/.evm-secrets-config.json'; const evmChainsOutputPath = 'cw_evm/lib/.secrets.g.dart'; +const solanaConfigPath = 'tool/.solana-secrets-config.json'; +const solanaOutputPath = 'cw_solana/lib/.secrets.g.dart'; Future main(List args) async => importSecretsConfig(); Future importSecretsConfig() async { @@ -21,6 +23,12 @@ Future importSecretsConfig() async { final evmChainsOutput = evmChainsInput.keys .fold('', (String acc, String val) => acc + generateConst(val, evmChainsInput)); + final solanaOutputFile = File(solanaOutputPath); + final solanaInput = + json.decode(File(solanaConfigPath).readAsStringSync()) as Map; + final solanaOutput = + solanaInput.keys.fold('', (String acc, String val) => acc + generateConst(val, solanaInput)); + if (outputFile.existsSync()) { await outputFile.delete(); } @@ -32,4 +40,10 @@ Future importSecretsConfig() async { } await evmChainsOutputFile.writeAsString(evmChainsOutput); + + if (solanaOutputFile.existsSync()) { + await solanaOutputFile.delete(); + } + + await solanaOutputFile.writeAsString(solanaOutput); } diff --git a/tool/utils/secret_key.dart b/tool/utils/secret_key.dart index f991c43cf..38b5129af 100644 --- a/tool/utils/secret_key.dart +++ b/tool/utils/secret_key.dart @@ -44,6 +44,10 @@ class SecretKey { SecretKey('polygonScanApiKey', () => ''), ]; + static final solanaSecrets = [ + SecretKey('ankrApiKey', () => ''), + ]; + final String name; final String Function() generate; } From a3a35f05e1d7347b1937caa08895833430c57fa5 Mon Sep 17 00:00:00 2001 From: Rafael <76502841+rafael-xmr@users.noreply.github.com> Date: Fri, 23 Feb 2024 13:13:30 -0300 Subject: [PATCH 2/9] Btc address types (#1263) * inital migration changes * feat: rest of changes * minor fix [skip ci] * fix: P2wshAddress & wallet address index * fix: address review comments * fix: address type restore * feat: add testnet * Fix review comments Remove bitcoin_base from cw_core * Fix address not matching selected type on start * remove un-necessary parameter [skip ci] * Remove bitcoin specific code from main lib Fix possible runtime exception from list wrong access * Minor fix * fix: fixes for Testnet * fix: bitcoin receive option dependency breaks monerocom * Fix issues when building Monero.com * feat: Transaction Builder changes * fix: discover addresses, testnet restoring, duplicate unspent coins, and taproot address vs schnorr sig tweak * fix: remove print * feat: improve error when failed broadcast response * feat: create fish shell env script * fix: unmodifiable maps * fix: build * fix: build * fix: computed observable side effect bug * feat: add nix script for android build_all * fix: wrong keypairs used for signing * fix: wrong addresses when using fromScriptPubKey scripts * fix(actual commit): testnet tx expanded + wrong addresses when using fromScriptPubKey scripts (update bitcoin_base deps) * fix: self-send [skip ci] * fix: p2wsh * fix: testnet fees * New versions * Update macos build number Minor UI fix * fix: use new bitcoin_base ref, fix tx list wrong hex value & refactor hidden vs hd use - if always use sideHd for isHidden, it is easier to simplify the functions instead of passing both which can be error prone - (ps: now this could probably be changed, for example from isHidden to isChange since with address list we now see "hidden" addresses) * Fix if condition to handle litecoin case * fix: self-send, change address was always making direction incoming * refactor: improve estimation function, add more inputs if balance missing * fix: new bitcoin_base update, fixes script issues * Update evm chain wallet service arguments * Fix translation [skip ci] * Fix translation [skip ci] * Update strings_fr.arb [skip ci] * fix: async isChange function not being awaited, refactor to reduce looping into a single place * fix: _address vs address, missing p2sh * fix: minor mistake in storing p2sh page type [skip ci] * refactor: use already matched addresses property * feat: improved perfomance for fetching transaction histories * feat: continue perfomance change, improve address discovery only to last address by type with history * fix: make sure transaction list is sorted by date * refactor: isTestnet only for bitcoin * fix: walletInfo type null case * fix: deprecated p2pk * refactor: make condition more readable * refactor: remove unnecessary Str variant * refactor: make condition more readable * fix: infinite loop possible * Revert removing isTestnet from other wallets [skip ci] * refactor: rename addresses when matched by receive type * Make the beta build [skip ci] Remove app_env.fish --------- Co-authored-by: OmarHatem --- assets/text/Release_Notes.txt | 8 +- cw_bitcoin/lib/address_from_output.dart | 36 +- cw_bitcoin/lib/address_to_output_script.dart | 24 +- cw_bitcoin/lib/bitcoin_address_record.dart | 56 +- .../lib/bitcoin_receive_page_option.dart | 42 ++ cw_bitcoin/lib/bitcoin_unspent.dart | 7 +- cw_bitcoin/lib/bitcoin_wallet.dart | 103 +-- cw_bitcoin/lib/bitcoin_wallet_addresses.dart | 48 +- cw_bitcoin/lib/bitcoin_wallet_service.dart | 35 +- cw_bitcoin/lib/electrum.dart | 133 ++-- cw_bitcoin/lib/electrum_transaction_info.dart | 83 ++- cw_bitcoin/lib/electrum_wallet.dart | 661 +++++++++++------- cw_bitcoin/lib/electrum_wallet_addresses.dart | 299 ++++---- cw_bitcoin/lib/electrum_wallet_snapshot.dart | 49 +- cw_bitcoin/lib/litecoin_wallet.dart | 106 +-- cw_bitcoin/lib/litecoin_wallet_addresses.dart | 43 +- cw_bitcoin/lib/litecoin_wallet_service.dart | 6 +- .../lib/pending_bitcoin_transaction.dart | 21 +- cw_bitcoin/lib/script_hash.dart | 7 +- cw_bitcoin/lib/utils.dart | 64 +- cw_bitcoin/pubspec.lock | 183 ++--- cw_bitcoin/pubspec.yaml | 5 + .../lib/src/bitcoin_cash_wallet.dart | 108 +-- .../src/bitcoin_cash_wallet_addresses.dart | 34 +- .../lib/src/bitcoin_cash_wallet_service.dart | 50 +- cw_bitcoin_cash/pubspec.yaml | 5 +- cw_core/lib/enumerate.dart | 13 + cw_core/lib/receive_page_option.dart | 21 + cw_core/lib/wallet_base.dart | 2 + cw_core/lib/wallet_info.dart | 6 + cw_core/lib/wallet_service.dart | 6 +- cw_core/pubspec.lock | 186 ++--- cw_ethereum/lib/ethereum_wallet_service.dart | 10 +- cw_evm/lib/evm_chain_wallet_service.dart | 6 +- cw_haven/lib/haven_wallet_service.dart | 6 +- cw_monero/lib/monero_wallet_service.dart | 8 +- cw_nano/lib/nano_wallet_service.dart | 6 +- cw_polygon/lib/polygon_wallet_service.dart | 12 +- lib/bitcoin/cw_bitcoin.dart | 352 +++++----- lib/core/address_validator.dart | 23 +- lib/core/wallet_creation_service.dart | 13 +- lib/di.dart | 2 +- lib/entities/default_settings_migration.dart | 21 +- lib/entities/receive_page_option.dart | 23 - lib/router.dart | 14 +- .../screens/dashboard/pages/address_page.dart | 28 +- .../advanced_privacy_settings_page.dart | 39 +- .../screens/new_wallet/new_wallet_page.dart | 14 +- .../screens/receive/anonpay_invoice_page.dart | 2 +- .../screens/receive/anonpay_receive_page.dart | 2 +- .../screens/restore/wallet_restore_page.dart | 8 +- .../anon_invoice_page_view_model.dart | 2 +- .../dashboard/dashboard_view_model.dart | 24 +- .../dashboard/receive_option_view_model.dart | 18 +- .../node_create_or_edit_view_model.dart | 2 + .../node_list/node_list_view_model.dart | 6 +- .../transaction_details_view_model.dart | 2 +- .../wallet_address_list_view_model.dart | 18 +- lib/view_model/wallet_creation_vm.dart | 11 + lib/view_model/wallet_new_vm.dart | 2 +- lib/view_model/wallet_restore_view_model.dart | 4 +- res/values/strings_ar.arb | 1 + res/values/strings_bg.arb | 1 + res/values/strings_cs.arb | 1 + res/values/strings_de.arb | 1 + res/values/strings_en.arb | 1 + res/values/strings_es.arb | 1 + res/values/strings_fr.arb | 1 + res/values/strings_ha.arb | 1 + res/values/strings_hi.arb | 1 + res/values/strings_hr.arb | 1 + res/values/strings_id.arb | 1 + res/values/strings_it.arb | 1 + res/values/strings_ja.arb | 1 + res/values/strings_ko.arb | 1 + res/values/strings_my.arb | 1 + res/values/strings_nl.arb | 1 + res/values/strings_pl.arb | 1 + res/values/strings_pt.arb | 1 + res/values/strings_ru.arb | 1 + res/values/strings_th.arb | 1 + res/values/strings_tl.arb | 1 + res/values/strings_tr.arb | 1 + res/values/strings_uk.arb | 1 + res/values/strings_ur.arb | 1 + res/values/strings_yo.arb | 1 + res/values/strings_zh.arb | 1 + scripts/android/app_env.sh | 4 +- scripts/android/shell.nix | 16 + scripts/ios/app_env.sh | 4 +- tool/configure.dart | 6 + 91 files changed, 1851 insertions(+), 1333 deletions(-) create mode 100644 cw_bitcoin/lib/bitcoin_receive_page_option.dart create mode 100644 cw_core/lib/enumerate.dart create mode 100644 cw_core/lib/receive_page_option.dart delete mode 100644 lib/entities/receive_page_option.dart create mode 100644 scripts/android/shell.nix diff --git a/assets/text/Release_Notes.txt b/assets/text/Release_Notes.txt index 2f5130bea..ac032e354 100644 --- a/assets/text/Release_Notes.txt +++ b/assets/text/Release_Notes.txt @@ -1,5 +1,3 @@ -Bitcoin transactions fixes and enhancements -EVM wallets enhancements (Ethereum and Polygon) -Improve wallet recovery and error tolerance -Enhance Background sync for Monero wallets -Bug fixes \ No newline at end of file +Support ALL Bitcoin address types (Legacy, Segwit (both variants), Taproot) +Enhance Sending/Receiving flow for Bitcoin +Improve fee calculations in Bitcoin \ No newline at end of file diff --git a/cw_bitcoin/lib/address_from_output.dart b/cw_bitcoin/lib/address_from_output.dart index d06ffe402..73bc101c4 100644 --- a/cw_bitcoin/lib/address_from_output.dart +++ b/cw_bitcoin/lib/address_from_output.dart @@ -1,23 +1,23 @@ -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!; + switch (script.getAddressType()) { + case P2pkhAddressType.p2pkh: + return P2pkhAddress.fromScriptPubkey(script: script).toAddress(network); + case P2shAddressType.p2pkInP2sh: + return P2shAddress.fromScriptPubkey(script: script).toAddress(network); + case SegwitAddresType.p2wpkh: + return P2wpkhAddress.fromScriptPubkey(script: script).toAddress(network); + case P2shAddressType.p2pkhInP2sh: + return P2shAddress.fromScriptPubkey(script: script).toAddress(network); + case SegwitAddresType.p2wsh: + return P2wshAddress.fromScriptPubkey(script: script).toAddress(network); + case SegwitAddresType.p2tr: + return P2trAddress.fromScriptPubkey(script: script).toAddress(network); + default: + } } catch (_) {} - try { - return bitcoin.P2WPKH( - data: PaymentData(output: script), - network: networkType) - .data - .address!; - } catch(_) {} - return ''; -} \ No newline at end of file +} diff --git a/cw_bitcoin/lib/address_to_output_script.dart b/cw_bitcoin/lib/address_to_output_script.dart index 01c7b67a5..6ae50132b 100644 --- a/cw_bitcoin/lib/address_to_output_script.dart +++ b/cw_bitcoin/lib/address_to_output_script.dart @@ -1,27 +1,9 @@ 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'; +import 'package:bitcoin_base/bitcoin_base.dart' as bitcoin; -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) { +List addressToOutputScript(String address, bitcoin.BasedUtxoNetwork network) { 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); + return bitcoin.addressToOutputScript(address: address, network: network); } 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..d8d908230 100644 --- a/cw_bitcoin/lib/bitcoin_address_record.dart +++ b/cw_bitcoin/lib/bitcoin_address_record.dart @@ -1,6 +1,9 @@ import 'dart:convert'; import 'package:bitbox/bitbox.dart' as bitbox; +import 'package:bitcoin_base/bitcoin_base.dart'; +import 'package:cw_bitcoin/script_hash.dart' as sh; + class BitcoinAddressRecord { BitcoinAddressRecord( this.address, { @@ -10,23 +13,41 @@ class BitcoinAddressRecord { int balance = 0, String name = '', bool isUsed = false, + required this.type, + String? scriptHash, + required this.network, }) : _txCount = txCount, _balance = balance, _name = name, - _isUsed = isUsed; + _isUsed = isUsed, + scriptHash = + scriptHash ?? (network != null ? sh.scriptHash(address, network: network) : null); - factory BitcoinAddressRecord.fromJSON(String jsonSource) { + 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); + 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: (decoded['network'] as String?) == null + ? network + : BasedUtxoNetwork.fromName(decoded['network'] as String), + ); } + @override + bool operator ==(Object o) => o is BitcoinAddressRecord && address == o.address; + final String address; bool isHidden; final int index; @@ -34,6 +55,8 @@ class BitcoinAddressRecord { int _balance; String _name; bool _isUsed; + String? scriptHash; + BasedUtxoNetwork? network; int get txCount => _txCount; @@ -50,21 +73,28 @@ class BitcoinAddressRecord { void setAsUsed() => _isUsed = 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 updateScriptHash(BasedUtxoNetwork network) { + scriptHash = sh.scriptHash(address, network: network); + return scriptHash!; + } + 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, + 'network': network?.value, }); } 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..2e246f532 --- /dev/null +++ b/cw_bitcoin/lib/bitcoin_receive_page_option.dart @@ -0,0 +1,42 @@ +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)'); + 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)'); + + const BitcoinReceivePageOption._(this.value); + + final String value; + + String toString() { + return value; + } + + static const all = [ + BitcoinReceivePageOption.p2wpkh, + BitcoinReceivePageOption.p2sh, + BitcoinReceivePageOption.p2tr, + BitcoinReceivePageOption.p2wsh, + BitcoinReceivePageOption.p2pkh + ]; + + factory BitcoinReceivePageOption.fromType(BitcoinAddressType type) { + switch (type) { + case SegwitAddresType.p2tr: + return BitcoinReceivePageOption.p2tr; + case SegwitAddresType.p2wsh: + return BitcoinReceivePageOption.p2wsh; + case P2pkhAddressType.p2pkh: + return BitcoinReceivePageOption.p2pkh; + case P2shAddressType.p2wpkhInP2sh: + return BitcoinReceivePageOption.p2sh; + case SegwitAddresType.p2wpkh: + default: + return BitcoinReceivePageOption.p2wpkh; + } + } +} diff --git a/cw_bitcoin/lib/bitcoin_unspent.dart b/cw_bitcoin/lib/bitcoin_unspent.dart index 9c198c27c..52edea091 100644 --- a/cw_bitcoin/lib/bitcoin_unspent.dart +++ b/cw_bitcoin/lib/bitcoin_unspent.dart @@ -6,10 +6,9 @@ class BitcoinUnspent extends Unspent { : 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(BitcoinAddressRecord address, Map json) => + BitcoinUnspent( + address, json['tx_hash'] as String, json['value'] as int, json['tx_pos'] as int); final BitcoinAddressRecord bitcoinAddressRecord; } diff --git a/cw_bitcoin/lib/bitcoin_wallet.dart b/cw_bitcoin/lib/bitcoin_wallet.dart index 9cdb78f2d..3b3e9c636 100644 --- a/cw_bitcoin/lib/bitcoin_wallet.dart +++ b/cw_bitcoin/lib/bitcoin_wallet.dart @@ -1,3 +1,4 @@ +import 'package:bitcoin_base/bitcoin_base.dart'; import 'package:cw_bitcoin/bitcoin_mnemonic.dart'; import 'package:cw_core/crypto_currency.dart'; import 'package:cw_core/unspent_coins_info.dart'; @@ -17,36 +18,42 @@ 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( + BitcoinWalletBase({ + required String mnemonic, + required String password, + required WalletInfo walletInfo, + required Box unspentCoinsInfo, + required Uint8List seedBytes, + String? addressPageType, + BasedUtxoNetwork? networkParam, + List? initialAddresses, + ElectrumBalance? initialBalance, + Map? initialRegularAddressIndex, + Map? initialChangeAddressIndex, + }) : super( mnemonic: mnemonic, password: password, walletInfo: walletInfo, unspentCoinsInfo: unspentCoinsInfo, - networkType: bitcoin.bitcoin, + networkType: networkParam == null + ? bitcoin.bitcoin + : networkParam == BitcoinNetwork.mainnet + ? bitcoin.bitcoin + : bitcoin.testnet, initialAddresses: initialAddresses, initialBalance: initialBalance, seedBytes: seedBytes, currency: CryptoCurrency.btc) { walletAddresses = BitcoinWalletAddresses( - walletInfo, - electrumClient: electrumClient, - initialAddresses: initialAddresses, - initialRegularAddressIndex: initialRegularAddressIndex, - initialChangeAddressIndex: initialChangeAddressIndex, - mainHd: hd, - sideHd: bitcoin.HDWallet.fromSeed(seedBytes, network: networkType) - .derivePath("m/0'/1"), - networkType: networkType); + walletInfo, + electrumClient: electrumClient, + initialAddresses: initialAddresses, + initialRegularAddressIndex: initialRegularAddressIndex, + initialChangeAddressIndex: initialChangeAddressIndex, + mainHd: hd, + sideHd: bitcoin.HDWallet.fromSeed(seedBytes, network: networkType).derivePath("m/0'/1"), + network: networkParam ?? network, + ); autorun((_) { this.walletAddresses.isEnabledAutoGenerateSubaddress = this.isEnabledAutoGenerateSubaddress; }); @@ -57,21 +64,26 @@ abstract class BitcoinWalletBase extends ElectrumWallet with Store { required String password, required WalletInfo walletInfo, required Box unspentCoinsInfo, + String? addressPageType, + BasedUtxoNetwork? network, List? initialAddresses, ElectrumBalance? initialBalance, - int initialRegularAddressIndex = 0, - int initialChangeAddressIndex = 0 + Map? initialRegularAddressIndex, + Map? initialChangeAddressIndex, }) async { return BitcoinWallet( - 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, + initialBalance: initialBalance, + seedBytes: await mnemonicToSeedBytes(mnemonic), + initialRegularAddressIndex: initialRegularAddressIndex, + initialChangeAddressIndex: initialChangeAddressIndex, + addressPageType: addressPageType, + networkParam: network, + ); } static Future open({ @@ -80,16 +92,21 @@ abstract class BitcoinWalletBase extends ElectrumWallet with Store { required Box unspentCoinsInfo, required String password, }) async { - final snp = await ElectrumWallletSnapshot.load(name, walletInfo.type, password); + final snp = await ElectrumWalletSnapshot.load(name, walletInfo.type, password, + walletInfo.network != null ? BasedUtxoNetwork.fromName(walletInfo.network!) : null); + return BitcoinWallet( - 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: 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, + addressPageType: snp.addressPageType, + networkParam: snp.network, + ); } -} \ No newline at end of file +} diff --git a/cw_bitcoin/lib/bitcoin_wallet_addresses.dart b/cw_bitcoin/lib/bitcoin_wallet_addresses.dart index 36d37127d..f12577492 100644 --- a/cw_bitcoin/lib/bitcoin_wallet_addresses.dart +++ b/cw_bitcoin/lib/bitcoin_wallet_addresses.dart @@ -1,6 +1,5 @@ -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:bitcoin_flutter/bitcoin_flutter.dart'; import 'package:cw_bitcoin/electrum_wallet_addresses.dart'; import 'package:cw_bitcoin/utils.dart'; import 'package:cw_core/wallet_info.dart'; @@ -11,24 +10,31 @@ 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.electrumClient, + super.initialAddresses, + super.initialRegularAddressIndex, + super.initialChangeAddressIndex, + }) : super(walletInfo); @override - String getAddress({required int index, required bitcoin.HDWallet hd}) => - generateP2WPKHAddress(hd: hd, index: index, networkType: networkType); + String getAddress({required int index, required HDWallet hd, BitcoinAddressType? addressType}) { + 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); + } } diff --git a/cw_bitcoin/lib/bitcoin_wallet_service.dart b/cw_bitcoin/lib/bitcoin_wallet_service.dart index 736ec1044..2b8c489d2 100644 --- a/cw_bitcoin/lib/bitcoin_wallet_service.dart +++ b/cw_bitcoin/lib/bitcoin_wallet_service.dart @@ -1,4 +1,5 @@ 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_wallet_creation_credentials.dart'; @@ -23,12 +24,17 @@ class BitcoinWalletService extends WalletService 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 wallet = await BitcoinWalletBase.create( - mnemonic: await generateMnemonic(), - password: credentials.password!, - walletInfo: credentials.walletInfo!, - unspentCoinsInfo: unspentCoinsInfoSource); + mnemonic: await generateMnemonic(), + password: credentials.password!, + walletInfo: credentials.walletInfo!, + unspentCoinsInfo: unspentCoinsInfoSource, + network: network, + ); await wallet.save(); await wallet.init(); return wallet; @@ -92,20 +98,27 @@ class BitcoinWalletService extends WalletService restoreFromKeys(BitcoinRestoreWalletFromWIFCredentials credentials) async => + Future restoreFromKeys(BitcoinRestoreWalletFromWIFCredentials credentials, + {bool? isTestnet}) async => throw UnimplementedError(); @override - Future restoreFromSeed(BitcoinRestoreWalletFromSeedCredentials credentials) async { + Future restoreFromSeed(BitcoinRestoreWalletFromSeedCredentials credentials, + {bool? isTestnet}) async { if (!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!, + mnemonic: credentials.mnemonic, + walletInfo: credentials.walletInfo!, + unspentCoinsInfo: unspentCoinsInfoSource, + network: network, + ); await wallet.save(); await wallet.init(); return wallet; diff --git a/cw_bitcoin/lib/electrum.dart b/cw_bitcoin/lib/electrum.dart index a05c251fe..51a53e285 100644 --- a/cw_bitcoin/lib/electrum.dart +++ b/cw_bitcoin/lib/electrum.dart @@ -2,12 +2,12 @@ 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:flutter/foundation.dart'; import 'package:rxdart/rxdart.dart'; -import 'package:collection/collection.dart'; +import 'package:http/http.dart' as http; String jsonrpcparams(List params) { final _params = params?.map((val) => '"${val.toString()}"')?.join(','); @@ -22,10 +22,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; @@ -51,8 +48,7 @@ class ElectrumClient { Timer? _aliveTimer; String unterminatedString; - Future connectToUri(Uri uri) async => - await connect(host: uri.host, port: uri.port); + Future connectToUri(Uri uri) async => await connect(host: uri.host, port: uri.port); Future connect({required String host, required int port}) async { try { @@ -104,21 +100,20 @@ 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 = ''; @@ -142,8 +137,7 @@ class ElectrumClient { } } - Future> version() => - call(method: 'server.version').then((dynamic result) { + Future> version() => call(method: 'server.version').then((dynamic result) { if (result is List) { return result.map((dynamic val) => val.toString()).toList(); } @@ -178,11 +172,10 @@ class ElectrumClient { }); Future>> getListUnspentWithAddress( - String address, NetworkType networkType) => + String address, BasedUtxoNetwork network) => call( - method: 'blockchain.scripthash.listunspent', - params: [scriptHash(address, networkType: networkType)]) - .then((dynamic result) { + method: 'blockchain.scripthash.listunspent', + params: [scriptHash(address, network: network)]).then((dynamic result) { if (result is List) { return result.map((dynamic val) { if (val is Map) { @@ -229,8 +222,7 @@ class ElectrumClient { return []; }); - Future> getTransactionRaw( - {required String hash}) async => + Future> getTransactionRaw({required String hash}) async => callWithTimeout(method: 'blockchain.transaction.get', params: [hash, true], timeout: 10000) .then((dynamic result) { if (result is Map) { @@ -240,8 +232,7 @@ class ElectrumClient { return {}; }); - Future getTransactionHex( - {required String hash}) async => + Future getTransactionHex({required String hash}) async => callWithTimeout(method: 'blockchain.transaction.get', params: [hash, false], timeout: 10000) .then((dynamic result) { if (result is String) { @@ -252,29 +243,40 @@ class ElectrumClient { }); Future broadcastTransaction( - {required String transactionRaw}) async => - call(method: 'blockchain.transaction.broadcast', params: [transactionRaw]) - .then((dynamic result) { - if (result is String) { - return result; + {required String transactionRaw, BasedUtxoNetwork? network}) async { + if (network == BitcoinNetwork.testnet) { + return http + .post(Uri(scheme: 'https', host: 'blockstream.info', path: '/testnet/api/tx'), + headers: {'Content-Type': 'application/json; charset=utf-8'}, + body: transactionRaw) + .then((http.Response response) { + if (response.statusCode == 200) { + return response.body; } - return ''; + throw Exception('Failed to broadcast transaction: ${response.body}'); }); + } - Future> getMerkle( - {required String hash, required int height}) async => - await call( - method: 'blockchain.transaction.get_merkle', - params: [hash, height]) as Map; + return call(method: 'blockchain.transaction.broadcast', params: [transactionRaw]) + .then((dynamic result) { + if (result is String) { + return result; + } - Future> getHeader({required int height}) async => - await call(method: 'blockchain.block.get_header', params: [height]) + 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]) as Map; + 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 +316,17 @@ class ElectrumClient { return []; }); - Future> feeRates() async { + Future> feeRates({BasedUtxoNetwork? network}) async { + if (network == BitcoinNetwork.testnet) { + return [1, 1, 1]; + } 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 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 +334,21 @@ class ElectrumClient { } } + // https://electrumx.readthedocs.io/en/latest/protocol-methods.html#blockchain-headers-subscribe + // example response: + // { + // "height": 520481, + // "hex": "00000020890208a0ae3a3892aa047c5468725846577cfcd9b512b50000000000000000005dc2b02f2d297a9064ee103036c14d678f9afc7e3d9409cf53fd58b82e938e8ecbeca05a2d2103188ce804c4" + // } + Future getCurrentBlockChainTip() => + call(method: 'blockchain.headers.subscribe').then((result) { + if (result is Map) { + return result["height"] as int; + } + + return null; + }); + BehaviorSubject? scripthashUpdate(String scripthash) { _id += 1; return subscribe( @@ -344,16 +358,14 @@ class ElectrumClient { } BehaviorSubject? subscribe( - {required String id, - required String method, - List params = const []}) { + {required String id, required String method, List params = const []}) { try { final subscription = BehaviorSubject(); _regisrySubscription(id, subscription); socket!.write(jsonrpc(method: method, id: _id, params: params)); return subscription; - } catch(e) { + } catch (e) { print(e.toString()); return null; } @@ -370,9 +382,7 @@ class ElectrumClient { } Future callWithTimeout( - {required String method, - List params = const [], - int timeout = 4000}) async { + {required String method, List params = const [], int timeout = 4000}) async { try { final completer = Completer(); _id += 1; @@ -386,7 +396,7 @@ class ElectrumClient { }); return completer.future; - } catch(e) { + } catch (e) { print(e.toString()); } } @@ -397,8 +407,8 @@ class ElectrumClient { 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,8 +429,7 @@ class ElectrumClient { } } - void _methodHandler( - {required String method, required Map request}) { + void _methodHandler({required String method, required Map request}) { switch (method) { case 'blockchain.scripthash.subscribe': final params = request['params'] as List; @@ -451,8 +460,8 @@ class ElectrumClient { _methodHandler(method: method, request: response); return; } - - if (id != null){ + + if (id != null) { _finish(id, result); } } diff --git a/cw_bitcoin/lib/electrum_transaction_info.dart b/cw_bitcoin/lib/electrum_transaction_info.dart index bf5ec2c4f..cfea0e089 100644 --- a/cw_bitcoin/lib/electrum_transaction_info.dart +++ b/cw_bitcoin/lib/electrum_transaction_info.dart @@ -1,3 +1,4 @@ +import 'package:bitcoin_base/bitcoin_base.dart'; import 'package:bitcoin_flutter/bitcoin_flutter.dart' as bitcoin; import 'package:bitcoin_flutter/src/payments/index.dart' show PaymentData; import 'package:cw_bitcoin/address_from_output.dart'; @@ -10,13 +11,12 @@ import 'package:cw_core/wallet_type.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, required this.height}); + final BtcTransaction originalTransaction; + final List ins; final int? time; final int confirmations; + final int height; } class ElectrumTransactionInfo extends TransactionInfo { @@ -39,8 +39,7 @@ class ElectrumTransactionInfo extends TransactionInfo { this.confirmations = confirmations; } - 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 +57,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 +66,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) || @@ -96,44 +91,50 @@ 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, required 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; - 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; } } - 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)); + + 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, fee: fee, @@ -152,8 +153,8 @@ class ElectrumTransactionInfo extends TransactionInfo { if (addresses != null) { tx.outs.forEach((out) { try { - final p2pkh = bitcoin.P2PKH( - data: PaymentData(output: out.script), network: bitcoin.bitcoin); + final p2pkh = + bitcoin.P2PKH(data: PaymentData(output: out.script), network: bitcoin.bitcoin); exist = addresses.contains(p2pkh.data.address); if (exist) { @@ -163,9 +164,8 @@ class ElectrumTransactionInfo extends TransactionInfo { }); } - final date = timestamp != null - ? DateTime.fromMillisecondsSinceEpoch(timestamp * 1000) - : DateTime.now(); + final date = + timestamp != null ? DateTime.fromMillisecondsSinceEpoch(timestamp * 1000) : DateTime.now(); return ElectrumTransactionInfo(type, id: tx.getId(), @@ -178,8 +178,7 @@ class ElectrumTransactionInfo extends TransactionInfo { confirmations: confirmations); } - factory ElectrumTransactionInfo.fromJson( - Map data, WalletType type) { + factory ElectrumTransactionInfo.fromJson(Map data, WalletType type) { return ElectrumTransactionInfo(type, id: data['id'] as String, height: data['height'] as int, diff --git a/cw_bitcoin/lib/electrum_wallet.dart b/cw_bitcoin/lib/electrum_wallet.dart index 8a41c1733..873fe2977 100644 --- a/cw_bitcoin/lib/electrum_wallet.dart +++ b/cw_bitcoin/lib/electrum_wallet.dart @@ -3,9 +3,10 @@ import 'dart:convert'; import 'dart:io'; import 'dart:math'; +import 'package:bitcoin_base/bitcoin_base.dart'; import 'package:bitcoin_flutter/bitcoin_flutter.dart' as bitcoin; +import 'package:bitcoin_base/bitcoin_base.dart' as bitcoin_base; import 'package:collection/collection.dart'; -import 'package:cw_bitcoin/address_to_output_script.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'; @@ -18,6 +19,7 @@ import 'package:cw_bitcoin/electrum_balance.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/litecoin_network.dart'; import 'package:cw_bitcoin/pending_bitcoin_transaction.dart'; import 'package:cw_bitcoin/script_hash.dart'; import 'package:cw_bitcoin/utils.dart'; @@ -37,6 +39,7 @@ import 'package:hex/hex.dart'; import 'package:hive/hive.dart'; import 'package:mobx/mobx.dart'; import 'package:rxdart/subjects.dart'; +import 'package:http/http.dart' as http; part 'electrum_wallet.g.dart'; @@ -73,6 +76,12 @@ abstract class ElectrumWalletBase } : {}), this.unspentCoinsInfo = unspentCoinsInfo, + this.network = networkType == bitcoin.bitcoin + ? BitcoinNetwork.mainnet + : networkType == litecoinNetwork + ? LitecoinNetwork.mainnet + : BitcoinNetwork.testnet, + this.isTestnet = networkType == bitcoin.testnet, super(walletInfo) { this.electrumClient = electrumClient ?? ElectrumClient(); this.walletInfo = walletInfo; @@ -106,13 +115,13 @@ abstract class ElectrumWalletBase @observable SyncStatus syncStatus; - List get scriptHashes => walletAddresses.addresses - .map((addr) => scriptHash(addr.address, networkType: networkType)) + List get scriptHashes => walletAddresses.addressesByReceiveType + .map((addr) => scriptHash(addr.address, network: network)) .toList(); - List get publicScriptHashes => walletAddresses.addresses + List get publicScriptHashes => walletAddresses.allAddresses .where((addr) => !addr.isHidden) - .map((addr) => scriptHash(addr.address, networkType: networkType)) + .map((addr) => scriptHash(addr.address, network: network)) .toList(); String get xpub => hd.base58!; @@ -121,6 +130,10 @@ abstract class ElectrumWalletBase String get seed => mnemonic; bitcoin.NetworkType networkType; + BasedUtxoNetwork network; + + @override + bool? isTestnet; @override BitcoinWalletKeys get keys => @@ -145,12 +158,11 @@ abstract class ElectrumWalletBase Future startSync() async { try { syncStatus = AttemptingSyncStatus(); - await walletAddresses.discoverAddresses(); await updateTransactions(); _subscribeForUpdates(); await updateUnspent(); await updateBalance(); - _feeRates = await electrumClient.feeRates(); + _feeRates = await electrumClient.feeRates(network: network); Timer.periodic( const Duration(minutes: 1), (timer) async => _feeRates = await electrumClient.feeRates()); @@ -181,183 +193,206 @@ abstract class ElectrumWalletBase } } - @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; + Future _estimateTxFeeAndInputsToUse( + int credentialsAmount, + bool sendAll, + List outputAddresses, + List outputs, + BitcoinTransactionCredentials transactionCredentials, + {int? inputsCount}) async { + final utxos = []; + List privateKeys = []; + + var leftAmount = credentialsAmount; var allInputsAmount = 0; - if (unspentCoins.isEmpty) { - await updateUnspent(); - } + for (int i = 0; i < unspentCoins.length; i++) { + final utx = unspentCoins[i]; - for (final utx in unspentCoins) { if (utx.isSending) { allInputsAmount += utx.value; - inputs.add(utx); - } - } - - 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); - } - - credentialsAmount = outputs.fold(0, (acc, value) => acc + value.formattedCryptoAmount!); - - if (allAmount - credentialsAmount < minAmount) { - throw BitcoinTransactionWrongBalanceException(currency); - } - - amount = credentialsAmount; - - if (transactionCredentials.feeRate != null) { - fee = calculateEstimatedFeeWithFeeRate(transactionCredentials.feeRate!, amount, - outputsCount: outputs.length + 1); - } 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); - } - - amount = output.sendAll || allAmount - credentialsAmount < minAmount - ? allAmount - : credentialsAmount; - - if (output.sendAll || amount == allAmount) { - fee = allAmountFee; - } else if (transactionCredentials.feeRate != null) { - fee = calculateEstimatedFeeWithFeeRate(transactionCredentials.feeRate!, amount); - } else { - fee = calculateEstimatedFee(transactionCredentials.priority, amount); - } - } - - 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) { + final address = _addressTypeFromStr(utx.address, network); + final privkey = generateECPrivate( + hd: utx.bitcoinAddressRecord.isHidden ? walletAddresses.sideHd : walletAddresses.mainHd, + index: utx.bitcoinAddressRecord.index, + network: network); + + privateKeys.add(privkey); + + utxos.add( + UtxoWithAddress( + utxo: BitcoinUtxo( + txHash: utx.hash, + value: BigInt.from(utx.value), + vout: utx.vout, + scriptType: _getScriptType(address), + ), + ownerDetails: + UtxoAddressDetails(publicKey: privkey.getPublic().toHex(), address: address), + ), + ); + + bool amountIsAcquired = !sendAll && leftAmount <= 0; + if ((inputsCount == null && amountIsAcquired) || inputsCount == i + 1) { break; } } } - if (inputs.isEmpty) { + if (utxos.isEmpty) { throw BitcoinTransactionNoInputsException(); } - if (amount <= 0 || totalInputAmount < totalAmount) { + var changeValue = allInputsAmount - credentialsAmount; + + if (!sendAll) { + if (changeValue > 0) { + final changeAddress = await walletAddresses.getChangeAddress(); + final address = _addressTypeFromStr(changeAddress, network); + outputAddresses.add(address); + outputs.add(BitcoinOutput(address: address, value: BigInt.from(changeValue))); + } + } + + final estimatedSize = BitcoinTransactionBuilder.estimateTransactionSize( + utxos: utxos, outputs: outputs, network: network); + + final fee = transactionCredentials.feeRate != null + ? feeAmountWithFeeRate(transactionCredentials.feeRate!, 0, 0, size: estimatedSize) + : feeAmountForPriority(transactionCredentials.priority!, 0, 0, size: estimatedSize); + + if (fee == 0) { 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; + var amount = credentialsAmount; - txb.addInput(input.hash, input.vout, null, p2wpkh.output); - } else { - txb.addInput(input.hash, input.vout); + final lastOutput = outputs.last; + if (!sendAll) { + if (changeValue > fee) { + // Here, lastOutput is change, deduct the fee from it + outputs[outputs.length - 1] = + BitcoinOutput(address: lastOutput.address, value: lastOutput.value - BigInt.from(fee)); } - }); - - 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; + // Here, if sendAll, the output amount equals to the input value - fee to fully spend every input on the transaction and have no amount for change + amount = allInputsAmount - fee; + outputs[outputs.length - 1] = + BitcoinOutput(address: lastOutput.address, value: BigInt.from(amount)); } - final changeValue = totalInputAmount - amount - feeAmount; + final totalAmount = amount + fee; - if (changeValue > minAmount) { - txb.addOutput(changeAddress, changeValue); + if (totalAmount > balance[currency]!.confirmed) { + throw BitcoinTransactionWrongBalanceException(currency); } - 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; + if (totalAmount > allInputsAmount) { + if (unspentCoins.where((utx) => utx.isSending).length == utxos.length) { + throw BitcoinTransactionWrongBalanceException(currency); + } else { + if (changeValue > fee) { + outputAddresses.removeLast(); + outputs.removeLast(); + } - txb.sign(vin: i, keyPair: keyPair, witnessValue: witnessValue); + return _estimateTxFeeAndInputsToUse( + credentialsAmount, sendAll, outputAddresses, outputs, transactionCredentials, + inputsCount: utxos.length + 1); + } } - return PendingBitcoinTransaction(txb.build(), type, - electrumClient: electrumClient, amount: amount, fee: fee) - ..addListener((transaction) async { - transactionHistory.addOne(transaction); - await updateBalance(); + return EstimatedTxResult(utxos: utxos, privateKeys: privateKeys, fee: fee, amount: amount); + } + + @override + Future createTransaction(Object credentials) async { + try { + final outputs = []; + final outputAddresses = []; + final transactionCredentials = credentials as BitcoinTransactionCredentials; + final hasMultiDestination = transactionCredentials.outputs.length > 1; + final sendAll = !hasMultiDestination && transactionCredentials.outputs.first.sendAll; + + var credentialsAmount = 0; + + for (final out in transactionCredentials.outputs) { + final outputAddress = out.isParsedAddress ? out.extractedAddress! : out.address; + final address = _addressTypeFromStr(outputAddress, network); + + outputAddresses.add(address); + + if (hasMultiDestination) { + if (out.sendAll || out.formattedCryptoAmount! <= 0) { + throw BitcoinTransactionWrongBalanceException(currency); + } + + final outputAmount = out.formattedCryptoAmount!; + credentialsAmount += outputAmount; + + outputs.add(BitcoinOutput(address: address, value: BigInt.from(outputAmount))); + } else { + if (!sendAll) { + final outputAmount = out.formattedCryptoAmount!; + credentialsAmount += outputAmount; + outputs.add(BitcoinOutput(address: address, value: BigInt.from(outputAmount))); + } else { + // The value will be changed after estimating the Tx size and deducting the fee from the total + outputs.add(BitcoinOutput(address: address, value: BigInt.from(0))); + } + } + } + + final estimatedTx = await _estimateTxFeeAndInputsToUse( + credentialsAmount, sendAll, outputAddresses, outputs, transactionCredentials); + + final txb = BitcoinTransactionBuilder( + utxos: estimatedTx.utxos, + outputs: outputs, + fee: BigInt.from(estimatedTx.fee), + network: network); + + final transaction = txb.buildTransaction((txDigest, utxo, publicKey, sighash) { + final key = estimatedTx.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); + } }); + + return PendingBitcoinTransaction(transaction, type, + electrumClient: electrumClient, + amount: estimatedTx.amount, + fee: estimatedTx.fee, + network: network) + ..addListener((transaction) async { + transactionHistory.addOne(transaction); + await updateBalance(); + }); + } catch (e) { + throw e; + } } 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() + '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(), + 'network_type': network == BitcoinNetwork.testnet ? 'testnet' : 'mainnet', }); int feeRate(TransactionPriority priority) { @@ -372,24 +407,29 @@ abstract class ElectrumWalletBase } } - int feeAmountForPriority( - BitcoinTransactionPriority priority, int inputsCount, int outputsCount) => - feeRate(priority) * estimatedTransactionSize(inputsCount, outputsCount); + int feeAmountForPriority(BitcoinTransactionPriority 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) { @@ -457,9 +497,6 @@ abstract class ElectrumWalletBase await transactionHistory.changePassword(password); } - bitcoin.ECPair keyPairFor({required int index}) => - generateKeyPair(hd: hd, index: index, network: networkType); - @override Future rescan({required int height}) async => throw UnimplementedError(); @@ -473,20 +510,23 @@ abstract class ElectrumWalletBase Future makePath() async => pathForWallet(name: walletInfo.name, type: walletInfo.type); Future updateUnspent() async { - final unspent = await Future.wait(walletAddresses.addresses.map((address) => electrumClient - .getListUnspentWithAddress(address.address, networkType) - .then((unspent) => unspent.map((unspent) { + List updatedUnspentCoins = []; + + final addressesSet = walletAddresses.allAddresses.map((addr) => addr.address).toSet(); + + await Future.wait(walletAddresses.allAddresses.map((address) => electrumClient + .getListUnspentWithAddress(address.address, network) + .then((unspent) => Future.forEach>(unspent, (unspent) async { 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; - }); + final coin = BitcoinUnspent.fromJSON(address, unspent); + final tx = await fetchTransactionInfo( + hash: coin.hash, height: 0, myAddresses: addressesSet); + coin.isChange = tx?.direction == TransactionDirection.outgoing; + updatedUnspentCoins.add(coin); + } catch (_) {} + })))); + + unspentCoins = updatedUnspentCoins; if (unspentCoinsInfo.isEmpty) { unspentCoins.forEach((coin) => _addCoinInfo(coin)); @@ -495,8 +535,10 @@ abstract class ElectrumWalletBase if (unspentCoins.isNotEmpty) { unspentCoins.forEach((coin) { - final coinInfoList = unspentCoinsInfo.values - .where((element) => element.walletId.contains(id) && element.hash.contains(coin.hash)); + 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; @@ -537,7 +579,8 @@ abstract class ElectrumWalletBase if (currentWalletUnspentCoins.isNotEmpty) { currentWalletUnspentCoins.forEach((element) { - final existUnspentCoins = unspentCoins.where((coin) => element.hash.contains(coin.hash)); + final existUnspentCoins = unspentCoins + .where((coin) => element.hash.contains(coin.hash) && element.vout == coin.vout); if (existUnspentCoins.isEmpty) { keys.add(element.key); @@ -555,92 +598,152 @@ abstract class ElectrumWalletBase 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; + String transactionHex; + int? time; + int confirmations = 0; + if (network == BitcoinNetwork.testnet) { + // Testnet public electrum server does not support verbose transaction fetching + transactionHex = await electrumClient.getTransactionHex(hash: hash); - 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); + final status = json.decode( + (await http.get(Uri.parse("https://blockstream.info/testnet/api/tx/$hash/status"))).body); + + time = status["block_time"] as int?; + final tip = await electrumClient.getCurrentBlockChainTip() ?? 0; + confirmations = tip - (status["block_height"] as int? ?? 0); + } else { + final verboseTransaction = await electrumClient.getTransactionRaw(hash: hash); + + transactionHex = verboseTransaction['hex'] as String; + time = verboseTransaction['time'] as int?; + confirmations = verboseTransaction['confirmations'] as int? ?? 0; } - return ElectrumTransactionBundle(original, ins: ins, time: time, confirmations: confirmations); + final original = bitcoin_base.BtcTransaction.fromRaw(transactionHex); + final ins = []; + + for (final vin in original.inputs) { + try { + final id = HEX.encode(HEX.decode(vin.txId).reversed.toList()); + final txHex = await electrumClient.getTransactionHex(hash: id); + final tx = bitcoin_base.BtcTransaction.fromRaw(txHex); + ins.add(tx); + } catch (_) { + ins.add(bitcoin_base.BtcTransaction.fromRaw( + await electrumClient.getTransactionHex(hash: vin.txId), + )); + } + } + + return ElectrumTransactionBundle(original, + ins: ins, time: time, confirmations: confirmations, height: height); } Future fetchTransactionInfo( - {required String hash, required int height}) async { + {required String hash, + required int height, + required Set myAddresses, + bool? retryOnFailure}) 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 (_) { + return ElectrumTransactionInfo.fromElectrumBundle( + await getTransactionExpanded(hash: hash, height: height), walletInfo.type, network, + addresses: myAddresses, height: height); + } catch (e) { + if (e is FormatException && retryOnFailure == true) { + await Future.delayed(const Duration(seconds: 2)); + return fetchTransactionInfo(hash: hash, height: height, myAddresses: myAddresses); + } return null; } } @override Future> fetchTransactions() async { - final addressHashes = {}; - final normalizedHistories = >[]; - final newTxCounts = {}; - - walletAddresses.addresses.forEach((addressRecord) { - final sh = scriptHash(addressRecord.address, networkType: networkType); - addressHashes[sh] = addressRecord; - newTxCounts[sh] = 0; - }); - try { - final histories = addressHashes.keys.map((scriptHash) => - electrumClient.getHistory(scriptHash).then((history) => {scriptHash: history})); - final historyResults = await Future.wait(histories); + final Map historiesWithDetails = {}; + final addressesSet = walletAddresses.allAddresses.map((addr) => addr.address).toSet(); + final currentHeight = await electrumClient.getCurrentBlockChainTip() ?? 0; - 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); + await Future.wait(ADDRESS_TYPES.map((type) { + final addressesByType = walletAddresses.allAddresses.where((addr) => addr.type == type); + + return Future.wait(addressesByType.map((addressRecord) async { + final history = await _fetchAddressHistory(addressRecord, addressesSet, currentHeight); + + if (history.isNotEmpty) { + addressRecord.txCount = history.length; + historiesWithDetails.addAll(history); + + final matchedAddresses = + addressesByType.where((addr) => addr.isHidden == addressRecord.isHidden); + + final isLastUsedAddress = + history.isNotEmpty && addressRecord.address == matchedAddresses.last.address; + + if (isLastUsedAddress) { + await walletAddresses.discoverAddresses( + matchedAddresses.toList(), + addressRecord.isHidden, + (address, addressesSet) => + _fetchAddressHistory(address, addressesSet, currentHeight) + .then((history) => history.isNotEmpty ? address.address : null), + type: type); + } } - }); - }); - - 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; - } - } - - addressHashes.forEach((sh, addressRecord) { - addressRecord.txCount = newTxCounts[sh] ?? 0; - }); - - 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 historiesWithDetails; + } catch (e) { + print(e.toString()); + return {}; + } + } + + Future> _fetchAddressHistory( + BitcoinAddressRecord addressRecord, Set addressesSet, int currentHeight) async { + try { + final Map historiesWithDetails = {}; + + final history = await electrumClient + .getHistory(addressRecord.scriptHash ?? addressRecord.updateScriptHash(network)); + + if (history.isNotEmpty) { + addressRecord.setAsUsed(); + + await Future.wait(history.map((transaction) async { + final 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 + storedTx.confirmations = currentHeight - height + 1; + storedTx.isPending = storedTx.confirmations == 0; + } + + historiesWithDetails[txid] = storedTx; + } else { + final tx = await fetchTransactionInfo( + hash: txid, height: height, myAddresses: addressesSet, 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 + transactionHistory.addOne(tx); + await transactionHistory.save(); + } + } + + return Future.value(null); + })); + } + + return historiesWithDetails; } catch (e) { print(e.toString()); return {}; @@ -654,10 +757,8 @@ abstract class ElectrumWalletBase } _isTransactionUpdating = true; - final transactions = await fetchTransactions(); - transactionHistory.addMany(transactions); + await fetchTransactions(); walletAddresses.updateReceiveAddresses(); - await transactionHistory.save(); _isTransactionUpdating = false; } catch (e, stacktrace) { print(stacktrace); @@ -688,11 +789,11 @@ abstract class ElectrumWalletBase } Future _fetchBalances() async { - final addresses = walletAddresses.addresses.toList(); + final addresses = walletAddresses.allAddresses.toList(); final balanceFutures = >>[]; for (var i = 0; i < addresses.length; i++) { final addressRecord = addresses[i]; - final sh = scriptHash(addressRecord.address, networkType: networkType); + final sh = scriptHash(addressRecord.address, network: network); final balanceFuture = electrumClient.getBalance(sh); balanceFutures.add(balanceFuture); } @@ -701,6 +802,7 @@ abstract class ElectrumWalletBase unspentCoinsInfo.values.forEach((info) { unspentCoins.forEach((element) { if (element.hash == info.hash && + element.vout == info.vout && info.isFrozen && element.bitcoinAddressRecord.address == info.address && element.value == info.value) { @@ -738,10 +840,10 @@ abstract class ElectrumWalletBase String getChangeAddress() { const minCountOfHiddenAddresses = 5; final random = Random(); - var addresses = walletAddresses.addresses.where((addr) => addr.isHidden).toList(); + var addresses = walletAddresses.allAddresses.where((addr) => addr.isHidden).toList(); if (addresses.length < minCountOfHiddenAddresses) { - addresses = walletAddresses.addresses.toList(); + addresses = walletAddresses.allAddresses.toList(); } return addresses[random.nextInt(addresses.length)].address; @@ -753,9 +855,62 @@ abstract class ElectrumWalletBase @override String signMessage(String message, {String? address = null}) { 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)); } } + +class EstimateTxParams { + EstimateTxParams( + {required this.amount, + required this.feeRate, + required this.priority, + required this.outputsCount, + required this.size}); + + final int amount; + final int feeRate; + final TransactionPriority priority; + final int outputsCount; + final int size; +} + +class EstimatedTxResult { + EstimatedTxResult( + {required this.utxos, required this.privateKeys, required this.fee, required this.amount}); + + final List utxos; + final List privateKeys; + final int fee; + final int amount; +} + +BitcoinBaseAddress _addressTypeFromStr(String address, BasedUtxoNetwork network) { + if (P2pkhAddress.regex.hasMatch(address)) { + return P2pkhAddress.fromAddress(address: address, network: network); + } else if (P2shAddress.regex.hasMatch(address)) { + return P2shAddress.fromAddress(address: address, network: network); + } else if (P2wshAddress.regex.hasMatch(address)) { + return P2wshAddress.fromAddress(address: address, network: network); + } else if (P2trAddress.regex.hasMatch(address)) { + return P2trAddress.fromAddress(address: address, network: network); + } else { + return P2wpkhAddress.fromAddress(address: address, network: network); + } +} + +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 { + return SegwitAddresType.p2wpkh; + } +} diff --git a/cw_bitcoin/lib/electrum_wallet_addresses.dart b/cw_bitcoin/lib/electrum_wallet_addresses.dart index 850d58f40..5880f5a19 100644 --- a/cw_bitcoin/lib/electrum_wallet_addresses.dart +++ b/cw_bitcoin/lib/electrum_wallet_addresses.dart @@ -1,8 +1,8 @@ -import 'package:bitbox/bitbox.dart' as bitbox; +import 'package:bitcoin_base/bitcoin_base.dart'; import 'package:bitcoin_flutter/bitcoin_flutter.dart' as bitcoin; +import 'package:bitbox/bitbox.dart' as bitbox; import 'package:cw_bitcoin/bitcoin_address_record.dart'; import 'package:cw_bitcoin/electrum.dart'; -import 'package:cw_bitcoin/script_hash.dart'; import 'package:cw_core/wallet_addresses.dart'; import 'package:cw_core/wallet_info.dart'; import 'package:cw_core/wallet_type.dart'; @@ -12,25 +12,41 @@ part 'electrum_wallet_addresses.g.dart'; class ElectrumWalletAddresses = ElectrumWalletAddressesBase with _$ElectrumWalletAddresses; +const List ADDRESS_TYPES = [ + SegwitAddresType.p2wpkh, + P2pkhAddressType.p2pkh, + SegwitAddresType.p2tr, + SegwitAddresType.p2wsh, + P2shAddressType.p2wpkhInP2sh, +]; + 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.electrumClient, + required this.network, + List? initialAddresses, + Map? initialRegularAddressIndex, + Map? initialChangeAddressIndex, + }) : _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 = walletInfo.addressPageType != null + ? BitcoinAddressType.fromValue(walletInfo.addressPageType!) + : SegwitAddresType.p2wpkh, + super(walletInfo) { + updateAddressesByMatch(); + } static const defaultReceiveAddressesCount = 22; static const defaultChangeAddressesCount = 17; @@ -40,37 +56,48 @@ abstract class ElectrumWalletAddressesBase extends WalletAddresses with Store { static String toLegacy(String address) => bitbox.Address.toLegacyAddress(address); - final ObservableList addresses; + final ObservableList _addresses; + // Matched by addressPageType + late ObservableList addressesByReceiveType; final ObservableList receiveAddresses; final ObservableList changeAddresses; final ElectrumClient electrumClient; - final bitcoin.NetworkType networkType; + final BasedUtxoNetwork network; final bitcoin.HDWallet mainHd; final bitcoin.HDWallet sideHd; + @observable + BitcoinAddressType _addressPageType = SegwitAddresType.p2wpkh; + + @computed + BitcoinAddressType get addressPageType => _addressPageType; + + @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; - } - final receiveAddress = receiveAddresses.first.address; + String receiveAddress; - return walletInfo.type == WalletType.bitcoinCash - ? toCashAddr(receiveAddress) - : receiveAddress; + final typeMatchingReceiveAddresses = receiveAddresses.where(_isAddressPageTypeMatch); + + if ((isEnabledAutoGenerateSubaddress && receiveAddresses.isEmpty) || + typeMatchingReceiveAddresses.isEmpty) { + receiveAddress = generateNewAddress().address; } else { - final receiveAddress = (receiveAddresses.first.address != addresses.first.address && - previousAddressRecord != null) - ? previousAddressRecord!.address - : addresses.first.address; + final previousAddressMatchesType = + previousAddressRecord != null && previousAddressRecord!.type == addressPageType; - return walletInfo.type == WalletType.bitcoinCash - ? toCashAddr(receiveAddress) - : receiveAddress; + if (previousAddressMatchesType && + typeMatchingReceiveAddresses.first.address != addressesByReceiveType.first.address) { + receiveAddress = previousAddressRecord!.address; + } else { + receiveAddress = typeMatchingReceiveAddresses.first.address; + } } + + return walletInfo.type == WalletType.bitcoinCash ? toCashAddr(receiveAddress) : receiveAddress; } @observable @@ -81,7 +108,7 @@ abstract class ElectrumWalletAddressesBase extends WalletAddresses with Store { if (addr.startsWith('bitcoincash:')) { addr = toLegacy(addr); } - final addressRecord = addresses.firstWhere((addressRecord) => addressRecord.address == addr); + final addressRecord = _addresses.firstWhere((addressRecord) => addressRecord.address == addr); previousAddressRecord = addressRecord; receiveAddresses.remove(addressRecord); @@ -89,16 +116,29 @@ abstract class ElectrumWalletAddressesBase extends WalletAddresses with Store { } @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; @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,22 +146,21 @@ 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(); + await _generateInitialAddresses(type: P2pkhAddressType.p2pkh); + await _generateInitialAddresses(type: P2shAddressType.p2wpkhInP2sh); + await _generateInitialAddresses(type: SegwitAddresType.p2tr); + await _generateInitialAddresses(type: SegwitAddresType.p2wsh); + updateAddressesByMatch(); updateReceiveAddresses(); updateChangeAddresses(); await updateAddressesInBox(); @@ -141,10 +180,9 @@ abstract class ElectrumWalletAddressesBase extends WalletAddresses with Store { 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) { @@ -157,19 +195,26 @@ abstract class ElectrumWalletAddressesBase extends WalletAddresses with Store { return address; } - BitcoinAddressRecord generateNewAddress({bitcoin.HDWallet? hd, String? label}) { - final isHidden = hd == sideHd; + BitcoinAddressRecord generateNewAddress({String label = ''}) { + final newAddressIndex = addressesByReceiveType.fold( + 0, (int acc, addressRecord) => addressRecord.isHidden == false ? acc + 1 : acc); - final newAddressIndex = addresses.fold( - 0, (int acc, addressRecord) => isHidden == addressRecord.isHidden ? acc + 1 : acc); - - final address = BitcoinAddressRecord(getAddress(index: newAddressIndex, hd: hd ?? sideHd), - index: newAddressIndex, isHidden: isHidden, name: label ?? ''); - addresses.add(address); + final address = BitcoinAddressRecord( + getAddress(index: newAddressIndex, hd: mainHd, addressType: addressPageType), + index: newAddressIndex, + isHidden: false, + name: label, + type: addressPageType, + network: network, + ); + _addresses.add(address); + updateAddressesByMatch(); return address; } - String getAddress({required int index, required bitcoin.HDWallet hd}) => ''; + String getAddress( + {required int index, required bitcoin.HDWallet hd, BitcoinAddressType? addressType}) => + ''; @override Future updateAddressesInBox() async { @@ -187,126 +232,138 @@ abstract class ElectrumWalletAddressesBase extends WalletAddresses with Store { if (address.startsWith('bitcoincash:')) { address = toLegacy(address); } - final addressRecord = addresses.firstWhere((addressRecord) => addressRecord.address == address); + final addressRecord = + _addresses.firstWhere((addressRecord) => addressRecord.address == address); addressRecord.setNewName(label); - final index = addresses.indexOf(addressRecord); - addresses.remove(addressRecord); - addresses.insert(index, addressRecord); + final index = _addresses.indexOf(addressRecord); + _addresses.remove(addressRecord); + _addresses.insert(index, addressRecord); + } + + @action + void updateAddressesByMatch() { + 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 + addressRecord.type == SegwitAddresType.p2wpkh); changeAddresses.addAll(newAddresses); } - Future _discoverAddresses(bitcoin.HDWallet hd, bool isHidden) async { - var hasAddrUse = true; - List addrs; - - if (addresses.isNotEmpty) { - - - 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); + @action + Future discoverAddresses(List addressList, bool isHidden, + Future Function(BitcoinAddressRecord, Set) getAddressHistory, + {BitcoinAddressType type = SegwitAddresType.p2wpkh}) async { + if (!isHidden) { + _validateSideHdAddresses(addressList.toList()); } - while (hasAddrUse) { - final addr = addrs.last.address; - hasAddrUse = await _hasAddressUsed(addr); + final newAddresses = await _createNewAddresses(gap, + startIndex: addressList.length, isHidden: isHidden, type: type); + addAddresses(newAddresses); - if (!hasAddrUse) { - break; - } + final addressesWithHistory = await Future.wait(newAddresses + .map((addr) => getAddressHistory(addr, _addresses.map((e) => e.address).toSet()))); + final isLastAddressUsed = addressesWithHistory.last == addressList.last.address; - 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( + getAddress(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; - } - - void validateSideHdAddresses(List addrWithTransactions) { + void _validateSideHdAddresses(List addrWithTransactions) { addrWithTransactions.forEach((element) { - if (element.address != getAddress(index: element.index, hd: mainHd)) element.isHidden = true; + if (element.address != + getAddress(index: element.index, hd: mainHd, addressType: element.type)) + element.isHidden = true; }); } + + @action + Future setAddressType(BitcoinAddressType type) async { + _addressPageType = type; + updateAddressesByMatch(); + walletInfo.addressPageType = addressPageType.toString(); + await walletInfo.save(); + } + + bool _isAddressPageTypeMatch(BitcoinAddressRecord addressRecord) { + return _isAddressByType(addressRecord, addressPageType); + } + + bitcoin.HDWallet _getHd(bool isHidden) => isHidden ? sideHd : mainHd; + bool _isAddressByType(BitcoinAddressRecord addr, BitcoinAddressType type) => addr.type == type; } diff --git a/cw_bitcoin/lib/electrum_wallet_snapshot.dart b/cw_bitcoin/lib/electrum_wallet_snapshot.dart index 86d3e2fed..98c3753db 100644 --- a/cw_bitcoin/lib/electrum_wallet_snapshot.dart +++ b/cw_bitcoin/lib/electrum_wallet_snapshot.dart @@ -1,12 +1,13 @@ 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/pathForWallet.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, @@ -14,19 +15,24 @@ class ElectrumWallletSnapshot { required this.addresses, required this.balance, required this.regularAddressIndex, - required this.changeAddressIndex}); + required this.changeAddressIndex, + required this.addressPageType, + required this.network, + }); final String name; final String password; final WalletType type; + final String addressPageType; + final BasedUtxoNetwork network; String mnemonic; List addresses; ElectrumBalance balance; - int regularAddressIndex; - int changeAddressIndex; + Map regularAddressIndex; + Map changeAddressIndex; - static Future load(String name, WalletType type, String password) async { + static Future load(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 data = json.decode(jsonSource) as Map; @@ -34,26 +40,39 @@ class ElectrumWallletSnapshot { final mnemonic = data['mnemonic'] as String; final addresses = addressesTmp .whereType() - .map((addr) => BitcoinAddressRecord.fromJSON(addr)) + .map((addr) => BitcoinAddressRecord.fromJSON(addr, network)) .toList(); 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}; 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') + }; + } 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, mnemonic: mnemonic, addresses: addresses, balance: balance, - regularAddressIndex: regularAddressIndex, - changeAddressIndex: changeAddressIndex); + regularAddressIndex: regularAddressIndexByType, + changeAddressIndex: changeAddressIndexByType, + addressPageType: data['address_page_type'] as String? ?? SegwitAddresType.p2wpkh.toString(), + network: data['network_type'] == 'testnet' ? BitcoinNetwork.testnet : BitcoinNetwork.mainnet, + ); } } diff --git a/cw_bitcoin/lib/litecoin_wallet.dart b/cw_bitcoin/lib/litecoin_wallet.dart index 222e95acc..d2379d5a5 100644 --- a/cw_bitcoin/lib/litecoin_wallet.dart +++ b/cw_bitcoin/lib/litecoin_wallet.dart @@ -1,3 +1,4 @@ +import 'package:bitcoin_base/bitcoin_base.dart'; import 'package:cw_bitcoin/bitcoin_mnemonic.dart'; import 'package:cw_bitcoin/bitcoin_transaction_priority.dart'; import 'package:cw_core/crypto_currency.dart'; @@ -20,17 +21,18 @@ part 'litecoin_wallet.g.dart'; class LitecoinWallet = LitecoinWalletBase with _$LitecoinWallet; abstract class LitecoinWalletBase extends ElectrumWallet with Store { - LitecoinWalletBase( - {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( + LitecoinWalletBase({ + required String mnemonic, + required String password, + required WalletInfo walletInfo, + required Box unspentCoinsInfo, + required Uint8List seedBytes, + String? addressPageType, + List? initialAddresses, + ElectrumBalance? initialBalance, + Map? initialRegularAddressIndex, + Map? initialChangeAddressIndex, + }) : super( mnemonic: mnemonic, password: password, walletInfo: walletInfo, @@ -41,41 +43,42 @@ abstract class LitecoinWalletBase extends ElectrumWallet with Store { 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,); + walletInfo, + electrumClient: electrumClient, + initialAddresses: initialAddresses, + initialRegularAddressIndex: initialRegularAddressIndex, + initialChangeAddressIndex: initialChangeAddressIndex, + mainHd: hd, + sideHd: bitcoin.HDWallet.fromSeed(seedBytes, network: networkType).derivePath("m/0'/1"), + network: network, + ); autorun((_) { this.walletAddresses.isEnabledAutoGenerateSubaddress = this.isEnabledAutoGenerateSubaddress; }); } - 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 { + static Future create( + {required String mnemonic, + required String password, + required WalletInfo walletInfo, + required Box unspentCoinsInfo, + String? addressPageType, + List? initialAddresses, + ElectrumBalance? initialBalance, + Map? initialRegularAddressIndex, + Map? initialChangeAddressIndex}) async { 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, + initialBalance: initialBalance, + seedBytes: await mnemonicToSeedBytes(mnemonic), + initialRegularAddressIndex: initialRegularAddressIndex, + initialChangeAddressIndex: initialChangeAddressIndex, + addressPageType: addressPageType, + ); } static Future open({ @@ -84,17 +87,20 @@ abstract class LitecoinWalletBase extends ElectrumWallet with Store { required Box unspentCoinsInfo, required String password, }) async { - final snp = await ElectrumWallletSnapshot.load (name, walletInfo.type, password); + final snp = + await ElectrumWalletSnapshot.load(name, walletInfo.type, password, LitecoinNetwork.mainnet); 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: 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, + addressPageType: snp.addressPageType, + ); } @override diff --git a/cw_bitcoin/lib/litecoin_wallet_addresses.dart b/cw_bitcoin/lib/litecoin_wallet_addresses.dart index a317fa9f2..993d17933 100644 --- a/cw_bitcoin/lib/litecoin_wallet_addresses.dart +++ b/cw_bitcoin/lib/litecoin_wallet_addresses.dart @@ -1,39 +1,28 @@ +import 'package:bitcoin_base/bitcoin_base.dart'; import 'package:bitcoin_flutter/bitcoin_flutter.dart' as bitcoin; -import 'package:cw_bitcoin/electrum.dart'; import 'package:cw_bitcoin/utils.dart'; -import 'package:cw_bitcoin/bitcoin_address_record.dart'; import 'package:cw_bitcoin/electrum_wallet_addresses.dart'; import 'package:cw_core/wallet_info.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.electrumClient, + super.initialAddresses, + super.initialRegularAddressIndex, + super.initialChangeAddressIndex, + }) : super(walletInfo); @override - String getAddress({required int index, required bitcoin.HDWallet hd}) => - generateP2WPKHAddress(hd: hd, index: index, networkType: networkType); -} \ No newline at end of file + String getAddress( + {required int index, required bitcoin.HDWallet hd, BitcoinAddressType? addressType}) => + generateP2WPKHAddress(hd: hd, index: index, network: network); +} diff --git a/cw_bitcoin/lib/litecoin_wallet_service.dart b/cw_bitcoin/lib/litecoin_wallet_service.dart index 69d1dfc7e..3d7462fa1 100644 --- a/cw_bitcoin/lib/litecoin_wallet_service.dart +++ b/cw_bitcoin/lib/litecoin_wallet_service.dart @@ -25,7 +25,7 @@ class LitecoinWalletService extends WalletService< WalletType getType() => WalletType.litecoin; @override - Future create(BitcoinNewWalletCredentials credentials) async { + Future create(BitcoinNewWalletCredentials credentials, {bool? isTestnet}) async { final wallet = await LitecoinWalletBase.create( mnemonic: await generateMnemonic(), password: credentials.password!, @@ -94,12 +94,12 @@ class LitecoinWalletService extends WalletService< @override Future restoreFromKeys( - BitcoinRestoreWalletFromWIFCredentials credentials) async => + BitcoinRestoreWalletFromWIFCredentials credentials, {bool? isTestnet}) async => throw UnimplementedError(); @override Future restoreFromSeed( - BitcoinRestoreWalletFromSeedCredentials credentials) async { + BitcoinRestoreWalletFromSeedCredentials credentials, {bool? isTestnet}) async { if (!validateMnemonic(credentials.mnemonic)) { throw BitcoinMnemonicIsIncorrectException(); } diff --git a/cw_bitcoin/lib/pending_bitcoin_transaction.dart b/cw_bitcoin/lib/pending_bitcoin_transaction.dart index e2dc10bfb..fa413febd 100644 --- a/cw_bitcoin/lib/pending_bitcoin_transaction.dart +++ b/cw_bitcoin/lib/pending_bitcoin_transaction.dart @@ -1,5 +1,5 @@ import 'package:cw_bitcoin/bitcoin_commit_transaction_exception.dart'; -import 'package:bitcoin_flutter/bitcoin_flutter.dart' as bitcoin; +import 'package:bitcoin_base/bitcoin_base.dart'; import 'package:cw_core/pending_transaction.dart'; import 'package:cw_bitcoin/electrum.dart'; import 'package:cw_bitcoin/bitcoin_amount_format.dart'; @@ -9,22 +9,21 @@ import 'package:cw_core/wallet_type.dart'; class PendingBitcoinTransaction with PendingTransaction { PendingBitcoinTransaction(this._tx, this.type, - {required this.electrumClient, - required this.amount, - required this.fee}) + {required this.electrumClient, required this.amount, required this.fee, this.network}) : _listeners = []; final WalletType type; - final bitcoin.Transaction _tx; + final BtcTransaction _tx; final ElectrumClient electrumClient; final int amount; final int fee; + final BasedUtxoNetwork? network; @override - String get id => _tx.getId(); + String get id => _tx.txId(); @override - String get hex => _tx.toHex(); + String get hex => _tx.serialize(); @override String get amountFormatted => bitcoinAmountToString(amount: amount); @@ -36,18 +35,16 @@ class PendingBitcoinTransaction with PendingTransaction { @override Future commit() async { - final result = - await electrumClient.broadcastTransaction(transactionRaw: _tx.toHex()); + final result = await electrumClient.broadcastTransaction(transactionRaw: hex, network: network); if (result.isEmpty) { throw BitcoinCommitTransactionException(); } - _listeners?.forEach((listener) => listener(transactionInfo())); + _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, diff --git a/cw_bitcoin/lib/script_hash.dart b/cw_bitcoin/lib/script_hash.dart index 76a1bfcf0..620d3d28a 100644 --- a/cw_bitcoin/lib/script_hash.dart +++ b/cw_bitcoin/lib/script_hash.dart @@ -1,9 +1,8 @@ -import 'package:bitcoin_flutter/bitcoin_flutter.dart' as bitcoin; +import 'package:bitcoin_base/bitcoin_base.dart'; import 'package:crypto/crypto.dart'; -String scriptHash(String address, {required bitcoin.NetworkType networkType}) { - final outputScript = - bitcoin.Address.addressToOutputScript(address, networkType); +String scriptHash(String address, {required BasedUtxoNetwork network}) { + final outputScript = addressToOutputScript(address: address, network: network); final parts = sha256.convert(outputScript).toString().split(''); var res = ''; diff --git a/cw_bitcoin/lib/utils.dart b/cw_bitcoin/lib/utils.dart index 0d5a413b3..b156ccba3 100644 --- a/cw_bitcoin/lib/utils.dart +++ b/cw_bitcoin/lib/utils.dart @@ -1,55 +1,33 @@ import 'dart:typed_data'; +import 'package:bitcoin_base/bitcoin_base.dart'; import 'package:flutter/foundation.dart'; import 'package:bitcoin_flutter/bitcoin_flutter.dart' as bitcoin; import 'package:bitcoin_flutter/src/payments/index.dart' show PaymentData; import 'package:hex/hex.dart'; -bitcoin.PaymentData generatePaymentData( - {required bitcoin.HDWallet hd, required int index}) => - PaymentData( - pubkey: Uint8List.fromList(HEX.decode(hd.derive(index).pubKey!))); +bitcoin.PaymentData generatePaymentData({required bitcoin.HDWallet hd, required int index}) => + PaymentData(pubkey: Uint8List.fromList(HEX.decode(hd.derive(index).pubKey!))); -bitcoin.ECPair generateKeyPair( - {required bitcoin.HDWallet hd, - required int index, - required bitcoin.NetworkType network}) => - bitcoin.ECPair.fromWIF(hd.derive(index).wif!, network: network); +ECPrivate generateECPrivate( + {required bitcoin.HDWallet hd, required int index, required BasedUtxoNetwork network}) => + ECPrivate.fromWif(hd.derive(index).wif!, netVersion: network.wifNetVer); String generateP2WPKHAddress( - {required bitcoin.HDWallet hd, - required int index, - required bitcoin.NetworkType networkType}) => - bitcoin - .P2WPKH( - data: PaymentData( - pubkey: - Uint8List.fromList(HEX.decode(hd.derive(index).pubKey!))), - network: networkType) - .data - .address!; + {required bitcoin.HDWallet hd, required int index, required BasedUtxoNetwork network}) => + ECPublic.fromHex(hd.derive(index).pubKey!).toP2wpkhAddress().toAddress(network); -String generateP2WPKHAddressByPath( - {required bitcoin.HDWallet hd, - required String path, - required bitcoin.NetworkType networkType}) => - bitcoin - .P2WPKH( - data: PaymentData( - pubkey: - Uint8List.fromList(HEX.decode(hd.derivePath(path).pubKey!))), - network: networkType) - .data - .address!; +String generateP2SHAddress( + {required bitcoin.HDWallet hd, required int index, required BasedUtxoNetwork network}) => + ECPublic.fromHex(hd.derive(index).pubKey!).toP2wpkhInP2sh().toAddress(network); + +String generateP2WSHAddress( + {required bitcoin.HDWallet hd, required int index, required BasedUtxoNetwork network}) => + ECPublic.fromHex(hd.derive(index).pubKey!).toP2wshAddress().toAddress(network); String generateP2PKHAddress( - {required bitcoin.HDWallet hd, - required int index, - required bitcoin.NetworkType networkType}) => - bitcoin - .P2PKH( - data: PaymentData( - pubkey: - Uint8List.fromList(HEX.decode(hd.derive(index).pubKey!))), - network: networkType) - .data - .address!; + {required bitcoin.HDWallet hd, required int index, required BasedUtxoNetwork network}) => + ECPublic.fromHex(hd.derive(index).pubKey!).toP2pkhAddress().toAddress(network); + +String generateP2TRAddress( + {required bitcoin.HDWallet hd, required int index, required BasedUtxoNetwork network}) => + ECPublic.fromHex(hd.derive(index).pubKey!).toTaprootAddress().toAddress(network); diff --git a/cw_bitcoin/pubspec.lock b/cw_bitcoin/pubspec.lock index 3344cb807..25e6f269d 100644 --- a/cw_bitcoin/pubspec.lock +++ b/cw_bitcoin/pubspec.lock @@ -21,18 +21,18 @@ packages: dependency: transitive description: name: args - sha256: "139d809800a412ebb26a3892da228b2d0ba36f0ef5d9a82166e5e52ec8d61611" + sha256: eef6c46b622e0494a36c5a12d10d77fb4e855501a91c1b9ef9339326e58f0596 url: "https://pub.dev" source: hosted - version: "2.3.2" + version: "2.4.2" asn1lib: dependency: transitive description: name: asn1lib - sha256: ab96a1cb3beeccf8145c52e449233fe68364c9641623acd3adad66f8184f1039 + sha256: c9c85fedbe2188b95133cbe960e16f5f448860f7133330e272edbbca5893ddc6 url: "https://pub.dev" source: hosted - version: "1.4.0" + version: "1.5.2" async: dependency: transitive description: @@ -75,6 +75,15 @@ packages: url: "https://github.com/cake-tech/bitbox-flutter.git" source: git version: "1.0.1" + bitcoin_base: + dependency: "direct main" + description: + path: "." + ref: cake-update-v1 + resolved-ref: "9611e9db77e92a8434e918cdfb620068f6fcb1aa" + url: "https://github.com/cake-tech/bitcoin_base.git" + source: git + version: "4.0.0" bitcoin_flutter: dependency: "direct main" description: @@ -84,6 +93,14 @@ packages: url: "https://github.com/cake-tech/bitcoin_flutter.git" source: git version: "2.1.0" + blockchain_utils: + dependency: "direct main" + description: + name: blockchain_utils + sha256: "9701dfaa74caad4daae1785f1ec4445cf7fb94e45620bc3a4aca1b9b281dc6c9" + url: "https://pub.dev" + source: hosted + version: "1.6.0" boolean_selector: dependency: transitive description: @@ -104,10 +121,10 @@ packages: dependency: transitive description: name: build - sha256: "3fbda25365741f8251b39f3917fb3c8e286a96fd068a5a242e11c2012d495777" + sha256: "80184af8b6cb3e5c1c4ec6d8544d27711700bc3e6d2efad04238c7b5290889f0" url: "https://pub.dev" source: hosted - version: "2.3.1" + version: "2.4.1" build_config: dependency: transitive description: @@ -120,10 +137,10 @@ packages: dependency: transitive description: name: build_daemon - sha256: "6bc5544ea6ce4428266e7ea680e945c68806c4aae2da0eb5e9ccf38df8d6acbf" + sha256: "0343061a33da9c5810b2d6cee51945127d8f4c060b7fbdd9d54917f0a3feaaa1" url: "https://pub.dev" source: hosted - version: "3.1.0" + version: "4.0.1" build_resolvers: dependency: "direct dev" description: @@ -136,18 +153,18 @@ packages: dependency: "direct dev" description: name: build_runner - sha256: b0a8a7b8a76c493e85f1b84bffa0588859a06197863dba8c9036b15581fd9727 + sha256: "581bacf68f89ec8792f5e5a0b2c4decd1c948e97ce659dc783688c8a88fbec21" url: "https://pub.dev" source: hosted - version: "2.3.3" + version: "2.4.8" build_runner_core: dependency: transitive description: name: build_runner_core - sha256: "14febe0f5bac5ae474117a36099b4de6f1dbc52df6c5e55534b3da9591bf4292" + sha256: "6d6ee4276b1c5f34f21fdf39425202712d2be82019983d52f351c94aafbc2c41" url: "https://pub.dev" source: hosted - version: "7.2.7" + version: "7.2.10" built_collection: dependency: transitive description: @@ -160,10 +177,10 @@ packages: dependency: transitive description: name: built_value - sha256: "169565c8ad06adb760c3645bf71f00bff161b00002cace266cad42c5d22a7725" + sha256: a3ec2e0f967bc47f69f95009bb93db936288d61d5343b9436e378b28a2f830c6 url: "https://pub.dev" source: hosted - version: "8.4.3" + version: "8.9.0" characters: dependency: transitive description: @@ -176,10 +193,10 @@ packages: dependency: transitive description: name: checked_yaml - sha256: "3d1505d91afa809d177efd4eed5bb0eb65805097a1463abdd2add076effae311" + sha256: feb6bed21949061731a7a75fc5d2aa727cf160b91af9a3e464c5e3a32e28b5ff url: "https://pub.dev" source: hosted - version: "2.0.2" + version: "2.0.3" clock: dependency: transitive description: @@ -192,10 +209,10 @@ packages: dependency: transitive description: name: code_builder - sha256: "0d43dd1288fd145de1ecc9a3948ad4a6d5a82f0a14c4fdd0892260787d975cbe" + sha256: f692079e25e7869c14132d39f223f8eec9830eb76131925143b2129c4bb01b37 url: "https://pub.dev" source: hosted - version: "4.4.0" + version: "4.10.0" collection: dependency: transitive description: @@ -216,18 +233,18 @@ packages: dependency: transitive description: name: crypto - sha256: aa274aa7774f8964e4f4f38cc994db7b6158dd36e9187aaceaddc994b35c6c67 + sha256: ff625774173754681d66daaf4a448684fb04b78f902da9cb3d308c19cc5e8bab url: "https://pub.dev" source: hosted - version: "3.0.2" + version: "3.0.3" cryptography: dependency: "direct main" description: name: cryptography - sha256: e0e37f79665cd5c86e8897f9abe1accfe813c0cc5299dab22256e22fddc1fef8 + sha256: df156c5109286340817d21fa7b62f9140f17915077127dd70f8bd7a2a0997a35 url: "https://pub.dev" source: hosted - version: "2.0.5" + version: "2.5.0" cw_core: dependency: "direct main" description: @@ -247,10 +264,10 @@ packages: dependency: transitive description: name: encrypt - sha256: "4fd4e4fdc21b9d7d4141823e1e6515cd94e7b8d84749504c232999fba25d9bbb" + sha256: "62d9aa4670cc2a8798bab89b39fc71b6dfbacf615de6cf5001fb39f7e4a996a2" url: "https://pub.dev" source: hosted - version: "5.0.1" + version: "5.0.3" fake_async: dependency: transitive description: @@ -263,10 +280,10 @@ packages: dependency: transitive description: name: ffi - sha256: a38574032c5f1dd06c4aee541789906c12ccaab8ba01446e800d9c5b79c4a978 + sha256: "7bf0adc28a23d395f19f3f1eb21dd7cfd1dd9f8e1c50051c069122e6853bc878" url: "https://pub.dev" source: hosted - version: "2.0.1" + version: "2.1.0" file: dependency: transitive description: @@ -292,10 +309,10 @@ packages: dependency: "direct main" description: name: flutter_mobx - sha256: "0da4add0016387a7bf309a0d0c41d36c6b3ae25ed7a176409267f166509e723e" + sha256: "4a5d062ff85ed3759f4aac6410ff0ffae32e324b2e71ca722ae1b37b32e865f4" url: "https://pub.dev" source: hosted - version: "2.0.6+5" + version: "2.2.0+2" flutter_test: dependency: "direct dev" description: flutter @@ -313,18 +330,18 @@ packages: dependency: transitive description: name: glob - sha256: "4515b5b6ddb505ebdd242a5f2cc5d22d3d6a80013789debfbda7777f47ea308c" + sha256: "0e7014b3b7d4dac1ca4d6114f82bf1782ee86745b9b42a92c9289c23d8a0ab63" url: "https://pub.dev" source: hosted - version: "2.1.1" + version: "2.1.2" graphs: dependency: transitive description: name: graphs - sha256: f9e130f3259f52d26f0cfc0e964513796dafed572fa52e45d2f8d6ca14db39b2 + sha256: aedc5a15e78fc65a6e23bcd927f24c64dd995062bcd1ca6eda65a3cff92a4d19 url: "https://pub.dev" source: hosted - version: "2.2.0" + version: "2.3.1" hex: dependency: transitive description: @@ -401,18 +418,18 @@ packages: dependency: transitive description: name: json_annotation - sha256: c33da08e136c3df0190bd5bbe51ae1df4a7d96e7954d1d7249fea2968a72d317 + sha256: b10a7b2ff83d83c777edba3c6a0f97045ddadd56c944e1a23a3fdf43a1bf4467 url: "https://pub.dev" source: hosted - version: "4.8.0" + version: "4.8.1" logging: dependency: transitive description: name: logging - sha256: "04094f2eb032cbb06c6f6e8d3607edcfcb0455e2bb6cbc010cb01171dcb64e6d" + sha256: "623a88c9594aa774443aa3eb2d41807a48486b5613e67599fb4c41c0ad47c340" url: "https://pub.dev" source: hosted - version: "1.1.1" + version: "1.2.0" matcher: dependency: transitive description: @@ -449,18 +466,26 @@ packages: dependency: "direct main" description: name: mobx - sha256: f1862bd92c6a903fab67338f27e2f731117c3cb9ea37cee1a487f9e4e0de314a + sha256: "74ee54012dc7c1b3276eaa960a600a7418ef5f9997565deb8fca1fd88fb36b78" url: "https://pub.dev" source: hosted - version: "2.1.3+1" + version: "2.3.0+1" mobx_codegen: dependency: "direct dev" description: name: mobx_codegen - sha256: "86122e410d8ea24dda0c69adb5c2a6ccadd5ce02ad46e144764e0d0184a06181" + sha256: d4beb9cea4b7b014321235f8fdc7c2193ee0fe1d1198e9da7403f8bc85c4407c url: "https://pub.dev" source: hosted - version: "2.1.1" + version: "2.3.0" + nested: + dependency: transitive + description: + name: nested + sha256: "03bac4c528c64c95c722ec99280375a6f2fc708eec17c7b3f07253b626cd2a20" + url: "https://pub.dev" + source: hosted + version: "1.0.0" package_config: dependency: transitive description: @@ -481,26 +506,26 @@ packages: dependency: "direct main" description: name: path_provider - sha256: a1aa8aaa2542a6bc57e381f132af822420216c80d4781f7aa085ca3229208aaa + sha256: b27217933eeeba8ff24845c34003b003b2b22151de3c908d0e679e8fe1aa078b url: "https://pub.dev" source: hosted - version: "2.1.1" + version: "2.1.2" path_provider_android: dependency: transitive description: name: path_provider_android - sha256: e595b98692943b4881b219f0a9e3945118d3c16bd7e2813f98ec6e532d905f72 + sha256: "477184d672607c0a3bf68fbbf601805f92ef79c82b64b4d6eb318cbca4c48668" url: "https://pub.dev" source: hosted - version: "2.2.1" + version: "2.2.2" path_provider_foundation: dependency: transitive description: name: path_provider_foundation - sha256: "19314d595120f82aca0ba62787d58dde2cc6b5df7d2f0daf72489e38d1b57f2d" + sha256: "5a7999be66e000916500be4f15a3633ebceb8302719b47b9cc49ce924125350f" url: "https://pub.dev" source: hosted - version: "2.3.1" + version: "2.3.2" path_provider_linux: dependency: transitive description: @@ -513,10 +538,10 @@ packages: dependency: transitive description: name: path_provider_platform_interface - sha256: "94b1e0dd80970c1ce43d5d4e050a9918fce4f4a775e6142424c30a29a363265c" + sha256: "88f5779f72ba699763fa3a3b06aa4bf6de76c8e5de842cf6f29e2e06476c2334" url: "https://pub.dev" source: hosted - version: "2.1.1" + version: "2.1.2" path_provider_windows: dependency: transitive description: @@ -529,26 +554,26 @@ packages: dependency: transitive description: name: platform - sha256: "4a451831508d7d6ca779f7ac6e212b4023dd5a7d08a27a63da33756410e32b76" + sha256: "12220bb4b65720483f8fa9450b4332347737cf8213dd2840d8b2c823e47243ec" url: "https://pub.dev" source: hosted - version: "3.1.0" + version: "3.1.4" plugin_platform_interface: dependency: transitive description: name: plugin_platform_interface - sha256: dbf0f707c78beedc9200146ad3cb0ab4d5da13c246336987be6940f026500d3a + sha256: "4820fbfdb9478b1ebae27888254d445073732dae3d6ea81f0b7e06d5dedc3f02" url: "https://pub.dev" source: hosted - version: "2.1.3" + version: "2.1.8" pointycastle: dependency: transitive description: name: pointycastle - sha256: db7306cf0249f838d1a24af52b5a5887c5bf7f31d8bb4e827d071dc0939ad346 + sha256: "43ac87de6e10afabc85c445745a7b799e04de84cebaa4fd7bf55a5e1e9604d29" url: "https://pub.dev" source: hosted - version: "3.6.2" + version: "3.7.4" pool: dependency: transitive description: @@ -557,30 +582,30 @@ packages: url: "https://pub.dev" source: hosted version: "1.5.1" - process: + provider: dependency: transitive description: - name: process - sha256: "53fd8db9cec1d37b0574e12f07520d582019cb6c44abf5479a01505099a34a09" + name: provider + sha256: "9a96a0a19b594dbc5bf0f1f27d2bc67d5f95957359b461cd9feb44ed6ae75096" url: "https://pub.dev" source: hosted - version: "4.2.4" + version: "6.1.1" pub_semver: dependency: transitive description: name: pub_semver - sha256: "307de764d305289ff24ad257ad5c5793ce56d04947599ad68b3baa124105fc17" + sha256: "40d3ab1bbd474c4c2328c91e3a7df8c6dd629b79ece4c4bd04bee496a224fb0c" url: "https://pub.dev" source: hosted - version: "2.1.3" + version: "2.1.4" pubspec_parse: dependency: transitive description: name: pubspec_parse - sha256: "75f6614d6dde2dc68948dffbaa4fe5dae32cd700eb9fb763fe11dfb45a3c4d0a" + sha256: c63b2876e58e194e4b0828fcb080ad0e06d051cb607a6be51a9e084f47cb9367 url: "https://pub.dev" source: hosted - version: "1.2.1" + version: "1.2.3" rxdart: dependency: "direct main" description: @@ -593,18 +618,18 @@ packages: dependency: transitive description: name: shelf - sha256: c24a96135a2ccd62c64b69315a14adc5c3419df63b4d7c05832a346fdb73682c + sha256: ad29c505aee705f41a4d8963641f91ac4cee3c8fad5947e033390a7bd8180fa4 url: "https://pub.dev" source: hosted - version: "1.4.0" + version: "1.4.1" shelf_web_socket: dependency: transitive description: name: shelf_web_socket - sha256: a988c0e8d8ffbdb8a28aa7ec8e449c260f3deb808781fe1284d22c5bba7156e8 + sha256: "9ca081be41c60190ebcb4766b2486a7d50261db7bd0f5d9615f2d653637a84c1" url: "https://pub.dev" source: hosted - version: "1.0.3" + version: "1.0.4" sky_engine: dependency: transitive description: flutter @@ -702,10 +727,10 @@ packages: dependency: transitive description: name: typed_data - sha256: "26f87ade979c47a150c9eaab93ccd2bebe70a27dc0b4b29517f2904f04eb11a5" + sha256: facc8d6582f16042dd49f2463ff1bd6e2c9ef9f3d5da3d9b087e244a7b564b3c url: "https://pub.dev" source: hosted - version: "1.3.1" + version: "1.3.2" unorm_dart: dependency: "direct main" description: @@ -726,42 +751,42 @@ packages: dependency: transitive description: name: watcher - sha256: "6a7f46926b01ce81bfc339da6a7f20afbe7733eff9846f6d6a5466aa4c6667c0" + sha256: "3d2ad6751b3c16cf07c7fca317a1413b3f26530319181b37e3b9039b84fc01d8" url: "https://pub.dev" source: hosted - version: "1.0.2" + version: "1.1.0" web_socket_channel: dependency: transitive description: name: web_socket_channel - sha256: ca49c0bc209c687b887f30527fb6a9d80040b072cc2990f34b9bec3e7663101b + sha256: d88238e5eac9a42bb43ca4e721edba3c08c6354d4a53063afaa568516217621b url: "https://pub.dev" source: hosted - version: "2.3.0" + version: "2.4.0" win32: dependency: transitive description: name: win32 - sha256: c9ebe7ee4ab0c2194e65d3a07d8c54c5d00bb001b76081c4a04cdb8448b59e46 + sha256: "350a11abd2d1d97e0cc7a28a81b781c08002aa2864d9e3f192ca0ffa18b06ed3" url: "https://pub.dev" source: hosted - version: "3.1.3" + version: "5.0.9" xdg_directories: dependency: transitive description: name: xdg_directories - sha256: bd512f03919aac5f1313eb8249f223bacf4927031bf60b02601f81f687689e86 + sha256: faea9dee56b520b55a566385b84f2e8de55e7496104adada9962e0bd11bcff1d url: "https://pub.dev" source: hosted - version: "0.2.0+3" + version: "1.0.4" yaml: dependency: transitive description: name: yaml - sha256: "23812a9b125b48d4007117254bca50abb6c712352927eece9e155207b1db2370" + sha256: "75769501ea3489fca56601ff33454fe45507ea3bfb014161abc3b43ae25989d5" url: "https://pub.dev" source: hosted - version: "3.1.1" + version: "3.1.2" sdks: dart: ">=3.0.0 <4.0.0" - flutter: ">=3.7.0" + flutter: ">=3.10.0" diff --git a/cw_bitcoin/pubspec.yaml b/cw_bitcoin/pubspec.yaml index a50ff68ad..847b77773 100644 --- a/cw_bitcoin/pubspec.yaml +++ b/cw_bitcoin/pubspec.yaml @@ -30,6 +30,11 @@ dependencies: rxdart: ^0.27.5 unorm_dart: ^0.2.0 cryptography: ^2.0.5 + bitcoin_base: + git: + url: https://github.com/cake-tech/bitcoin_base.git + ref: cake-update-v1 + blockchain_utils: ^1.6.0 dev_dependencies: flutter_test: diff --git a/cw_bitcoin_cash/lib/src/bitcoin_cash_wallet.dart b/cw_bitcoin_cash/lib/src/bitcoin_cash_wallet.dart index 1b87e2231..3c40cf9e9 100644 --- a/cw_bitcoin_cash/lib/src/bitcoin_cash_wallet.dart +++ b/cw_bitcoin_cash/lib/src/bitcoin_cash_wallet.dart @@ -28,17 +28,18 @@ part 'bitcoin_cash_wallet.g.dart'; class BitcoinCashWallet = BitcoinCashWalletBase with _$BitcoinCashWallet; abstract class BitcoinCashWalletBase extends ElectrumWallet with Store { - BitcoinCashWalletBase( - {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( + BitcoinCashWalletBase({ + required String mnemonic, + required String password, + required WalletInfo walletInfo, + required Box unspentCoinsInfo, + required Uint8List seedBytes, + String? addressPageType, + List? initialAddresses, + ElectrumBalance? initialBalance, + Map? initialRegularAddressIndex, + Map? initialChangeAddressIndex, + }) : super( mnemonic: mnemonic, password: password, walletInfo: walletInfo, @@ -48,40 +49,43 @@ abstract class BitcoinCashWalletBase extends ElectrumWallet with Store { initialBalance: initialBalance, seedBytes: seedBytes, currency: CryptoCurrency.bch) { - walletAddresses = BitcoinCashWalletAddresses(walletInfo, - electrumClient: electrumClient, - initialAddresses: initialAddresses, - initialRegularAddressIndex: initialRegularAddressIndex, - initialChangeAddressIndex: initialChangeAddressIndex, - mainHd: hd, - sideHd: bitcoin.HDWallet.fromSeed(seedBytes) - .derivePath("m/44'/145'/0'/1"), - networkType: networkType); + walletAddresses = BitcoinCashWalletAddresses( + walletInfo, + electrumClient: electrumClient, + initialAddresses: initialAddresses, + initialRegularAddressIndex: initialRegularAddressIndex, + initialChangeAddressIndex: initialChangeAddressIndex, + mainHd: hd, + sideHd: bitcoin.HDWallet.fromSeed(seedBytes).derivePath("m/44'/145'/0'/1"), + network: network, + ); autorun((_) { this.walletAddresses.isEnabledAutoGenerateSubaddress = this.isEnabledAutoGenerateSubaddress; }); } - static Future create( {required String mnemonic, required String password, required WalletInfo walletInfo, required Box unspentCoinsInfo, + String? addressPageType, List? initialAddresses, ElectrumBalance? initialBalance, - int initialRegularAddressIndex = 0, - int initialChangeAddressIndex = 0}) async { + Map? initialRegularAddressIndex, + Map? initialChangeAddressIndex}) async { return BitcoinCashWallet( - mnemonic: mnemonic, - password: password, - walletInfo: walletInfo, - unspentCoinsInfo: unspentCoinsInfo, - initialAddresses: initialAddresses, - initialBalance: initialBalance, - seedBytes: await Mnemonic.toSeed(mnemonic), - initialRegularAddressIndex: initialRegularAddressIndex, - initialChangeAddressIndex: initialChangeAddressIndex); + mnemonic: mnemonic, + password: password, + walletInfo: walletInfo, + unspentCoinsInfo: unspentCoinsInfo, + initialAddresses: initialAddresses, + initialBalance: initialBalance, + seedBytes: await Mnemonic.toSeed(mnemonic), + initialRegularAddressIndex: initialRegularAddressIndex, + initialChangeAddressIndex: initialChangeAddressIndex, + addressPageType: addressPageType, + ); } static Future open({ @@ -90,17 +94,20 @@ abstract class BitcoinCashWalletBase extends ElectrumWallet with Store { required Box unspentCoinsInfo, required String password, }) async { - final snp = await ElectrumWallletSnapshot.load(name, walletInfo.type, password); + final snp = await ElectrumWalletSnapshot.load( + name, walletInfo.type, password, BitcoinCashNetwork.mainnet); return BitcoinCashWallet( - mnemonic: snp.mnemonic, - password: password, - walletInfo: walletInfo, - unspentCoinsInfo: unspentCoinsInfo, - initialAddresses: snp.addresses, - initialBalance: snp.balance, - seedBytes: await Mnemonic.toSeed(snp.mnemonic), - initialRegularAddressIndex: snp.regularAddressIndex, - initialChangeAddressIndex: snp.changeAddressIndex); + mnemonic: snp.mnemonic, + password: password, + walletInfo: walletInfo, + unspentCoinsInfo: unspentCoinsInfo, + initialAddresses: snp.addresses, + initialBalance: snp.balance, + seedBytes: await Mnemonic.toSeed(snp.mnemonic), + initialRegularAddressIndex: snp.regularAddressIndex, + initialChangeAddressIndex: snp.changeAddressIndex, + addressPageType: snp.addressPageType, + ); } @override @@ -270,20 +277,18 @@ abstract class BitcoinCashWalletBase extends ElectrumWallet with Store { electrumClient: electrumClient, amount: amount, fee: fee); } - bitbox.ECPair generateKeyPair( - {required bitcoin.HDWallet hd, - required int index}) => + bitbox.ECPair generateKeyPair({required bitcoin.HDWallet hd, required int index}) => bitbox.ECPair.fromWIF(hd.derive(index).wif!); @override - int feeAmountForPriority( - BitcoinTransactionPriority priority, int inputsCount, int outputsCount) => + int feeAmountForPriority(BitcoinTransactionPriority priority, int inputsCount, int outputsCount, + {int? size}) => feeRate(priority) * bitbox.BitcoinCash.getByteCount(inputsCount, outputsCount); - int feeAmountWithFeeRate(int feeRate, int inputsCount, int outputsCount) => + int feeAmountWithFeeRate(int feeRate, int inputsCount, int outputsCount, {int? size}) => feeRate * bitbox.BitcoinCash.getByteCount(inputsCount, outputsCount); - int calculateEstimatedFeeWithFeeRate(int feeRate, int? amount, {int? outputsCount}) { + int calculateEstimatedFeeWithFeeRate(int feeRate, int? amount, {int? outputsCount, int? size}) { int inputsCount = 0; int totalValue = 0; @@ -323,9 +328,10 @@ abstract class BitcoinCashWalletBase extends ElectrumWallet with Store { @override String signMessage(String message, {String? address = null}) { final index = address != null - ? walletAddresses.addresses + ? walletAddresses.allAddresses .firstWhere((element) => element.address == AddressUtils.toLegacyAddress(address)) - .index : null; + .index + : null; final HD = index == null ? hd : hd.derive(index); return base64Encode(HD.signMessage(message)); } diff --git a/cw_bitcoin_cash/lib/src/bitcoin_cash_wallet_addresses.dart b/cw_bitcoin_cash/lib/src/bitcoin_cash_wallet_addresses.dart index 1709c4d8f..8291ce2a5 100644 --- a/cw_bitcoin_cash/lib/src/bitcoin_cash_wallet_addresses.dart +++ b/cw_bitcoin_cash/lib/src/bitcoin_cash_wallet_addresses.dart @@ -1,6 +1,5 @@ +import 'package:bitcoin_base/bitcoin_base.dart'; 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:cw_bitcoin/electrum_wallet_addresses.dart'; import 'package:cw_bitcoin/utils.dart'; import 'package:cw_core/wallet_info.dart'; @@ -11,24 +10,19 @@ part 'bitcoin_cash_wallet_addresses.g.dart'; class BitcoinCashWalletAddresses = BitcoinCashWalletAddressesBase with _$BitcoinCashWalletAddresses; abstract class BitcoinCashWalletAddressesBase extends ElectrumWalletAddresses with Store { - BitcoinCashWalletAddressesBase(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); + BitcoinCashWalletAddressesBase( + WalletInfo walletInfo, { + required super.mainHd, + required super.sideHd, + required super.network, + required super.electrumClient, + super.initialAddresses, + super.initialRegularAddressIndex, + super.initialChangeAddressIndex, + }) : super(walletInfo); @override - String getAddress({required int index, required bitcoin.HDWallet hd}) => - generateP2PKHAddress(hd: hd, index: index, networkType: networkType); + String getAddress( + {required int index, required bitcoin.HDWallet hd, BitcoinAddressType? addressType}) => + generateP2PKHAddress(hd: hd, index: index, network: network); } diff --git a/cw_bitcoin_cash/lib/src/bitcoin_cash_wallet_service.dart b/cw_bitcoin_cash/lib/src/bitcoin_cash_wallet_service.dart index f66e38ca7..df8e841f8 100644 --- a/cw_bitcoin_cash/lib/src/bitcoin_cash_wallet_service.dart +++ b/cw_bitcoin_cash/lib/src/bitcoin_cash_wallet_service.dart @@ -2,10 +2,7 @@ import 'dart:io'; import 'package:bip39/bip39.dart'; import 'package:cw_bitcoin_cash/cw_bitcoin_cash.dart'; -import 'package:cw_core/balance.dart'; import 'package:cw_core/pathForWallet.dart'; -import 'package:cw_core/transaction_history.dart'; -import 'package:cw_core/transaction_info.dart'; import 'package:cw_core/unspent_coins_info.dart'; import 'package:cw_core/wallet_base.dart'; import 'package:cw_core/wallet_info.dart'; @@ -15,8 +12,7 @@ import 'package:collection/collection.dart'; import 'package:hive/hive.dart'; class BitcoinCashWalletService extends WalletService { + BitcoinCashRestoreWalletFromSeedCredentials, BitcoinCashRestoreWalletFromWIFCredentials> { BitcoinCashWalletService(this.walletInfoSource, this.unspentCoinsInfoSource); final Box walletInfoSource; @@ -30,13 +26,9 @@ class BitcoinCashWalletService extends WalletService create( - credentials) async { - final strength = (credentials.seedPhraseLength == 12) - ? 128 - : (credentials.seedPhraseLength == 24) - ? 256 - : 128; + Future create(credentials, {bool? isTestnet}) async { + final strength = credentials.seedPhraseLength == 24 ? 256 : 128; + final wallet = await BitcoinCashWalletBase.create( mnemonic: await Mnemonic.generate(strength: strength), password: credentials.password!, @@ -49,21 +41,25 @@ class BitcoinCashWalletService extends WalletService 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 BitcoinCashWalletBase.open( - password: password, name: name, walletInfo: walletInfo, + password: password, + name: name, + walletInfo: walletInfo, unspentCoinsInfo: unspentCoinsInfoSource); await wallet.init(); saveBackup(name); return wallet; - } catch(_) { + } catch (_) { await restoreWalletFilesFromBackup(name); final wallet = await BitcoinCashWalletBase.open( - password: password, name: name, walletInfo: walletInfo, - unspentCoinsInfo: unspentCoinsInfoSource); + password: password, + name: name, + walletInfo: walletInfo, + unspentCoinsInfo: unspentCoinsInfoSource); await wallet.init(); return wallet; } @@ -71,17 +67,16 @@ class BitcoinCashWalletService extends WalletService 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); } @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 BitcoinCashWalletBase.open( password: password, name: currentName, @@ -99,15 +94,14 @@ class BitcoinCashWalletService extends WalletService - restoreFromKeys(credentials) { + Future restoreFromKeys(credentials, {bool? isTestnet}) { // TODO: implement restoreFromKeys throw UnimplementedError('restoreFromKeys() is not implemented'); } @override - Future restoreFromSeed( - BitcoinCashRestoreWalletFromSeedCredentials credentials) async { + Future restoreFromSeed(BitcoinCashRestoreWalletFromSeedCredentials credentials, + {bool? isTestnet}) async { if (!validateMnemonic(credentials.mnemonic)) { throw BitcoinCashMnemonicIsIncorrectException(); } diff --git a/cw_bitcoin_cash/pubspec.yaml b/cw_bitcoin_cash/pubspec.yaml index 49a5efb15..9c098c0ff 100644 --- a/cw_bitcoin_cash/pubspec.yaml +++ b/cw_bitcoin_cash/pubspec.yaml @@ -29,7 +29,10 @@ dependencies: git: url: https://github.com/cake-tech/bitbox-flutter.git ref: master - bitcoin_base: ^3.0.1 + bitcoin_base: + git: + url: https://github.com/cake-tech/bitcoin_base.git + ref: cake-update-v1 diff --git a/cw_core/lib/enumerate.dart b/cw_core/lib/enumerate.dart new file mode 100644 index 000000000..d92347e91 --- /dev/null +++ b/cw_core/lib/enumerate.dart @@ -0,0 +1,13 @@ +abstract class Enumerate { + String get value; + + @override + operator ==(other) { + if (identical(other, this)) return true; + if (other is! Enumerate) return false; + return other.runtimeType == runtimeType && value == other.value; + } + + @override + int get hashCode => value.hashCode; +} diff --git a/cw_core/lib/receive_page_option.dart b/cw_core/lib/receive_page_option.dart new file mode 100644 index 000000000..786d07bc5 --- /dev/null +++ b/cw_core/lib/receive_page_option.dart @@ -0,0 +1,21 @@ +import 'package:cw_core/enumerate.dart'; + +class ReceivePageOption implements Enumerate { + static const mainnet = ReceivePageOption._('mainnet'); + static const anonPayInvoice = ReceivePageOption._('anonPayInvoice'); + static const anonPayDonationLink = ReceivePageOption._('anonPayDonationLink'); + + const ReceivePageOption._(this.value); + + final String value; + + String toString() { + return value; + } +} + +const ReceivePageOptions = [ + ReceivePageOption.mainnet, + ReceivePageOption.anonPayInvoice, + ReceivePageOption.anonPayDonationLink +]; diff --git a/cw_core/lib/wallet_base.dart b/cw_core/lib/wallet_base.dart index 09b423c14..49f1bdc94 100644 --- a/cw_core/lib/wallet_base.dart +++ b/cw_core/lib/wallet_base.dart @@ -88,4 +88,6 @@ abstract class WalletBase renameWalletFiles(String newWalletName); String signMessage(String message, {String? address = null}) => throw UnimplementedError(); + + bool? isTestnet; } diff --git a/cw_core/lib/wallet_info.dart b/cw_core/lib/wallet_info.dart index c4ccea00a..2a44175a7 100644 --- a/cw_core/lib/wallet_info.dart +++ b/cw_core/lib/wallet_info.dart @@ -148,6 +148,12 @@ class WalletInfo extends HiveObject { @HiveField(17) String? derivationPath; + @HiveField(18) + String? addressPageType; + + @HiveField(19) + String? network; + String get yatLastUsedAddress => yatLastUsedAddressRaw ?? ''; set yatLastUsedAddress(String address) { diff --git a/cw_core/lib/wallet_service.dart b/cw_core/lib/wallet_service.dart index 3b4908386..22981b9db 100644 --- a/cw_core/lib/wallet_service.dart +++ b/cw_core/lib/wallet_service.dart @@ -9,11 +9,11 @@ abstract class WalletService { WalletType getType(); - Future create(N credentials); + Future create(N credentials, {bool? isTestnet}); - Future restoreFromSeed(RFS credentials); + Future restoreFromSeed(RFS credentials, {bool? isTestnet}); - Future restoreFromKeys(RFK credentials); + Future restoreFromKeys(RFK credentials, {bool? isTestnet}); Future openWallet(String name, String password); diff --git a/cw_core/pubspec.lock b/cw_core/pubspec.lock index aacbd9ddd..678e57b54 100644 --- a/cw_core/pubspec.lock +++ b/cw_core/pubspec.lock @@ -5,34 +5,34 @@ packages: dependency: transitive description: name: _fe_analyzer_shared - sha256: "4897882604d919befd350648c7f91926a9d5de99e67b455bf0917cc2362f4bb8" + sha256: eb376e9acf6938204f90eb3b1f00b578640d3188b4c8a8ec054f9f479af8d051 url: "https://pub.dev" source: hosted - version: "47.0.0" + version: "64.0.0" analyzer: dependency: transitive description: name: analyzer - sha256: "690e335554a8385bc9d787117d9eb52c0c03ee207a607e593de3c9d71b1cfe80" + sha256: "69f54f967773f6c26c7dcb13e93d7ccee8b17a641689da39e878d5cf13b06893" url: "https://pub.dev" source: hosted - version: "4.7.0" + version: "6.2.0" args: dependency: transitive description: name: args - sha256: "139d809800a412ebb26a3892da228b2d0ba36f0ef5d9a82166e5e52ec8d61611" + sha256: eef6c46b622e0494a36c5a12d10d77fb4e855501a91c1b9ef9339326e58f0596 url: "https://pub.dev" source: hosted - version: "2.3.2" + version: "2.4.2" asn1lib: dependency: transitive description: name: asn1lib - sha256: ab96a1cb3beeccf8145c52e449233fe68364c9641623acd3adad66f8184f1039 + sha256: "21afe4333076c02877d14f4a89df111e658a6d466cbfc802eb705eb91bd5adfd" url: "https://pub.dev" source: hosted - version: "1.4.0" + version: "1.5.0" async: dependency: transitive description: @@ -53,10 +53,10 @@ packages: dependency: transitive description: name: build - sha256: "3fbda25365741f8251b39f3917fb3c8e286a96fd068a5a242e11c2012d495777" + sha256: "80184af8b6cb3e5c1c4ec6d8544d27711700bc3e6d2efad04238c7b5290889f0" url: "https://pub.dev" source: hosted - version: "2.3.1" + version: "2.4.1" build_config: dependency: transitive description: @@ -69,34 +69,34 @@ packages: dependency: transitive description: name: build_daemon - sha256: "6bc5544ea6ce4428266e7ea680e945c68806c4aae2da0eb5e9ccf38df8d6acbf" + sha256: "0343061a33da9c5810b2d6cee51945127d8f4c060b7fbdd9d54917f0a3feaaa1" url: "https://pub.dev" source: hosted - version: "3.1.0" + version: "4.0.1" build_resolvers: dependency: "direct dev" description: name: build_resolvers - sha256: "687cf90a3951affac1bd5f9ecb5e3e90b60487f3d9cdc359bb310f8876bb02a6" + sha256: "339086358431fa15d7eca8b6a36e5d783728cf025e559b834f4609a1fcfb7b0a" url: "https://pub.dev" source: hosted - version: "2.0.10" + version: "2.4.2" build_runner: dependency: "direct dev" description: name: build_runner - sha256: b0a8a7b8a76c493e85f1b84bffa0588859a06197863dba8c9036b15581fd9727 + sha256: "581bacf68f89ec8792f5e5a0b2c4decd1c948e97ce659dc783688c8a88fbec21" url: "https://pub.dev" source: hosted - version: "2.3.3" + version: "2.4.8" build_runner_core: dependency: transitive description: name: build_runner_core - sha256: "14febe0f5bac5ae474117a36099b4de6f1dbc52df6c5e55534b3da9591bf4292" + sha256: c9e32d21dd6626b5c163d48b037ce906bbe428bc23ab77bcd77bb21e593b6185 url: "https://pub.dev" source: hosted - version: "7.2.7" + version: "7.2.11" built_collection: dependency: transitive description: @@ -109,10 +109,10 @@ packages: dependency: transitive description: name: built_value - sha256: "169565c8ad06adb760c3645bf71f00bff161b00002cace266cad42c5d22a7725" + sha256: c9aabae0718ec394e5bc3c7272e6bb0dc0b32201a08fe185ec1d8401d3e39309 url: "https://pub.dev" source: hosted - version: "8.4.3" + version: "8.8.1" characters: dependency: transitive description: @@ -125,10 +125,10 @@ packages: dependency: transitive description: name: checked_yaml - sha256: "3d1505d91afa809d177efd4eed5bb0eb65805097a1463abdd2add076effae311" + sha256: feb6bed21949061731a7a75fc5d2aa727cf160b91af9a3e464c5e3a32e28b5ff url: "https://pub.dev" source: hosted - version: "2.0.2" + version: "2.0.3" clock: dependency: transitive description: @@ -141,10 +141,10 @@ packages: dependency: transitive description: name: code_builder - sha256: "0d43dd1288fd145de1ecc9a3948ad4a6d5a82f0a14c4fdd0892260787d975cbe" + sha256: f692079e25e7869c14132d39f223f8eec9830eb76131925143b2129c4bb01b37 url: "https://pub.dev" source: hosted - version: "4.4.0" + version: "4.10.0" collection: dependency: transitive description: @@ -165,26 +165,26 @@ packages: dependency: transitive description: name: crypto - sha256: aa274aa7774f8964e4f4f38cc994db7b6158dd36e9187aaceaddc994b35c6c67 + sha256: ff625774173754681d66daaf4a448684fb04b78f902da9cb3d308c19cc5e8bab url: "https://pub.dev" source: hosted - version: "3.0.2" + version: "3.0.3" dart_style: dependency: transitive description: name: dart_style - sha256: "7a03456c3490394c8e7665890333e91ae8a49be43542b616e414449ac358acd4" + sha256: "40ae61a5d43feea6d24bd22c0537a6629db858963b99b4bc1c3db80676f32368" url: "https://pub.dev" source: hosted - version: "2.2.4" + version: "2.3.4" encrypt: dependency: "direct main" description: name: encrypt - sha256: "4fd4e4fdc21b9d7d4141823e1e6515cd94e7b8d84749504c232999fba25d9bbb" + sha256: "62d9aa4670cc2a8798bab89b39fc71b6dfbacf615de6cf5001fb39f7e4a996a2" url: "https://pub.dev" source: hosted - version: "5.0.1" + version: "5.0.3" fake_async: dependency: transitive description: @@ -197,10 +197,10 @@ packages: dependency: transitive description: name: ffi - sha256: a38574032c5f1dd06c4aee541789906c12ccaab8ba01446e800d9c5b79c4a978 + sha256: "7bf0adc28a23d395f19f3f1eb21dd7cfd1dd9f8e1c50051c069122e6853bc878" url: "https://pub.dev" source: hosted - version: "2.0.1" + version: "2.1.0" file: dependency: "direct main" description: @@ -226,10 +226,10 @@ packages: dependency: "direct main" description: name: flutter_mobx - sha256: "0da4add0016387a7bf309a0d0c41d36c6b3ae25ed7a176409267f166509e723e" + sha256: "4a5d062ff85ed3759f4aac6410ff0ffae32e324b2e71ca722ae1b37b32e865f4" url: "https://pub.dev" source: hosted - version: "2.0.6+5" + version: "2.2.0+2" flutter_test: dependency: "direct dev" description: flutter @@ -247,18 +247,18 @@ packages: dependency: transitive description: name: glob - sha256: "4515b5b6ddb505ebdd242a5f2cc5d22d3d6a80013789debfbda7777f47ea308c" + sha256: "0e7014b3b7d4dac1ca4d6114f82bf1782ee86745b9b42a92c9289c23d8a0ab63" url: "https://pub.dev" source: hosted - version: "2.1.1" + version: "2.1.2" graphs: dependency: transitive description: name: graphs - sha256: f9e130f3259f52d26f0cfc0e964513796dafed572fa52e45d2f8d6ca14db39b2 + sha256: aedc5a15e78fc65a6e23bcd927f24c64dd995062bcd1ca6eda65a3cff92a4d19 url: "https://pub.dev" source: hosted - version: "2.2.0" + version: "2.3.1" hive: dependency: transitive description: @@ -327,18 +327,18 @@ packages: dependency: transitive description: name: json_annotation - sha256: c33da08e136c3df0190bd5bbe51ae1df4a7d96e7954d1d7249fea2968a72d317 + sha256: b10a7b2ff83d83c777edba3c6a0f97045ddadd56c944e1a23a3fdf43a1bf4467 url: "https://pub.dev" source: hosted - version: "4.8.0" + version: "4.8.1" logging: dependency: transitive description: name: logging - sha256: "04094f2eb032cbb06c6f6e8d3607edcfcb0455e2bb6cbc010cb01171dcb64e6d" + sha256: "623a88c9594aa774443aa3eb2d41807a48486b5613e67599fb4c41c0ad47c340" url: "https://pub.dev" source: hosted - version: "1.1.1" + version: "1.2.0" matcher: dependency: transitive description: @@ -375,18 +375,26 @@ packages: dependency: "direct main" description: name: mobx - sha256: f1862bd92c6a903fab67338f27e2f731117c3cb9ea37cee1a487f9e4e0de314a + sha256: "74ee54012dc7c1b3276eaa960a600a7418ef5f9997565deb8fca1fd88fb36b78" url: "https://pub.dev" source: hosted - version: "2.1.3+1" + version: "2.3.0+1" mobx_codegen: dependency: "direct dev" description: name: mobx_codegen - sha256: "86122e410d8ea24dda0c69adb5c2a6ccadd5ce02ad46e144764e0d0184a06181" + sha256: b26c7f9c20b38f0ea572c1ed3f29d8e027cb265538bbd1aed3ec198642cfca42 url: "https://pub.dev" source: hosted - version: "2.1.1" + version: "2.6.0+1" + nested: + dependency: transitive + description: + name: nested + sha256: "03bac4c528c64c95c722ec99280375a6f2fc708eec17c7b3f07253b626cd2a20" + url: "https://pub.dev" + source: hosted + version: "1.0.0" package_config: dependency: transitive description: @@ -407,26 +415,26 @@ packages: dependency: "direct main" description: name: path_provider - sha256: a1aa8aaa2542a6bc57e381f132af822420216c80d4781f7aa085ca3229208aaa + sha256: b27217933eeeba8ff24845c34003b003b2b22151de3c908d0e679e8fe1aa078b url: "https://pub.dev" source: hosted - version: "2.1.1" + version: "2.1.2" path_provider_android: dependency: transitive description: name: path_provider_android - sha256: e595b98692943b4881b219f0a9e3945118d3c16bd7e2813f98ec6e532d905f72 + sha256: "477184d672607c0a3bf68fbbf601805f92ef79c82b64b4d6eb318cbca4c48668" url: "https://pub.dev" source: hosted - version: "2.2.1" + version: "2.2.2" path_provider_foundation: dependency: transitive description: name: path_provider_foundation - sha256: "19314d595120f82aca0ba62787d58dde2cc6b5df7d2f0daf72489e38d1b57f2d" + sha256: "5a7999be66e000916500be4f15a3633ebceb8302719b47b9cc49ce924125350f" url: "https://pub.dev" source: hosted - version: "2.3.1" + version: "2.3.2" path_provider_linux: dependency: transitive description: @@ -439,10 +447,10 @@ packages: dependency: transitive description: name: path_provider_platform_interface - sha256: "94b1e0dd80970c1ce43d5d4e050a9918fce4f4a775e6142424c30a29a363265c" + sha256: "88f5779f72ba699763fa3a3b06aa4bf6de76c8e5de842cf6f29e2e06476c2334" url: "https://pub.dev" source: hosted - version: "2.1.1" + version: "2.1.2" path_provider_windows: dependency: transitive description: @@ -455,26 +463,26 @@ packages: dependency: transitive description: name: platform - sha256: "4a451831508d7d6ca779f7ac6e212b4023dd5a7d08a27a63da33756410e32b76" + sha256: "12220bb4b65720483f8fa9450b4332347737cf8213dd2840d8b2c823e47243ec" url: "https://pub.dev" source: hosted - version: "3.1.0" + version: "3.1.4" plugin_platform_interface: dependency: transitive description: name: plugin_platform_interface - sha256: dbf0f707c78beedc9200146ad3cb0ab4d5da13c246336987be6940f026500d3a + sha256: "4820fbfdb9478b1ebae27888254d445073732dae3d6ea81f0b7e06d5dedc3f02" url: "https://pub.dev" source: hosted - version: "2.1.3" + version: "2.1.8" pointycastle: dependency: transitive description: name: pointycastle - sha256: db7306cf0249f838d1a24af52b5a5887c5bf7f31d8bb4e827d071dc0939ad346 + sha256: "43ac87de6e10afabc85c445745a7b799e04de84cebaa4fd7bf55a5e1e9604d29" url: "https://pub.dev" source: hosted - version: "3.6.2" + version: "3.7.4" pool: dependency: transitive description: @@ -483,46 +491,46 @@ packages: url: "https://pub.dev" source: hosted version: "1.5.1" - process: + provider: dependency: transitive description: - name: process - sha256: "53fd8db9cec1d37b0574e12f07520d582019cb6c44abf5479a01505099a34a09" + name: provider + sha256: "9a96a0a19b594dbc5bf0f1f27d2bc67d5f95957359b461cd9feb44ed6ae75096" url: "https://pub.dev" source: hosted - version: "4.2.4" + version: "6.1.1" pub_semver: dependency: transitive description: name: pub_semver - sha256: "307de764d305289ff24ad257ad5c5793ce56d04947599ad68b3baa124105fc17" + sha256: "40d3ab1bbd474c4c2328c91e3a7df8c6dd629b79ece4c4bd04bee496a224fb0c" url: "https://pub.dev" source: hosted - version: "2.1.3" + version: "2.1.4" pubspec_parse: dependency: transitive description: name: pubspec_parse - sha256: "75f6614d6dde2dc68948dffbaa4fe5dae32cd700eb9fb763fe11dfb45a3c4d0a" + sha256: c63b2876e58e194e4b0828fcb080ad0e06d051cb607a6be51a9e084f47cb9367 url: "https://pub.dev" source: hosted - version: "1.2.1" + version: "1.2.3" shelf: dependency: transitive description: name: shelf - sha256: c24a96135a2ccd62c64b69315a14adc5c3419df63b4d7c05832a346fdb73682c + sha256: ad29c505aee705f41a4d8963641f91ac4cee3c8fad5947e033390a7bd8180fa4 url: "https://pub.dev" source: hosted - version: "1.4.0" + version: "1.4.1" shelf_web_socket: dependency: transitive description: name: shelf_web_socket - sha256: a988c0e8d8ffbdb8a28aa7ec8e449c260f3deb808781fe1284d22c5bba7156e8 + sha256: "9ca081be41c60190ebcb4766b2486a7d50261db7bd0f5d9615f2d653637a84c1" url: "https://pub.dev" source: hosted - version: "1.0.3" + version: "1.0.4" sky_engine: dependency: transitive description: flutter @@ -540,18 +548,18 @@ packages: dependency: transitive description: name: source_gen - sha256: "2d79738b6bbf38a43920e2b8d189e9a3ce6cc201f4b8fc76be5e4fe377b1c38d" + sha256: "14658ba5f669685cd3d63701d01b31ea748310f7ab854e471962670abcf57832" url: "https://pub.dev" source: hosted - version: "1.2.6" + version: "1.5.0" source_helper: dependency: transitive description: name: source_helper - sha256: "3b67aade1d52416149c633ba1bb36df44d97c6b51830c2198e934e3fca87ca1f" + sha256: "6adebc0006c37dd63fe05bca0a929b99f06402fc95aa35bf36d67f5c06de01fd" url: "https://pub.dev" source: hosted - version: "1.3.3" + version: "1.3.4" source_span: dependency: transitive description: @@ -620,10 +628,10 @@ packages: dependency: transitive description: name: typed_data - sha256: "26f87ade979c47a150c9eaab93ccd2bebe70a27dc0b4b29517f2904f04eb11a5" + sha256: facc8d6582f16042dd49f2463ff1bd6e2c9ef9f3d5da3d9b087e244a7b564b3c url: "https://pub.dev" source: hosted - version: "1.3.1" + version: "1.3.2" vector_math: dependency: transitive description: @@ -636,42 +644,42 @@ packages: dependency: transitive description: name: watcher - sha256: "6a7f46926b01ce81bfc339da6a7f20afbe7733eff9846f6d6a5466aa4c6667c0" + sha256: "3d2ad6751b3c16cf07c7fca317a1413b3f26530319181b37e3b9039b84fc01d8" url: "https://pub.dev" source: hosted - version: "1.0.2" + version: "1.1.0" web_socket_channel: dependency: transitive description: name: web_socket_channel - sha256: ca49c0bc209c687b887f30527fb6a9d80040b072cc2990f34b9bec3e7663101b + sha256: d88238e5eac9a42bb43ca4e721edba3c08c6354d4a53063afaa568516217621b url: "https://pub.dev" source: hosted - version: "2.3.0" + version: "2.4.0" win32: dependency: transitive description: name: win32 - sha256: c9ebe7ee4ab0c2194e65d3a07d8c54c5d00bb001b76081c4a04cdb8448b59e46 + sha256: "350a11abd2d1d97e0cc7a28a81b781c08002aa2864d9e3f192ca0ffa18b06ed3" url: "https://pub.dev" source: hosted - version: "3.1.3" + version: "5.0.9" xdg_directories: dependency: transitive description: name: xdg_directories - sha256: bd512f03919aac5f1313eb8249f223bacf4927031bf60b02601f81f687689e86 + sha256: faea9dee56b520b55a566385b84f2e8de55e7496104adada9962e0bd11bcff1d url: "https://pub.dev" source: hosted - version: "0.2.0+3" + version: "1.0.4" yaml: dependency: transitive description: name: yaml - sha256: "23812a9b125b48d4007117254bca50abb6c712352927eece9e155207b1db2370" + sha256: "75769501ea3489fca56601ff33454fe45507ea3bfb014161abc3b43ae25989d5" url: "https://pub.dev" source: hosted - version: "3.1.1" + version: "3.1.2" sdks: dart: ">=3.0.0 <4.0.0" - flutter: ">=3.7.0" + flutter: ">=3.10.0" diff --git a/cw_ethereum/lib/ethereum_wallet_service.dart b/cw_ethereum/lib/ethereum_wallet_service.dart index 5e8c22718..53c8bfea9 100644 --- a/cw_ethereum/lib/ethereum_wallet_service.dart +++ b/cw_ethereum/lib/ethereum_wallet_service.dart @@ -16,7 +16,7 @@ class EthereumWalletService extends EVMChainWalletService { WalletType getType() => WalletType.ethereum; @override - Future create(EVMChainNewWalletCredentials credentials) async { + Future create(EVMChainNewWalletCredentials credentials, {bool? isTestnet}) async { final strength = credentials.seedPhraseLength == 24 ? 256 : 128; final mnemonic = bip39.generateMnemonic(strength: strength); @@ -52,7 +52,6 @@ class EthereumWalletService extends EVMChainWalletService { saveBackup(name); return wallet; } catch (_) { - await restoreWalletFilesFromBackup(name); final wallet = await EthereumWallet.open( @@ -84,7 +83,8 @@ class EthereumWalletService extends EVMChainWalletService { } @override - Future restoreFromKeys(EVMChainRestoreWalletFromPrivateKey credentials) async { + Future restoreFromKeys(EVMChainRestoreWalletFromPrivateKey credentials, + {bool? isTestnet}) async { final wallet = EthereumWallet( password: credentials.password!, privateKey: credentials.privateKey, @@ -100,8 +100,8 @@ class EthereumWalletService extends EVMChainWalletService { } @override - Future restoreFromSeed( - EVMChainRestoreWalletFromSeedCredentials credentials) async { + Future restoreFromSeed(EVMChainRestoreWalletFromSeedCredentials credentials, + {bool? isTestnet}) async { if (!bip39.validateMnemonic(credentials.mnemonic)) { throw EthereumMnemonicIsIncorrectException(); } diff --git a/cw_evm/lib/evm_chain_wallet_service.dart b/cw_evm/lib/evm_chain_wallet_service.dart index 988a38684..d77a3a81a 100644 --- a/cw_evm/lib/evm_chain_wallet_service.dart +++ b/cw_evm/lib/evm_chain_wallet_service.dart @@ -22,7 +22,7 @@ abstract class EVMChainWalletService extends WalletSer WalletType getType(); @override - Future create(EVMChainNewWalletCredentials credentials); + Future create(EVMChainNewWalletCredentials credentials, {bool? isTestnet}); @override Future openWallet(String name, String password); @@ -31,10 +31,10 @@ abstract class EVMChainWalletService extends WalletSer Future rename(String currentName, String password, String newName); @override - Future restoreFromKeys(EVMChainRestoreWalletFromPrivateKey credentials); + Future restoreFromKeys(EVMChainRestoreWalletFromPrivateKey credentials, {bool? isTestnet}); @override - Future restoreFromSeed(EVMChainRestoreWalletFromSeedCredentials credentials); + Future restoreFromSeed(EVMChainRestoreWalletFromSeedCredentials credentials, {bool? isTestnet}); @override Future isWalletExit(String name) async => diff --git a/cw_haven/lib/haven_wallet_service.dart b/cw_haven/lib/haven_wallet_service.dart index dd7713e08..d4808c2d6 100644 --- a/cw_haven/lib/haven_wallet_service.dart +++ b/cw_haven/lib/haven_wallet_service.dart @@ -68,7 +68,7 @@ class HavenWalletService extends WalletService< WalletType getType() => WalletType.haven; @override - Future create(HavenNewWalletCredentials credentials) async { + Future create(HavenNewWalletCredentials credentials, {bool? isTestnet}) async { try { final path = await pathForWallet(name: credentials.name, type: getType()); await haven_wallet_manager.createWallet( @@ -174,7 +174,7 @@ class HavenWalletService extends WalletService< @override Future restoreFromKeys( - HavenRestoreWalletFromKeysCredentials credentials) async { + HavenRestoreWalletFromKeysCredentials credentials, {bool? isTestnet}) async { try { final path = await pathForWallet(name: credentials.name, type: getType()); await haven_wallet_manager.restoreFromKeys( @@ -198,7 +198,7 @@ class HavenWalletService extends WalletService< @override Future restoreFromSeed( - HavenRestoreWalletFromSeedCredentials credentials) async { + HavenRestoreWalletFromSeedCredentials credentials, {bool? isTestnet}) async { try { final path = await pathForWallet(name: credentials.name, type: getType()); await haven_wallet_manager.restoreFromSeed( diff --git a/cw_monero/lib/monero_wallet_service.dart b/cw_monero/lib/monero_wallet_service.dart index 3dea7fc0e..1f33dbb3d 100644 --- a/cw_monero/lib/monero_wallet_service.dart +++ b/cw_monero/lib/monero_wallet_service.dart @@ -68,7 +68,7 @@ class MoneroWalletService extends WalletService WalletType.monero; @override - Future create(MoneroNewWalletCredentials credentials) async { + Future create(MoneroNewWalletCredentials credentials, {bool? isTestnet}) async { try { final path = await pathForWallet(name: credentials.name, type: getType()); @@ -203,7 +203,8 @@ class MoneroWalletService extends WalletService restoreFromKeys(MoneroRestoreWalletFromKeysCredentials credentials) async { + Future restoreFromKeys(MoneroRestoreWalletFromKeysCredentials credentials, + {bool? isTestnet}) async { try { final path = await pathForWallet(name: credentials.name, type: getType()); await monero_wallet_manager.restoreFromKeys( @@ -227,7 +228,8 @@ class MoneroWalletService extends WalletService restoreFromSeed(MoneroRestoreWalletFromSeedCredentials credentials) async { + Future restoreFromSeed(MoneroRestoreWalletFromSeedCredentials credentials, + {bool? isTestnet}) async { // Restore from Polyseed if (Polyseed.isValidSeed(credentials.mnemonic)) { return restoreFromPolyseed(credentials); diff --git a/cw_nano/lib/nano_wallet_service.dart b/cw_nano/lib/nano_wallet_service.dart index a76f0393d..7ab502d49 100644 --- a/cw_nano/lib/nano_wallet_service.dart +++ b/cw_nano/lib/nano_wallet_service.dart @@ -26,7 +26,7 @@ class NanoWalletService extends WalletService WalletType.nano; @override - Future create(NanoNewWalletCredentials credentials) async { + Future create(NanoNewWalletCredentials credentials, {bool? isTestnet}) async { // nano standard: DerivationType derivationType = DerivationType.nano; String seedKey = NanoSeeds.generateSeed(); @@ -79,7 +79,7 @@ class NanoWalletService extends WalletService restoreFromKeys(NanoRestoreWalletFromKeysCredentials credentials) async { + Future restoreFromKeys(NanoRestoreWalletFromKeysCredentials credentials, {bool? isTestnet}) async { if (credentials.seedKey.contains(' ')) { throw Exception("Invalid key!"); } else { @@ -113,7 +113,7 @@ class NanoWalletService extends WalletService restoreFromSeed(NanoRestoreWalletFromSeedCredentials credentials) async { + Future restoreFromSeed(NanoRestoreWalletFromSeedCredentials credentials, {bool? isTestnet}) async { if (credentials.mnemonic.contains(' ')) { if (!bip39.validateMnemonic(credentials.mnemonic)) { throw nm.NanoMnemonicIsIncorrectException(); diff --git a/cw_polygon/lib/polygon_wallet_service.dart b/cw_polygon/lib/polygon_wallet_service.dart index 0199a1552..59e14abbf 100644 --- a/cw_polygon/lib/polygon_wallet_service.dart +++ b/cw_polygon/lib/polygon_wallet_service.dart @@ -19,7 +19,7 @@ class PolygonWalletService extends EVMChainWalletService { WalletType getType() => WalletType.polygon; @override - Future create(EVMChainNewWalletCredentials credentials) async { + Future create(EVMChainNewWalletCredentials credentials, {bool? isTestnet}) async { final strength = credentials.seedPhraseLength == 24 ? 256 : 128; final mnemonic = bip39.generateMnemonic(strength: strength); @@ -62,7 +62,7 @@ class PolygonWalletService extends EVMChainWalletService { password: password, walletInfo: walletInfo, ); - + await wallet.init(); await wallet.save(); return wallet; @@ -70,8 +70,8 @@ class PolygonWalletService extends EVMChainWalletService { } @override - Future restoreFromKeys(EVMChainRestoreWalletFromPrivateKey credentials) async { - + Future restoreFromKeys(EVMChainRestoreWalletFromPrivateKey credentials, + {bool? isTestnet}) async { final wallet = PolygonWallet( password: credentials.password!, privateKey: credentials.privateKey, @@ -87,8 +87,8 @@ class PolygonWalletService extends EVMChainWalletService { } @override - Future restoreFromSeed( - EVMChainRestoreWalletFromSeedCredentials credentials) async { + Future restoreFromSeed(EVMChainRestoreWalletFromSeedCredentials credentials, + {bool? isTestnet}) async { if (!bip39.validateMnemonic(credentials.mnemonic)) { throw PolygonMnemonicIsIncorrectException(); } diff --git a/lib/bitcoin/cw_bitcoin.dart b/lib/bitcoin/cw_bitcoin.dart index 688825013..f9c20d45e 100644 --- a/lib/bitcoin/cw_bitcoin.dart +++ b/lib/bitcoin/cw_bitcoin.dart @@ -1,181 +1,191 @@ part of 'bitcoin.dart'; class CWBitcoin extends Bitcoin { - @override - TransactionPriority getMediumTransactionPriority() => BitcoinTransactionPriority.medium; - - @override - WalletCredentials createBitcoinRestoreWalletFromSeedCredentials({ - required String name, - required String mnemonic, - required String password}) - => BitcoinRestoreWalletFromSeedCredentials(name: name, mnemonic: mnemonic, password: password); - - @override - WalletCredentials createBitcoinRestoreWalletFromWIFCredentials({ - required String name, - required String password, - required String wif, - WalletInfo? walletInfo}) - => BitcoinRestoreWalletFromWIFCredentials(name: name, password: password, wif: wif, walletInfo: walletInfo); - - @override - WalletCredentials createBitcoinNewWalletCredentials({ - required String name, - WalletInfo? walletInfo}) - => BitcoinNewWalletCredentials(name: name, walletInfo: walletInfo); - - @override - List getWordList() => wordlist; - - @override - Map getWalletKeys(Object wallet) { - final bitcoinWallet = wallet as ElectrumWallet; - final keys = bitcoinWallet.keys; - - return { - 'wif': keys.wif, - 'privateKey': keys.privateKey, - 'publicKey': keys.publicKey - }; - } - - @override - List getTransactionPriorities() - => BitcoinTransactionPriority.all; - - @override - List getLitecoinTransactionPriorities() - => LitecoinTransactionPriority.all; - - @override - TransactionPriority deserializeBitcoinTransactionPriority(int raw) - => BitcoinTransactionPriority.deserialize(raw: raw); - - @override - TransactionPriority deserializeLitecoinTransactionPriority(int raw) - => LitecoinTransactionPriority.deserialize(raw: raw); - - @override - int getFeeRate(Object wallet, TransactionPriority priority) { - final bitcoinWallet = wallet as ElectrumWallet; - return bitcoinWallet.feeRate(priority); - } - - @override - Future generateNewAddress(Object wallet, String label) async { - final bitcoinWallet = wallet as ElectrumWallet; - await bitcoinWallet.walletAddresses.generateNewAddress(label: label, hd: bitcoinWallet.hd); - await wallet.save(); - } - - @override - Future updateAddress(Object wallet,String address, String label) async { - final bitcoinWallet = wallet as ElectrumWallet; - bitcoinWallet.walletAddresses.updateAddress(address, label); - await wallet.save(); - } - - @override - Object createBitcoinTransactionCredentials(List outputs, {required TransactionPriority priority, int? feeRate}) - => BitcoinTransactionCredentials( - outputs.map((out) => OutputInfo( - fiatAmount: out.fiatAmount, - cryptoAmount: out.cryptoAmount, - address: out.address, - note: out.note, - sendAll: out.sendAll, - extractedAddress: out.extractedAddress, - isParsedAddress: out.isParsedAddress, - formattedCryptoAmount: out.formattedCryptoAmount)) - .toList(), - priority: priority as BitcoinTransactionPriority, - feeRate: feeRate); - - @override - Object createBitcoinTransactionCredentialsRaw(List outputs, {TransactionPriority? priority, required int feeRate}) - => BitcoinTransactionCredentials( - outputs, - priority: priority != null ? priority as BitcoinTransactionPriority : null, - feeRate: feeRate); - - @override - List getAddresses(Object wallet) { - final bitcoinWallet = wallet as ElectrumWallet; - return bitcoinWallet.walletAddresses.addresses - .map((BitcoinAddressRecord addr) => addr.address) - .toList(); - } - - @override - @computed - List getSubAddresses(Object wallet) { - final electrumWallet = wallet as ElectrumWallet; - return electrumWallet.walletAddresses.addresses - .map((BitcoinAddressRecord addr) => ElectrumSubAddress( - id: addr.index, - name: addr.name, - address: electrumWallet.type == WalletType.bitcoinCash ? addr.cashAddr : addr.address, - txCount: addr.txCount, - balance: addr.balance, - isChange: addr.isHidden)) - .toList(); - } - - @override - String getAddress(Object wallet) { - final bitcoinWallet = wallet as ElectrumWallet; - return bitcoinWallet.walletAddresses.address; - } - - @override - String formatterBitcoinAmountToString({required int amount}) - => bitcoinAmountToString(amount: amount); - - @override - double formatterBitcoinAmountToDouble({required int amount}) - => bitcoinAmountToDouble(amount: amount); - - @override - int formatterStringDoubleToBitcoinAmount(String amount) - => stringDoubleToBitcoinAmount(amount); + @override + TransactionPriority getMediumTransactionPriority() => BitcoinTransactionPriority.medium; @override - String bitcoinTransactionPriorityWithLabel(TransactionPriority priority, int rate) - => (priority as BitcoinTransactionPriority).labelWithRate(rate); - - @override - List getUnspents(Object wallet) { - final bitcoinWallet = wallet as ElectrumWallet; - return bitcoinWallet.unspentCoins; - } - - Future updateUnspents(Object wallet) async { - final bitcoinWallet = wallet as ElectrumWallet; - await bitcoinWallet.updateUnspent(); - } - - WalletService createBitcoinWalletService(Box walletInfoSource, Box unspentCoinSource) { - return BitcoinWalletService(walletInfoSource, unspentCoinSource); - } - - WalletService createLitecoinWalletService(Box walletInfoSource, Box unspentCoinSource) { - return LitecoinWalletService(walletInfoSource, unspentCoinSource); - } - - @override - TransactionPriority getBitcoinTransactionPriorityMedium() - => BitcoinTransactionPriority.medium; + WalletCredentials createBitcoinRestoreWalletFromSeedCredentials( + {required String name, required String mnemonic, required String password}) => + BitcoinRestoreWalletFromSeedCredentials(name: name, mnemonic: mnemonic, password: password); @override - TransactionPriority getLitecoinTransactionPriorityMedium() - => LitecoinTransactionPriority.medium; + WalletCredentials createBitcoinRestoreWalletFromWIFCredentials( + {required String name, + required String password, + required String wif, + WalletInfo? walletInfo}) => + BitcoinRestoreWalletFromWIFCredentials( + name: name, password: password, wif: wif, walletInfo: walletInfo); @override - TransactionPriority getBitcoinTransactionPrioritySlow() - => BitcoinTransactionPriority.slow; - + WalletCredentials createBitcoinNewWalletCredentials( + {required String name, WalletInfo? walletInfo}) => + BitcoinNewWalletCredentials(name: name, walletInfo: walletInfo); + @override - TransactionPriority getLitecoinTransactionPrioritySlow() - => LitecoinTransactionPriority.slow; -} \ No newline at end of file + List getWordList() => wordlist; + + @override + Map getWalletKeys(Object wallet) { + final bitcoinWallet = wallet as ElectrumWallet; + final keys = bitcoinWallet.keys; + + return { + 'wif': keys.wif, + 'privateKey': keys.privateKey, + 'publicKey': keys.publicKey + }; + } + + @override + List getTransactionPriorities() => BitcoinTransactionPriority.all; + + @override + List getLitecoinTransactionPriorities() => LitecoinTransactionPriority.all; + + @override + TransactionPriority deserializeBitcoinTransactionPriority(int raw) => + BitcoinTransactionPriority.deserialize(raw: raw); + + @override + TransactionPriority deserializeLitecoinTransactionPriority(int raw) => + LitecoinTransactionPriority.deserialize(raw: raw); + + @override + int getFeeRate(Object wallet, TransactionPriority priority) { + final bitcoinWallet = wallet as ElectrumWallet; + return bitcoinWallet.feeRate(priority); + } + + @override + Future generateNewAddress(Object wallet, String label) async { + final bitcoinWallet = wallet as ElectrumWallet; + await bitcoinWallet.walletAddresses.generateNewAddress(label: label); + await wallet.save(); + } + + @override + Future updateAddress(Object wallet, String address, String label) async { + final bitcoinWallet = wallet as ElectrumWallet; + bitcoinWallet.walletAddresses.updateAddress(address, label); + await wallet.save(); + } + + @override + Object createBitcoinTransactionCredentials(List outputs, + {required TransactionPriority priority, int? feeRate}) => + BitcoinTransactionCredentials( + outputs + .map((out) => OutputInfo( + fiatAmount: out.fiatAmount, + cryptoAmount: out.cryptoAmount, + address: out.address, + note: out.note, + sendAll: out.sendAll, + extractedAddress: out.extractedAddress, + isParsedAddress: out.isParsedAddress, + formattedCryptoAmount: out.formattedCryptoAmount)) + .toList(), + priority: priority as BitcoinTransactionPriority, + feeRate: feeRate); + + @override + Object createBitcoinTransactionCredentialsRaw(List outputs, + {TransactionPriority? priority, required int feeRate}) => + BitcoinTransactionCredentials(outputs, + priority: priority != null ? priority as BitcoinTransactionPriority : null, + feeRate: feeRate); + + @override + List getAddresses(Object wallet) { + final bitcoinWallet = wallet as ElectrumWallet; + return bitcoinWallet.walletAddresses.addressesByReceiveType + .map((BitcoinAddressRecord addr) => addr.address) + .toList(); + } + + @override + @computed + List getSubAddresses(Object wallet) { + final electrumWallet = wallet as ElectrumWallet; + return electrumWallet.walletAddresses.addressesByReceiveType + .map((BitcoinAddressRecord addr) => ElectrumSubAddress( + id: addr.index, + name: addr.name, + address: electrumWallet.type == WalletType.bitcoinCash ? addr.cashAddr : addr.address, + txCount: addr.txCount, + balance: addr.balance, + isChange: addr.isHidden)) + .toList(); + } + + @override + String getAddress(Object wallet) { + final bitcoinWallet = wallet as ElectrumWallet; + return bitcoinWallet.walletAddresses.address; + } + + @override + String formatterBitcoinAmountToString({required int amount}) => + bitcoinAmountToString(amount: amount); + + @override + double formatterBitcoinAmountToDouble({required int amount}) => + bitcoinAmountToDouble(amount: amount); + + @override + int formatterStringDoubleToBitcoinAmount(String amount) => stringDoubleToBitcoinAmount(amount); + + @override + String bitcoinTransactionPriorityWithLabel(TransactionPriority priority, int rate) => + (priority as BitcoinTransactionPriority).labelWithRate(rate); + + @override + List getUnspents(Object wallet) { + final bitcoinWallet = wallet as ElectrumWallet; + return bitcoinWallet.unspentCoins; + } + + Future updateUnspents(Object wallet) async { + final bitcoinWallet = wallet as ElectrumWallet; + await bitcoinWallet.updateUnspent(); + } + + WalletService createBitcoinWalletService( + Box walletInfoSource, Box unspentCoinSource) { + return BitcoinWalletService(walletInfoSource, unspentCoinSource); + } + + WalletService createLitecoinWalletService( + Box walletInfoSource, Box unspentCoinSource) { + return LitecoinWalletService(walletInfoSource, unspentCoinSource); + } + + @override + TransactionPriority getBitcoinTransactionPriorityMedium() => BitcoinTransactionPriority.medium; + + @override + TransactionPriority getLitecoinTransactionPriorityMedium() => LitecoinTransactionPriority.medium; + + @override + TransactionPriority getBitcoinTransactionPrioritySlow() => BitcoinTransactionPriority.slow; + + @override + TransactionPriority getLitecoinTransactionPrioritySlow() => LitecoinTransactionPriority.slow; + + @override + Future setAddressType(Object wallet, dynamic option) async { + final bitcoinWallet = wallet as ElectrumWallet; + await bitcoinWallet.walletAddresses.setAddressType(option as BitcoinAddressType); + } + + @override + BitcoinReceivePageOption getSelectedAddressType(Object wallet) { + final bitcoinWallet = wallet as ElectrumWallet; + return BitcoinReceivePageOption.fromType(bitcoinWallet.walletAddresses.addressPageType); + } + + @override + List getBitcoinReceivePageOptions() => BitcoinReceivePageOption.all; +} diff --git a/lib/core/address_validator.dart b/lib/core/address_validator.dart index 21f3c3557..432471655 100644 --- a/lib/core/address_validator.dart +++ b/lib/core/address_validator.dart @@ -1,4 +1,4 @@ -import 'package:bitcoin_flutter/bitcoin_flutter.dart' as bitcoin; +import 'package:bitcoin_base/bitcoin_base.dart'; import 'package:cake_wallet/generated/i18n.dart'; import 'package:cake_wallet/core/validator.dart'; import 'package:cake_wallet/solana/solana.dart'; @@ -9,7 +9,9 @@ class AddressValidator extends TextValidator { AddressValidator({required CryptoCurrency type}) : super( errorMessage: S.current.error_text_address, - useAdditionalValidation: type == CryptoCurrency.btc ? bitcoin.Address.validateAddress : null, + useAdditionalValidation: type == CryptoCurrency.btc + ? (String txt) => validateAddress(address: txt, network: BitcoinNetwork.mainnet) + : null, pattern: getPattern(type), length: getLength(type)); @@ -24,7 +26,7 @@ class AddressValidator extends TextValidator { return '^[0-9a-zA-Z]{59}\$|^[0-9a-zA-Z]{92}\$|^[0-9a-zA-Z]{104}\$' '|^[0-9a-zA-Z]{105}\$|^addr1[0-9a-zA-Z]{98}\$'; case CryptoCurrency.btc: - return '^3[0-9a-zA-Z]{32}\$|^3[0-9a-zA-Z]{33}\$|^bc1[0-9a-zA-Z]{59}\$'; + return '^${P2pkhAddress.regex.pattern}\$|^${P2shAddress.regex.pattern}\$|^${P2wpkhAddress.regex.pattern}\$|${P2trAddress.regex.pattern}\$|^${P2wshAddress.regex.pattern}\$'; case CryptoCurrency.nano: return '[0-9a-zA-Z_]'; case CryptoCurrency.banano: @@ -89,7 +91,7 @@ class AddressValidator extends TextValidator { case CryptoCurrency.dai: case CryptoCurrency.dash: case CryptoCurrency.eos: - return '[0-9a-zA-Z]'; + return '[0-9a-zA-Z]'; case CryptoCurrency.bch: return '^(?!bitcoincash:)[0-9a-zA-Z]*\$|^(?!bitcoincash:)q|p[0-9a-zA-Z]{41}\$|^(?!bitcoincash:)q|p[0-9a-zA-Z]{42}\$|^bitcoincash:q|p[0-9a-zA-Z]{41}\$|^bitcoincash:q|p[0-9a-zA-Z]{42}\$'; case CryptoCurrency.bnb: @@ -268,12 +270,11 @@ class AddressValidator extends TextValidator { '|([^0-9a-zA-Z]|^)8[0-9a-zA-Z]{94}([^0-9a-zA-Z]|\$)' '|([^0-9a-zA-Z]|^)[0-9a-zA-Z]{106}([^0-9a-zA-Z]|\$)'; case CryptoCurrency.btc: - return '([^0-9a-zA-Z]|^)1[0-9a-zA-Z]{32}([^0-9a-zA-Z]|\$)' - '|([^0-9a-zA-Z]|^)1[0-9a-zA-Z]{33}([^0-9a-zA-Z]|\$)' - '|([^0-9a-zA-Z]|^)3[0-9a-zA-Z]{32}([^0-9a-zA-Z]|\$)' - '|([^0-9a-zA-Z]|^)3[0-9a-zA-Z]{33}([^0-9a-zA-Z]|\$)' - '|([^0-9a-zA-Z]|^)bc1[0-9a-zA-Z]{39}([^0-9a-zA-Z]|\$)' - '|([^0-9a-zA-Z]|^)bc1[0-9a-zA-Z]{59}([^0-9a-zA-Z]|\$)'; + return '([^0-9a-zA-Z]|^)${P2pkhAddress.regex.pattern}|\$)' + '([^0-9a-zA-Z]|^)${P2shAddress.regex.pattern}|\$)' + '([^0-9a-zA-Z]|^)${P2wpkhAddress.regex.pattern}|\$)' + '([^0-9a-zA-Z]|^)${P2wshAddress.regex.pattern}|\$)' + '([^0-9a-zA-Z]|^)${P2trAddress.regex.pattern}|\$)'; case CryptoCurrency.ltc: return '([^0-9a-zA-Z]|^)^L[a-zA-Z0-9]{26,33}([^0-9a-zA-Z]|\$)' '|([^0-9a-zA-Z]|^)[LM][a-km-zA-HJ-NP-Z1-9]{26,33}([^0-9a-zA-Z]|\$)' @@ -297,4 +298,4 @@ class AddressValidator extends TextValidator { return null; } } -} \ No newline at end of file +} diff --git a/lib/core/wallet_creation_service.dart b/lib/core/wallet_creation_service.dart index 8548f079f..31a893ad6 100644 --- a/lib/core/wallet_creation_service.dart +++ b/lib/core/wallet_creation_service.dart @@ -2,7 +2,6 @@ import 'package:cake_wallet/di.dart'; import 'package:cake_wallet/store/settings_store.dart'; import 'package:cw_core/wallet_info.dart'; import 'package:cake_wallet/entities/preferences_key.dart'; -import 'package:flutter/foundation.dart'; import 'package:flutter_secure_storage/flutter_secure_storage.dart'; import 'package:hive/hive.dart'; import 'package:shared_preferences/shared_preferences.dart'; @@ -55,7 +54,7 @@ class WalletCreationService { } } - Future create(WalletCredentials credentials) async { + Future create(WalletCredentials credentials, {bool? isTestnet}) async { checkIfExists(credentials.name); final password = generateWalletPassword(); credentials.password = password; @@ -63,7 +62,7 @@ class WalletCreationService { credentials.seedPhraseLength = settingsStore.seedPhraseLength.value; } await keyService.saveWalletPassword(password: password, walletName: credentials.name); - final wallet = await _service!.create(credentials); + final wallet = await _service!.create(credentials, isTestnet: isTestnet); if (wallet.type == WalletType.monero) { await sharedPreferences.setBool( @@ -73,12 +72,12 @@ class WalletCreationService { return wallet; } - Future restoreFromKeys(WalletCredentials credentials) async { + Future restoreFromKeys(WalletCredentials credentials, {bool? isTestnet}) async { checkIfExists(credentials.name); final password = generateWalletPassword(); credentials.password = password; await keyService.saveWalletPassword(password: password, walletName: credentials.name); - final wallet = await _service!.restoreFromKeys(credentials); + final wallet = await _service!.restoreFromKeys(credentials, isTestnet: isTestnet); if (wallet.type == WalletType.monero) { await sharedPreferences.setBool( @@ -88,12 +87,12 @@ class WalletCreationService { return wallet; } - Future restoreFromSeed(WalletCredentials credentials) async { + Future restoreFromSeed(WalletCredentials credentials, {bool? isTestnet}) async { checkIfExists(credentials.name); final password = generateWalletPassword(); credentials.password = password; await keyService.saveWalletPassword(password: password, walletName: credentials.name); - final wallet = await _service!.restoreFromSeed(credentials); + final wallet = await _service!.restoreFromSeed(credentials, isTestnet: isTestnet); if (wallet.type == WalletType.monero) { await sharedPreferences.setBool( diff --git a/lib/di.dart b/lib/di.dart index 4a005a4de..473eaed00 100644 --- a/lib/di.dart +++ b/lib/di.dart @@ -13,7 +13,7 @@ import 'package:cake_wallet/core/yat_service.dart'; import 'package:cake_wallet/entities/background_tasks.dart'; import 'package:cake_wallet/entities/exchange_api_mode.dart'; import 'package:cake_wallet/entities/parse_address_from_domain.dart'; -import 'package:cake_wallet/entities/receive_page_option.dart'; +import 'package:cw_core/receive_page_option.dart'; import 'package:cake_wallet/ethereum/ethereum.dart'; import 'package:cake_wallet/nano/nano.dart'; import 'package:cake_wallet/ionia/ionia_anypay.dart'; diff --git a/lib/entities/default_settings_migration.dart b/lib/entities/default_settings_migration.dart index 019276227..fb3e9e80c 100644 --- a/lib/entities/default_settings_migration.dart +++ b/lib/entities/default_settings_migration.dart @@ -23,6 +23,10 @@ import 'package:collection/collection.dart'; const newCakeWalletMoneroUri = 'xmr-node.cakewallet.com:18081'; const cakeWalletBitcoinElectrumUri = 'electrum.cakewallet.com:50002'; +const publicBitcoinTestnetElectrumAddress = 'electrum.blockstream.info'; +const publicBitcoinTestnetElectrumPort = '60002'; +const publicBitcoinTestnetElectrumUri = + '$publicBitcoinTestnetElectrumAddress:$publicBitcoinTestnetElectrumPort'; const cakeWalletLitecoinElectrumUri = 'ltc-electrum.cakewallet.com:50002'; const havenDefaultNodeUri = 'nodes.havenprotocol.org:443'; const ethereumDefaultNodeUri = 'ethereum.publicnode.com'; @@ -334,6 +338,12 @@ Node? getBitcoinDefaultElectrumServer({required Box nodes}) { nodes.values.firstWhereOrNull((node) => node.type == WalletType.bitcoin); } +Node? getBitcoinTestnetDefaultElectrumServer({required Box nodes}) { + return nodes.values + .firstWhereOrNull((Node node) => node.uriRaw == publicBitcoinTestnetElectrumUri) ?? + nodes.values.firstWhereOrNull((node) => node.type == WalletType.bitcoin); +} + Node? getLitecoinDefaultElectrumServer({required Box nodes}) { return nodes.values .firstWhereOrNull((Node node) => node.uriRaw == cakeWalletLitecoinElectrumUri) ?? @@ -503,8 +513,15 @@ Future rewriteSecureStoragePin({required FlutterSecureStorage secureStorag } Future changeBitcoinCurrentElectrumServerToDefault( - {required SharedPreferences sharedPreferences, required Box nodes}) async { - final server = getBitcoinDefaultElectrumServer(nodes: nodes); + {required SharedPreferences sharedPreferences, + required Box nodes, + bool? isTestnet}) async { + Node? server; + if (isTestnet == true) { + server = getBitcoinTestnetDefaultElectrumServer(nodes: nodes); + } else { + server = getBitcoinDefaultElectrumServer(nodes: nodes); + } final serverId = server?.key as int? ?? 0; await sharedPreferences.setInt(PreferencesKey.currentBitcoinElectrumSererIdKey, serverId); diff --git a/lib/entities/receive_page_option.dart b/lib/entities/receive_page_option.dart deleted file mode 100644 index 3ee9abe96..000000000 --- a/lib/entities/receive_page_option.dart +++ /dev/null @@ -1,23 +0,0 @@ - -enum ReceivePageOption { - mainnet, - anonPayInvoice, - anonPayDonationLink; - - @override - String toString() { - String label = ''; - switch (this) { - case ReceivePageOption.mainnet: - label = 'Mainnet'; - break; - case ReceivePageOption.anonPayInvoice: - label = 'Trocador AnonPay Invoice'; - break; - case ReceivePageOption.anonPayDonationLink: - label = 'Trocador AnonPay Donation Link'; - break; - } - return label; - } -} diff --git a/lib/router.dart b/lib/router.dart index b7b7c9a8e..ef7b7f31e 100644 --- a/lib/router.dart +++ b/lib/router.dart @@ -532,13 +532,19 @@ Route createRoute(RouteSettings settings) { builder: (_) => getIt.get(param1: title, param2: url)); case Routes.advancedPrivacySettings: - final type = settings.arguments as WalletType; + final args = settings.arguments as Map; + final type = args['type'] as WalletType; + final useTestnet = args['useTestnet'] as bool; + final toggleTestnet = args['toggleTestnet'] as Function(bool? val); return CupertinoPageRoute( builder: (_) => AdvancedPrivacySettingsPage( - getIt.get(param1: type), - getIt.get(param1: type, param2: false), - getIt.get())); + useTestnet, + toggleTestnet, + getIt.get(param1: type), + getIt.get(param1: type, param2: false), + getIt.get(), + )); case Routes.anonPayInvoicePage: final args = settings.arguments as List; diff --git a/lib/src/screens/dashboard/pages/address_page.dart b/lib/src/screens/dashboard/pages/address_page.dart index b4460edc9..7b7c84c28 100644 --- a/lib/src/screens/dashboard/pages/address_page.dart +++ b/lib/src/screens/dashboard/pages/address_page.dart @@ -5,7 +5,8 @@ import 'package:cake_wallet/src/screens/base_page.dart'; import 'package:cake_wallet/src/screens/monero_accounts/monero_account_list_page.dart'; import 'package:cake_wallet/anonpay/anonpay_donation_link_info.dart'; import 'package:cake_wallet/entities/preferences_key.dart'; -import 'package:cake_wallet/entities/receive_page_option.dart'; +import 'package:cw_core/receive_page_option.dart'; +import 'package:cw_bitcoin/bitcoin_receive_page_option.dart'; import 'package:cake_wallet/src/screens/dashboard/widgets/present_receive_option_picker.dart'; import 'package:cake_wallet/src/widgets/gradient_background.dart'; import 'package:cake_wallet/src/widgets/keyboard_done_button.dart'; @@ -27,6 +28,7 @@ import 'package:keyboard_actions/keyboard_actions.dart'; import 'package:mobx/mobx.dart'; import 'package:shared_preferences/shared_preferences.dart'; import 'package:cake_wallet/themes/extensions/balance_page_theme.dart'; +import 'package:bitcoin_base/bitcoin_base.dart'; class AddressPage extends BasePage { AddressPage({ @@ -69,7 +71,7 @@ class AddressPage extends BasePage { size: 16, ); final _closeButton = - currentTheme.type == ThemeType.dark ? closeButtonImageDarkTheme : closeButtonImage; + currentTheme.type == ThemeType.dark ? closeButtonImageDarkTheme : closeButtonImage; bool isMobileView = responsiveLayoutUtil.shouldRenderMobileUI; @@ -163,11 +165,10 @@ class AddressPage extends BasePage { return SelectButton( text: addressListViewModel.buttonTitle, onTap: () async => dashboardViewModel.isAutoGenerateSubaddressesEnabled && - (WalletType.monero == addressListViewModel.wallet.type || - WalletType.haven == addressListViewModel.wallet.type) + (WalletType.monero == addressListViewModel.wallet.type || + WalletType.haven == addressListViewModel.wallet.type) ? await showPopUp( - context: context, - builder: (_) => getIt.get()) + context: context, builder: (_) => getIt.get()) : Navigator.of(context).pushNamed(Routes.receive), textColor: Theme.of(context).extension()!.textColor, color: Theme.of(context).extension()!.syncedBackgroundColor, @@ -229,6 +230,21 @@ class AddressPage extends BasePage { ); } break; + case BitcoinReceivePageOption.p2pkh: + addressListViewModel.setAddressType(P2pkhAddressType.p2pkh); + break; + case BitcoinReceivePageOption.p2sh: + addressListViewModel.setAddressType(P2shAddressType.p2wpkhInP2sh); + break; + case BitcoinReceivePageOption.p2wpkh: + addressListViewModel.setAddressType(SegwitAddresType.p2wpkh); + break; + case BitcoinReceivePageOption.p2tr: + addressListViewModel.setAddressType(SegwitAddresType.p2tr); + break; + case BitcoinReceivePageOption.p2wsh: + addressListViewModel.setAddressType(SegwitAddresType.p2wsh); + break; default: } }); diff --git a/lib/src/screens/new_wallet/advanced_privacy_settings_page.dart b/lib/src/screens/new_wallet/advanced_privacy_settings_page.dart index a4bd6c7b9..26478345e 100644 --- a/lib/src/screens/new_wallet/advanced_privacy_settings_page.dart +++ b/lib/src/screens/new_wallet/advanced_privacy_settings_page.dart @@ -1,3 +1,4 @@ +import 'package:cake_wallet/entities/default_settings_migration.dart'; import 'package:cake_wallet/entities/exchange_api_mode.dart'; import 'package:cake_wallet/entities/fiat_api_mode.dart'; import 'package:cake_wallet/entities/seed_phrase_length.dart'; @@ -11,6 +12,7 @@ import 'package:cake_wallet/view_model/node_list/node_create_or_edit_view_model. import 'package:cake_wallet/view_model/advanced_privacy_settings_view_model.dart'; import 'package:cake_wallet/view_model/seed_type_view_model.dart'; import 'package:cake_wallet/view_model/settings/choices_list_item.dart'; +import 'package:cw_core/wallet_type.dart'; import 'package:flutter_mobx/flutter_mobx.dart'; import 'package:flutter/material.dart'; import 'package:cake_wallet/generated/i18n.dart'; @@ -19,7 +21,7 @@ import 'package:cake_wallet/src/widgets/primary_button.dart'; import 'package:cake_wallet/src/widgets/scollable_with_bottom_section.dart'; class AdvancedPrivacySettingsPage extends BasePage { - AdvancedPrivacySettingsPage( + AdvancedPrivacySettingsPage(this.useTestnet, this.toggleUseTestnet, this.advancedPrivacySettingsViewModel, this.nodeViewModel, this.seedTypeViewModel); final AdvancedPrivacySettingsViewModel advancedPrivacySettingsViewModel; @@ -29,13 +31,16 @@ class AdvancedPrivacySettingsPage extends BasePage { @override String get title => S.current.privacy_settings; + final bool useTestnet; + final Function(bool? val) toggleUseTestnet; + @override - Widget body(BuildContext context) => AdvancedPrivacySettingsBody( + Widget body(BuildContext context) => AdvancedPrivacySettingsBody(useTestnet, toggleUseTestnet, advancedPrivacySettingsViewModel, nodeViewModel, seedTypeViewModel); } class AdvancedPrivacySettingsBody extends StatefulWidget { - const AdvancedPrivacySettingsBody( + const AdvancedPrivacySettingsBody(this.useTestnet, this.toggleUseTestnet, this.privacySettingsViewModel, this.nodeViewModel, this.seedTypeViewModel, {Key? key}) : super(key: key); @@ -44,6 +49,9 @@ class AdvancedPrivacySettingsBody extends StatefulWidget { final NodeCreateOrEditViewModel nodeViewModel; final SeedTypeViewModel seedTypeViewModel; + final bool useTestnet; + final Function(bool? val) toggleUseTestnet; + @override _AdvancedPrivacySettingsBodyState createState() => _AdvancedPrivacySettingsBodyState(); } @@ -52,9 +60,14 @@ class _AdvancedPrivacySettingsBodyState extends State(); + bool? testnetValue; @override Widget build(BuildContext context) { + if (testnetValue == null && widget.useTestnet != null) { + testnetValue = widget.useTestnet; + } + return Container( padding: EdgeInsets.only(top: 24), child: ScrollableWithBottomSection( @@ -125,6 +138,19 @@ class _AdvancedPrivacySettingsBodyState extends State WalletNameForm( - _walletNewVM, currentTheme.type == ThemeType.dark ? walletNameImage : walletNameLightImage, _seedTypeViewModel); + _walletNewVM, + currentTheme.type == ThemeType.dark ? walletNameImage : walletNameLightImage, + _seedTypeViewModel); } class WalletNameForm extends StatefulWidget { @@ -187,7 +189,6 @@ class _WalletNameFormState extends State { ), ), ), - if (_walletNewVM.hasLanguageSelector) ...[ if (_walletNewVM.hasSeedType) ...[ Observer( @@ -222,7 +223,7 @@ class _WalletNameFormState extends State { ), ), ) - ] + ], ], ), ), @@ -245,8 +246,11 @@ class _WalletNameFormState extends State { const SizedBox(height: 25), GestureDetector( onTap: () { - Navigator.of(context) - .pushNamed(Routes.advancedPrivacySettings, arguments: _walletNewVM.type); + Navigator.of(context).pushNamed(Routes.advancedPrivacySettings, arguments: { + "type": _walletNewVM.type, + "useTestnet": _walletNewVM.useTestnet, + "toggleTestnet": _walletNewVM.toggleUseTestnet + }); }, child: Text(S.of(context).advanced_settings), ), diff --git a/lib/src/screens/receive/anonpay_invoice_page.dart b/lib/src/screens/receive/anonpay_invoice_page.dart index fc835c72d..f33cdcc5b 100644 --- a/lib/src/screens/receive/anonpay_invoice_page.dart +++ b/lib/src/screens/receive/anonpay_invoice_page.dart @@ -4,7 +4,7 @@ import 'package:cake_wallet/anonpay/anonpay_donation_link_info.dart'; import 'package:cake_wallet/core/execution_state.dart'; import 'package:cake_wallet/di.dart'; import 'package:cake_wallet/entities/preferences_key.dart'; -import 'package:cake_wallet/entities/receive_page_option.dart'; +import 'package:cw_core/receive_page_option.dart'; import 'package:cake_wallet/routes.dart'; import 'package:cake_wallet/src/screens/dashboard/widgets/present_receive_option_picker.dart'; import 'package:cake_wallet/src/screens/receive/widgets/anonpay_input_form.dart'; diff --git a/lib/src/screens/receive/anonpay_receive_page.dart b/lib/src/screens/receive/anonpay_receive_page.dart index b602abde6..7d71e3a22 100644 --- a/lib/src/screens/receive/anonpay_receive_page.dart +++ b/lib/src/screens/receive/anonpay_receive_page.dart @@ -1,7 +1,7 @@ import 'package:cake_wallet/anonpay/anonpay_info_base.dart'; import 'package:cake_wallet/anonpay/anonpay_invoice_info.dart'; import 'package:cake_wallet/entities/qr_view_data.dart'; -import 'package:cake_wallet/entities/receive_page_option.dart'; +import 'package:cw_core/receive_page_option.dart'; import 'package:cake_wallet/generated/i18n.dart'; import 'package:cake_wallet/routes.dart'; import 'package:cake_wallet/src/screens/base_page.dart'; diff --git a/lib/src/screens/restore/wallet_restore_page.dart b/lib/src/screens/restore/wallet_restore_page.dart index 899aacd19..fe5ac8487 100644 --- a/lib/src/screens/restore/wallet_restore_page.dart +++ b/lib/src/screens/restore/wallet_restore_page.dart @@ -210,8 +210,12 @@ class WalletRestorePage extends BasePage { const SizedBox(height: 25), GestureDetector( onTap: () { - Navigator.of(context).pushNamed(Routes.advancedPrivacySettings, - arguments: walletRestoreViewModel.type); + Navigator.of(context) + .pushNamed(Routes.advancedPrivacySettings, arguments: { + 'type': walletRestoreViewModel.type, + 'useTestnet': walletRestoreViewModel.useTestnet, + 'toggleTestnet': walletRestoreViewModel.toggleUseTestnet + }); }, child: Text(S.of(context).advanced_settings), ), diff --git a/lib/view_model/anon_invoice_page_view_model.dart b/lib/view_model/anon_invoice_page_view_model.dart index 53e8473a0..187eea375 100644 --- a/lib/view_model/anon_invoice_page_view_model.dart +++ b/lib/view_model/anon_invoice_page_view_model.dart @@ -4,7 +4,7 @@ import 'package:cake_wallet/anonpay/anonpay_request.dart'; import 'package:cake_wallet/core/execution_state.dart'; import 'package:cake_wallet/entities/fiat_currency.dart'; import 'package:cake_wallet/entities/preferences_key.dart'; -import 'package:cake_wallet/entities/receive_page_option.dart'; +import 'package:cw_core/receive_page_option.dart'; import 'package:cake_wallet/store/settings_store.dart'; import 'package:cw_core/crypto_currency.dart'; import 'package:cw_core/currency.dart'; diff --git a/lib/view_model/dashboard/dashboard_view_model.dart b/lib/view_model/dashboard/dashboard_view_model.dart index a794c2262..da5eb0373 100644 --- a/lib/view_model/dashboard/dashboard_view_model.dart +++ b/lib/view_model/dashboard/dashboard_view_model.dart @@ -148,17 +148,21 @@ abstract class DashboardViewModelBase with Store { monero!.getTransactionInfoAccountId(tx) == monero!.getCurrentAccount(wallet).id) .toList(); - transactions = ObservableList.of(_accountTransactions.map((transaction) => - TransactionListItem( - transaction: transaction, - balanceViewModel: balanceViewModel, - settingsStore: appStore.settingsStore))); + final sortedTransactions = [..._accountTransactions]; + sortedTransactions.sort((a, b) => a.date.compareTo(b.date)); + + transactions = ObservableList.of(sortedTransactions.map((transaction) => TransactionListItem( + transaction: transaction, + balanceViewModel: balanceViewModel, + settingsStore: appStore.settingsStore))); } else { - transactions = ObservableList.of(wallet.transactionHistory.transactions.values.map( - (transaction) => TransactionListItem( - transaction: transaction, - balanceViewModel: balanceViewModel, - settingsStore: appStore.settingsStore))); + final sortedTransactions = [...wallet.transactionHistory.transactions.values]; + sortedTransactions.sort((a, b) => a.date.compareTo(b.date)); + + transactions = ObservableList.of(sortedTransactions.map((transaction) => TransactionListItem( + transaction: transaction, + balanceViewModel: balanceViewModel, + settingsStore: appStore.settingsStore))); } // TODO: nano sub-account generation is disabled: diff --git a/lib/view_model/dashboard/receive_option_view_model.dart b/lib/view_model/dashboard/receive_option_view_model.dart index 0eaa2a5f0..1e4726eee 100644 --- a/lib/view_model/dashboard/receive_option_view_model.dart +++ b/lib/view_model/dashboard/receive_option_view_model.dart @@ -1,4 +1,5 @@ -import 'package:cake_wallet/entities/receive_page_option.dart'; +import 'package:cake_wallet/bitcoin/bitcoin.dart'; +import 'package:cw_core/receive_page_option.dart'; import 'package:cw_core/wallet_base.dart'; import 'package:cw_core/wallet_type.dart'; import 'package:mobx/mobx.dart'; @@ -9,11 +10,20 @@ class ReceiveOptionViewModel = ReceiveOptionViewModelBase with _$ReceiveOptionVi abstract class ReceiveOptionViewModelBase with Store { ReceiveOptionViewModelBase(this._wallet, this.initialPageOption) - : selectedReceiveOption = initialPageOption ?? ReceivePageOption.mainnet, + : selectedReceiveOption = initialPageOption ?? + (_wallet.type == WalletType.bitcoin + ? bitcoin!.getSelectedAddressType(_wallet) + : ReceivePageOption.mainnet), _options = [] { final walletType = _wallet.type; - _options = - walletType == WalletType.haven ? [ReceivePageOption.mainnet] : ReceivePageOption.values; + _options = walletType == WalletType.haven + ? [ReceivePageOption.mainnet] + : walletType == WalletType.bitcoin + ? [ + ...bitcoin!.getBitcoinReceivePageOptions(), + ...ReceivePageOptions.where((element) => element != ReceivePageOption.mainnet) + ] + : ReceivePageOptions; } final WalletBase _wallet; diff --git a/lib/view_model/node_list/node_create_or_edit_view_model.dart b/lib/view_model/node_list/node_create_or_edit_view_model.dart index 0fb9a83c6..e323268a0 100644 --- a/lib/view_model/node_list/node_create_or_edit_view_model.dart +++ b/lib/view_model/node_list/node_create_or_edit_view_model.dart @@ -65,6 +65,8 @@ abstract class NodeCreateOrEditViewModelBase with Store { bool get hasAuthCredentials => _walletType == WalletType.monero || _walletType == WalletType.haven; + bool get hasTestnetSupport => _walletType == WalletType.bitcoin; + String get uri { var uri = address; diff --git a/lib/view_model/node_list/node_list_view_model.dart b/lib/view_model/node_list/node_list_view_model.dart index 5526cc6d2..9c2d2611e 100644 --- a/lib/view_model/node_list/node_list_view_model.dart +++ b/lib/view_model/node_list/node_list_view_model.dart @@ -52,7 +52,11 @@ abstract class NodeListViewModelBase with Store { switch (_appStore.wallet!.type) { case WalletType.bitcoin: - node = getBitcoinDefaultElectrumServer(nodes: _nodeSource)!; + if (_appStore.wallet!.isTestnet == true) { + node = getBitcoinTestnetDefaultElectrumServer(nodes: _nodeSource)!; + } else { + node = getBitcoinDefaultElectrumServer(nodes: _nodeSource)!; + } break; case WalletType.monero: node = getMoneroDefaultNode(nodes: _nodeSource); diff --git a/lib/view_model/transaction_details_view_model.dart b/lib/view_model/transaction_details_view_model.dart index 04eaf25e4..1b1ceb814 100644 --- a/lib/view_model/transaction_details_view_model.dart +++ b/lib/view_model/transaction_details_view_model.dart @@ -119,7 +119,7 @@ abstract class TransactionDetailsViewModelBase with Store { case WalletType.monero: return 'https://monero.com/tx/${txId}'; case WalletType.bitcoin: - return 'https://mempool.space/tx/${txId}'; + return 'https://mempool.space/${wallet.isTestnet == true ? "testnet/" : ""}tx/${txId}'; case WalletType.litecoin: return 'https://blockchair.com/litecoin/transaction/${txId}'; case WalletType.bitcoinCash: diff --git a/lib/view_model/wallet_address_list/wallet_address_list_view_model.dart b/lib/view_model/wallet_address_list/wallet_address_list_view_model.dart index bde535a23..a2aab5251 100644 --- a/lib/view_model/wallet_address_list/wallet_address_list_view_model.dart +++ b/lib/view_model/wallet_address_list/wallet_address_list_view_model.dart @@ -388,9 +388,6 @@ abstract class WalletAddressListViewModelBase extends WalletChangeListenerViewMo wallet.type == WalletType.bitcoin || wallet.type == WalletType.litecoin; - // wallet.type == WalletType.nano || - // wallet.type == WalletType.banano; TODO: nano accounts are disabled for now - @computed bool get isElectrumWallet => wallet.type == WalletType.bitcoin || @@ -409,16 +406,17 @@ abstract class WalletAddressListViewModelBase extends WalletChangeListenerViewMo void setAddress(WalletAddressListItem address) => wallet.walletAddresses.address = address.address; + @action + Future setAddressType(dynamic option) async { + if (wallet.type == WalletType.bitcoin) { + await bitcoin!.setAddressType(wallet, option); + } + } + void _init() { _baseItems = []; - if (wallet.type == WalletType.monero || - wallet.type == - WalletType - .haven /*|| - wallet.type == WalletType.nano || - wallet.type == WalletType.banano*/ - ) { + if (wallet.type == WalletType.monero || wallet.type == WalletType.haven) { _baseItems.add(WalletAccountListHeader()); } diff --git a/lib/view_model/wallet_creation_vm.dart b/lib/view_model/wallet_creation_vm.dart index 45306905c..4a1e054d6 100644 --- a/lib/view_model/wallet_creation_vm.dart +++ b/lib/view_model/wallet_creation_vm.dart @@ -23,6 +23,12 @@ abstract class WalletCreationVMBase with Store { : state = InitialExecutionState(), name = ''; + @observable + bool _useTestnet = false; + + @computed + bool get useTestnet => _useTestnet; + @observable String name; @@ -94,4 +100,9 @@ abstract class WalletCreationVMBase with Store { Future processFromRestoredWallet( WalletCredentials credentials, RestoredWallet restoreWallet) => throw UnimplementedError(); + + @action + void toggleUseTestnet(bool? value) { + _useTestnet = value ?? !_useTestnet; + } } diff --git a/lib/view_model/wallet_new_vm.dart b/lib/view_model/wallet_new_vm.dart index 6f3e0280e..8b19108ec 100644 --- a/lib/view_model/wallet_new_vm.dart +++ b/lib/view_model/wallet_new_vm.dart @@ -87,6 +87,6 @@ abstract class WalletNewVMBase extends WalletCreationVM with Store { @override Future process(WalletCredentials credentials) async { walletCreationService.changeWalletType(type: type); - return walletCreationService.create(credentials); + return walletCreationService.create(credentials, isTestnet: useTestnet); } } diff --git a/lib/view_model/wallet_restore_view_model.dart b/lib/view_model/wallet_restore_view_model.dart index 98dce3d92..93ca813d6 100644 --- a/lib/view_model/wallet_restore_view_model.dart +++ b/lib/view_model/wallet_restore_view_model.dart @@ -207,9 +207,9 @@ abstract class WalletRestoreViewModelBase extends WalletCreationVM with Store { @override Future process(WalletCredentials credentials) async { if (mode == WalletRestoreMode.keys) { - return walletCreationService.restoreFromKeys(credentials); + return walletCreationService.restoreFromKeys(credentials, isTestnet: useTestnet); } - return walletCreationService.restoreFromSeed(credentials); + return walletCreationService.restoreFromSeed(credentials, isTestnet: useTestnet); } } diff --git a/res/values/strings_ar.arb b/res/values/strings_ar.arb index 3e3f595be..9e822c474 100644 --- a/res/values/strings_ar.arb +++ b/res/values/strings_ar.arb @@ -719,6 +719,7 @@ "use_card_info_two": "يتم تحويل الأموال إلى الدولار الأمريكي عند الاحتفاظ بها في الحساب المدفوع مسبقًا ، وليس بالعملات الرقمية.", "use_ssl": "استخدم SSL", "use_suggested": "استخدام المقترح", + "use_testnet": "استخدم testnet", "variable_pair_not_supported": "هذا الزوج المتغير غير مدعوم في التبادلات المحددة", "verification": "تَحَقّق", "verify_with_2fa": "تحقق مع Cake 2FA", diff --git a/res/values/strings_bg.arb b/res/values/strings_bg.arb index 9d64e36ae..8dbdf4b98 100644 --- a/res/values/strings_bg.arb +++ b/res/values/strings_bg.arb @@ -719,6 +719,7 @@ "use_card_info_two": "Средствата се обръщат в USD, когато биват запазени в предплатената карта, а не в дигитална валута.", "use_ssl": "Използване на SSL", "use_suggested": "Използване на предложеното", + "use_testnet": "Използвайте TestNet", "variable_pair_not_supported": "Този variable pair не се поддържа от избраната борса", "verification": "Потвърждаване", "verify_with_2fa": "Проверете с Cake 2FA", diff --git a/res/values/strings_cs.arb b/res/values/strings_cs.arb index 8665efc2c..ff22febf0 100644 --- a/res/values/strings_cs.arb +++ b/res/values/strings_cs.arb @@ -719,6 +719,7 @@ "use_card_info_two": "Prostředky jsou převedeny na USD, když jsou drženy na předplaceném účtu, nikoliv na digitální měnu.", "use_ssl": "Použít SSL", "use_suggested": "Použít doporučený", + "use_testnet": "Použijte testNet", "variable_pair_not_supported": "Tento pár s tržním kurzem není ve zvolené směnárně podporován", "verification": "Ověření", "verify_with_2fa": "Ověřte pomocí Cake 2FA", diff --git a/res/values/strings_de.arb b/res/values/strings_de.arb index 28e3d4996..366d7d529 100644 --- a/res/values/strings_de.arb +++ b/res/values/strings_de.arb @@ -721,6 +721,7 @@ "use_card_info_two": "Guthaben werden auf dem Prepaid-Konto in USD umgerechnet, nicht in digitale Währung.", "use_ssl": "SSL verwenden", "use_suggested": "Vorgeschlagen verwenden", + "use_testnet": "TESTNET verwenden", "variable_pair_not_supported": "Dieses Variablenpaar wird von den ausgewählten Börsen nicht unterstützt", "verification": "Verifizierung", "verify_with_2fa": "Verifizieren Sie mit Cake 2FA", diff --git a/res/values/strings_en.arb b/res/values/strings_en.arb index aae06f6b0..b7aecc739 100644 --- a/res/values/strings_en.arb +++ b/res/values/strings_en.arb @@ -719,6 +719,7 @@ "use_card_info_two": "Funds are converted to USD when they're held in the prepaid account, not in digital currencies.", "use_ssl": "Use SSL", "use_suggested": "Use Suggested", + "use_testnet": "Use Testnet", "variable_pair_not_supported": "This variable pair is not supported with the selected exchanges", "verification": "Verification", "verify_with_2fa": "Verify with Cake 2FA", diff --git a/res/values/strings_es.arb b/res/values/strings_es.arb index d9e786467..832d66b5b 100644 --- a/res/values/strings_es.arb +++ b/res/values/strings_es.arb @@ -720,6 +720,7 @@ "use_card_info_two": "Los fondos se convierten a USD cuando se mantienen en la cuenta prepaga, no en monedas digitales.", "use_ssl": "Utilice SSL", "use_suggested": "Usar sugerido", + "use_testnet": "Use TestNet", "variable_pair_not_supported": "Este par de variables no es compatible con los intercambios seleccionados", "verification": "Verificación", "verify_with_2fa": "Verificar con Cake 2FA", diff --git a/res/values/strings_fr.arb b/res/values/strings_fr.arb index 8b4fc10d1..80b44d6ed 100644 --- a/res/values/strings_fr.arb +++ b/res/values/strings_fr.arb @@ -719,6 +719,7 @@ "use_card_info_two": "Les fonds sont convertis en USD lorsqu'ils sont détenus sur le compte prépayé, et non en devises numériques.", "use_ssl": "Utiliser SSL", "use_suggested": "Suivre la suggestion", + "use_testnet": "Utiliser TestNet", "variable_pair_not_supported": "Cette paire variable n'est pas prise en charge avec les échanges sélectionnés", "verification": "Vérification", "verify_with_2fa": "Vérifier avec Cake 2FA", diff --git a/res/values/strings_ha.arb b/res/values/strings_ha.arb index 6d1a5db0c..e1b77f346 100644 --- a/res/values/strings_ha.arb +++ b/res/values/strings_ha.arb @@ -721,6 +721,7 @@ "use_card_info_two": "Ana canza kuɗi zuwa dalar Amurka lokacin da ake riƙe su a cikin asusun da aka riga aka biya, ba cikin agogon dijital ba.", "use_ssl": "Yi amfani da SSL", "use_suggested": "Amfani da Shawarwari", + "use_testnet": "Amfani da gwaji", "variable_pair_not_supported": "Ba a samun goyan bayan wannan m biyu tare da zaɓaɓɓun musayar", "verification": "tabbatar", "verify_with_2fa": "Tabbatar da Cake 2FA", diff --git a/res/values/strings_hi.arb b/res/values/strings_hi.arb index eaeffab92..555e1fff5 100644 --- a/res/values/strings_hi.arb +++ b/res/values/strings_hi.arb @@ -721,6 +721,7 @@ "use_card_info_two": "डिजिटल मुद्राओं में नहीं, प्रीपेड खाते में रखे जाने पर निधियों को यूएसडी में बदल दिया जाता है।", "use_ssl": "उपयोग SSL", "use_suggested": "सुझाए गए का प्रयोग करें", + "use_testnet": "टेस्टनेट का उपयोग करें", "variable_pair_not_supported": "यह परिवर्तनीय जोड़ी चयनित एक्सचेंजों के साथ समर्थित नहीं है", "verification": "सत्यापन", "verify_with_2fa": "केक 2FA के साथ सत्यापित करें", diff --git a/res/values/strings_hr.arb b/res/values/strings_hr.arb index a915030f4..2b2e80198 100644 --- a/res/values/strings_hr.arb +++ b/res/values/strings_hr.arb @@ -719,6 +719,7 @@ "use_card_info_two": "Sredstva se pretvaraju u USD kada se drže na prepaid računu, a ne u digitalnim valutama.", "use_ssl": "Koristi SSL", "use_suggested": "Koristite predloženo", + "use_testnet": "Koristite TestNet", "variable_pair_not_supported": "Ovaj par varijabli nije podržan s odabranim burzama", "verification": "Potvrda", "verify_with_2fa": "Provjerite s Cake 2FA", diff --git a/res/values/strings_id.arb b/res/values/strings_id.arb index ed53b7e62..10af2f8ad 100644 --- a/res/values/strings_id.arb +++ b/res/values/strings_id.arb @@ -722,6 +722,7 @@ "use_card_info_two": "Dana dikonversi ke USD ketika disimpan dalam akun pra-bayar, bukan dalam mata uang digital.", "use_ssl": "Gunakan SSL", "use_suggested": "Gunakan yang Disarankan", + "use_testnet": "Gunakan TestNet", "variable_pair_not_supported": "Pasangan variabel ini tidak didukung dengan bursa yang dipilih", "verification": "Verifikasi", "verify_with_2fa": "Verifikasi dengan Cake 2FA", diff --git a/res/values/strings_it.arb b/res/values/strings_it.arb index d83619855..a4093f077 100644 --- a/res/values/strings_it.arb +++ b/res/values/strings_it.arb @@ -721,6 +721,7 @@ "use_card_info_two": "I fondi vengono convertiti in USD quando sono detenuti nel conto prepagato, non in valute digitali.", "use_ssl": "Usa SSL", "use_suggested": "Usa suggerito", + "use_testnet": "Usa TestNet", "variable_pair_not_supported": "Questa coppia di variabili non è supportata con gli scambi selezionati", "verification": "Verifica", "verify_with_2fa": "Verifica con Cake 2FA", diff --git a/res/values/strings_ja.arb b/res/values/strings_ja.arb index e04d8c000..001e26f65 100644 --- a/res/values/strings_ja.arb +++ b/res/values/strings_ja.arb @@ -720,6 +720,7 @@ "use_card_info_two": "デジタル通貨ではなく、プリペイドアカウントで保持されている場合、資金は米ドルに変換されます。", "use_ssl": "SSLを使用する", "use_suggested": "推奨を使用", + "use_testnet": "テストネットを使用します", "variable_pair_not_supported": "この変数ペアは、選択した取引所ではサポートされていません", "verification": "検証", "verify_with_2fa": "Cake 2FA で検証する", diff --git a/res/values/strings_ko.arb b/res/values/strings_ko.arb index cfd3df6c9..bc708b246 100644 --- a/res/values/strings_ko.arb +++ b/res/values/strings_ko.arb @@ -720,6 +720,7 @@ "use_card_info_two": "디지털 화폐가 아닌 선불 계정에 보유하면 자금이 USD로 변환됩니다.", "use_ssl": "SSL 사용", "use_suggested": "추천 사용", + "use_testnet": "TestNet을 사용하십시오", "variable_pair_not_supported": "이 변수 쌍은 선택한 교환에서 지원되지 않습니다.", "verification": "검증", "verify_with_2fa": "케이크 2FA로 확인", diff --git a/res/values/strings_my.arb b/res/values/strings_my.arb index 281bb6cea..56cdb802d 100644 --- a/res/values/strings_my.arb +++ b/res/values/strings_my.arb @@ -719,6 +719,7 @@ "use_card_info_two": "ဒစ်ဂျစ်တယ်ငွေကြေးများဖြင့်မဟုတ်ဘဲ ကြိုတင်ငွေပေးချေသည့်အကောင့်တွင် သိမ်းထားသည့်အခါ ရန်ပုံငွေများကို USD သို့ ပြောင်းလဲပါသည်။", "use_ssl": "SSL ကိုသုံးပါ။", "use_suggested": "အကြံပြုထားသည်ကို အသုံးပြုပါ။", + "use_testnet": "testnet ကိုသုံးပါ", "variable_pair_not_supported": "ရွေးချယ်ထားသော ဖလှယ်မှုများဖြင့် ဤပြောင်းလဲနိုင်သောအတွဲကို ပံ့ပိုးမထားပါ။", "verification": "စိစစ်ခြင်း။", "verify_with_2fa": "Cake 2FA ဖြင့် စစ်ဆေးပါ။", diff --git a/res/values/strings_nl.arb b/res/values/strings_nl.arb index 19573b116..3ab2c06e6 100644 --- a/res/values/strings_nl.arb +++ b/res/values/strings_nl.arb @@ -719,6 +719,7 @@ "use_card_info_two": "Tegoeden worden omgezet naar USD wanneer ze op de prepaid-rekening staan, niet in digitale valuta.", "use_ssl": "Gebruik SSL", "use_suggested": "Gebruik aanbevolen", + "use_testnet": "Gebruik testnet", "variable_pair_not_supported": "Dit variabelenpaar wordt niet ondersteund met de geselecteerde uitwisselingen", "verification": "Verificatie", "verify_with_2fa": "Controleer met Cake 2FA", diff --git a/res/values/strings_pl.arb b/res/values/strings_pl.arb index 33fd1408a..855fb6680 100644 --- a/res/values/strings_pl.arb +++ b/res/values/strings_pl.arb @@ -719,6 +719,7 @@ "use_card_info_two": "Środki są przeliczane na USD, gdy są przechowywane na koncie przedpłaconym, a nie w walutach cyfrowych.", "use_ssl": "Użyj SSL", "use_suggested": "Użyj sugerowane", + "use_testnet": "Użyj testne", "variable_pair_not_supported": "Ta para zmiennych nie jest obsługiwana na wybranych giełdach", "verification": "Weryfikacja", "verify_with_2fa": "Sprawdź za pomocą Cake 2FA", diff --git a/res/values/strings_pt.arb b/res/values/strings_pt.arb index 649551d01..df3f0cdd1 100644 --- a/res/values/strings_pt.arb +++ b/res/values/strings_pt.arb @@ -721,6 +721,7 @@ "use_card_info_two": "Os fundos são convertidos para USD quando mantidos na conta pré-paga, não em moedas digitais.", "use_ssl": "Use SSL", "use_suggested": "Uso sugerido", + "use_testnet": "Use testNet", "variable_pair_not_supported": "Este par de variáveis não é compatível com as trocas selecionadas", "verification": "Verificação", "verify_with_2fa": "Verificar com Cake 2FA", diff --git a/res/values/strings_ru.arb b/res/values/strings_ru.arb index 16c294ef7..ebd444461 100644 --- a/res/values/strings_ru.arb +++ b/res/values/strings_ru.arb @@ -720,6 +720,7 @@ "use_card_info_two": "Средства конвертируются в доллары США, когда они хранятся на предоплаченном счете, а не в цифровых валютах.", "use_ssl": "Использовать SSL", "use_suggested": "Использовать предложенный", + "use_testnet": "Используйте Testnet", "variable_pair_not_supported": "Эта пара переменных не поддерживается выбранными биржами.", "verification": "Проверка", "verify_with_2fa": "Подтвердить с помощью Cake 2FA", diff --git a/res/values/strings_th.arb b/res/values/strings_th.arb index dc72090d4..5e04f780f 100644 --- a/res/values/strings_th.arb +++ b/res/values/strings_th.arb @@ -719,6 +719,7 @@ "use_card_info_two": "เงินจะถูกแปลงค่าเป็นดอลลาร์สหรัฐเมื่อถือไว้ในบัญชีสำรองเงิน ไม่ใช่สกุลเงินดิจิตอล", "use_ssl": "ใช้ SSL", "use_suggested": "ใช้ที่แนะนำ", + "use_testnet": "ใช้ testnet", "variable_pair_not_supported": "คู่ความสัมพันธ์ที่เปลี่ยนแปลงได้นี้ไม่สนับสนุนกับหุ้นที่เลือก", "verification": "การตรวจสอบ", "verify_with_2fa": "ตรวจสอบกับ Cake 2FA", diff --git a/res/values/strings_tl.arb b/res/values/strings_tl.arb index 4090a4669..6be45a997 100644 --- a/res/values/strings_tl.arb +++ b/res/values/strings_tl.arb @@ -719,6 +719,7 @@ "use_card_info_two": "Ang mga pondo ay na -convert sa USD kapag gaganapin sila sa prepaid account, hindi sa mga digital na pera.", "use_ssl": "Gumamit ng SSL", "use_suggested": "Gumamit ng iminungkahing", + "use_testnet": "Gumamit ng testnet", "variable_pair_not_supported": "Ang variable na pares na ito ay hindi suportado sa mga napiling palitan", "verification": "Pag -verify", "verify_with_2fa": "Mag -verify sa cake 2FA", diff --git a/res/values/strings_tr.arb b/res/values/strings_tr.arb index 39eab9f86..60773f37d 100644 --- a/res/values/strings_tr.arb +++ b/res/values/strings_tr.arb @@ -719,6 +719,7 @@ "use_card_info_two": "Paralar, dijital para birimlerinde değil, ön ödemeli hesapta tutulduğunda USD'ye dönüştürülür.", "use_ssl": "SSL kullan", "use_suggested": "Önerileni Kullan", + "use_testnet": "TestNet kullanın", "variable_pair_not_supported": "Bu değişken paritesi seçilen borsalarda desteklenmemekte", "verification": "Doğrulama", "verify_with_2fa": "Cake 2FA ile Doğrulayın", diff --git a/res/values/strings_uk.arb b/res/values/strings_uk.arb index b655a902d..ae4efdd8f 100644 --- a/res/values/strings_uk.arb +++ b/res/values/strings_uk.arb @@ -720,6 +720,7 @@ "use_card_info_two": "Кошти конвертуються в долари США, якщо вони зберігаються на передплаченому рахунку, а не в цифрових валютах.", "use_ssl": "Використати SSL", "use_suggested": "Використати запропоноване", + "use_testnet": "Використовуйте тестову мережу", "variable_pair_not_supported": "Ця пара змінних не підтримується вибраними біржами", "verification": "Перевірка", "verify_with_2fa": "Перевірте за допомогою Cake 2FA", diff --git a/res/values/strings_ur.arb b/res/values/strings_ur.arb index 0dcd31069..929f7de2e 100644 --- a/res/values/strings_ur.arb +++ b/res/values/strings_ur.arb @@ -721,6 +721,7 @@ "use_card_info_two": "رقوم کو امریکی ڈالر میں تبدیل کیا جاتا ہے جب پری پیڈ اکاؤنٹ میں رکھا جاتا ہے، ڈیجیٹل کرنسیوں میں نہیں۔", "use_ssl": "SSL استعمال کریں۔", "use_suggested": "تجویز کردہ استعمال کریں۔", + "use_testnet": "ٹیسٹ نیٹ استعمال کریں", "variable_pair_not_supported": "یہ متغیر جوڑا منتخب ایکسچینجز کے ساتھ تعاون یافتہ نہیں ہے۔", "verification": "تصدیق", "verify_with_2fa": "کیک 2FA سے تصدیق کریں۔", diff --git a/res/values/strings_yo.arb b/res/values/strings_yo.arb index af21c001b..9813cc976 100644 --- a/res/values/strings_yo.arb +++ b/res/values/strings_yo.arb @@ -720,6 +720,7 @@ "use_card_info_two": "A pààrọ̀ owó sí owó Amẹ́ríkà tó bá wà nínú àkanti t'á ti fikún tẹ́lẹ̀tẹ́lẹ̀. A kò kó owó náà nínú owó ayélujára.", "use_ssl": "Lo SSL", "use_suggested": "Lo àbá", + "use_testnet": "Lo tele", "variable_pair_not_supported": "A kì í ṣe k'á fi àwọn ilé pàṣípààrọ̀ yìí ṣe pàṣípààrọ̀ irú owó méji yìí", "verification": "Ìjẹ́rìísí", "verify_with_2fa": "Ṣeẹda pẹlu Cake 2FA", diff --git a/res/values/strings_zh.arb b/res/values/strings_zh.arb index 6c245d30f..3a2104ecc 100644 --- a/res/values/strings_zh.arb +++ b/res/values/strings_zh.arb @@ -719,6 +719,7 @@ "use_card_info_two": "预付账户中的资金转换为美元,不是数字货币。", "use_ssl": "使用SSL", "use_suggested": "使用建议", + "use_testnet": "使用TestNet", "variable_pair_not_supported": "所选交易所不支持此变量对", "verification": "验证", "verify_with_2fa": "用 Cake 2FA 验证", diff --git a/scripts/android/app_env.sh b/scripts/android/app_env.sh index f659239e7..9251ec31a 100644 --- a/scripts/android/app_env.sh +++ b/scripts/android/app_env.sh @@ -22,8 +22,8 @@ MONERO_COM_PACKAGE="com.monero.app" MONERO_COM_SCHEME="monero.com" CAKEWALLET_NAME="Cake Wallet" -CAKEWALLET_VERSION="4.13.3" -CAKEWALLET_BUILD_NUMBER=192 +CAKEWALLET_VERSION="4.14.0" +CAKEWALLET_BUILD_NUMBER=193 CAKEWALLET_BUNDLE_ID="com.cakewallet.cake_wallet" CAKEWALLET_PACKAGE="com.cakewallet.cake_wallet" CAKEWALLET_SCHEME="cakewallet" diff --git a/scripts/android/shell.nix b/scripts/android/shell.nix new file mode 100644 index 000000000..b89da09c0 --- /dev/null +++ b/scripts/android/shell.nix @@ -0,0 +1,16 @@ +{ pkgs ? import {} }: + +pkgs.mkShell { + buildInputs = [ + pkgs.curl + pkgs.unzip + pkgs.automake + pkgs.file + pkgs.pkg-config + pkgs.git + pkgs.libtool + pkgs.ncurses5 + pkgs.openjdk8 + pkgs.clang + ]; +} diff --git a/scripts/ios/app_env.sh b/scripts/ios/app_env.sh index bb4ca77f8..47d80013c 100644 --- a/scripts/ios/app_env.sh +++ b/scripts/ios/app_env.sh @@ -18,8 +18,8 @@ MONERO_COM_BUILD_NUMBER=73 MONERO_COM_BUNDLE_ID="com.cakewallet.monero" CAKEWALLET_NAME="Cake Wallet" -CAKEWALLET_VERSION="4.13.3" -CAKEWALLET_BUILD_NUMBER=212 +CAKEWALLET_VERSION="4.14.0" +CAKEWALLET_BUILD_NUMBER=213 CAKEWALLET_BUNDLE_ID="com.fotolockr.cakewallet" HAVEN_NAME="Haven" diff --git a/tool/configure.dart b/tool/configure.dart index bd2e4227c..3a69e86b1 100644 --- a/tool/configure.dart +++ b/tool/configure.dart @@ -72,6 +72,7 @@ import 'package:cake_wallet/view_model/send/output.dart'; import 'package:cw_core/wallet_type.dart'; import 'package:hive/hive.dart';"""; const bitcoinCWHeaders = """ +import 'package:cw_bitcoin/bitcoin_receive_page_option.dart'; import 'package:cw_bitcoin/electrum_wallet.dart'; import 'package:cw_bitcoin/bitcoin_unspent.dart'; import 'package:cw_bitcoin/bitcoin_mnemonic.dart'; @@ -82,6 +83,7 @@ import 'package:cw_bitcoin/bitcoin_amount_format.dart'; import 'package:cw_bitcoin/bitcoin_address_record.dart'; import 'package:cw_bitcoin/bitcoin_transaction_credentials.dart'; import 'package:cw_bitcoin/litecoin_wallet_service.dart'; +import 'package:bitcoin_base/bitcoin_base.dart'; import 'package:mobx/mobx.dart'; """; const bitcoinCwPart = "part 'cw_bitcoin.dart';"; @@ -139,6 +141,10 @@ abstract class Bitcoin { TransactionPriority getLitecoinTransactionPriorityMedium(); TransactionPriority getBitcoinTransactionPrioritySlow(); TransactionPriority getLitecoinTransactionPrioritySlow(); + + Future setAddressType(Object wallet, dynamic option); + BitcoinReceivePageOption getSelectedAddressType(Object wallet); + List getBitcoinReceivePageOptions(); } """; From cbc0c3afd6bcb5999bc0928d9aa209e34d674849 Mon Sep 17 00:00:00 2001 From: Omar Hatem Date: Fri, 23 Feb 2024 19:09:24 +0200 Subject: [PATCH 3/9] Generic fixes (#1304) * fix mobx no element error * fix mobx issue * Remove unused code * Enhance error handling for monero sync failure case * Separate litecoin mnemonic exception from bitcoin * - Enable onramper for polygon - Add Kaspa validation * Set null as the default length of address validation * Modify EVM fee text [skip ci] * Add seed length option to polygon * Add digibyte * Update configure_cake_wallet.sh and fix conflicts * Pin bottom section * Fix Solana missing isTestnet param --- .gitignore | 1 + assets/images/digibyte.png | Bin 0 -> 6008 bytes configure_cake_wallet.sh | 1 + cw_bitcoin/lib/bitcoin_wallet_service.dart | 2 +- cw_bitcoin/lib/litecoin_wallet_service.dart | 4 +- ...t => mnemonic_is_incorrect_exception.dart} | 6 +++ cw_core/lib/crypto_currency.dart | 9 ++-- cw_evm/lib/evm_chain_wallet_addresses.dart | 1 + cw_monero/lib/monero_wallet.dart | 20 ++++---- cw_solana/lib/default_spl_tokens.dart | 2 - cw_solana/lib/solana_wallet_service.dart | 8 ++-- lib/bitcoin_cash/cw_bitcoin_cash.dart | 6 --- lib/core/address_validator.dart | 4 +- lib/entities/provider_types.dart | 4 +- lib/src/screens/send/send_page.dart | 5 +- .../scollable_with_bottom_section.dart | 43 +++++++----------- .../advanced_privacy_settings_view_model.dart | 24 ++++++++-- .../unspent_coins_list_view_model.dart | 6 ++- lib/view_model/wallet_keys_view_model.dart | 4 +- tool/configure.dart | 4 -- 20 files changed, 84 insertions(+), 70 deletions(-) create mode 100644 assets/images/digibyte.png rename cw_bitcoin/lib/{bitcoin_mnemonic_is_incorrect_exception.dart => mnemonic_is_incorrect_exception.dart} (50%) diff --git a/.gitignore b/.gitignore index 25edfcfb0..6f2d0a182 100644 --- a/.gitignore +++ b/.gitignore @@ -86,6 +86,7 @@ cw_monero/cw_monero/android/.cxx/ **/*.g.dart android/key.properties +android/app/key.jks **/tool/.secrets-prod.json **/tool/.secrets-test.json diff --git a/assets/images/digibyte.png b/assets/images/digibyte.png new file mode 100644 index 0000000000000000000000000000000000000000..0045c68521f06503057c4168c7ec3bc3a6b48905 GIT binary patch literal 6008 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 literal 0 HcmV?d00001 diff --git a/configure_cake_wallet.sh b/configure_cake_wallet.sh index 5009cd9e3..cc55e8fcc 100755 --- a/configure_cake_wallet.sh +++ b/configure_cake_wallet.sh @@ -30,6 +30,7 @@ cd cw_bitcoin && flutter pub get && flutter packages pub run build_runner build 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_solana && 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 diff --git a/cw_bitcoin/lib/bitcoin_wallet_service.dart b/cw_bitcoin/lib/bitcoin_wallet_service.dart index 2b8c489d2..38e769d15 100644 --- a/cw_bitcoin/lib/bitcoin_wallet_service.dart +++ b/cw_bitcoin/lib/bitcoin_wallet_service.dart @@ -1,7 +1,7 @@ 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/mnemonic_is_incorrect_exception.dart'; import 'package:cw_bitcoin/bitcoin_wallet_creation_credentials.dart'; import 'package:cw_core/unspent_coins_info.dart'; import 'package:cw_core/wallet_base.dart'; diff --git a/cw_bitcoin/lib/litecoin_wallet_service.dart b/cw_bitcoin/lib/litecoin_wallet_service.dart index 3d7462fa1..ee3b0e628 100644 --- a/cw_bitcoin/lib/litecoin_wallet_service.dart +++ b/cw_bitcoin/lib/litecoin_wallet_service.dart @@ -2,7 +2,7 @@ import 'dart:io'; 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/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'; @@ -101,7 +101,7 @@ class LitecoinWalletService extends WalletService< Future restoreFromSeed( BitcoinRestoreWalletFromSeedCredentials credentials, {bool? isTestnet}) async { if (!validateMnemonic(credentials.mnemonic)) { - throw BitcoinMnemonicIsIncorrectException(); + throw LitecoinMnemonicIsIncorrectException(); } final wallet = await LitecoinWalletBase.create( 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_core/lib/crypto_currency.dart b/cw_core/lib/crypto_currency.dart index 62c0ad437..67581ecb8 100644 --- a/cw_core/lib/crypto_currency.dart +++ b/cw_core/lib/crypto_currency.dart @@ -9,7 +9,8 @@ class CryptoCurrency extends EnumerableItem with Serializable implemen required this.decimals, this.fullName, this.iconPath, - this.tag, this.enabled = false, + this.tag, + this.enabled = false, }) : super(title: title, raw: raw); @@ -100,6 +101,7 @@ class CryptoCurrency extends EnumerableItem with Serializable implemen CryptoCurrency.usdtPoly, CryptoCurrency.usdcEPoly, CryptoCurrency.kaspa, + CryptoCurrency.digibyte, ]; static const havenCurrencies = [ @@ -211,8 +213,9 @@ class CryptoCurrency extends EnumerableItem with Serializable implemen static const banano = CryptoCurrency(title: 'BAN', fullName: 'Banano', raw: 86, name: 'banano', iconPath: 'assets/images/nano_icon.png', decimals: 29); static const usdtPoly = CryptoCurrency(title: 'USDT', tag: 'POLY', fullName: 'Tether USD (PoS)', raw: 87, name: 'usdtpoly', iconPath: 'assets/images/usdt_icon.png', decimals: 6); static const usdcEPoly = CryptoCurrency(title: 'USDC.E', tag: 'POLY', fullName: 'USD Coin (PoS)', raw: 88, name: 'usdcepoly', iconPath: 'assets/images/usdc_icon.png', decimals: 6); - static const kaspa = CryptoCurrency(title: 'KAS', fullName: 'Kaspa', raw: 89, name: 'kaspa', iconPath: 'assets/images/kaspa_icon.png', decimals: 8); - static const usdtSol = CryptoCurrency(title: 'USDT', tag: 'SOL', fullName: 'USDT Tether', raw: 90, name: 'usdtsol', iconPath: 'assets/images/usdt_icon.png', decimals: 6); + static const kaspa = CryptoCurrency(title: 'KAS', fullName: 'Kaspa', raw: 89, name: 'kas', iconPath: 'assets/images/kaspa_icon.png', decimals: 8); + static const digibyte = CryptoCurrency(title: 'DGB', fullName: 'DigiByte', raw: 90, name: 'dgb', iconPath: 'assets/images/digibyte.png', decimals: 8); + static const usdtSol = CryptoCurrency(title: 'USDT', tag: 'SOL', fullName: 'USDT Tether', raw: 90, name: 'usdtsol', iconPath: 'assets/images/usdt_icon.png', decimals: 6); static final Map _rawCurrencyMap = diff --git a/cw_evm/lib/evm_chain_wallet_addresses.dart b/cw_evm/lib/evm_chain_wallet_addresses.dart index d5d39f21d..4615d79ed 100644 --- a/cw_evm/lib/evm_chain_wallet_addresses.dart +++ b/cw_evm/lib/evm_chain_wallet_addresses.dart @@ -14,6 +14,7 @@ abstract class EVMChainWalletAddressesBase extends WalletAddresses with Store { super(walletInfo); @override + @observable String address; @override diff --git a/cw_monero/lib/monero_wallet.dart b/cw_monero/lib/monero_wallet.dart index 1a34e4bd6..ab4bfb0b0 100644 --- a/cw_monero/lib/monero_wallet.dart +++ b/cw_monero/lib/monero_wallet.dart @@ -576,15 +576,19 @@ abstract class MoneroWalletBase return; } - final height = _getHeightByDate(walletInfo.date); - - if (height > MIN_RESTORE_HEIGHT) { - monero_wallet.setRecoveringFromSeed(isRecovery: true); - monero_wallet.setRefreshFromBlockHeight(height: height); - return; + int height = 0; + try { + height = _getHeightByDate(walletInfo.date); + } catch (e, s) { + onError?.call(FlutterErrorDetails( + exception: e, + stack: s, + library: this.runtimeType.toString(), + )); } - throw Exception("height isn't > $MIN_RESTORE_HEIGHT!"); + monero_wallet.setRecoveringFromSeed(isRecovery: true); + monero_wallet.setRefreshFromBlockHeight(height: height); } int _getHeightDistance(DateTime date) { @@ -600,7 +604,7 @@ abstract class MoneroWalletBase final heightDistance = _getHeightDistance(date); if (nodeHeight <= 0) { - // the node returned 0 (an error state), so lets just restore from cache: + // the node returned 0 (an error state) throw Exception("nodeHeight is <= 0!"); } diff --git a/cw_solana/lib/default_spl_tokens.dart b/cw_solana/lib/default_spl_tokens.dart index f96d62d86..7acad78e0 100644 --- a/cw_solana/lib/default_spl_tokens.dart +++ b/cw_solana/lib/default_spl_tokens.dart @@ -25,7 +25,6 @@ class DefaultSPLTokens { mintAddress: '2FPyTwcZLUg1MDrwsyoP4D6s1tM7hAkHYRjkNb5w6Pxk', decimal: 6, mint: 'soEth', - enabled: true, iconPath: 'assets/images/eth_icon.png', ), SPLToken( @@ -34,7 +33,6 @@ class DefaultSPLTokens { mintAddress: 'So11111111111111111111111111111111111111112', decimal: 9, mint: 'WSOL', - enabled: true, iconPath: 'assets/images/sol_icon.png', ), SPLToken( diff --git a/cw_solana/lib/solana_wallet_service.dart b/cw_solana/lib/solana_wallet_service.dart index 8abf1ffbb..b3ff22e7e 100644 --- a/cw_solana/lib/solana_wallet_service.dart +++ b/cw_solana/lib/solana_wallet_service.dart @@ -19,7 +19,7 @@ class SolanaWalletService extends WalletService walletInfoSource; @override - Future create(SolanaNewWalletCredentials credentials) async { + Future create(SolanaNewWalletCredentials credentials, {bool? isTestnet}) async { final strength = credentials.seedPhraseLength == 24 ? 256 : 128; final mnemonic = bip39.generateMnemonic(strength: strength); @@ -67,7 +67,8 @@ class SolanaWalletService extends WalletService restoreFromKeys(SolanaRestoreWalletFromPrivateKey credentials) async { + Future restoreFromKeys(SolanaRestoreWalletFromPrivateKey credentials, + {bool? isTestnet}) async { final wallet = SolanaWallet( password: credentials.password!, privateKey: credentials.privateKey, @@ -82,7 +83,8 @@ class SolanaWalletService extends WalletService restoreFromSeed(SolanaRestoreWalletFromSeedCredentials credentials) async { + Future restoreFromSeed(SolanaRestoreWalletFromSeedCredentials credentials, + {bool? isTestnet}) async { if (!bip39.validateMnemonic(credentials.mnemonic)) { throw SolanaMnemonicIsIncorrectException(); } diff --git a/lib/bitcoin_cash/cw_bitcoin_cash.dart b/lib/bitcoin_cash/cw_bitcoin_cash.dart index 7dbb8614f..6e169209f 100644 --- a/lib/bitcoin_cash/cw_bitcoin_cash.dart +++ b/lib/bitcoin_cash/cw_bitcoin_cash.dart @@ -1,12 +1,6 @@ part of 'bitcoin_cash.dart'; class CWBitcoinCash extends BitcoinCash { - @override - String getMnemonic(int? strength) => Mnemonic.generate(); - - @override - Uint8List getSeedFromMnemonic(String seed) => Mnemonic.toSeed(seed); - @override String getCashAddrFormat(String address) => AddressUtils.getCashAddrFormat(address); diff --git a/lib/core/address_validator.dart b/lib/core/address_validator.dart index 432471655..853762a1c 100644 --- a/lib/core/address_validator.dart +++ b/lib/core/address_validator.dart @@ -257,9 +257,9 @@ class AddressValidator extends TextValidator { case CryptoCurrency.near: return [64]; case CryptoCurrency.btcln: - return null; + case CryptoCurrency.kaspa: default: - return []; + return null; } } diff --git a/lib/entities/provider_types.dart b/lib/entities/provider_types.dart index 9a4b68dd7..f9c2f1a82 100644 --- a/lib/entities/provider_types.dart +++ b/lib/entities/provider_types.dart @@ -66,7 +66,7 @@ class ProvidersHelper { case WalletType.bitcoinCash: return [ProviderType.askEachTime, ProviderType.onramper, ProviderType.robinhood]; case WalletType.polygon: - return [ProviderType.askEachTime, ProviderType.dfx]; + return [ProviderType.askEachTime, ProviderType.onramper, ProviderType.dfx]; case WalletType.solana: return [ProviderType.askEachTime, ProviderType.onramper, ProviderType.robinhood]; case WalletType.none: @@ -89,7 +89,7 @@ class ProvidersHelper { case WalletType.bitcoinCash: return [ProviderType.askEachTime, ProviderType.moonpaySell]; case WalletType.polygon: - return [ProviderType.askEachTime, ProviderType.dfx]; + return [ProviderType.askEachTime, ProviderType.onramper, ProviderType.dfx]; case WalletType.solana: return [ ProviderType.askEachTime, diff --git a/lib/src/screens/send/send_page.dart b/lib/src/screens/send/send_page.dart index 5870b1c4d..a3b7eaf85 100644 --- a/lib/src/screens/send/send_page.dart +++ b/lib/src/screens/send/send_page.dart @@ -1,6 +1,7 @@ import 'package:cake_wallet/core/auth_service.dart'; import 'package:cake_wallet/entities/fiat_currency.dart'; import 'package:cake_wallet/entities/template.dart'; +import 'package:cake_wallet/reactions/wallet_connect.dart'; import 'package:cake_wallet/src/screens/dashboard/widgets/sync_indicator_icon.dart'; import 'package:cake_wallet/src/screens/send/widgets/send_card.dart'; import 'package:cake_wallet/src/widgets/add_template_button.dart'; @@ -420,7 +421,9 @@ class SendPage extends BasePage { amount: S.of(_dialogContext).send_amount, amountValue: sendViewModel.pendingTransaction!.amountFormatted, fiatAmountValue: sendViewModel.pendingTransactionFiatAmountFormatted, - fee: S.of(_dialogContext).send_fee, + fee: isEVMCompatibleChain(sendViewModel.walletType) + ? S.of(_dialogContext).send_estimated_fee + : S.of(_dialogContext).send_fee, feeValue: sendViewModel.pendingTransaction!.feeFormatted, feeFiatAmount: sendViewModel.pendingTransactionFeeFiatAmountFormatted, outputs: sendViewModel.outputs, diff --git a/lib/src/widgets/scollable_with_bottom_section.dart b/lib/src/widgets/scollable_with_bottom_section.dart index 53f56716c..2487e6130 100644 --- a/lib/src/widgets/scollable_with_bottom_section.dart +++ b/lib/src/widgets/scollable_with_bottom_section.dart @@ -14,37 +14,28 @@ class ScrollableWithBottomSection extends StatefulWidget { final EdgeInsets? bottomSectionPadding; @override - ScrollableWithBottomSectionState createState() => - ScrollableWithBottomSectionState(); + ScrollableWithBottomSectionState createState() => ScrollableWithBottomSectionState(); } -class ScrollableWithBottomSectionState - extends State { +class ScrollableWithBottomSectionState extends State { @override Widget build(BuildContext context) { - return LayoutBuilder(builder: (context, constraints) { - return SingleChildScrollView( - // physics: - // const AlwaysScrollableScrollPhysics(), // const NeverScrollableScrollPhysics(), // - child: ConstrainedBox( - constraints: BoxConstraints( - minHeight: constraints.heightConstraints().maxHeight), - child: Column( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - Padding( - padding: widget.contentPadding ?? - EdgeInsets.only(left: 20, right: 20), - child: widget.content, - ), - Padding( - padding: widget.bottomSectionPadding ?? - EdgeInsets.only(bottom: 20, right: 20, left: 20), - child: widget.bottomSection) - ], + return Column( + children: [ + Expanded( + child: SingleChildScrollView( + child: Padding( + padding: widget.contentPadding ?? EdgeInsets.only(left: 20, right: 20), + child: widget.content, + ), ), ), - ); - }); + Padding( + padding: widget.bottomSectionPadding?.copyWith(top: 10) ?? + EdgeInsets.only(top: 10, bottom: 20, right: 20, left: 20), + child: widget.bottomSection, + ), + ], + ); } } diff --git a/lib/view_model/advanced_privacy_settings_view_model.dart b/lib/view_model/advanced_privacy_settings_view_model.dart index 14033b368..b78d831a4 100644 --- a/lib/view_model/advanced_privacy_settings_view_model.dart +++ b/lib/view_model/advanced_privacy_settings_view_model.dart @@ -27,11 +27,25 @@ abstract class AdvancedPrivacySettingsViewModelBase with Store { final SettingsStore _settingsStore; - bool get hasSeedPhraseLengthOption => - type == WalletType.bitcoinCash || - type == WalletType.ethereum || - type == WalletType.polygon || - type == WalletType.solana; + bool get hasSeedPhraseLengthOption { + // convert to switch case so that it give a syntax error when adding a new wallet type + // thus we don't forget about it + switch (type) { + case WalletType.ethereum: + case WalletType.bitcoinCash: + case WalletType.polygon: + case WalletType.solana: + return true; + case WalletType.monero: + case WalletType.none: + case WalletType.bitcoin: + case WalletType.litecoin: + case WalletType.haven: + case WalletType.nano: + case WalletType.banano: + return false; + } + } bool get hasSeedTypeOption => type == WalletType.monero; diff --git a/lib/view_model/unspent_coins/unspent_coins_list_view_model.dart b/lib/view_model/unspent_coins/unspent_coins_list_view_model.dart index 6380bb07e..3b90aff41 100644 --- a/lib/view_model/unspent_coins/unspent_coins_list_view_model.dart +++ b/lib/view_model/unspent_coins/unspent_coins_list_view_model.dart @@ -15,7 +15,8 @@ class UnspentCoinsListViewModel = UnspentCoinsListViewModelBase with _$UnspentCo abstract class UnspentCoinsListViewModelBase with Store { UnspentCoinsListViewModelBase( {required this.wallet, required Box unspentCoinsInfo}) - : _unspentCoinsInfo = unspentCoinsInfo { + : _unspentCoinsInfo = unspentCoinsInfo, + _items = ObservableList() { _updateUnspentCoinsInfo(); _updateUnspents(); } @@ -23,7 +24,8 @@ abstract class UnspentCoinsListViewModelBase with Store { WalletBase wallet; final Box _unspentCoinsInfo; - final ObservableList _items = ObservableList(); + @observable + ObservableList _items; @computed ObservableList get items => _items; diff --git a/lib/view_model/wallet_keys_view_model.dart b/lib/view_model/wallet_keys_view_model.dart index a84f1a4c4..d88316a04 100644 --- a/lib/view_model/wallet_keys_view_model.dart +++ b/lib/view_model/wallet_keys_view_model.dart @@ -20,9 +20,7 @@ abstract class WalletKeysViewModelBase with Store { WalletKeysViewModelBase(this._appStore) : title = _appStore.wallet!.type == WalletType.bitcoin || _appStore.wallet!.type == WalletType.litecoin || - _appStore.wallet!.type == WalletType.bitcoinCash || - _appStore.wallet!.type == WalletType.ethereum || - _appStore.wallet!.type == WalletType.polygon + _appStore.wallet!.type == WalletType.bitcoinCash ? S.current.wallet_seed : S.current.wallet_keys, _restoreHeight = _appStore.wallet!.walletInfo.restoreHeight, diff --git a/tool/configure.dart b/tool/configure.dart index 3a69e86b1..fb1647e13 100644 --- a/tool/configure.dart +++ b/tool/configure.dart @@ -729,10 +729,6 @@ import 'package:cw_bitcoin/bitcoin_transaction_priority.dart'; const bitcoinCashCwPart = "part 'cw_bitcoin_cash.dart';"; const bitcoinCashContent = """ abstract class BitcoinCash { - String getMnemonic(int? strength); - - Uint8List getSeedFromMnemonic(String seed); - String getCashAddrFormat(String address); WalletService createBitcoinCashWalletService( From 52983edf91fb9b11fe9ed1ac69fbe40301d10ece Mon Sep 17 00:00:00 2001 From: tuxsudo Date: Fri, 23 Feb 2024 13:49:21 -0500 Subject: [PATCH 4/9] UI Enhancements (#1308) * Center the account text on the balance page * Modify dashboard button spacing * Change default theme to Dark theme * Remove legacyTheme and modify theme order * Add condition for Monero.com wallet default theme * Add locale for new themes * Add new themes * Update padding * Fixes * Update settings_store.dart --------- Co-authored-by: Omar Hatem --- lib/core/backup_service.dart | 2 -- lib/entities/preferences_key.dart | 1 - lib/palette.dart | 3 +++ lib/src/screens/dashboard/dashboard_page.dart | 2 +- .../widgets/home_screen_account_widget.dart | 4 ++-- lib/store/settings_store.dart | 11 +++------ lib/themes/monero_dark_theme.dart | 4 ++-- lib/themes/purple_dark_theme.dart | 13 +++++++++++ lib/themes/red_dark_theme.dart | 13 +++++++++++ lib/themes/red_light_theme.dart | 13 +++++++++++ lib/themes/theme_list.dart | 23 +++++++++++++++---- res/values/strings_ar.arb | 3 +++ res/values/strings_bg.arb | 3 +++ res/values/strings_cs.arb | 3 +++ res/values/strings_de.arb | 3 +++ res/values/strings_en.arb | 3 +++ res/values/strings_es.arb | 3 +++ res/values/strings_fr.arb | 3 +++ res/values/strings_ha.arb | 3 +++ res/values/strings_hi.arb | 3 +++ res/values/strings_hr.arb | 3 +++ res/values/strings_id.arb | 3 +++ res/values/strings_it.arb | 3 +++ res/values/strings_ja.arb | 3 +++ res/values/strings_ko.arb | 3 +++ res/values/strings_my.arb | 3 +++ res/values/strings_nl.arb | 3 +++ res/values/strings_pl.arb | 3 +++ res/values/strings_pt.arb | 3 +++ res/values/strings_ru.arb | 3 +++ res/values/strings_th.arb | 3 +++ res/values/strings_tl.arb | 3 +++ res/values/strings_tr.arb | 3 +++ res/values/strings_uk.arb | 3 +++ res/values/strings_ur.arb | 3 +++ res/values/strings_yo.arb | 3 +++ res/values/strings_zh.arb | 3 +++ 37 files changed, 147 insertions(+), 20 deletions(-) create mode 100644 lib/themes/purple_dark_theme.dart create mode 100644 lib/themes/red_dark_theme.dart create mode 100644 lib/themes/red_light_theme.dart diff --git a/lib/core/backup_service.dart b/lib/core/backup_service.dart index 9b5c4c8db..2ec5f293d 100644 --- a/lib/core/backup_service.dart +++ b/lib/core/backup_service.dart @@ -464,8 +464,6 @@ class BackupService { PreferencesKey.disableSellKey: _sharedPreferences.getBool(PreferencesKey.disableSellKey), PreferencesKey.defaultBuyProvider: _sharedPreferences.getInt(PreferencesKey.defaultBuyProvider), - PreferencesKey.isDarkThemeLegacy: - _sharedPreferences.getBool(PreferencesKey.isDarkThemeLegacy), PreferencesKey.currentPinLength: _sharedPreferences.getInt(PreferencesKey.currentPinLength), PreferencesKey.currentTransactionPriorityKeyLegacy: _sharedPreferences.getInt(PreferencesKey.currentTransactionPriorityKeyLegacy), diff --git a/lib/entities/preferences_key.dart b/lib/entities/preferences_key.dart index 7adb2df7f..0dd251aaa 100644 --- a/lib/entities/preferences_key.dart +++ b/lib/entities/preferences_key.dart @@ -28,7 +28,6 @@ class PreferencesKey { static const disableExchangeKey = 'disable_exchange'; static const exchangeStatusKey = 'exchange_status'; static const currentTheme = 'current_theme'; - static const isDarkThemeLegacy = 'dark_theme'; static const displayActionListModeKey = 'display_list_mode'; static const currentPinLength = 'current_pin_length'; static const currentLanguageCode = 'language_code'; diff --git a/lib/palette.dart b/lib/palette.dart index 46e2e8165..58cbeb697 100644 --- a/lib/palette.dart +++ b/lib/palette.dart @@ -23,6 +23,7 @@ class Palette { static const Color cornflower = Color.fromRGBO(85, 147, 240, 1.0); static const Color royalBlue = Color.fromRGBO(43, 114, 221, 1.0); static const Color lightRed = Color.fromRGBO(227, 87, 87, 1.0); + static const Color veryLightRed = Color.fromRGBO(239, 156, 156, 1.0); static const Color persianRed = Color.fromRGBO(206, 55, 55, 1.0); static const Color blueCraiola = Color.fromRGBO(69, 110, 255, 1.0); static const Color blueGreyCraiola = Color.fromRGBO(106, 177, 207, 1.0); @@ -97,4 +98,6 @@ class PaletteDark { static const Color matrixGreen = Color.fromRGBO(18, 229, 90, 1.0); static const Color moneroOrange = Color.fromRGBO(255, 102, 0, 1.0); static const Color moneroCard = Color.fromRGBO(20, 21, 24, 1.0); + static const Color red = Color.fromRGBO(190, 30, 30, 1.0); + static const Color darkPurple = Color.fromRGBO(117, 36, 204, 1.0); } diff --git a/lib/src/screens/dashboard/dashboard_page.dart b/lib/src/screens/dashboard/dashboard_page.dart index 356c69c00..61e7d6176 100644 --- a/lib/src/screens/dashboard/dashboard_page.dart +++ b/lib/src/screens/dashboard/dashboard_page.dart @@ -224,7 +224,7 @@ class _DashboardPageView extends BasePage { .syncedBackgroundColor, ), child: Container( - padding: EdgeInsets.only(left: 32, right: 32), + padding: EdgeInsets.only(left: 24, right: 32), child: Row( mainAxisAlignment: MainAxisAlignment.spaceBetween, children: MainActions.all diff --git a/lib/src/screens/dashboard/widgets/home_screen_account_widget.dart b/lib/src/screens/dashboard/widgets/home_screen_account_widget.dart index fa036978f..df20c5c43 100644 --- a/lib/src/screens/dashboard/widgets/home_screen_account_widget.dart +++ b/lib/src/screens/dashboard/widgets/home_screen_account_widget.dart @@ -19,8 +19,8 @@ class HomeScreenAccountWidget extends StatelessWidget { builder: (_) => getIt.get()); }, behavior: HitTestBehavior.opaque, - child: Container( - height: 100.0, + child: Padding( + padding: EdgeInsets.only(top: 25, bottom: 25, left: 25, right: 0), child: Row( mainAxisSize: MainAxisSize.min, mainAxisAlignment: MainAxisAlignment.center, diff --git a/lib/store/settings_store.dart b/lib/store/settings_store.dart index 672b29269..488049656 100644 --- a/lib/store/settings_store.dart +++ b/lib/store/settings_store.dart @@ -19,6 +19,7 @@ import 'package:cake_wallet/exchange/provider/trocador_exchange_provider.dart'; import 'package:cake_wallet/view_model/settings/sync_mode.dart'; import 'package:cake_wallet/utils/device_info.dart'; import 'package:cake_wallet/ethereum/ethereum.dart'; +import 'package:cake_wallet/wallet_type_utils.dart'; import 'package:cw_core/transaction_priority.dart'; import 'package:cake_wallet/themes/theme_base.dart'; import 'package:cake_wallet/themes/theme_list.dart'; @@ -789,12 +790,9 @@ abstract class SettingsStoreBase with Store { final exchangeStatus = ExchangeApiMode.deserialize( raw: sharedPreferences.getInt(PreferencesKey.exchangeStatusKey) ?? ExchangeApiMode.enabled.raw); - final legacyTheme = (sharedPreferences.getBool(PreferencesKey.isDarkThemeLegacy) ?? false) - ? ThemeType.dark.index - : ThemeType.bright.index; final savedTheme = initialTheme ?? ThemeList.deserialize( - raw: sharedPreferences.getInt(PreferencesKey.currentTheme) ?? legacyTheme); + raw: sharedPreferences.getInt(PreferencesKey.currentTheme) ?? (isMoneroOnly ? ThemeList.moneroDarkTheme.raw : ThemeList.darkTheme.raw)); final actionListDisplayMode = ObservableList(); actionListDisplayMode.addAll(deserializeActionlistDisplayModes( sharedPreferences.getInt(PreferencesKey.displayActionListModeKey) ?? defaultActionsMode)); @@ -1152,11 +1150,8 @@ abstract class SettingsStoreBase with Store { exchangeStatus = ExchangeApiMode.deserialize( raw: sharedPreferences.getInt(PreferencesKey.exchangeStatusKey) ?? ExchangeApiMode.enabled.raw); - final legacyTheme = (sharedPreferences.getBool(PreferencesKey.isDarkThemeLegacy) ?? false) - ? ThemeType.dark.index - : ThemeType.bright.index; currentTheme = ThemeList.deserialize( - raw: sharedPreferences.getInt(PreferencesKey.currentTheme) ?? legacyTheme); + raw: sharedPreferences.getInt(PreferencesKey.currentTheme) ?? (isMoneroOnly ? ThemeList.moneroDarkTheme.raw : ThemeList.darkTheme.raw)); actionlistDisplayMode = ObservableList(); actionlistDisplayMode.addAll(deserializeActionlistDisplayModes( sharedPreferences.getInt(PreferencesKey.displayActionListModeKey) ?? defaultActionsMode)); diff --git a/lib/themes/monero_dark_theme.dart b/lib/themes/monero_dark_theme.dart index 4931d74bc..1478ba8c5 100644 --- a/lib/themes/monero_dark_theme.dart +++ b/lib/themes/monero_dark_theme.dart @@ -95,12 +95,12 @@ class MoneroDarkTheme extends DarkTheme { @override CakeMenuTheme get menuTheme => super.menuTheme.copyWith( - headerFirstGradientColor: containerColor, + headerFirstGradientColor: primaryColor, headerSecondGradientColor: containerColor, backgroundColor: containerColor, subnameTextColor: Colors.grey, dividerColor: colorScheme.secondaryContainer, - iconColor: colorScheme.secondaryContainer, + iconColor: Colors.white, settingActionsIconColor: colorScheme.secondaryContainer); @override diff --git a/lib/themes/purple_dark_theme.dart b/lib/themes/purple_dark_theme.dart new file mode 100644 index 000000000..04365de38 --- /dev/null +++ b/lib/themes/purple_dark_theme.dart @@ -0,0 +1,13 @@ +import 'package:cake_wallet/generated/i18n.dart'; +import 'package:cake_wallet/palette.dart'; +import 'package:cake_wallet/themes/monero_dark_theme.dart'; +import 'package:flutter/material.dart'; + +class PurpleDarkTheme extends MoneroDarkTheme { + PurpleDarkTheme({required int raw}) : super(raw: raw); + + @override + String get title => S.current.purple_dark_theme; + @override + Color get primaryColor => PaletteDark.darkPurple; +} \ No newline at end of file diff --git a/lib/themes/red_dark_theme.dart b/lib/themes/red_dark_theme.dart new file mode 100644 index 000000000..d378fc66a --- /dev/null +++ b/lib/themes/red_dark_theme.dart @@ -0,0 +1,13 @@ +import 'package:cake_wallet/generated/i18n.dart'; +import 'package:cake_wallet/palette.dart'; +import 'package:cake_wallet/themes/monero_dark_theme.dart'; +import 'package:flutter/material.dart'; + +class RedDarkTheme extends MoneroDarkTheme { + RedDarkTheme({required int raw}) : super(raw: raw); + + @override + String get title => S.current.red_dark_theme; + @override + Color get primaryColor => PaletteDark.red; +} \ No newline at end of file diff --git a/lib/themes/red_light_theme.dart b/lib/themes/red_light_theme.dart new file mode 100644 index 000000000..47a995a11 --- /dev/null +++ b/lib/themes/red_light_theme.dart @@ -0,0 +1,13 @@ +import 'package:cake_wallet/generated/i18n.dart'; +import 'package:cake_wallet/palette.dart'; +import 'package:cake_wallet/themes/monero_light_theme.dart'; +import 'package:flutter/material.dart'; + +class RedLightTheme extends MoneroLightTheme { + RedLightTheme({required int raw}) : super(raw: raw); + + @override + String get title => S.current.red_light_theme; + @override + Color get primaryColor => Palette.darkRed; +} \ No newline at end of file diff --git a/lib/themes/theme_list.dart b/lib/themes/theme_list.dart index cb65dc2b9..6d0d44225 100644 --- a/lib/themes/theme_list.dart +++ b/lib/themes/theme_list.dart @@ -3,23 +3,29 @@ import 'package:cake_wallet/themes/dark_theme.dart'; import 'package:cake_wallet/themes/light_theme.dart'; import 'package:cake_wallet/themes/monero_light_theme.dart'; import 'package:cake_wallet/themes/monero_dark_theme.dart'; +import 'package:cake_wallet/themes/purple_dark_theme.dart'; import 'package:cake_wallet/themes/matrix_green_theme.dart'; import 'package:cake_wallet/themes/bitcoin_dark_theme.dart'; import 'package:cake_wallet/themes/bitcoin_light_theme.dart'; import 'package:cake_wallet/themes/high_contrast_theme.dart'; +import 'package:cake_wallet/themes/red_dark_theme.dart'; +import 'package:cake_wallet/themes/red_light_theme.dart'; import 'package:cake_wallet/themes/theme_base.dart'; class ThemeList { static final all = [ - brightTheme, - lightTheme, darkTheme, + lightTheme, + brightTheme, moneroDarkTheme, moneroLightTheme, - matrixGreenTheme, + purpleDarkTheme, bitcoinDarkTheme, bitcoinLightTheme, - highContrastTheme + matrixGreenTheme, + redDarkTheme, + redLightTheme, + highContrastTheme, ]; static final lightTheme = LightTheme(raw: 0); @@ -31,6 +37,9 @@ class ThemeList { static final bitcoinDarkTheme = BitcoinDarkTheme(raw: 6); static final bitcoinLightTheme = BitcoinLightTheme(raw: 7); static final highContrastTheme = HighContrastTheme(raw: 8); + static final redLightTheme = RedLightTheme(raw: 9); + static final redDarkTheme = RedDarkTheme(raw: 10); + static final purpleDarkTheme = PurpleDarkTheme(raw: 11); static ThemeBase deserialize({required int raw}) { switch (raw) { @@ -52,6 +61,12 @@ class ThemeList { return bitcoinLightTheme; case 8: return highContrastTheme; + case 9: + return redLightTheme; + case 10: + return redDarkTheme; + case 11: + return purpleDarkTheme; default: throw Exception( 'Unexpected token raw: $raw for deserialization of ThemeBase'); diff --git a/res/values/strings_ar.arb b/res/values/strings_ar.arb index 9e822c474..af92cead4 100644 --- a/res/values/strings_ar.arb +++ b/res/values/strings_ar.arb @@ -429,6 +429,7 @@ "provider_error": "خطأ ${provider}", "public_key": "مفتاح عمومي", "purchase_gift_card": "شراء بطاقة هدايا", + "purple_dark_theme": "موضوع الظلام الأرجواني", "qr_fullscreen": "انقر لفتح ال QR بملء الشاشة", "qr_payment_amount": "يحتوي هذا ال QR على مبلغ الدفع. هل تريد تغير المبلغ فوق القيمة الحالية؟", "question_to_disable_2fa": "هل أنت متأكد أنك تريد تعطيل Cake 2FA؟ لن تكون هناك حاجة إلى رمز 2FA للوصول إلى المحفظة ووظائف معينة.", @@ -440,6 +441,8 @@ "reconnect": "أعد الاتصال", "reconnect_alert_text": "هل أنت متأكد من رغبتك في إعادة الاتصال؟", "reconnection": "إعادة الاتصال", + "red_dark_theme": "موضوع الظلام الأحمر", + "red_light_theme": "موضوع الضوء الأحمر", "redeemed": "استردت", "refund_address": "عنوان إعادة المال", "reject": "ﺾﻓﺮﻳ", diff --git a/res/values/strings_bg.arb b/res/values/strings_bg.arb index 8dbdf4b98..9f46ac2f4 100644 --- a/res/values/strings_bg.arb +++ b/res/values/strings_bg.arb @@ -429,6 +429,7 @@ "provider_error": "Грешка на ${provider} ", "public_key": "Публичен ключ", "purchase_gift_card": "Купуване на Gift Card", + "purple_dark_theme": "Лилава тъмна тема", "qr_fullscreen": "Натиснете, за да отворите QR кода на цял екран", "qr_payment_amount": "Този QR код съдържа сума за плащане. Искате ли да промените стойността?", "question_to_disable_2fa": "Сигурни ли сте, че искате да деактивирате Cake 2FA? Вече няма да е необходим 2FA код за достъп до портфейла и определени функции.", @@ -440,6 +441,8 @@ "reconnect": "Reconnect", "reconnect_alert_text": "Сигурни ли сте, че искате да се свържете отново?", "reconnection": "Свързване отново", + "red_dark_theme": "Червена тъмна тема", + "red_light_theme": "Тема на червената светлина", "redeemed": "Използвани", "refund_address": "Refund address", "reject": "Отхвърляне", diff --git a/res/values/strings_cs.arb b/res/values/strings_cs.arb index ff22febf0..f47c7acb3 100644 --- a/res/values/strings_cs.arb +++ b/res/values/strings_cs.arb @@ -429,6 +429,7 @@ "provider_error": "${provider} chyba", "public_key": "Veřejný klíč", "purchase_gift_card": "Objednat dárkovou kartu", + "purple_dark_theme": "Fialové temné téma", "qr_fullscreen": "Poklepáním otevřete QR kód na celé obrazovce", "qr_payment_amount": "Tento QR kód obsahuje i částku. Chcete přepsat současnou hodnotu?", "question_to_disable_2fa": "Opravdu chcete deaktivovat Cake 2FA? Pro přístup k peněžence a některým funkcím již nebude potřeba kód 2FA.", @@ -440,6 +441,8 @@ "reconnect": "Znovu připojit", "reconnect_alert_text": "Opravdu se chcete znovu připojit?", "reconnection": "Znovu připojit", + "red_dark_theme": "Červené temné téma", + "red_light_theme": "Téma červeného světla", "redeemed": "Uplatněné", "refund_address": "Adresa pro vrácení", "reject": "Odmítnout", diff --git a/res/values/strings_de.arb b/res/values/strings_de.arb index 366d7d529..338112022 100644 --- a/res/values/strings_de.arb +++ b/res/values/strings_de.arb @@ -430,6 +430,7 @@ "provider_error": "${provider}-Fehler", "public_key": "Öffentlicher Schlüssel", "purchase_gift_card": "Geschenkkarte kaufen", + "purple_dark_theme": "Lila dunkle Thema", "qr_fullscreen": "Tippen Sie hier, um den QR-Code im Vollbildmodus zu öffnen", "qr_payment_amount": "This QR code contains a payment amount. Do you want to overwrite the current value?", "question_to_disable_2fa": "Sind Sie sicher, dass Sie Cake 2FA deaktivieren möchten? Für den Zugriff auf die Wallet und bestimmte Funktionen wird kein 2FA-Code mehr benötigt.", @@ -441,6 +442,8 @@ "reconnect": "Erneut verbinden", "reconnect_alert_text": "Sind Sie sicher, dass Sie sich neu verbinden möchten?", "reconnection": "Neu verbinden", + "red_dark_theme": "Red Dark Thema", + "red_light_theme": "Rotlichtthema", "redeemed": "Versilbert", "refund_address": "Rückerstattungsadresse", "reject": "Ablehnen", diff --git a/res/values/strings_en.arb b/res/values/strings_en.arb index b7aecc739..86dc6518d 100644 --- a/res/values/strings_en.arb +++ b/res/values/strings_en.arb @@ -429,6 +429,7 @@ "provider_error": "${provider} error", "public_key": "Public key", "purchase_gift_card": "Purchase Gift Card", + "purple_dark_theme": "Purple Dark Theme", "qr_fullscreen": "Tap to open full screen QR code", "qr_payment_amount": "This QR code contains a payment amount. Do you want to overwrite the current value?", "question_to_disable_2fa": "Are you sure that you want to disable Cake 2FA? A 2FA code will no longer be needed to access the wallet and certain functions.", @@ -440,6 +441,8 @@ "reconnect": "Reconnect", "reconnect_alert_text": "Are you sure you want to reconnect?", "reconnection": "Reconnection", + "red_dark_theme": "Red Dark Theme", + "red_light_theme": "Red Light Theme", "redeemed": "Redeemed", "refund_address": "Refund address", "reject": "Reject", diff --git a/res/values/strings_es.arb b/res/values/strings_es.arb index 832d66b5b..09ad9948b 100644 --- a/res/values/strings_es.arb +++ b/res/values/strings_es.arb @@ -430,6 +430,7 @@ "provider_error": "${provider} error", "public_key": "Clave pública", "purchase_gift_card": "Comprar tarjeta de regalo", + "purple_dark_theme": "Tema morado oscuro", "qr_fullscreen": "Toque para abrir el código QR en pantalla completa", "qr_payment_amount": "This QR code contains a payment amount. Do you want to overwrite the current value?", "question_to_disable_2fa": "¿Está seguro de que desea deshabilitar Cake 2FA? Ya no se necesitará un código 2FA para acceder a la billetera y a ciertas funciones.", @@ -441,6 +442,8 @@ "reconnect": "Volver a conectar", "reconnect_alert_text": "¿Estás seguro de reconectar?", "reconnection": "Reconexión", + "red_dark_theme": "Tema rojo oscuro", + "red_light_theme": "Tema de la luz roja", "redeemed": "Redimido", "refund_address": "Dirección de reembolso", "reject": "Rechazar", diff --git a/res/values/strings_fr.arb b/res/values/strings_fr.arb index 80b44d6ed..e552dfafa 100644 --- a/res/values/strings_fr.arb +++ b/res/values/strings_fr.arb @@ -429,6 +429,7 @@ "provider_error": "Erreur de ${provider}", "public_key": "Clef publique", "purchase_gift_card": "Acheter une carte-cadeau", + "purple_dark_theme": "THÈME PURPLE DARK", "qr_fullscreen": "Appuyez pour ouvrir le QR code en mode plein écran", "qr_payment_amount": "Ce QR code contient un montant de paiement. Voulez-vous remplacer la valeur actuelle ?", "question_to_disable_2fa": "Êtes-vous sûr de vouloir désactiver Cake 2FA ? Un code 2FA ne sera plus nécessaire pour accéder au portefeuille (wallet) et à certaines fonctions.", @@ -440,6 +441,8 @@ "reconnect": "Reconnecter", "reconnect_alert_text": "Êtes vous certain de vouloir vous reconnecter ?", "reconnection": "Reconnexion", + "red_dark_theme": "Thème rouge noir", + "red_light_theme": "Thème de la lumière rouge", "redeemed": "Converties", "refund_address": "Adresse de Remboursement", "reject": "Rejeter", diff --git a/res/values/strings_ha.arb b/res/values/strings_ha.arb index e1b77f346..40f6b2857 100644 --- a/res/values/strings_ha.arb +++ b/res/values/strings_ha.arb @@ -431,6 +431,7 @@ "provider_error": "${provider} kuskure", "public_key": "Maɓallin jama'a", "purchase_gift_card": "Katin Kyautar Sayi", + "purple_dark_theme": "M duhu jigo", "qr_fullscreen": "Matsa don buɗe lambar QR na cikakken allo", "qr_payment_amount": "Wannan QR code yana da adadin kuɗi. Kuna so ku overwrite wannan adadi?", "question_to_disable_2fa": "Ka tabbata cewa kana son kashe cake 2fa? Ba za a sake buƙatar lambar 2FA ba don samun damar yin walat da takamaiman ayyuka.", @@ -442,6 +443,8 @@ "reconnect": "Sake haɗawa", "reconnect_alert_text": "Shin kun tabbata kuna son sake haɗawa?", "reconnection": "Sake haɗawa", + "red_dark_theme": "Ja duhu taken", + "red_light_theme": "Ja mai haske", "redeemed": "An fanshi", "refund_address": "Adireshin maidowa", "reject": "Ƙi", diff --git a/res/values/strings_hi.arb b/res/values/strings_hi.arb index 555e1fff5..d35a5f716 100644 --- a/res/values/strings_hi.arb +++ b/res/values/strings_hi.arb @@ -430,6 +430,7 @@ "provider_error": "${provider} त्रुटि", "public_key": "सार्वजनिक कुंजी", "purchase_gift_card": "गिफ्ट कार्ड खरीदें", + "purple_dark_theme": "पर्पल डार्क थीम", "qr_fullscreen": "फ़ुल स्क्रीन क्यूआर कोड खोलने के लिए टैप करें", "qr_payment_amount": "This QR code contains a payment amount. Do you want to overwrite the current value?", "question_to_disable_2fa": "क्या आप सुनिश्चित हैं कि आप Cake 2FA को अक्षम करना चाहते हैं? वॉलेट और कुछ कार्यों तक पहुँचने के लिए अब 2FA कोड की आवश्यकता नहीं होगी।", @@ -442,6 +443,8 @@ "reconnect": "रिकनेक्ट", "reconnect_alert_text": "क्या आप पुन: कनेक्ट होना सुनिश्चित करते हैं?", "reconnection": "पुनर्संयोजन", + "red_dark_theme": "लाल डार्क थीम", + "red_light_theme": "लाल प्रकाश थीम", "redeemed": "रिडीम किया गया", "refund_address": "वापसी का पता", "reject": "अस्वीकार करना", diff --git a/res/values/strings_hr.arb b/res/values/strings_hr.arb index 2b2e80198..c41310441 100644 --- a/res/values/strings_hr.arb +++ b/res/values/strings_hr.arb @@ -429,6 +429,7 @@ "provider_error": "${provider} greška", "public_key": "Javni ključ", "purchase_gift_card": "Kupnja darovne kartice", + "purple_dark_theme": "Ljubičasta tamna tema", "qr_fullscreen": "Dodirnite za otvaranje QR koda preko cijelog zaslona", "qr_payment_amount": "This QR code contains a payment amount. Do you want to overwrite the current value?", "question_to_disable_2fa": "Jeste li sigurni da želite onemogućiti Cake 2FA? 2FA kod više neće biti potreban za pristup novčaniku i određenim funkcijama.", @@ -440,6 +441,8 @@ "reconnect": "Ponovno povezivanje", "reconnect_alert_text": "Jeste li sigurni da se želite ponovno povezati?", "reconnection": "Ponovno povezivanje", + "red_dark_theme": "Crvena tamna tema", + "red_light_theme": "Tema crvenog svjetla", "redeemed": "otkupljeno", "refund_address": "Adresa za povrat", "reject": "Odbiti", diff --git a/res/values/strings_id.arb b/res/values/strings_id.arb index 10af2f8ad..a9b9b50b3 100644 --- a/res/values/strings_id.arb +++ b/res/values/strings_id.arb @@ -431,6 +431,7 @@ "provider_error": "${provider} error", "public_key": "Kunci publik", "purchase_gift_card": "Beli Kartu Hadiah", + "purple_dark_theme": "Tema gelap ungu", "qr_fullscreen": "Tap untuk membuka layar QR code penuh", "qr_payment_amount": "QR code ini berisi jumlah pembayaran. Apakah Anda ingin menimpa nilai saat ini?", "question_to_disable_2fa": "Apakah Anda yakin ingin menonaktifkan Cake 2FA? Kode 2FA tidak lagi diperlukan untuk mengakses dompet dan fungsi tertentu.", @@ -442,6 +443,8 @@ "reconnect": "Sambungkan kembali", "reconnect_alert_text": "Apakah Anda yakin ingin menyambungkan kembali?", "reconnection": "Koneksi kembali", + "red_dark_theme": "Tema gelap merah", + "red_light_theme": "Tema lampu merah", "redeemed": "Ditukar", "refund_address": "Alamat pengembalian", "reject": "Menolak", diff --git a/res/values/strings_it.arb b/res/values/strings_it.arb index a4093f077..9682e3d7f 100644 --- a/res/values/strings_it.arb +++ b/res/values/strings_it.arb @@ -431,6 +431,7 @@ "provider_error": "${provider} errore", "public_key": "Chiave pubblica", "purchase_gift_card": "Acquista carta regalo", + "purple_dark_theme": "Tema oscuro viola", "qr_fullscreen": "Tocca per aprire il codice QR a schermo intero", "qr_payment_amount": "Questo codice QR contiene l'ammontare del pagamento. Vuoi sovrascrivere il varlore attuale?", "question_to_disable_2fa": "Sei sicuro di voler disabilitare Cake 2FA? Non sarà più necessario un codice 2FA per accedere al portafoglio e ad alcune funzioni.", @@ -442,6 +443,8 @@ "reconnect": "Riconnetti", "reconnect_alert_text": "Sei sicuro di volerti riconnettere?", "reconnection": "Riconnessione", + "red_dark_theme": "Red Dark Theme", + "red_light_theme": "Tema della luce rossa", "redeemed": "Redento", "refund_address": "Indirizzo di rimborso", "reject": "Rifiutare", diff --git a/res/values/strings_ja.arb b/res/values/strings_ja.arb index 001e26f65..0f4513eee 100644 --- a/res/values/strings_ja.arb +++ b/res/values/strings_ja.arb @@ -430,6 +430,7 @@ "provider_error": "${provider} エラー", "public_key": "公開鍵", "purchase_gift_card": "ギフトカードを購入", + "purple_dark_theme": "紫色の暗いテーマ", "qr_fullscreen": "タップして全画面QRコードを開く", "qr_payment_amount": "This QR code contains a payment amount. Do you want to overwrite the current value?", "question_to_disable_2fa": "Cake 2FA を無効にしてもよろしいですか?ウォレットと特定の機能にアクセスするために 2FA コードは必要なくなります。", @@ -441,6 +442,8 @@ "reconnect": "再接続", "reconnect_alert_text": "再接続しますか?", "reconnection": "再接続", + "red_dark_theme": "赤い暗いテーマ", + "red_light_theme": "赤色光のテーマ", "redeemed": "償還", "refund_address": "払い戻し住所", "reject": "拒否する", diff --git a/res/values/strings_ko.arb b/res/values/strings_ko.arb index bc708b246..2dd80cf5c 100644 --- a/res/values/strings_ko.arb +++ b/res/values/strings_ko.arb @@ -430,6 +430,7 @@ "provider_error": "${provider} 오류", "public_key": "공개 키", "purchase_gift_card": "기프트 카드 구매", + "purple_dark_theme": "보라색 어두운 테마", "qr_fullscreen": "전체 화면 QR 코드를 열려면 탭하세요.", "qr_payment_amount": "This QR code contains a payment amount. Do you want to overwrite the current value?", "question_to_disable_2fa": "Cake 2FA를 비활성화하시겠습니까? 지갑 및 특정 기능에 액세스하는 데 더 이상 2FA 코드가 필요하지 않습니다.", @@ -441,6 +442,8 @@ "reconnect": "다시 연결", "reconnect_alert_text": "다시 연결 하시겠습니까?", "reconnection": "재 연결", + "red_dark_theme": "빨간 어두운 테마", + "red_light_theme": "빨간불 테마", "redeemed": "구함", "refund_address": "환불 주소", "reject": "거부하다", diff --git a/res/values/strings_my.arb b/res/values/strings_my.arb index 56cdb802d..1bc338dcb 100644 --- a/res/values/strings_my.arb +++ b/res/values/strings_my.arb @@ -429,6 +429,7 @@ "provider_error": "${provider} အမှား", "public_key": "အများသူငှာသော့", "purchase_gift_card": "လက်ဆောင်ကတ်ဝယ်ပါ။", + "purple_dark_theme": "ခရမ်းရောင် Drwing Theme", "qr_fullscreen": "မျက်နှာပြင်အပြည့် QR ကုဒ်ကိုဖွင့်ရန် တို့ပါ။", "qr_payment_amount": "ဤ QR ကုဒ်တွင် ငွေပေးချေမှုပမာဏတစ်ခုပါရှိသည်။ လက်ရှိတန်ဖိုးကို ထပ်ရေးလိုပါသလား။", "question_to_disable_2fa": "Cake 2FA ကို ပိတ်လိုသည်မှာ သေချာပါသလား။ ပိုက်ဆံအိတ်နှင့် အချို့သောလုပ်ဆောင်ချက်များကို အသုံးပြုရန်အတွက် 2FA ကုဒ်တစ်ခု မလိုအပ်တော့ပါ။", @@ -440,6 +441,8 @@ "reconnect": "ပြန်လည်ချိတ်ဆက်ပါ။", "reconnect_alert_text": "ပြန်လည်ချိတ်ဆက်လိုသည်မှာ သေချာပါသလား။ ?", "reconnection": "ပြန်လည်ချိတ်ဆက်မှု", + "red_dark_theme": "အနီရောင်မှောင်မိုက်ဆောင်ပုဒ်", + "red_light_theme": "အနီရောင်အလင်းအကြောင်းအရာ", "redeemed": "ရွေးနှုတ်ခဲ့သည်။", "refund_address": "ပြန်အမ်းငွေလိပ်စာ", "reject": "ငြင်းပယ်ပါ။", diff --git a/res/values/strings_nl.arb b/res/values/strings_nl.arb index 3ab2c06e6..56175b636 100644 --- a/res/values/strings_nl.arb +++ b/res/values/strings_nl.arb @@ -429,6 +429,7 @@ "provider_error": "${provider} fout", "public_key": "Publieke sleutel", "purchase_gift_card": "Cadeaubon kopen", + "purple_dark_theme": "Paars donker thema", "qr_fullscreen": "Tik om de QR-code op volledig scherm te openen", "qr_payment_amount": "This QR code contains a payment amount. Do you want to overwrite the current value?", "question_to_disable_2fa": "Weet je zeker dat je Cake 2FA wilt uitschakelen? Er is geen 2FA-code meer nodig om toegang te krijgen tot de portemonnee en bepaalde functies.", @@ -440,6 +441,8 @@ "reconnect": "Sluit", "reconnect_alert_text": "Weet u zeker dat u opnieuw verbinding wilt maken?", "reconnection": "Reconnection", + "red_dark_theme": "Rood donker thema", + "red_light_theme": "Rood licht thema", "redeemed": "Verzilverd", "refund_address": "Adres voor terugbetaling", "reject": "Afwijzen", diff --git a/res/values/strings_pl.arb b/res/values/strings_pl.arb index 855fb6680..cbd1a7171 100644 --- a/res/values/strings_pl.arb +++ b/res/values/strings_pl.arb @@ -429,6 +429,7 @@ "provider_error": "${provider} pomyłka", "public_key": "Klucz publiczny", "purchase_gift_card": "Kup kartę podarunkową", + "purple_dark_theme": "Purple Dark Temat", "qr_fullscreen": "Dotknij, aby otworzyć pełnoekranowy kod QR", "qr_payment_amount": "Ten kod QR zawiera kwotę do zapłaty. Czy chcesz nadpisać obecną wartość?", "question_to_disable_2fa": "Czy na pewno chcesz wyłączyć Cake 2FA? Kod 2FA nie będzie już potrzebny do uzyskania dostępu do portfela i niektórych funkcji.", @@ -440,6 +441,8 @@ "reconnect": "Połącz ponownie", "reconnect_alert_text": "Czy na pewno ponownie się ponownie połączysz?", "reconnection": "Ponowne łączenie", + "red_dark_theme": "Czerwony Mroczny motyw", + "red_light_theme": "Motyw czerwony światło", "redeemed": "wykupione", "refund_address": "Adres do zwrotu", "reject": "Odrzucić", diff --git a/res/values/strings_pt.arb b/res/values/strings_pt.arb index df3f0cdd1..7520f9327 100644 --- a/res/values/strings_pt.arb +++ b/res/values/strings_pt.arb @@ -431,6 +431,7 @@ "provider_error": "${provider} erro", "public_key": "Chave pública", "purchase_gift_card": "Comprar vale-presente", + "purple_dark_theme": "Tema escuro roxo", "qr_fullscreen": "Toque para abrir o código QR em tela cheia", "qr_payment_amount": "This QR code contains a payment amount. Do you want to overwrite the current value?", "question_to_disable_2fa": "Tem certeza de que deseja desativar o Cake 2FA? Um código 2FA não será mais necessário para acessar a carteira e certas funções.", @@ -442,6 +443,8 @@ "reconnect": "Reconectar", "reconnect_alert_text": "Você tem certeza de que deseja reconectar?", "reconnection": "Reconectar", + "red_dark_theme": "Tema escuro vermelho", + "red_light_theme": "Tema da luz vermelha", "redeemed": "Resgatado", "refund_address": "Endereço de reembolso", "reject": "Rejeitar", diff --git a/res/values/strings_ru.arb b/res/values/strings_ru.arb index ebd444461..ee31e003a 100644 --- a/res/values/strings_ru.arb +++ b/res/values/strings_ru.arb @@ -430,6 +430,7 @@ "provider_error": "${provider} ошибка", "public_key": "Публичный ключ", "purchase_gift_card": "Купить подарочную карту", + "purple_dark_theme": "Пурпурная темная тема", "qr_fullscreen": "Нажмите, чтобы открыть полноэкранный QR-код", "qr_payment_amount": "This QR code contains a payment amount. Do you want to overwrite the current value?", "question_to_disable_2fa": "Вы уверены, что хотите отключить Cake 2FA? Код 2FA больше не потребуется для доступа к кошельку и некоторым функциям.", @@ -441,6 +442,8 @@ "reconnect": "Переподключиться", "reconnect_alert_text": "Вы хотите переподключиться?", "reconnection": "Переподключение", + "red_dark_theme": "Красная темная тема", + "red_light_theme": "Тема красного света", "redeemed": "искуплен", "refund_address": "Адрес возврата", "reject": "Отклонять", diff --git a/res/values/strings_th.arb b/res/values/strings_th.arb index 5e04f780f..3fddae170 100644 --- a/res/values/strings_th.arb +++ b/res/values/strings_th.arb @@ -429,6 +429,7 @@ "provider_error": "ข้อผิดพลาด ${provider}", "public_key": "คีย์สาธารณะ", "purchase_gift_card": "ซื้อบัตรของขวัญ", + "purple_dark_theme": "ธีมสีม่วงเข้ม", "qr_fullscreen": "แตะเพื่อเปิดหน้าจอ QR code แบบเต็มจอ", "qr_payment_amount": "QR code นี้มีจำนวนการชำระเงิน คุณต้องการเขียนทับค่าปัจจุบันหรือไม่?", "question_to_disable_2fa": "คุณแน่ใจหรือไม่ว่าต้องการปิดการใช้งาน Cake 2FA ไม่จำเป็นต้องใช้รหัส 2FA ในการเข้าถึงกระเป๋าเงินและฟังก์ชั่นบางอย่างอีกต่อไป", @@ -440,6 +441,8 @@ "reconnect": "เชื่อมต่อใหม่", "reconnect_alert_text": "คุณแน่ใจหรือไม่ว่าต้องการเชื่อมต่อใหม่?", "reconnection": "เชื่อมต่อใหม่", + "red_dark_theme": "ธีมสีแดงเข้ม", + "red_light_theme": "ธีมแสงสีแดง", "redeemed": "แลกของขวัญ", "refund_address": "ที่อยู่สำหรับส่งคืน", "reject": "ปฏิเสธ", diff --git a/res/values/strings_tl.arb b/res/values/strings_tl.arb index 6be45a997..6dc6d793a 100644 --- a/res/values/strings_tl.arb +++ b/res/values/strings_tl.arb @@ -429,6 +429,7 @@ "provider_error": "${provider} error", "public_key": "Pampublikong susi", "purchase_gift_card": "Bumili ng Gift Card", + "purple_dark_theme": "Purple Madilim na Tema", "qr_fullscreen": "Tapikin upang buksan ang buong screen QR code", "qr_payment_amount": "Ang QR code na ito ay naglalaman ng isang halaga ng pagbabayad. Nais mo bang i -overwrite ang kasalukuyang halaga?", "question_to_disable_2fa": "Sigurado ka bang nais mong huwag paganahin ang cake 2fa? Ang isang 2FA code ay hindi na kinakailangan upang ma -access ang pitaka at ilang mga pag -andar.", @@ -440,6 +441,8 @@ "reconnect": "Kumonekta muli", "reconnect_alert_text": "Sigurado ka bang nais mong muling kumonekta?", "reconnection": "Pag -ugnay muli", + "red_dark_theme": "Red Madilim na Tema", + "red_light_theme": "Red light tema", "redeemed": "Tinubos", "refund_address": "Refund address", "reject": "Tanggihan", diff --git a/res/values/strings_tr.arb b/res/values/strings_tr.arb index 60773f37d..585ef495a 100644 --- a/res/values/strings_tr.arb +++ b/res/values/strings_tr.arb @@ -429,6 +429,7 @@ "provider_error": "${provider} hatası", "public_key": "Genel Anahtar", "purchase_gift_card": "Hediye Kartı Satın Al", + "purple_dark_theme": "Mor karanlık tema", "qr_fullscreen": "QR kodunu tam ekranda açmak için dokun", "qr_payment_amount": "Bu QR kodu ödeme tutarını içeriyor. Geçerli miktarın üzerine yazmak istediğine emin misin?", "question_to_disable_2fa": "Cake 2FA'yı devre dışı bırakmak istediğinizden emin misiniz? M-cüzdana ve belirli işlevlere erişmek için artık 2FA koduna gerek kalmayacak.", @@ -440,6 +441,8 @@ "reconnect": "Yeniden Bağlan", "reconnect_alert_text": "Yeniden bağlanmak istediğinden emin misin?", "reconnection": "Yeniden bağlantı", + "red_dark_theme": "Kırmızı Karanlık Tema", + "red_light_theme": "Kırmızı Işık Teması", "redeemed": "Kullanılmış", "refund_address": "İade adresi", "reject": "Reddetmek", diff --git a/res/values/strings_uk.arb b/res/values/strings_uk.arb index ae4efdd8f..a22fa8fb1 100644 --- a/res/values/strings_uk.arb +++ b/res/values/strings_uk.arb @@ -429,6 +429,7 @@ "provider_error": "${provider} помилка", "public_key": "Публічний ключ", "purchase_gift_card": "Придбати подарункову картку", + "purple_dark_theme": "Фіолетова темна тема", "qr_fullscreen": "Торкніться, щоб відкрити QR-код на весь екран", "qr_payment_amount": "This QR code contains a payment amount. Do you want to overwrite the current value?", "question_to_disable_2fa": "Ви впевнені, що хочете вимкнути Cake 2FA? Код 2FA більше не потрібен для доступу до гаманця та певних функцій.", @@ -440,6 +441,8 @@ "reconnect": "Перепідключитися", "reconnect_alert_text": "Ви хочете перепідключитися?", "reconnection": "Перепідключення", + "red_dark_theme": "Червона темна тема", + "red_light_theme": "Тема червоного світла", "redeeded": "Викуплено", "redeemed": "Викуплений", "refund_address": "Адреса повернення коштів", diff --git a/res/values/strings_ur.arb b/res/values/strings_ur.arb index 929f7de2e..35e17feac 100644 --- a/res/values/strings_ur.arb +++ b/res/values/strings_ur.arb @@ -431,6 +431,7 @@ "provider_error": "${provider} خرابی۔", "public_key": "عوامی کلید", "purchase_gift_card": "گفٹ کارڈ خریدیں۔", + "purple_dark_theme": "ارغوانی ڈارک تھیم", "qr_fullscreen": "فل سکرین QR کوڈ کھولنے کے لیے تھپتھپائیں۔", "qr_payment_amount": "اس QR کوڈ میں ادائیگی کی رقم شامل ہے۔ کیا آپ موجودہ قدر کو اوور رائٹ کرنا چاہتے ہیں؟", "question_to_disable_2fa": "کیا آپ واقعی کیک 2FA کو غیر فعال کرنا چاہتے ہیں؟ بٹوے اور بعض افعال تک رسائی کے لیے اب 2FA کوڈ کی ضرورت نہیں ہوگی۔", @@ -442,6 +443,8 @@ "reconnect": "دوبارہ جڑیں۔", "reconnect_alert_text": "کیا آپ واقعی دوبارہ جڑنا چاہتے ہیں؟", "reconnection": "دوبارہ رابطہ", + "red_dark_theme": "ریڈ ڈارک تھیم", + "red_light_theme": "ریڈ لائٹ تھیم", "redeemed": "چھڑایا", "refund_address": "رقم کی واپسی کا پتہ", "reject": "ﺎﻧﺮﮐ ﺩﺭ", diff --git a/res/values/strings_yo.arb b/res/values/strings_yo.arb index 9813cc976..0f141ebdf 100644 --- a/res/values/strings_yo.arb +++ b/res/values/strings_yo.arb @@ -430,6 +430,7 @@ "provider_error": "Àṣìṣe ${provider}", "public_key": "Kọ́kọ́rọ́ tó kò àdáni", "purchase_gift_card": "Ra káàdì ìrajà t'á lò nínú irú kan ìtajà", + "purple_dark_theme": "Akọle dudu dudu", "qr_fullscreen": "Àmì ìlujá túbọ̀ máa tóbi tí o bá tẹ̀", "qr_payment_amount": "Iye owó t'á ránṣé wà nínú àmì ìlujá yìí. Ṣé ẹ fẹ́ pààrọ̀ ẹ̀?", "question_to_disable_2fa": "Ṣe o wa daadaa pe o fẹ ko 2FA Cake? Ko si itumọ ti a yoo nilo lati ranse si iwe iwe naa ati eyikeyi iṣẹ ti o ni.", @@ -441,6 +442,8 @@ "reconnect": "Ṣe àtúnse", "reconnect_alert_text": "Ṣó dá ẹ lójú pé ẹ fẹ́ ṣe àtúnse?", "reconnection": "Àtúnṣe", + "red_dark_theme": "Akọle dudu pupa", + "red_light_theme": "Akori ina pupa", "redeemed": "Ó lílò", "refund_address": "Àdírẹ́sì t'ẹ́ gba owó sí", "reject": "Kọ", diff --git a/res/values/strings_zh.arb b/res/values/strings_zh.arb index 3a2104ecc..ffea168a9 100644 --- a/res/values/strings_zh.arb +++ b/res/values/strings_zh.arb @@ -429,6 +429,7 @@ "provider_error": "${provider} 错误", "public_key": "公钥", "purchase_gift_card": "购买礼品卡", + "purple_dark_theme": "紫色的黑暗主题", "qr_fullscreen": "点击打开全屏二维码", "qr_payment_amount": "This QR code contains a payment amount. Do you want to overwrite the current value?", "question_to_disable_2fa": "您确定要禁用 Cake 2FA 吗?访问钱包和某些功能将不再需要 2FA 代码。", @@ -440,6 +441,8 @@ "reconnect": "重新连接", "reconnect_alert_text": "您确定要重新连接吗?", "reconnection": "重新连接", + "red_dark_theme": "红色的黑暗主题", + "red_light_theme": "红灯主题", "redeemed": "赎回", "refund_address": "退款地址", "reject": "拒绝", From 948669b5c2fc3464f548e7c725f2a7c1e8936c89 Mon Sep 17 00:00:00 2001 From: tuxsudo Date: Fri, 23 Feb 2024 15:00:16 -0500 Subject: [PATCH 5/9] Fix colors (#1311) * Fix colors * Update colors * oops --- lib/palette.dart | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/lib/palette.dart b/lib/palette.dart index 58cbeb697..6681888c8 100644 --- a/lib/palette.dart +++ b/lib/palette.dart @@ -3,7 +3,7 @@ import 'package:flutter/material.dart'; class Palette { static const Color green = Color.fromRGBO(39, 206, 80, 1.0); static const Color red = Color.fromRGBO(255, 51, 51, 1.0); - static const Color darkRed = Color.fromRGBO(204, 38, 38, 1.0); + static const Color darkRed = Color.fromRGBO(205, 0, 0, 1.0); static const Color blueAlice = Color.fromRGBO(229, 247, 255, 1.0); static const Color lightBlue = Color.fromRGBO(172, 203, 238, 1.0); static const Color lavender = Color.fromRGBO(237, 245, 252, 1.0); @@ -98,6 +98,6 @@ class PaletteDark { static const Color matrixGreen = Color.fromRGBO(18, 229, 90, 1.0); static const Color moneroOrange = Color.fromRGBO(255, 102, 0, 1.0); static const Color moneroCard = Color.fromRGBO(20, 21, 24, 1.0); - static const Color red = Color.fromRGBO(190, 30, 30, 1.0); - static const Color darkPurple = Color.fromRGBO(117, 36, 204, 1.0); + static const Color red = Color.fromRGBO(195, 0, 0, 1.0); + static const Color darkPurple = Color.fromRGBO(109, 14, 210, 1.0); } From 5b1f17c1fbcfbf3b4989421779acbfafb5269f05 Mon Sep 17 00:00:00 2001 From: Matthew Fosse Date: Fri, 23 Feb 2024 16:50:17 -0800 Subject: [PATCH 6/9] fix (#1232) --- macos/Podfile | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/macos/Podfile b/macos/Podfile index fe5678c70..16db2b54c 100644 --- a/macos/Podfile +++ b/macos/Podfile @@ -41,4 +41,20 @@ post_install do |installer| config.build_settings['MACOSX_DEPLOYMENT_TARGET'] = '12.0' end end + + installer.aggregate_targets.each do |target| + target.xcconfigs.each do |variant, xcconfig| + xcconfig_path = target.client_root + target.xcconfig_relative_path(variant) + IO.write(xcconfig_path, IO.read(xcconfig_path).gsub("DT_TOOLCHAIN_DIR", "TOOLCHAIN_DIR")) + end + end + + installer.pods_project.targets.each do |target| + target.build_configurations.each do |config| + if config.base_configuration_reference.is_a? Xcodeproj::Project::Object::PBXFileReference + xcconfig_path = config.base_configuration_reference.real_path + IO.write(xcconfig_path, IO.read(xcconfig_path).gsub("DT_TOOLCHAIN_DIR", "TOOLCHAIN_DIR")) + end + end + end end From 2549b0fa0a7171846e7dd872a54dc2e41075e6f6 Mon Sep 17 00:00:00 2001 From: tuxsudo Date: Tue, 27 Feb 2024 21:15:18 -0500 Subject: [PATCH 7/9] Replace purple with "Cake Dark Blue" theme and make it default (#1314) --- lib/palette.dart | 2 ++ lib/store/settings_store.dart | 4 ++-- lib/themes/cake_dark_theme.dart | 24 ++++++++++++++++++++++++ lib/themes/purple_dark_theme.dart | 13 ------------- lib/themes/theme_list.dart | 10 +++++----- res/values/strings_ar.arb | 7 ++++--- res/values/strings_bg.arb | 7 ++++--- res/values/strings_cs.arb | 7 ++++--- res/values/strings_de.arb | 9 +++++---- res/values/strings_en.arb | 7 ++++--- res/values/strings_es.arb | 7 ++++--- res/values/strings_fr.arb | 7 ++++--- res/values/strings_ha.arb | 7 ++++--- res/values/strings_hi.arb | 7 ++++--- res/values/strings_hr.arb | 7 ++++--- res/values/strings_id.arb | 7 ++++--- res/values/strings_it.arb | 7 ++++--- res/values/strings_ja.arb | 7 ++++--- res/values/strings_ko.arb | 9 +++++---- res/values/strings_my.arb | 7 ++++--- res/values/strings_nl.arb | 7 ++++--- res/values/strings_pl.arb | 7 ++++--- res/values/strings_pt.arb | 7 ++++--- res/values/strings_ru.arb | 7 ++++--- res/values/strings_th.arb | 7 ++++--- res/values/strings_tl.arb | 7 ++++--- res/values/strings_tr.arb | 7 ++++--- res/values/strings_uk.arb | 7 ++++--- res/values/strings_ur.arb | 7 ++++--- res/values/strings_yo.arb | 7 ++++--- res/values/strings_zh.arb | 7 ++++--- 31 files changed, 139 insertions(+), 100 deletions(-) create mode 100644 lib/themes/cake_dark_theme.dart delete mode 100644 lib/themes/purple_dark_theme.dart diff --git a/lib/palette.dart b/lib/palette.dart index 6681888c8..eb0ff50e9 100644 --- a/lib/palette.dart +++ b/lib/palette.dart @@ -100,4 +100,6 @@ class PaletteDark { static const Color moneroCard = Color.fromRGBO(20, 21, 24, 1.0); static const Color red = Color.fromRGBO(195, 0, 0, 1.0); static const Color darkPurple = Color.fromRGBO(109, 14, 210, 1.0); + static const Color cakeBlue = Color.fromRGBO(0, 184, 250, 1.0); + static const Color darkBlue = Color.fromRGBO(0, 123, 168, 1.0); } diff --git a/lib/store/settings_store.dart b/lib/store/settings_store.dart index 488049656..fe6c98826 100644 --- a/lib/store/settings_store.dart +++ b/lib/store/settings_store.dart @@ -792,7 +792,7 @@ abstract class SettingsStoreBase with Store { ExchangeApiMode.enabled.raw); final savedTheme = initialTheme ?? ThemeList.deserialize( - raw: sharedPreferences.getInt(PreferencesKey.currentTheme) ?? (isMoneroOnly ? ThemeList.moneroDarkTheme.raw : ThemeList.darkTheme.raw)); + raw: sharedPreferences.getInt(PreferencesKey.currentTheme) ?? (isMoneroOnly ? ThemeList.moneroDarkTheme.raw : ThemeList.cakeDarkTheme.raw)); final actionListDisplayMode = ObservableList(); actionListDisplayMode.addAll(deserializeActionlistDisplayModes( sharedPreferences.getInt(PreferencesKey.displayActionListModeKey) ?? defaultActionsMode)); @@ -1151,7 +1151,7 @@ abstract class SettingsStoreBase with Store { raw: sharedPreferences.getInt(PreferencesKey.exchangeStatusKey) ?? ExchangeApiMode.enabled.raw); currentTheme = ThemeList.deserialize( - raw: sharedPreferences.getInt(PreferencesKey.currentTheme) ?? (isMoneroOnly ? ThemeList.moneroDarkTheme.raw : ThemeList.darkTheme.raw)); + raw: sharedPreferences.getInt(PreferencesKey.currentTheme) ?? (isMoneroOnly ? ThemeList.moneroDarkTheme.raw : ThemeList.cakeDarkTheme.raw)); actionlistDisplayMode = ObservableList(); actionlistDisplayMode.addAll(deserializeActionlistDisplayModes( sharedPreferences.getInt(PreferencesKey.displayActionListModeKey) ?? defaultActionsMode)); diff --git a/lib/themes/cake_dark_theme.dart b/lib/themes/cake_dark_theme.dart new file mode 100644 index 000000000..262ee1d64 --- /dev/null +++ b/lib/themes/cake_dark_theme.dart @@ -0,0 +1,24 @@ +import 'package:cake_wallet/generated/i18n.dart'; +import 'package:cake_wallet/palette.dart'; +import 'package:cake_wallet/themes/monero_dark_theme.dart'; +import 'package:flutter/material.dart'; +import 'package:cake_wallet/themes/extensions/menu_theme.dart'; + +class CakeDarkTheme extends MoneroDarkTheme { + CakeDarkTheme({required int raw}) : super(raw: raw); + + @override + String get title => S.current.cake_dark_theme; + @override + Color get primaryColor => PaletteDark.cakeBlue; + + @override + CakeMenuTheme get menuTheme => super.menuTheme.copyWith( + headerFirstGradientColor: PaletteDark.darkBlue, + headerSecondGradientColor: containerColor, + backgroundColor: containerColor, + subnameTextColor: Colors.grey, + dividerColor: colorScheme.secondaryContainer, + iconColor: Colors.white, + settingActionsIconColor: colorScheme.secondaryContainer); +} \ No newline at end of file diff --git a/lib/themes/purple_dark_theme.dart b/lib/themes/purple_dark_theme.dart deleted file mode 100644 index 04365de38..000000000 --- a/lib/themes/purple_dark_theme.dart +++ /dev/null @@ -1,13 +0,0 @@ -import 'package:cake_wallet/generated/i18n.dart'; -import 'package:cake_wallet/palette.dart'; -import 'package:cake_wallet/themes/monero_dark_theme.dart'; -import 'package:flutter/material.dart'; - -class PurpleDarkTheme extends MoneroDarkTheme { - PurpleDarkTheme({required int raw}) : super(raw: raw); - - @override - String get title => S.current.purple_dark_theme; - @override - Color get primaryColor => PaletteDark.darkPurple; -} \ No newline at end of file diff --git a/lib/themes/theme_list.dart b/lib/themes/theme_list.dart index 6d0d44225..3ca7346b3 100644 --- a/lib/themes/theme_list.dart +++ b/lib/themes/theme_list.dart @@ -3,7 +3,7 @@ import 'package:cake_wallet/themes/dark_theme.dart'; import 'package:cake_wallet/themes/light_theme.dart'; import 'package:cake_wallet/themes/monero_light_theme.dart'; import 'package:cake_wallet/themes/monero_dark_theme.dart'; -import 'package:cake_wallet/themes/purple_dark_theme.dart'; +import 'package:cake_wallet/themes/cake_dark_theme.dart'; import 'package:cake_wallet/themes/matrix_green_theme.dart'; import 'package:cake_wallet/themes/bitcoin_dark_theme.dart'; import 'package:cake_wallet/themes/bitcoin_light_theme.dart'; @@ -14,12 +14,12 @@ import 'package:cake_wallet/themes/theme_base.dart'; class ThemeList { static final all = [ + cakeDarkTheme, darkTheme, lightTheme, - brightTheme, moneroDarkTheme, moneroLightTheme, - purpleDarkTheme, + brightTheme, bitcoinDarkTheme, bitcoinLightTheme, matrixGreenTheme, @@ -39,7 +39,7 @@ class ThemeList { static final highContrastTheme = HighContrastTheme(raw: 8); static final redLightTheme = RedLightTheme(raw: 9); static final redDarkTheme = RedDarkTheme(raw: 10); - static final purpleDarkTheme = PurpleDarkTheme(raw: 11); + static final cakeDarkTheme = CakeDarkTheme(raw: 11); static ThemeBase deserialize({required int raw}) { switch (raw) { @@ -66,7 +66,7 @@ class ThemeList { case 10: return redDarkTheme; case 11: - return purpleDarkTheme; + return cakeDarkTheme; default: throw Exception( 'Unexpected token raw: $raw for deserialization of ThemeBase'); diff --git a/res/values/strings_ar.arb b/res/values/strings_ar.arb index af92cead4..a5ea72ce7 100644 --- a/res/values/strings_ar.arb +++ b/res/values/strings_ar.arb @@ -84,6 +84,7 @@ "buy_with": "اشتر بواسطة", "by_cake_pay": "عن طريق Cake Pay", "cake_2fa_preset": " كعكة 2FA مسبقا", + "cake_dark_theme": "موضوع الكعكة الظلام", "cake_pay_account_note": "قم بالتسجيل باستخدام عنوان بريد إلكتروني فقط لمشاهدة البطاقات وشرائها. حتى أن بعضها متوفر بسعر مخفض!", "cake_pay_learn_more": "شراء واسترداد بطاقات الهدايا على الفور في التطبيق!\nاسحب من اليسار إلى اليمين لمعرفة المزيد.", "cake_pay_subtitle": "شراء بطاقات هدايا مخفضة السعر (الولايات المتحدة فقط)", @@ -732,6 +733,7 @@ "view_key_private": "مفتاح العرض (خاص)", "view_key_public": "مفتاح العرض (عام)", "view_transaction_on": "عرض العملية على", + "waitFewSecondForTxUpdate": "ﺕﻼﻣﺎﻌﻤﻟﺍ ﻞﺠﺳ ﻲﻓ ﺔﻠﻣﺎﻌﻤﻟﺍ ﺲﻜﻌﻨﺗ ﻰﺘﺣ ﻥﺍﻮﺛ ﻊﻀﺒﻟ ﺭﺎﻈﺘﻧﻻﺍ ﻰﺟﺮﻳ", "wallet_keys": "سييد المحفظة / المفاتيح", "wallet_list_create_new_wallet": "إنشاء محفظة جديدة", "wallet_list_edit_wallet": "تحرير المحفظة", @@ -782,6 +784,5 @@ "you_pay": "انت تدفع", "you_will_get": "حول الى", "you_will_send": "تحويل من", - "yy": "YY", - "waitFewSecondForTxUpdate": "ﺕﻼﻣﺎﻌﻤﻟﺍ ﻞﺠﺳ ﻲﻓ ﺔﻠﻣﺎﻌﻤﻟﺍ ﺲﻜﻌﻨﺗ ﻰﺘﺣ ﻥﺍﻮﺛ ﻊﻀﺒﻟ ﺭﺎﻈﺘﻧﻻﺍ ﻰﺟﺮﻳ" -} + "yy": "YY" +} \ No newline at end of file diff --git a/res/values/strings_bg.arb b/res/values/strings_bg.arb index 9f46ac2f4..a42460619 100644 --- a/res/values/strings_bg.arb +++ b/res/values/strings_bg.arb @@ -84,6 +84,7 @@ "buy_with": "Купуване чрез", "by_cake_pay": "от Cake Pay", "cake_2fa_preset": "Торта 2FA Preset", + "cake_dark_theme": "Торта тъмна тема", "cake_pay_account_note": "Регистрайте се само с един имейл, за да виждате и купувате карти. За някои има дори и отстъпка!", "cake_pay_learn_more": "Купете и използвайте гифткарти директно в приложението!\nПлъзнете отляво надясно, за да научите още.", "cake_pay_subtitle": "Купете гифткарти на намалени цени (само за САЩ)", @@ -732,6 +733,7 @@ "view_key_private": "View key (таен)", "view_key_public": "View key (публичен)", "view_transaction_on": "Вижте транзакция на ", + "waitFewSecondForTxUpdate": "Моля, изчакайте няколко секунди, докато транзакцията се отрази в историята на транзакциите", "wallet_keys": "Seed/keys на портфейла", "wallet_list_create_new_wallet": "Създаване на нов портфейл", "wallet_list_edit_wallet": "Редактиране на портфейла", @@ -782,6 +784,5 @@ "you_pay": "Вие плащате", "you_will_get": "Обръщане в", "you_will_send": "Обръщане от", - "yy": "гг", - "waitFewSecondForTxUpdate": "Моля, изчакайте няколко секунди, докато транзакцията се отрази в историята на транзакциите" -} + "yy": "гг" +} \ No newline at end of file diff --git a/res/values/strings_cs.arb b/res/values/strings_cs.arb index f47c7acb3..8970d394a 100644 --- a/res/values/strings_cs.arb +++ b/res/values/strings_cs.arb @@ -84,6 +84,7 @@ "buy_with": "Nakoupit pomocí", "by_cake_pay": "od Cake Pay", "cake_2fa_preset": "Předvolba Cake 2FA", + "cake_dark_theme": "Dort tmavé téma", "cake_pay_account_note": "Přihlaste se svou e-mailovou adresou pro zobrazení a nákup karet. Některé jsou dostupné ve slevě!", "cake_pay_learn_more": "Okamžitý nákup a uplatnění dárkových karet v aplikaci!\nPřejeďte prstem zleva doprava pro další informace.", "cake_pay_subtitle": "Kupte si zlevněné dárkové karty (pouze USA)", @@ -732,6 +733,7 @@ "view_key_private": "Klíč pro zobrazení (soukromý)", "view_key_public": "Klíč pro zobrazení (veřejný)", "view_transaction_on": "Zobrazit transakci na ", + "waitFewSecondForTxUpdate": "Počkejte několik sekund, než se transakce projeví v historii transakcí", "wallet_keys": "Seed/klíče peněženky", "wallet_list_create_new_wallet": "Vytvořit novou peněženku", "wallet_list_edit_wallet": "Upravit peněženku", @@ -782,6 +784,5 @@ "you_pay": "Zaplatíte", "you_will_get": "Směnit na", "you_will_send": "Směnit z", - "yy": "YY", - "waitFewSecondForTxUpdate": "Počkejte několik sekund, než se transakce projeví v historii transakcí" -} + "yy": "YY" +} \ No newline at end of file diff --git a/res/values/strings_de.arb b/res/values/strings_de.arb index 338112022..93d421061 100644 --- a/res/values/strings_de.arb +++ b/res/values/strings_de.arb @@ -84,6 +84,7 @@ "buy_with": "Kaufen mit", "by_cake_pay": "von Cake Pay", "cake_2fa_preset": "Kuchen 2FA-Voreinstellung", + "cake_dark_theme": "Cake Dark Thema", "cake_pay_account_note": "Melden Sie sich nur mit einer E-Mail-Adresse an, um Karten anzuzeigen und zu kaufen. Einige sind sogar mit Rabatt erhältlich!", "cake_pay_learn_more": "Kaufen und lösen Sie Geschenkkarten sofort in der App ein!\nWischen Sie von links nach rechts, um mehr zu erfahren.", "cake_pay_subtitle": "Kaufen Sie ermäßigte Geschenkkarten (nur USA)", @@ -409,8 +410,8 @@ "placeholder_transactions": "Ihre Transaktionen werden hier angezeigt", "please_fill_totp": "Bitte geben Sie den 8-stelligen Code ein, der auf Ihrem anderen Gerät vorhanden ist", "please_make_selection": "Bitte treffen Sie unten eine Auswahl zum Erstellen oder Wiederherstellen Ihrer Wallet.", - "Please_reference_document": "Weitere Informationen finden Sie in den Dokumenten unten.", "please_reference_document": "Bitte verweisen Sie auf die folgenden Dokumente, um weitere Informationen zu erhalten.", + "Please_reference_document": "Weitere Informationen finden Sie in den Dokumenten unten.", "please_select": "Bitte auswählen:", "please_select_backup_file": "Bitte wählen Sie die Sicherungsdatei und geben Sie das Sicherungskennwort ein.", "please_try_to_connect_to_another_node": "Bitte versuchen Sie, sich mit einem anderen Knoten zu verbinden", @@ -734,6 +735,7 @@ "view_key_private": "View Key (geheim)", "view_key_public": "View Key (öffentlich)", "view_transaction_on": "Anzeigen der Transaktion auf ", + "waitFewSecondForTxUpdate": "Bitte warten Sie einige Sekunden, bis die Transaktion im Transaktionsverlauf angezeigt wird", "waiting_payment_confirmation": "Warte auf Zahlungsbestätigung", "wallet_keys": "Wallet-Seed/-Schlüssel", "wallet_list_create_new_wallet": "Neue Wallet erstellen", @@ -785,6 +787,5 @@ "you_pay": "Sie bezahlen", "you_will_get": "Konvertieren zu", "you_will_send": "Konvertieren von", - "yy": "YY", - "waitFewSecondForTxUpdate": "Bitte warten Sie einige Sekunden, bis die Transaktion im Transaktionsverlauf angezeigt wird" -} + "yy": "YY" +} \ No newline at end of file diff --git a/res/values/strings_en.arb b/res/values/strings_en.arb index 86dc6518d..4ccade317 100644 --- a/res/values/strings_en.arb +++ b/res/values/strings_en.arb @@ -84,6 +84,7 @@ "buy_with": "Buy with", "by_cake_pay": "by Cake Pay", "cake_2fa_preset": "Cake 2FA Preset", + "cake_dark_theme": "Cake Dark Theme", "cake_pay_account_note": "Sign up with just an email address to see and purchase cards. Some are even available at a discount!", "cake_pay_learn_more": "Instantly purchase and redeem gift cards in the app!\nSwipe left to right to learn more.", "cake_pay_subtitle": "Buy discounted gift cards (USA only)", @@ -732,6 +733,7 @@ "view_key_private": "View key (private)", "view_key_public": "View key (public)", "view_transaction_on": "View Transaction on ", + "waitFewSecondForTxUpdate": "Kindly wait for a few seconds for transaction to reflect in transactions history", "wallet_keys": "Wallet seed/keys", "wallet_list_create_new_wallet": "Create New Wallet", "wallet_list_edit_wallet": "Edit wallet", @@ -782,6 +784,5 @@ "you_pay": "You Pay", "you_will_get": "Convert to", "you_will_send": "Convert from", - "yy": "YY", - "waitFewSecondForTxUpdate": "Kindly wait for a few seconds for transaction to reflect in transactions history" -} + "yy": "YY" +} \ No newline at end of file diff --git a/res/values/strings_es.arb b/res/values/strings_es.arb index 09ad9948b..611f3bd35 100644 --- a/res/values/strings_es.arb +++ b/res/values/strings_es.arb @@ -84,6 +84,7 @@ "buy_with": "Compra con", "by_cake_pay": "por Cake Pay", "cake_2fa_preset": "Pastel 2FA preestablecido", + "cake_dark_theme": "Tema oscuro del pastel", "cake_pay_account_note": "Regístrese con solo una dirección de correo electrónico para ver y comprar tarjetas. ¡Algunas incluso están disponibles con descuento!", "cake_pay_learn_more": "¡Compre y canjee tarjetas de regalo al instante en la aplicación!\nDeslice el dedo de izquierda a derecha para obtener más información.", "cake_pay_subtitle": "Compre tarjetas de regalo con descuento (solo EE. UU.)", @@ -733,6 +734,7 @@ "view_key_private": "View clave (privado)", "view_key_public": "View clave (público)", "view_transaction_on": "View Transaction on ", + "waitFewSecondForTxUpdate": "Espere unos segundos para que la transacción se refleje en el historial de transacciones.", "wallet_keys": "Billetera semilla/claves", "wallet_list_create_new_wallet": "Crear nueva billetera", "wallet_list_edit_wallet": "Editar billetera", @@ -783,6 +785,5 @@ "you_pay": "Tú pagas", "you_will_get": "Convertir a", "you_will_send": "Convertir de", - "yy": "YY", - "waitFewSecondForTxUpdate": "Espere unos segundos para que la transacción se refleje en el historial de transacciones." -} + "yy": "YY" +} \ No newline at end of file diff --git a/res/values/strings_fr.arb b/res/values/strings_fr.arb index e552dfafa..f00a85310 100644 --- a/res/values/strings_fr.arb +++ b/res/values/strings_fr.arb @@ -84,6 +84,7 @@ "buy_with": "Acheter avec", "by_cake_pay": "par Cake Pay", "cake_2fa_preset": "Cake 2FA prédéfini", + "cake_dark_theme": "Thème sombre du gâteau", "cake_pay_account_note": "Inscrivez-vous avec juste une adresse e-mail pour voir et acheter des cartes. Certaines sont même disponibles à prix réduit !", "cake_pay_learn_more": "Achetez et utilisez instantanément des cartes-cadeaux dans l'application !\nBalayer de gauche à droite pour en savoir plus.", "cake_pay_subtitle": "Achetez des cartes-cadeaux à prix réduit (États-Unis uniquement)", @@ -732,6 +733,7 @@ "view_key_private": "Clef d'audit (view key) (privée)", "view_key_public": "Clef d'audit (view key) (publique)", "view_transaction_on": "Voir la Transaction sur ", + "waitFewSecondForTxUpdate": "Veuillez attendre quelques secondes pour que la transaction soit reflétée dans l'historique des transactions.", "wallet_keys": "Phrase secrète (seed)/Clefs du portefeuille (wallet)", "wallet_list_create_new_wallet": "Créer un Nouveau Portefeuille (Wallet)", "wallet_list_edit_wallet": "Modifier le portefeuille", @@ -782,6 +784,5 @@ "you_pay": "Vous payez", "you_will_get": "Convertir vers", "you_will_send": "Convertir depuis", - "yy": "AA", - "waitFewSecondForTxUpdate": "Veuillez attendre quelques secondes pour que la transaction soit reflétée dans l'historique des transactions." -} + "yy": "AA" +} \ No newline at end of file diff --git a/res/values/strings_ha.arb b/res/values/strings_ha.arb index 40f6b2857..ab314af7f 100644 --- a/res/values/strings_ha.arb +++ b/res/values/strings_ha.arb @@ -84,6 +84,7 @@ "buy_with": "Saya da", "by_cake_pay": "da Cake Pay", "cake_2fa_preset": "Cake 2FA saiti", + "cake_dark_theme": "Cake Dark Jigo", "cake_pay_account_note": "Yi rajista tare da adireshin imel kawai don gani da siyan katunan. Wasu ma suna samuwa a rangwame!", "cake_pay_learn_more": "Nan take siya ku kwaso katunan kyaututtuka a cikin app!\nTake hagu zuwa dama don ƙarin koyo.", "cake_pay_subtitle": "Sayi katunan kyauta masu rahusa (Amurka kawai)", @@ -734,6 +735,7 @@ "view_key_private": "Duba maɓallin (maɓallin kalmar sirri)", "view_key_public": "Maɓallin Duba (maɓallin jama'a)", "view_transaction_on": "Dubo aikace-aikacen akan", + "waitFewSecondForTxUpdate": "Da fatan za a jira ƴan daƙiƙa don ciniki don yin tunani a tarihin ma'amala", "wallet_keys": "Iri/maɓalli na walat", "wallet_list_create_new_wallet": "Ƙirƙiri Sabon Wallet", "wallet_list_edit_wallet": "Gyara walat", @@ -784,6 +786,5 @@ "you_pay": "Ka Bayar", "you_will_get": "Maida zuwa", "you_will_send": "Maida daga", - "yy": "YY", - "waitFewSecondForTxUpdate": "Da fatan za a jira ƴan daƙiƙa don ciniki don yin tunani a tarihin ma'amala" -} + "yy": "YY" +} \ No newline at end of file diff --git a/res/values/strings_hi.arb b/res/values/strings_hi.arb index d35a5f716..da7d97c46 100644 --- a/res/values/strings_hi.arb +++ b/res/values/strings_hi.arb @@ -84,6 +84,7 @@ "buy_with": "के साथ खरीदें", "by_cake_pay": "केकपे द्वारा", "cake_2fa_preset": "केक 2एफए प्रीसेट", + "cake_dark_theme": "केक डार्क थीम", "cake_pay_account_note": "कार्ड देखने और खरीदने के लिए केवल एक ईमेल पते के साथ साइन अप करें। कुछ छूट पर भी उपलब्ध हैं!", "cake_pay_learn_more": "ऐप में उपहार कार्ड तुरंत खरीदें और रिडीम करें!\nअधिक जानने के लिए बाएं से दाएं स्वाइप करें।", "cake_pay_subtitle": "रियायती उपहार कार्ड खरीदें (केवल यूएसए)", @@ -734,6 +735,7 @@ "view_key_private": "कुंजी देखें(निजी)", "view_key_public": "कुंजी देखें (जनता)", "view_transaction_on": "View Transaction on ", + "waitFewSecondForTxUpdate": "लेन-देन इतिहास में लेन-देन प्रतिबिंबित होने के लिए कृपया कुछ सेकंड प्रतीक्षा करें", "wallet_keys": "बटुआ बीज / चाबियाँ", "wallet_list_create_new_wallet": "नया बटुआ बनाएँ", "wallet_list_edit_wallet": "बटुआ संपादित करें", @@ -784,6 +786,5 @@ "you_pay": "आप भुगतान करते हैं", "you_will_get": "में बदलें", "you_will_send": "से रूपांतरित करें", - "yy": "वाईवाई", - "waitFewSecondForTxUpdate": "लेन-देन इतिहास में लेन-देन प्रतिबिंबित होने के लिए कृपया कुछ सेकंड प्रतीक्षा करें" -} + "yy": "वाईवाई" +} \ No newline at end of file diff --git a/res/values/strings_hr.arb b/res/values/strings_hr.arb index c41310441..22b486a1c 100644 --- a/res/values/strings_hr.arb +++ b/res/values/strings_hr.arb @@ -84,6 +84,7 @@ "buy_with": "Kupite s", "by_cake_pay": "od Cake Paya", "cake_2fa_preset": "Cake 2FA Preset", + "cake_dark_theme": "TOKA DARKA TEMA", "cake_pay_account_note": "Prijavite se samo s adresom e-pošte da biste vidjeli i kupili kartice. Neke su čak dostupne uz popust!", "cake_pay_learn_more": "Azonnal vásárolhat és válthat be ajándékutalványokat az alkalmazásban!\nTovábbi információért csúsztassa balról jobbra az ujját.", "cake_pay_subtitle": "Kupite darovne kartice s popustom (samo SAD)", @@ -732,6 +733,7 @@ "view_key_private": "View key (privatni)", "view_key_public": "View key (javni)", "view_transaction_on": "View Transaction on ", + "waitFewSecondForTxUpdate": "Pričekajte nekoliko sekundi da se transakcija prikaže u povijesti transakcija", "wallet_keys": "Pristupni izraz/ključ novčanika", "wallet_list_create_new_wallet": "Izradi novi novčanik", "wallet_list_edit_wallet": "Uredi novčanik", @@ -782,6 +784,5 @@ "you_pay": "Vi plaćate", "you_will_get": "Razmijeni u", "you_will_send": "Razmijeni iz", - "yy": "GG", - "waitFewSecondForTxUpdate": "Pričekajte nekoliko sekundi da se transakcija prikaže u povijesti transakcija" -} + "yy": "GG" +} \ No newline at end of file diff --git a/res/values/strings_id.arb b/res/values/strings_id.arb index a9b9b50b3..a2f3ed017 100644 --- a/res/values/strings_id.arb +++ b/res/values/strings_id.arb @@ -84,6 +84,7 @@ "buy_with": "Beli dengan", "by_cake_pay": "oleh Cake Pay", "cake_2fa_preset": "Preset Kue 2FA", + "cake_dark_theme": "Tema Kue Gelap", "cake_pay_account_note": "Daftar hanya dengan alamat email untuk melihat dan membeli kartu. Beberapa di antaranya bahkan tersedia dengan diskon!", "cake_pay_learn_more": "Beli dan tukar kartu hadiah secara instan di aplikasi!\nGeser ke kanan untuk informasi lebih lanjut.", "cake_pay_subtitle": "Beli kartu hadiah dengan harga diskon (hanya USA)", @@ -735,6 +736,7 @@ "view_key_private": "Kunci tampilan (privat)", "view_key_public": "Kunci tampilan (publik)", "view_transaction_on": "Lihat Transaksi di ", + "waitFewSecondForTxUpdate": "Mohon tunggu beberapa detik hingga transaksi terlihat di riwayat transaksi", "wallet_keys": "Seed/kunci dompet", "wallet_list_create_new_wallet": "Buat Dompet Baru", "wallet_list_edit_wallet": "Edit dompet", @@ -785,6 +787,5 @@ "you_pay": "Anda Membayar", "you_will_get": "Konversi ke", "you_will_send": "Konversi dari", - "yy": "YY", - "waitFewSecondForTxUpdate": "Mohon tunggu beberapa detik hingga transaksi terlihat di riwayat transaksi" -} + "yy": "YY" +} \ No newline at end of file diff --git a/res/values/strings_it.arb b/res/values/strings_it.arb index 9682e3d7f..015b63d47 100644 --- a/res/values/strings_it.arb +++ b/res/values/strings_it.arb @@ -84,6 +84,7 @@ "buy_with": "Acquista con", "by_cake_pay": "da Cake Pay", "cake_2fa_preset": "Torta 2FA Preset", + "cake_dark_theme": "Tema oscuro della torta", "cake_pay_account_note": "Iscriviti con solo un indirizzo email per vedere e acquistare le carte. Alcune sono anche disponibili con uno sconto!", "cake_pay_learn_more": "Acquista e riscatta istantaneamente carte regalo nell'app!\nScorri da sinistra a destra per saperne di più.", "cake_pay_subtitle": "Acquista buoni regalo scontati (solo USA)", @@ -734,6 +735,7 @@ "view_key_private": "Chiave di visualizzazione (privata)", "view_key_public": "Chiave di visualizzazione (pubblica)", "view_transaction_on": "View Transaction on ", + "waitFewSecondForTxUpdate": "Attendi qualche secondo affinché la transazione venga riflessa nella cronologia delle transazioni", "waiting_payment_confirmation": "In attesa di conferma del pagamento", "wallet_keys": "Seme Portafoglio /chiavi", "wallet_list_create_new_wallet": "Crea Nuovo Portafoglio", @@ -785,6 +787,5 @@ "you_pay": "Tu paghi", "you_will_get": "Converti a", "you_will_send": "Conveti da", - "yy": "YY", - "waitFewSecondForTxUpdate": "Attendi qualche secondo affinché la transazione venga riflessa nella cronologia delle transazioni" -} + "yy": "YY" +} \ No newline at end of file diff --git a/res/values/strings_ja.arb b/res/values/strings_ja.arb index 0f4513eee..36ec1932b 100644 --- a/res/values/strings_ja.arb +++ b/res/values/strings_ja.arb @@ -84,6 +84,7 @@ "buy_with": "で購入", "by_cake_pay": "by Cake Pay", "cake_2fa_preset": "ケーキ 2FA プリセット", + "cake_dark_theme": "ケーキ暗いテーマ", "cake_pay_account_note": "メールアドレスだけでサインアップして、カードを表示して購入できます。割引価格で利用できるカードもあります!", "cake_pay_learn_more": "アプリですぐにギフトカードを購入して引き換えましょう!\n左から右にスワイプして詳細をご覧ください。", "cake_pay_subtitle": "割引ギフトカードを購入する (米国のみ)", @@ -733,6 +734,7 @@ "view_key_private": "ビューキー (プライベート)", "view_key_public": "ビューキー (パブリック)", "view_transaction_on": "View Transaction on ", + "waitFewSecondForTxUpdate": "取引履歴に取引が反映されるまで数秒お待ちください。", "wallet_keys": "ウォレットシード/キー", "wallet_list_create_new_wallet": "新しいウォレットを作成", "wallet_list_edit_wallet": "ウォレットを編集する", @@ -783,6 +785,5 @@ "you_pay": "あなたが支払う", "you_will_get": "に変換", "you_will_send": "から変換", - "yy": "YY", - "waitFewSecondForTxUpdate": "取引履歴に取引が反映されるまで数秒お待ちください。" -} + "yy": "YY" +} \ No newline at end of file diff --git a/res/values/strings_ko.arb b/res/values/strings_ko.arb index 2dd80cf5c..b51f42e8f 100644 --- a/res/values/strings_ko.arb +++ b/res/values/strings_ko.arb @@ -84,6 +84,7 @@ "buy_with": "구매", "by_cake_pay": "Cake Pay로", "cake_2fa_preset": "케이크 2FA 프리셋", + "cake_dark_theme": "케이크 다크 테마", "cake_pay_account_note": "이메일 주소로 가입하면 카드를 보고 구매할 수 있습니다. 일부는 할인된 가격으로 사용 가능합니다!", "cake_pay_learn_more": "앱에서 즉시 기프트 카드를 구매하고 사용하세요!\n자세히 알아보려면 왼쪽에서 오른쪽으로 스와이프하세요.", "cake_pay_subtitle": "할인된 기프트 카드 구매(미국만 해당)", @@ -409,8 +410,8 @@ "placeholder_transactions": "거래가 여기에 표시됩니다", "please_fill_totp": "다른 기기에 있는 8자리 코드를 입력하세요.", "please_make_selection": "아래에서 선택하십시오 지갑 만들기 또는 복구.", - "Please_reference_document": "자세한 내용은 아래 문서를 참조하십시오.", "please_reference_document": "자세한 내용은 아래 문서를 참조하십시오.", + "Please_reference_document": "자세한 내용은 아래 문서를 참조하십시오.", "please_select": "선택 해주세요:", "please_select_backup_file": "백업 파일을 선택하고 백업 암호를 입력하십시오.", "please_try_to_connect_to_another_node": "다른 노드에 연결을 시도하십시오", @@ -733,6 +734,7 @@ "view_key_private": "키보기(은밀한)", "view_key_public": "키보기 (공공의)", "view_transaction_on": "View Transaction on ", + "waitFewSecondForTxUpdate": "거래 내역에 거래가 반영될 때까지 몇 초 정도 기다려 주세요.", "wallet_keys": "지갑 시드 / 키", "wallet_list_create_new_wallet": "새 월렛 만들기", "wallet_list_edit_wallet": "지갑 수정", @@ -784,6 +786,5 @@ "you_will_get": "로 변환하다", "you_will_send": "다음에서 변환", "YY": "YY", - "yy": "YY", - "waitFewSecondForTxUpdate": "거래 내역에 거래가 반영될 때까지 몇 초 정도 기다려 주세요." -} + "yy": "YY" +} \ No newline at end of file diff --git a/res/values/strings_my.arb b/res/values/strings_my.arb index 1bc338dcb..fe719a82b 100644 --- a/res/values/strings_my.arb +++ b/res/values/strings_my.arb @@ -84,6 +84,7 @@ "buy_with": "အတူဝယ်ပါ။", "by_cake_pay": "Cake Pay ဖြင့်", "cake_2fa_preset": "ကိတ်မုန့် 2FA ကြိုတင်သတ်မှတ်", + "cake_dark_theme": "ကိတ်မုန့် Dark Theme", "cake_pay_account_note": "ကတ်များကြည့်ရှုဝယ်ယူရန် အီးမေးလ်လိပ်စာတစ်ခုဖြင့် စာရင်းသွင်းပါ။ အချို့ကို လျှော့ဈေးဖြင့်ပင် ရနိုင်သည်။", "cake_pay_learn_more": "အက်ပ်ရှိ လက်ဆောင်ကတ်များကို ချက်ချင်းဝယ်ယူပြီး ကူပွန်ဖြင့် လဲလှယ်ပါ။\nပိုမိုလေ့လာရန် ဘယ်မှညာသို့ ပွတ်ဆွဲပါ။", "cake_pay_subtitle": "လျှော့စျေးလက်ဆောင်ကတ်များဝယ်ပါ (USA သာ)", @@ -732,6 +733,7 @@ "view_key_private": "သော့ကိုကြည့်ရန် (သီးသန့်)", "view_key_public": "သော့ကိုကြည့်ရန် (အများပြည်သူ)", "view_transaction_on": "ငွေလွှဲခြင်းကို ဖွင့်ကြည့်ပါ။", + "waitFewSecondForTxUpdate": "ငွေပေးငွေယူ မှတ်တမ်းတွင် ရောင်ပြန်ဟပ်ရန် စက္ကန့်အနည်းငယ်စောင့်ပါ။", "wallet_keys": "ပိုက်ဆံအိတ် အစေ့/သော့များ", "wallet_list_create_new_wallet": "Wallet အသစ်ဖန်တီးပါ။", "wallet_list_edit_wallet": "ပိုက်ဆံအိတ်ကို တည်းဖြတ်ပါ။", @@ -782,6 +784,5 @@ "you_pay": "သင်ပေးချေပါ။", "you_will_get": "သို့ပြောင်းပါ။", "you_will_send": "မှပြောင်းပါ။", - "yy": "YY", - "waitFewSecondForTxUpdate": "ငွေပေးငွေယူ မှတ်တမ်းတွင် ရောင်ပြန်ဟပ်ရန် စက္ကန့်အနည်းငယ်စောင့်ပါ။" -} + "yy": "YY" +} \ No newline at end of file diff --git a/res/values/strings_nl.arb b/res/values/strings_nl.arb index 56175b636..ed5054abe 100644 --- a/res/values/strings_nl.arb +++ b/res/values/strings_nl.arb @@ -84,6 +84,7 @@ "buy_with": "Koop met", "by_cake_pay": "door Cake Pay", "cake_2fa_preset": "Taart 2FA Voorinstelling", + "cake_dark_theme": "Cake Dark Theme", "cake_pay_account_note": "Meld u aan met alleen een e-mailadres om kaarten te bekijken en te kopen. Sommige zijn zelfs met korting verkrijgbaar!", "cake_pay_learn_more": "Koop en wissel cadeaubonnen direct in de app in!\nSwipe van links naar rechts voor meer informatie.", "cake_pay_subtitle": "Koop cadeaubonnen met korting (alleen VS)", @@ -732,6 +733,7 @@ "view_key_private": "Bekijk sleutel (privaat)", "view_key_public": "Bekijk sleutel (openbaar)", "view_transaction_on": "View Transaction on ", + "waitFewSecondForTxUpdate": "Wacht een paar seconden totdat de transactie wordt weergegeven in de transactiegeschiedenis", "waiting_payment_confirmation": "In afwachting van betalingsbevestiging", "wallet_keys": "Portemonnee zaad/sleutels", "wallet_list_create_new_wallet": "Maak een nieuwe portemonnee", @@ -783,6 +785,5 @@ "you_pay": "U betaalt", "you_will_get": "Converteren naar", "you_will_send": "Converteren van", - "yy": "JJ", - "waitFewSecondForTxUpdate": "Wacht een paar seconden totdat de transactie wordt weergegeven in de transactiegeschiedenis" -} + "yy": "JJ" +} \ No newline at end of file diff --git a/res/values/strings_pl.arb b/res/values/strings_pl.arb index cbd1a7171..aa567799e 100644 --- a/res/values/strings_pl.arb +++ b/res/values/strings_pl.arb @@ -84,6 +84,7 @@ "buy_with": "Kup za pomocą", "by_cake_pay": "przez Cake Pay", "cake_2fa_preset": "Ciasto 2FA Preset", + "cake_dark_theme": "Cake Dark Temat", "cake_pay_account_note": "Zarejestruj się, używając tylko adresu e-mail, aby przeglądać i kupować karty. Niektóre są nawet dostępne ze zniżką!", "cake_pay_learn_more": "Kupuj i wykorzystuj karty podarunkowe od razu w aplikacji!\nPrzesuń od lewej do prawej, aby dowiedzieć się więcej.", "cake_pay_subtitle": "Kup karty upominkowe ze zniżką (tylko USA)", @@ -732,6 +733,7 @@ "view_key_private": "Prywatny Klucz Wglądu", "view_key_public": "Publiczny Klucz Wglądu", "view_transaction_on": "Zobacz transakcje na ", + "waitFewSecondForTxUpdate": "Poczekaj kilka sekund, aż transakcja zostanie odzwierciedlona w historii transakcji", "wallet_keys": "Klucze portfela", "wallet_list_create_new_wallet": "Utwórz nowy portfel", "wallet_list_edit_wallet": "Edytuj portfel", @@ -782,6 +784,5 @@ "you_pay": "Płacisz", "you_will_get": "Konwertuj na", "you_will_send": "Konwertuj z", - "yy": "RR", - "waitFewSecondForTxUpdate": "Poczekaj kilka sekund, aż transakcja zostanie odzwierciedlona w historii transakcji" -} + "yy": "RR" +} \ No newline at end of file diff --git a/res/values/strings_pt.arb b/res/values/strings_pt.arb index 7520f9327..b0aa7cab5 100644 --- a/res/values/strings_pt.arb +++ b/res/values/strings_pt.arb @@ -84,6 +84,7 @@ "buy_with": "Compre com", "by_cake_pay": "por Cake Pay", "cake_2fa_preset": "Predefinição de bolo 2FA", + "cake_dark_theme": "Bolo tema escuro", "cake_pay_account_note": "Inscreva-se com apenas um endereço de e-mail para ver e comprar cartões. Alguns estão até com desconto!", "cake_pay_learn_more": "Compre e resgate vales-presente instantaneamente no app!\nDeslize da esquerda para a direita para saber mais.", "cake_pay_subtitle": "Compre vales-presente com desconto (somente nos EUA)", @@ -734,6 +735,7 @@ "view_key_private": "Chave de visualização (privada)", "view_key_public": "Chave de visualização (pública)", "view_transaction_on": "View Transaction on ", + "waitFewSecondForTxUpdate": "Aguarde alguns segundos para que a transação seja refletida no histórico de transações", "waiting_payment_confirmation": "Aguardando confirmação de pagamento", "wallet_keys": "Semente/chaves da carteira", "wallet_list_create_new_wallet": "Criar nova carteira", @@ -785,6 +787,5 @@ "you_pay": "Você paga", "you_will_get": "Converter para", "you_will_send": "Converter de", - "yy": "aa", - "waitFewSecondForTxUpdate": "Aguarde alguns segundos para que a transação seja refletida no histórico de transações" -} + "yy": "aa" +} \ No newline at end of file diff --git a/res/values/strings_ru.arb b/res/values/strings_ru.arb index ee31e003a..bfb7b62d7 100644 --- a/res/values/strings_ru.arb +++ b/res/values/strings_ru.arb @@ -84,6 +84,7 @@ "buy_with": "Купить с помощью", "by_cake_pay": "от Cake Pay", "cake_2fa_preset": "Торт 2FA Preset", + "cake_dark_theme": "Тейт темная тема", "cake_pay_account_note": "Зарегистрируйтесь, указав только адрес электронной почты, чтобы просматривать и покупать карты. Некоторые даже доступны со скидкой!", "cake_pay_learn_more": "Мгновенно покупайте и используйте подарочные карты в приложении!\nПроведите по экрану слева направо, чтобы узнать больше.", "cake_pay_subtitle": "Покупайте подарочные карты со скидкой (только для США)", @@ -733,6 +734,7 @@ "view_key_private": "Приватный ключ просмотра", "view_key_public": "Публичный ключ просмотра", "view_transaction_on": "View Transaction on ", + "waitFewSecondForTxUpdate": "Пожалуйста, подождите несколько секунд, чтобы транзакция отразилась в истории транзакций.", "wallet_keys": "Мнемоническая фраза/ключи кошелька", "wallet_list_create_new_wallet": "Создать новый кошелёк", "wallet_list_edit_wallet": "Изменить кошелек", @@ -783,6 +785,5 @@ "you_pay": "Вы платите", "you_will_get": "Конвертировать в", "you_will_send": "Конвертировать из", - "yy": "ГГ", - "waitFewSecondForTxUpdate": "Пожалуйста, подождите несколько секунд, чтобы транзакция отразилась в истории транзакций." -} + "yy": "ГГ" +} \ No newline at end of file diff --git a/res/values/strings_th.arb b/res/values/strings_th.arb index 3fddae170..5da17828f 100644 --- a/res/values/strings_th.arb +++ b/res/values/strings_th.arb @@ -84,6 +84,7 @@ "buy_with": "ซื้อด้วย", "by_cake_pay": "โดย Cake Pay", "cake_2fa_preset": "เค้ก 2FA ที่ตั้งไว้ล่วงหน้า", + "cake_dark_theme": "ธีมเค้กมืด", "cake_pay_account_note": "ลงทะเบียนด้วยอีเมลเพียงอย่างเดียวเพื่อดูและซื้อบัตร บางบัตรอาจมีส่วนลด!", "cake_pay_learn_more": "ซื้อและเบิกบัตรของขวัญในแอพพลิเคชันทันที!\nกระแทกขวาไปซ้ายเพื่อเรียนรู้เพิ่มเติม", "cake_pay_subtitle": "ซื้อบัตรของขวัญราคาถูก (สำหรับสหรัฐอเมริกาเท่านั้น)", @@ -732,6 +733,7 @@ "view_key_private": "คีย์มุมมอง (ส่วนตัว)", "view_key_public": "คีย์มุมมอง (สาธารณะ)", "view_transaction_on": "ดูการทำธุรกรรมบน ", + "waitFewSecondForTxUpdate": "กรุณารอสักครู่เพื่อให้ธุรกรรมปรากฏในประวัติการทำธุรกรรม", "wallet_keys": "ซีดของกระเป๋า/คีย์", "wallet_list_create_new_wallet": "สร้างกระเป๋าใหม่", "wallet_list_edit_wallet": "แก้ไขกระเป๋าสตางค์", @@ -782,6 +784,5 @@ "you_pay": "คุณจ่าย", "you_will_get": "แปลงเป็น", "you_will_send": "แปลงจาก", - "yy": "ปี", - "waitFewSecondForTxUpdate": "กรุณารอสักครู่เพื่อให้ธุรกรรมปรากฏในประวัติการทำธุรกรรม" -} + "yy": "ปี" +} \ No newline at end of file diff --git a/res/values/strings_tl.arb b/res/values/strings_tl.arb index 6dc6d793a..e0a0b1eae 100644 --- a/res/values/strings_tl.arb +++ b/res/values/strings_tl.arb @@ -84,6 +84,7 @@ "buy_with": "Bumili ka", "by_cake_pay": "sa pamamagitan ng cake pay", "cake_2fa_preset": "Cake 2fa preset", + "cake_dark_theme": "Cake madilim na tema", "cake_pay_account_note": "Mag -sign up na may isang email address lamang upang makita at bumili ng mga kard. Ang ilan ay magagamit kahit sa isang diskwento!", "cake_pay_learn_more": "Agad na bumili at tubusin ang mga kard ng regalo sa app!\nMag -swipe pakaliwa sa kanan upang matuto nang higit pa.", "cake_pay_subtitle": "Bumili ng mga diskwento na gift card (USA lamang)", @@ -732,6 +733,7 @@ "view_key_private": "Tingnan ang Key (Pribado)", "view_key_public": "Tingnan ang Key (Publiko)", "view_transaction_on": "Tingnan ang transaksyon sa", + "waitFewSecondForTxUpdate": "Mangyaring maghintay ng ilang segundo para makita ang transaksyon sa history ng mga transaksyon", "wallet_keys": "Mga buto/susi ng pitaka", "wallet_list_create_new_wallet": "Lumikha ng bagong pitaka", "wallet_list_edit_wallet": "I -edit ang Wallet", @@ -782,6 +784,5 @@ "you_pay": "Magbabayad ka", "you_will_get": "Mag -convert sa", "you_will_send": "I -convert mula sa", - "yy": "YY", - "waitFewSecondForTxUpdate": "Mangyaring maghintay ng ilang segundo para makita ang transaksyon sa history ng mga transaksyon" -} + "yy": "YY" +} \ No newline at end of file diff --git a/res/values/strings_tr.arb b/res/values/strings_tr.arb index 585ef495a..c4d64305e 100644 --- a/res/values/strings_tr.arb +++ b/res/values/strings_tr.arb @@ -84,6 +84,7 @@ "buy_with": "Şunun ile al: ", "by_cake_pay": "Cake Pay tarafından", "cake_2fa_preset": "Kek 2FA Ön Ayarı", + "cake_dark_theme": "Kek Koyu Tema", "cake_pay_account_note": "Kartları görmek ve satın almak için sadece bir e-posta adresiyle kaydolun. Hatta bazıları indirimli olarak bile mevcut!", "cake_pay_learn_more": "Uygulamada anında hediye kartları satın alın ve harcayın!\nDaha fazla öğrenmek için soldan sağa kaydır.", "cake_pay_subtitle": "İndirimli hediye kartları satın alın (yalnızca ABD)", @@ -732,6 +733,7 @@ "view_key_private": "İzleme anahtarı (özel)", "view_key_public": "İzleme anahtarı (genel)", "view_transaction_on": "İşlemi şurada görüntüle ", + "waitFewSecondForTxUpdate": "İşlemin işlem geçmişine yansıması için lütfen birkaç saniye bekleyin", "wallet_keys": "Cüzdan tohumu/anahtarları", "wallet_list_create_new_wallet": "Yeni Cüzdan Oluştur", "wallet_list_edit_wallet": "Cüzdanı düzenle", @@ -782,6 +784,5 @@ "you_pay": "Şu kadar ödeyeceksin: ", "you_will_get": "Biçimine dönüştür:", "you_will_send": "Biçiminden dönüştür:", - "yy": "YY", - "waitFewSecondForTxUpdate": "İşlemin işlem geçmişine yansıması için lütfen birkaç saniye bekleyin" -} + "yy": "YY" +} \ No newline at end of file diff --git a/res/values/strings_uk.arb b/res/values/strings_uk.arb index a22fa8fb1..59371cdbe 100644 --- a/res/values/strings_uk.arb +++ b/res/values/strings_uk.arb @@ -84,6 +84,7 @@ "buy_with": "Купити за допомогою", "by_cake_pay": "від Cake Pay", "cake_2fa_preset": "Торт 2FA Preset", + "cake_dark_theme": "Темна тема торта", "cake_pay_account_note": "Зареєструйтеся, використовуючи лише адресу електронної пошти, щоб переглядати та купувати картки. Деякі навіть доступні зі знижкою!", "cake_pay_learn_more": "Миттєво купуйте та активуйте подарункові картки в додатку!\nПроведіть пальцем зліва направо, щоб дізнатися більше.", "cake_pay_subtitle": "Купуйте подарункові картки зі знижкою (тільки для США)", @@ -733,6 +734,7 @@ "view_key_private": "Приватний ключ перегляду", "view_key_public": "Публічний ключ перегляду", "view_transaction_on": "View Transaction on ", + "waitFewSecondForTxUpdate": "Будь ласка, зачекайте кілька секунд, поки транзакція відобразиться в історії транзакцій", "wallet_keys": "Мнемонічна фраза/ключі гаманця", "wallet_list_create_new_wallet": "Створити новий гаманець", "wallet_list_edit_wallet": "Редагувати гаманець", @@ -783,6 +785,5 @@ "you_pay": "Ви платите", "you_will_get": "Конвертувати в", "you_will_send": "Конвертувати з", - "yy": "YY", - "waitFewSecondForTxUpdate": "Будь ласка, зачекайте кілька секунд, поки транзакція відобразиться в історії транзакцій" -} + "yy": "YY" +} \ No newline at end of file diff --git a/res/values/strings_ur.arb b/res/values/strings_ur.arb index 35e17feac..73b758561 100644 --- a/res/values/strings_ur.arb +++ b/res/values/strings_ur.arb @@ -84,6 +84,7 @@ "buy_with": "کے ساتھ خریدیں۔", "by_cake_pay": "Cake پے کے ذریعے", "cake_2fa_preset": "کیک 2FA پیش سیٹ", + "cake_dark_theme": "کیک ڈارک تھیم", "cake_pay_account_note": "کارڈز دیکھنے اور خریدنے کے لیے صرف ایک ای میل ایڈریس کے ساتھ سائن اپ کریں۔ کچھ رعایت پر بھی دستیاب ہیں!", "cake_pay_learn_more": "ایپ میں فوری طور پر گفٹ کارڈز خریدیں اور بھنائیں!\\nمزید جاننے کے لیے بائیں سے دائیں سوائپ کریں۔", "cake_pay_subtitle": "رعایتی گفٹ کارڈز خریدیں (صرف امریکہ)", @@ -734,6 +735,7 @@ "view_key_private": "کلید دیکھیں (نجی)", "view_key_public": "کلید دیکھیں (عوامی)", "view_transaction_on": "لین دین دیکھیں آن", + "waitFewSecondForTxUpdate": "۔ﮟﯾﺮﮐ ﺭﺎﻈﺘﻧﺍ ﺎﮐ ﮉﻨﮑﯿﺳ ﺪﻨﭼ ﻡﺮﮐ ﮦﺍﺮﺑ ﮯﯿﻟ ﮯﮐ ﮯﻧﺮﮐ ﯽﺳﺎﮑﻋ ﯽﮐ ﻦﯾﺩ ﻦﯿﻟ ﮟﯿﻣ ﺦﯾﺭﺎﺗ ﯽﮐ ﻦ", "wallet_keys": "بٹوے کے بیج / چابیاں", "wallet_list_create_new_wallet": "نیا والیٹ بنائیں", "wallet_list_edit_wallet": "بٹوے میں ترمیم کریں۔", @@ -784,6 +786,5 @@ "you_pay": "تم ادا کرو", "you_will_get": "میں تبدیل کریں۔", "you_will_send": "سے تبدیل کریں۔", - "yy": "YY", - "waitFewSecondForTxUpdate": "۔ﮟﯾﺮﮐ ﺭﺎﻈﺘﻧﺍ ﺎﮐ ﮉﻨﮑﯿﺳ ﺪﻨﭼ ﻡﺮﮐ ﮦﺍﺮﺑ ﮯﯿﻟ ﮯﮐ ﮯﻧﺮﮐ ﯽﺳﺎﮑﻋ ﯽﮐ ﻦﯾﺩ ﻦﯿﻟ ﮟﯿﻣ ﺦﯾﺭﺎﺗ ﯽﮐ ﻦ" -} + "yy": "YY" +} \ No newline at end of file diff --git a/res/values/strings_yo.arb b/res/values/strings_yo.arb index 0f141ebdf..4ccf10ff6 100644 --- a/res/values/strings_yo.arb +++ b/res/values/strings_yo.arb @@ -84,6 +84,7 @@ "buy_with": "Rà pẹ̀lú", "by_cake_pay": "láti ọwọ́ Cake Pay", "cake_2fa_preset": "Cake 2FA Tito", + "cake_dark_theme": "Akara oyinbo dudu koko", "cake_pay_account_note": "Ẹ fi àdírẹ́sì ímeèlì nìkan forúkọ sílẹ̀ k'ẹ́ rí àti ra àwọn káàdì. Ẹ lè fi owó tó kéré jù ra àwọn káàdì kan!", "cake_pay_learn_more": "Láìpẹ́ ra àti lo àwọn káàdí ìrajà t'á lò nínú irú kan ìtajà nínú áàpù!\nẸ tẹ̀ òsì de ọ̀tún láti kọ́ jù.", "cake_pay_subtitle": "Ra àwọn káàdì ìrajà t'á lò nínú ìtajà kan fún owó tí kò pọ̀ (USA nìkan)", @@ -733,6 +734,7 @@ "view_key_private": "Kọ́kọ́rọ́ ìwò (àdáni)", "view_key_public": "Kọ́kọ́rọ́ ìwò (kò àdáni)", "view_transaction_on": "Wo pàṣípààrọ̀ lórí ", + "waitFewSecondForTxUpdate": "Fi inurere duro fun awọn iṣeju diẹ fun idunadura lati ṣe afihan ninu itan-akọọlẹ iṣowo", "wallet_keys": "Hóró/kọ́kọ́rọ́ àpamọ́wọ́", "wallet_list_create_new_wallet": "Ṣe àpamọ́wọ́ títun", "wallet_list_edit_wallet": "Ṣatunkọ apamọwọ", @@ -783,6 +785,5 @@ "you_pay": "Ẹ sàn", "you_will_get": "Ṣe pàṣípààrọ̀ sí", "you_will_send": "Ṣe pàṣípààrọ̀ láti", - "yy": "Ọd", - "waitFewSecondForTxUpdate": "Fi inurere duro fun awọn iṣeju diẹ fun idunadura lati ṣe afihan ninu itan-akọọlẹ iṣowo" -} + "yy": "Ọd" +} \ No newline at end of file diff --git a/res/values/strings_zh.arb b/res/values/strings_zh.arb index ffea168a9..3cff27996 100644 --- a/res/values/strings_zh.arb +++ b/res/values/strings_zh.arb @@ -84,6 +84,7 @@ "buy_with": "一起购买", "by_cake_pay": "通过 Cake Pay", "cake_2fa_preset": "蛋糕 2FA 预设", + "cake_dark_theme": "蛋糕黑暗主题", "cake_pay_account_note": "只需使用電子郵件地址註冊即可查看和購買卡片。有些甚至可以打折!", "cake_pay_learn_more": "立即在应用中购买和兑换礼品卡!\n从左向右滑动以了解详情。", "cake_pay_subtitle": "购买打折礼品卡(仅限美国)", @@ -732,6 +733,7 @@ "view_key_private": "View 密钥(私钥)", "view_key_public": "View 密钥(公钥)", "view_transaction_on": "View Transaction on ", + "waitFewSecondForTxUpdate": "请等待几秒钟,交易才会反映在交易历史记录中", "wallet_keys": "钱包种子/密钥", "wallet_list_create_new_wallet": "创建新钱包", "wallet_list_edit_wallet": "编辑钱包", @@ -782,6 +784,5 @@ "you_pay": "你付钱", "you_will_get": "转换到", "you_will_send": "转换自", - "yy": "YY", - "waitFewSecondForTxUpdate": "请等待几秒钟,交易才会反映在交易历史记录中" -} + "yy": "YY" +} \ No newline at end of file From 10fd32fb2e167c57f34bb74c84c908188f631561 Mon Sep 17 00:00:00 2001 From: Serhii Date: Fri, 1 Mar 2024 21:38:48 +0200 Subject: [PATCH 8/9] Cw 586 display user twitter image in birdpay (#1315) * Update address_validator.dart * add twitter profile image * mastodon profile image * fix data types --- lib/core/address_validator.dart | 10 +- lib/entities/parse_address_from_domain.dart | 27 +++- lib/entities/parsed_address.dart | 67 +++++--- lib/mastodon/mastodon_user.dart | 5 +- .../widgets/extract_address_from_parsed.dart | 8 + lib/src/widgets/alert_with_one_action.dart | 12 +- lib/src/widgets/base_alert_dialog.dart | 148 ++++++++++++------ lib/twitter/twitter_api.dart | 2 +- lib/twitter/twitter_user.dart | 7 +- 9 files changed, 200 insertions(+), 86 deletions(-) diff --git a/lib/core/address_validator.dart b/lib/core/address_validator.dart index 853762a1c..84fcb9e2e 100644 --- a/lib/core/address_validator.dart +++ b/lib/core/address_validator.dart @@ -270,11 +270,11 @@ class AddressValidator extends TextValidator { '|([^0-9a-zA-Z]|^)8[0-9a-zA-Z]{94}([^0-9a-zA-Z]|\$)' '|([^0-9a-zA-Z]|^)[0-9a-zA-Z]{106}([^0-9a-zA-Z]|\$)'; case CryptoCurrency.btc: - return '([^0-9a-zA-Z]|^)${P2pkhAddress.regex.pattern}|\$)' - '([^0-9a-zA-Z]|^)${P2shAddress.regex.pattern}|\$)' - '([^0-9a-zA-Z]|^)${P2wpkhAddress.regex.pattern}|\$)' - '([^0-9a-zA-Z]|^)${P2wshAddress.regex.pattern}|\$)' - '([^0-9a-zA-Z]|^)${P2trAddress.regex.pattern}|\$)'; + return '([^0-9a-zA-Z]|^)([1mn][a-km-zA-HJ-NP-Z1-9]{25,34})([^0-9a-zA-Z]|\$)' //P2pkhAddress type + '|([^0-9a-zA-Z]|^)([23][a-km-zA-HJ-NP-Z1-9]{25,34})([^0-9a-zA-Z]|\$)' //P2shAddress type + '|([^0-9a-zA-Z]|^)((bc|tb)1q[ac-hj-np-z02-9]{25,39})([^0-9a-zA-Z]|\$)' //P2wpkhAddress type + '|([^0-9a-zA-Z]|^)((bc|tb)1q[ac-hj-np-z02-9]{40,80})([^0-9a-zA-Z]|\$)' //P2wshAddress type + '|([^0-9a-zA-Z]|^)((bc|tb)1p([ac-hj-np-z02-9]{39}|[ac-hj-np-z02-9]{59}|[ac-hj-np-z02-9]{8,89}))([^0-9a-zA-Z]|\$)'; //P2trAddress type case CryptoCurrency.ltc: return '([^0-9a-zA-Z]|^)^L[a-zA-Z0-9]{26,33}([^0-9a-zA-Z]|\$)' '|([^0-9a-zA-Z]|^)[LM][a-km-zA-HJ-NP-Z1-9]{26,33}([^0-9a-zA-Z]|\$)' diff --git a/lib/entities/parse_address_from_domain.dart b/lib/entities/parse_address_from_domain.dart index 52bcc495b..3ebc08c55 100644 --- a/lib/entities/parse_address_from_domain.dart +++ b/lib/entities/parse_address_from_domain.dart @@ -69,16 +69,20 @@ class AddressResolver { } - Future resolve(BuildContext context, String text, String ticker) async { + Future resolve(BuildContext context, String text, String ticker) async { try { if (text.startsWith('@') && !text.substring(1).contains('@')) { - if(settingsStore.lookupsTwitter) { + if (settingsStore.lookupsTwitter) { final formattedName = text.substring(1); final twitterUser = await TwitterApi.lookupUserByName(userName: formattedName); final addressFromBio = extractAddressByType( raw: twitterUser.description, type: CryptoCurrency.fromString(ticker)); if (addressFromBio != null) { - return ParsedAddress.fetchTwitterAddress(address: addressFromBio, name: text); + return ParsedAddress.fetchTwitterAddress( + address: addressFromBio, + name: text, + profileImageUrl: twitterUser.profileImageUrl, + profileName: twitterUser.name); } final pinnedTweet = twitterUser.pinnedTweet?.text; @@ -86,7 +90,11 @@ class AddressResolver { final addressFromPinnedTweet = extractAddressByType(raw: pinnedTweet, type: CryptoCurrency.fromString(ticker)); if (addressFromPinnedTweet != null) { - return ParsedAddress.fetchTwitterAddress(address: addressFromPinnedTweet, name: text); + return ParsedAddress.fetchTwitterAddress( + address: addressFromPinnedTweet, + name: text, + profileImageUrl: twitterUser.profileImageUrl, + profileName: twitterUser.name); } } } @@ -107,7 +115,11 @@ class AddressResolver { extractAddressByType(raw: mastodonUser.note, type: CryptoCurrency.fromString(ticker)); if (addressFromBio != null) { - return ParsedAddress.fetchMastodonAddress(address: addressFromBio, name: text); + return ParsedAddress.fetchMastodonAddress( + address: addressFromBio, + name: text, + profileImageUrl: mastodonUser.profileImageUrl, + profileName: mastodonUser.username); } else { final pinnedPosts = await MastodonAPI.getPinnedPosts(userId: mastodonUser.id, apiHost: hostName); @@ -119,7 +131,10 @@ class AddressResolver { if (addressFromPinnedPost != null) { return ParsedAddress.fetchMastodonAddress( - address: addressFromPinnedPost, name: text); + address: addressFromPinnedPost, + name: text, + profileImageUrl: mastodonUser.profileImageUrl, + profileName: mastodonUser.username); } } } diff --git a/lib/entities/parsed_address.dart b/lib/entities/parsed_address.dart index d414a827d..d87deb9e8 100644 --- a/lib/entities/parsed_address.dart +++ b/lib/entities/parsed_address.dart @@ -1,7 +1,6 @@ import 'package:cake_wallet/entities/openalias_record.dart'; import 'package:cake_wallet/entities/yat_record.dart'; - enum ParseFrom { unstoppableDomains, openAlias, @@ -20,36 +19,37 @@ class ParsedAddress { required this.addresses, this.name = '', this.description = '', + this.profileImageUrl = '', + this.profileName = '', this.parseFrom = ParseFrom.notParsed, }); factory ParsedAddress.fetchEmojiAddress({ List? addresses, required String name, - }){ - if (addresses?.isEmpty ?? true) { - return ParsedAddress( - addresses: [name], parseFrom: ParseFrom.yatRecord); - } - return ParsedAddress( - addresses: addresses!.map((e) => e.address).toList(), - name: name, - parseFrom: ParseFrom.yatRecord, - ); + }) { + if (addresses?.isEmpty ?? true) { + return ParsedAddress(addresses: [name], parseFrom: ParseFrom.yatRecord); + } + return ParsedAddress( + addresses: addresses!.map((e) => e.address).toList(), + name: name, + parseFrom: ParseFrom.yatRecord, + ); } factory ParsedAddress.fetchUnstoppableDomainAddress({ String? address, required String name, - }){ - if (address?.isEmpty ?? true) { - return ParsedAddress(addresses: [name]); - } - return ParsedAddress( - addresses: [address!], - name: name, - parseFrom: ParseFrom.unstoppableDomains, - ); + }) { + if (address?.isEmpty ?? true) { + return ParsedAddress(addresses: [name]); + } + return ParsedAddress( + addresses: [address!], + name: name, + parseFrom: ParseFrom.unstoppableDomains, + ); } factory ParsedAddress.fetchOpenAliasAddress( @@ -65,7 +65,7 @@ class ParsedAddress { ); } - factory ParsedAddress.fetchFioAddress({required String address, required String name}){ + factory ParsedAddress.fetchFioAddress({required String address, required String name}) { return ParsedAddress( addresses: [address], name: name, @@ -73,23 +73,37 @@ class ParsedAddress { ); } - factory ParsedAddress.fetchTwitterAddress({required String address, required String name}){ + factory ParsedAddress.fetchTwitterAddress( + {required String address, + required String name, + required String profileImageUrl, + required String profileName, + String? description}) { return ParsedAddress( addresses: [address], name: name, + description: description ?? '', + profileImageUrl: profileImageUrl, + profileName: profileName, parseFrom: ParseFrom.twitter, ); } - factory ParsedAddress.fetchMastodonAddress({required String address, required String name}){ + factory ParsedAddress.fetchMastodonAddress( + {required String address, + required String name, + required String profileImageUrl, + required String profileName}) { return ParsedAddress( addresses: [address], name: name, - parseFrom: ParseFrom.mastodon + parseFrom: ParseFrom.mastodon, + profileImageUrl: profileImageUrl, + profileName: profileName, ); } - factory ParsedAddress.fetchContactAddress({required String address, required String name}){ + factory ParsedAddress.fetchContactAddress({required String address, required String name}) { return ParsedAddress( addresses: [address], name: name, @@ -116,6 +130,7 @@ class ParsedAddress { final List addresses; final String name; final String description; + final String profileImageUrl; + final String profileName; final ParseFrom parseFrom; - } diff --git a/lib/mastodon/mastodon_user.dart b/lib/mastodon/mastodon_user.dart index f5a29f298..1832c083e 100644 --- a/lib/mastodon/mastodon_user.dart +++ b/lib/mastodon/mastodon_user.dart @@ -1,12 +1,14 @@ class MastodonUser { String id; String username; + String profileImageUrl; String acct; String note; MastodonUser({ required this.id, required this.username, + required this.profileImageUrl, required this.acct, required this.note, }); @@ -14,9 +16,10 @@ class MastodonUser { factory MastodonUser.fromJson(Map json) { return MastodonUser( id: json['id'] as String, - username: json['username'] as String, + username: json['username'] as String? ?? '', acct: json['acct'] as String, note: json['note'] as String, + profileImageUrl: json['avatar'] as String? ?? '' ); } } diff --git a/lib/src/screens/send/widgets/extract_address_from_parsed.dart b/lib/src/screens/send/widgets/extract_address_from_parsed.dart index bb09d4ca3..42e646d58 100644 --- a/lib/src/screens/send/widgets/extract_address_from_parsed.dart +++ b/lib/src/screens/send/widgets/extract_address_from_parsed.dart @@ -11,6 +11,8 @@ Future extractAddressFromParsed( var title = ''; var content = ''; var address = ''; + var profileImageUrl = ''; + var profileName = ''; switch (parsedAddress.parseFrom) { case ParseFrom.unstoppableDomains: @@ -37,11 +39,15 @@ Future extractAddressFromParsed( title = S.of(context).address_detected; content = S.of(context).extracted_address_content('${parsedAddress.name} (Twitter)'); address = parsedAddress.addresses.first; + profileImageUrl = parsedAddress.profileImageUrl; + profileName = parsedAddress.profileName; break; case ParseFrom.mastodon: title = S.of(context).address_detected; content = S.of(context).extracted_address_content('${parsedAddress.name} (Mastodon)'); address = parsedAddress.addresses.first; + profileImageUrl = parsedAddress.profileImageUrl; + profileName = parsedAddress.profileName; break; case ParseFrom.nostr: title = S.of(context).address_detected; @@ -95,6 +101,8 @@ Future extractAddressFromParsed( return AlertWithOneAction( alertTitle: title, + headerTitleText: profileName.isEmpty ? null : profileName, + headerImageProfileUrl: profileImageUrl.isEmpty ? null : profileImageUrl, alertContent: content, buttonText: S.of(context).ok, buttonAction: () => Navigator.of(context).pop()); diff --git a/lib/src/widgets/alert_with_one_action.dart b/lib/src/widgets/alert_with_one_action.dart index c06114f5b..7ad0ac1af 100644 --- a/lib/src/widgets/alert_with_one_action.dart +++ b/lib/src/widgets/alert_with_one_action.dart @@ -7,7 +7,9 @@ class AlertWithOneAction extends BaseAlertDialog { required this.alertContent, required this.buttonText, required this.buttonAction, - this.alertBarrierDismissible = true + this.alertBarrierDismissible = true, + this.headerTitleText, + this.headerImageProfileUrl }); final String alertTitle; @@ -15,6 +17,8 @@ class AlertWithOneAction extends BaseAlertDialog { final String buttonText; final VoidCallback buttonAction; final bool alertBarrierDismissible; + final String? headerTitleText; + final String? headerImageProfileUrl; @override String get titleText => alertTitle; @@ -25,6 +29,12 @@ class AlertWithOneAction extends BaseAlertDialog { @override bool get barrierDismissible => alertBarrierDismissible; + @override + String? get headerImageUrl => headerImageProfileUrl; + + @override + String? get headerText => headerTitleText; + @override Widget actionButtons(BuildContext context) { return Container( diff --git a/lib/src/widgets/base_alert_dialog.dart b/lib/src/widgets/base_alert_dialog.dart index 02a1f6ad0..e9ef522df 100644 --- a/lib/src/widgets/base_alert_dialog.dart +++ b/lib/src/widgets/base_alert_dialog.dart @@ -1,3 +1,4 @@ +import 'package:cake_wallet/src/screens/receive/widgets/header_tile.dart'; import 'package:cake_wallet/themes/extensions/cake_text_theme.dart'; import 'dart:ui'; import 'package:cake_wallet/src/widgets/section_divider.dart'; @@ -5,19 +6,34 @@ import 'package:cake_wallet/themes/extensions/alert_theme.dart'; import 'package:flutter/material.dart'; class BaseAlertDialog extends StatelessWidget { + String? get headerText => ''; + String get titleText => ''; + String get contentText => ''; + String get leftActionButtonText => ''; + String get rightActionButtonText => ''; + bool get isDividerExists => false; + VoidCallback get actionLeft => () {}; + VoidCallback get actionRight => () {}; + bool get barrierDismissible => true; + Color? get leftActionButtonTextColor => null; + Color? get rightActionButtonTextColor => null; + Color? get leftActionButtonColor => null; + Color? get rightActionButtonColor => null; + String? get headerImageUrl => null; + Widget title(BuildContext context) { return Text( titleText, @@ -32,6 +48,23 @@ class BaseAlertDialog extends StatelessWidget { ); } + Widget headerTitle(BuildContext context) { + return Padding( + padding: const EdgeInsets.only(top: 10.0), + child: Text( + headerText!, + textAlign: TextAlign.center, + style: TextStyle( + fontSize: 25, + fontFamily: 'Lato', + fontWeight: FontWeight.w600, + color: Theme.of(context).extension()!.titleColor, + decoration: TextDecoration.none, + ), + ), + ); + } + Widget content(BuildContext context) { return Text( contentText, @@ -48,17 +81,17 @@ class BaseAlertDialog extends StatelessWidget { Widget actionButtons(BuildContext context) { return Container( - height: 60, - child: Row( - mainAxisSize: MainAxisSize.max, - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ + height: 60, + child: Row( + mainAxisSize: MainAxisSize.max, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ Expanded( child: TextButton( onPressed: actionLeft, style: TextButton.styleFrom( - backgroundColor: leftActionButtonColor ?? - Theme.of(context).dialogBackgroundColor, + backgroundColor: + leftActionButtonColor ?? Theme.of(context).dialogBackgroundColor, shape: const RoundedRectangleBorder( borderRadius: BorderRadius.all(Radius.zero))), child: Text( @@ -79,8 +112,8 @@ class BaseAlertDialog extends StatelessWidget { child: TextButton( onPressed: actionRight, style: TextButton.styleFrom( - backgroundColor: rightActionButtonColor ?? - Theme.of(context).dialogBackgroundColor, + backgroundColor: + rightActionButtonColor ?? Theme.of(context).dialogBackgroundColor, shape: const RoundedRectangleBorder( borderRadius: BorderRadius.all(Radius.zero))), child: Text( @@ -90,8 +123,7 @@ class BaseAlertDialog extends StatelessWidget { fontSize: 15, fontFamily: 'Lato', fontWeight: FontWeight.w600, - color: rightActionButtonTextColor ?? - Theme.of(context).primaryColor, + color: rightActionButtonTextColor ?? Theme.of(context).primaryColor, decoration: TextDecoration.none, ), )), @@ -100,6 +132,24 @@ class BaseAlertDialog extends StatelessWidget { )); } + Widget headerImage(BuildContext context, String imageUrl) { + return Positioned( + top: -50, + left: 0, + right: 0, + child: CircleAvatar( + radius: 50, + backgroundColor: Colors.white, + child: ClipOval( + child: Image.network( + imageUrl, + fit: BoxFit.cover, + ), + ), + ), + ); + } + @override Widget build(BuildContext context) { return GestureDetector( @@ -109,43 +159,51 @@ class BaseAlertDialog extends StatelessWidget { child: BackdropFilter( filter: ImageFilter.blur(sigmaX: 3.0, sigmaY: 3.0), child: Container( - decoration: BoxDecoration( - color: - Theme.of(context).extension()!.backdropColor), + decoration: + BoxDecoration(color: Theme.of(context).extension()!.backdropColor), child: Center( child: GestureDetector( onTap: () => null, - child: ClipRRect( - borderRadius: BorderRadius.all(Radius.circular(30)), - child: Container( - width: 300, - color: Theme.of(context).dialogBackgroundColor, - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - Column( - crossAxisAlignment: CrossAxisAlignment.center, - children: [ - Padding( - padding: EdgeInsets.fromLTRB(24, 20, 24, 0), - child: title(context), - ), - isDividerExists - ? Padding( - padding: EdgeInsets.only(top: 16, bottom: 8), - child: const HorizontalSectionDivider(), - ) - : Offstage(), - Padding( - padding: EdgeInsets.fromLTRB(24, 8, 24, 32), - child: content(context), - ) - ], - ), - const HorizontalSectionDivider(), - actionButtons(context) - ], - ), + child: Container( + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(30), + color: Theme.of(context).dialogBackgroundColor), + width: 300, + child: Stack( + clipBehavior: Clip.none, + children: [ + if (headerImageUrl != null) headerImage(context, headerImageUrl!), + Column( + mainAxisSize: MainAxisSize.min, + children: [ + if (headerImageUrl != null) const SizedBox(height: 50), + Column( + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + if (headerText != null) headerTitle(context), + Padding( + padding: EdgeInsets.fromLTRB(24, 20, 24, 0), + child: title(context), + ), + isDividerExists + ? Padding( + padding: EdgeInsets.only(top: 16, bottom: 8), + child: const HorizontalSectionDivider(), + ) + : Offstage(), + Padding( + padding: EdgeInsets.fromLTRB(24, 8, 24, 32), + child: content(context), + ) + ], + ), + const HorizontalSectionDivider(), + ClipRRect( + borderRadius: BorderRadius.all(Radius.circular(30)), + child: actionButtons(context)) + ], + ), + ], ), ), ), diff --git a/lib/twitter/twitter_api.dart b/lib/twitter/twitter_api.dart index 24121c9c0..bf6298dae 100644 --- a/lib/twitter/twitter_api.dart +++ b/lib/twitter/twitter_api.dart @@ -12,7 +12,7 @@ class TwitterApi { static Future lookupUserByName({required String userName}) async { final queryParams = { - 'user.fields': 'description', + 'user.fields': 'description,profile_image_url', 'expansions': 'pinned_tweet_id', 'tweet.fields': 'note_tweet' }; diff --git a/lib/twitter/twitter_user.dart b/lib/twitter/twitter_user.dart index c0eb5431c..01db25684 100644 --- a/lib/twitter/twitter_user.dart +++ b/lib/twitter/twitter_user.dart @@ -4,20 +4,25 @@ class TwitterUser { required this.username, required this.name, required this.description, + required this.profileImageUrl, this.pinnedTweet}); final String id; final String username; final String name; final String description; + final String profileImageUrl; final Tweet? pinnedTweet; factory TwitterUser.fromJson(Map json, [Tweet? pinnedTweet]) { + final profileImageUrl = json['data']['profile_image_url'] as String? ?? ''; + final scaledProfileImageUrl = profileImageUrl.replaceFirst('normal', '200x200'); return TwitterUser( id: json['data']['id'] as String, - username: json['data']['username'] as String, + username: json['data']['username'] as String? ?? '', name: json['data']['name'] as String, description: json['data']['description'] as String? ?? '', + profileImageUrl: scaledProfileImageUrl, pinnedTweet: pinnedTweet, ); } From c7deeaea9b65330c5fe9894da3ee4f4ab9cd9d11 Mon Sep 17 00:00:00 2001 From: Omar Hatem Date: Mon, 4 Mar 2024 19:32:10 +0200 Subject: [PATCH 9/9] New versions (#1312) * New versions Fix issues with Monero.com * Add sending for Solana tokens exchanges * Add default keyword for P2WPKH [skip ci] * chore: Switch solana commitment to confirmed to reduced blockhash expiration (#1313) * Modify test workflow to send arm64-v8a build only * Fix workflow build path * Remove unnecessary reverse of txId * Show case sensitive evm wallet address * Revert default Cake Theme add custom package id for test builds * Fix workflow script * Fix workflow * hash branch name * hash branch name * Update versions * Add user image to Nostr Add fetching address from text for tokens * Fix test app package id * fix: Solana message improvement (#1316) --------- Co-authored-by: Adegoke David <64401859+Blazebrain@users.noreply.github.com> --- .github/workflows/pr_test_build.yml | 28 +++++++--------- assets/text/Monerocom_Release_Notes.txt | 5 ++- assets/text/Release_Notes.txt | 5 ++- .../lib/bitcoin_receive_page_option.dart | 4 +-- cw_bitcoin/lib/electrum_wallet.dart | 14 ++------ cw_core/lib/crypto_currency.dart | 12 ++++++- cw_evm/lib/evm_chain_wallet.dart | 2 +- cw_solana/lib/solana_client.dart | 11 ++++--- ios/Podfile | 4 +-- ios/Podfile.lock | 24 +++++++------- ios/Runner.xcodeproj/project.pbxproj | 11 ++----- lib/bitcoin/cw_bitcoin.dart | 21 ++++++++++-- lib/core/address_validator.dart | 10 ++++++ lib/entities/parse_address_from_domain.dart | 33 +++++++++++-------- lib/entities/parsed_address.dart | 8 ++++- .../screens/dashboard/pages/address_page.dart | 21 +++--------- .../widgets/extract_address_from_parsed.dart | 2 ++ lib/src/widgets/base_alert_dialog.dart | 1 - lib/store/settings_store.dart | 12 +++++-- lib/twitter/twitter_api.dart | 31 ++++++++++------- .../exchange/exchange_trade_view_model.dart | 7 +++- macos/Flutter/GeneratedPluginRegistrant.swift | 2 +- macos/Podfile.lock | 14 ++++---- pubspec_base.yaml | 6 +++- scripts/android/app_env.sh | 6 ++-- scripts/ios/app_env.sh | 6 ++-- scripts/macos/app_env.sh | 8 ++--- tool/configure.dart | 16 +++++---- 28 files changed, 186 insertions(+), 138 deletions(-) diff --git a/.github/workflows/pr_test_build.yml b/.github/workflows/pr_test_build.yml index b5fe24f18..4df215e13 100644 --- a/.github/workflows/pr_test_build.yml +++ b/.github/workflows/pr_test_build.yml @@ -104,17 +104,7 @@ jobs: - 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_solana && 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 + ./model_generator.sh - name: Add secrets run: | @@ -159,12 +149,16 @@ jobs: echo "const ankrApiKey = '${{ secrets.ANKR_API_KEY }}';" >> cw_solana/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 + run: | + hash=`sha512sum <<<"${{ env.BRANCH_NAME }}"` + substring=${hash:0:15} + echo substring + echo -e "id=com.cakewallet.test_$(substring)\nname=${{ env.BRANCH_NAME }}" > /opt/android/cake_wallet/android/app.properties - name: Build run: | cd /opt/android/cake_wallet - flutter build apk --release + flutter build apk --release --split-per-abi # - name: Push to App Center # run: | @@ -181,21 +175,21 @@ jobs: - name: Rename apk file run: | - cd /opt/android/cake_wallet/build/app/outputs/apk/release + cd /opt/android/cake_wallet/build/app/outputs/flutter-apk mkdir test-apk - cp app-release.apk test-apk/${{env.BRANCH_NAME}}.apk + cp app-arm64-v8a-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/ + 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/apk/release/app-release.apk + 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 diff --git a/assets/text/Monerocom_Release_Notes.txt b/assets/text/Monerocom_Release_Notes.txt index 7cf786332..732e58a18 100644 --- a/assets/text/Monerocom_Release_Notes.txt +++ b/assets/text/Monerocom_Release_Notes.txt @@ -1,3 +1,2 @@ -Improve wallet recovery and error tolerance -Enhance Background sync for Monero wallets -Bug fixes \ No newline at end of file +New themes +Bug fixes and enhancements \ No newline at end of file diff --git a/assets/text/Release_Notes.txt b/assets/text/Release_Notes.txt index ac032e354..1c2ec154c 100644 --- a/assets/text/Release_Notes.txt +++ b/assets/text/Release_Notes.txt @@ -1,3 +1,6 @@ +Add Solana wallet Support ALL Bitcoin address types (Legacy, Segwit (both variants), Taproot) Enhance Sending/Receiving flow for Bitcoin -Improve fee calculations in Bitcoin \ No newline at end of file +Improve fee calculations in Bitcoin +New themes +Bug fixes and enhancements \ No newline at end of file diff --git a/cw_bitcoin/lib/bitcoin_receive_page_option.dart b/cw_bitcoin/lib/bitcoin_receive_page_option.dart index 2e246f532..2d2339a41 100644 --- a/cw_bitcoin/lib/bitcoin_receive_page_option.dart +++ b/cw_bitcoin/lib/bitcoin_receive_page_option.dart @@ -2,7 +2,7 @@ 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)'); + 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)'); @@ -18,9 +18,9 @@ class BitcoinReceivePageOption implements ReceivePageOption { static const all = [ BitcoinReceivePageOption.p2wpkh, - BitcoinReceivePageOption.p2sh, BitcoinReceivePageOption.p2tr, BitcoinReceivePageOption.p2wsh, + BitcoinReceivePageOption.p2sh, BitcoinReceivePageOption.p2pkh ]; diff --git a/cw_bitcoin/lib/electrum_wallet.dart b/cw_bitcoin/lib/electrum_wallet.dart index 873fe2977..c3f40a235 100644 --- a/cw_bitcoin/lib/electrum_wallet.dart +++ b/cw_bitcoin/lib/electrum_wallet.dart @@ -35,7 +35,6 @@ import 'package:cw_core/utils/file.dart'; import 'package:cw_core/wallet_base.dart'; import 'package:cw_core/wallet_info.dart'; import 'package:flutter/foundation.dart'; -import 'package:hex/hex.dart'; import 'package:hive/hive.dart'; import 'package:mobx/mobx.dart'; import 'package:rxdart/subjects.dart'; @@ -623,16 +622,9 @@ abstract class ElectrumWalletBase final ins = []; for (final vin in original.inputs) { - try { - final id = HEX.encode(HEX.decode(vin.txId).reversed.toList()); - final txHex = await electrumClient.getTransactionHex(hash: id); - final tx = bitcoin_base.BtcTransaction.fromRaw(txHex); - ins.add(tx); - } catch (_) { - ins.add(bitcoin_base.BtcTransaction.fromRaw( - await electrumClient.getTransactionHex(hash: vin.txId), - )); - } + final txHex = await electrumClient.getTransactionHex(hash: vin.txId); + final tx = bitcoin_base.BtcTransaction.fromRaw(txHex); + ins.add(tx); } return ElectrumTransactionBundle(original, diff --git a/cw_core/lib/crypto_currency.dart b/cw_core/lib/crypto_currency.dart index 67581ecb8..ce509015c 100644 --- a/cw_core/lib/crypto_currency.dart +++ b/cw_core/lib/crypto_currency.dart @@ -102,6 +102,7 @@ class CryptoCurrency extends EnumerableItem with Serializable implemen CryptoCurrency.usdcEPoly, CryptoCurrency.kaspa, CryptoCurrency.digibyte, + CryptoCurrency.usdtSol, ]; static const havenCurrencies = [ @@ -246,7 +247,16 @@ class CryptoCurrency extends EnumerableItem with Serializable implemen return CryptoCurrency._rawCurrencyMap[raw]!; } - static CryptoCurrency fromString(String name) { + // TODO: refactor this + static CryptoCurrency fromString(String name, {CryptoCurrency? walletCurrency}) { + try { + return CryptoCurrency.all.firstWhere((element) => + element.title.toLowerCase() == name && + (element.tag == null || + element.tag == walletCurrency?.title || + element.tag == walletCurrency?.tag)); + } catch (_) {} + if (CryptoCurrency._nameCurrencyMap[name.toLowerCase()] == null) { final s = 'Unexpected token: $name for CryptoCurrency fromString'; throw ArgumentError.value(name, 'name', s); diff --git a/cw_evm/lib/evm_chain_wallet.dart b/cw_evm/lib/evm_chain_wallet.dart index ea19a8557..0fb282960 100644 --- a/cw_evm/lib/evm_chain_wallet.dart +++ b/cw_evm/lib/evm_chain_wallet.dart @@ -146,7 +146,7 @@ abstract class EVMChainWalletBase privateKey: _hexPrivateKey, password: _password, ); - walletAddresses.address = _evmChainPrivateKey.address.toString(); + walletAddresses.address = _evmChainPrivateKey.address.hexEip55; await save(); } diff --git a/cw_solana/lib/solana_client.dart b/cw_solana/lib/solana_client.dart index ececc56ba..ea4a9161a 100644 --- a/cw_solana/lib/solana_client.dart +++ b/cw_solana/lib/solana_client.dart @@ -263,7 +263,7 @@ class SolanaWalletClient { required Ed25519HDKeyPair ownerKeypair, List references = const [], }) async { - const commitment = Commitment.finalized; + const commitment = Commitment.confirmed; final latestBlockhash = await _client!.rpcClient.getLatestBlockhash(commitment: commitment).value; @@ -394,9 +394,7 @@ class SolanaWalletClient { funder: ownerKeypair, ); } catch (e) { - throw Exception( - 'Error while creating an associated token account for the recipient: ${e.toString()}', - ); + throw Exception('Insufficient lamports balance to complete this transaction'); } // Input by the user @@ -468,7 +466,10 @@ class SolanaWalletClient { required SignedTx signedTransaction, required Commitment commitment, }) async { - final signature = await _client!.rpcClient.sendTransaction(signedTransaction.encode()); + final signature = await _client!.rpcClient.sendTransaction( + signedTransaction.encode(), + preflightCommitment: commitment, + ); _client!.waitForSignatureStatus(signature, status: commitment); diff --git a/ios/Podfile b/ios/Podfile index 027d48ceb..00b5fd2df 100644 --- a/ios/Podfile +++ b/ios/Podfile @@ -1,5 +1,5 @@ # Uncomment this line to define a global platform for your project -platform :ios, '11.0' +platform :ios, '12.0' source 'https://github.com/CocoaPods/Specs.git' # CocoaPods analytics sends network stats synchronously affecting flutter build latency. @@ -44,7 +44,7 @@ post_install do |installer| flutter_additional_ios_build_settings(target) target.build_configurations.each do |config| - config.build_settings['IPHONEOS_DEPLOYMENT_TARGET'] = '11.0' + config.build_settings['IPHONEOS_DEPLOYMENT_TARGET'] = '12.0' config.build_settings['GCC_PREPROCESSOR_DEFINITIONS'] ||= [ '$(inherited)', diff --git a/ios/Podfile.lock b/ios/Podfile.lock index 2545e90ce..4f3aea7ec 100644 --- a/ios/Podfile.lock +++ b/ios/Podfile.lock @@ -7,7 +7,7 @@ PODS: - connectivity_plus (0.0.1): - Flutter - ReachabilitySwift - - CryptoSwift (1.7.1) + - CryptoSwift (1.8.1) - cw_haven (0.0.1): - cw_haven/Boost (= 0.0.1) - cw_haven/Haven (= 0.0.1) @@ -132,9 +132,9 @@ PODS: - permission_handler_apple (9.1.1): - Flutter - ReachabilitySwift (5.0.0) - - SDWebImage (5.16.0): - - SDWebImage/Core (= 5.16.0) - - SDWebImage/Core (5.16.0) + - SDWebImage (5.18.11): + - SDWebImage/Core (= 5.18.11) + - SDWebImage/Core (5.18.11) - sensitive_clipboard (0.0.1): - Flutter - share_plus (0.0.1): @@ -142,9 +142,9 @@ PODS: - shared_preferences_foundation (0.0.1): - Flutter - FlutterMacOS - - SwiftProtobuf (1.22.0) + - SwiftProtobuf (1.25.2) - SwiftyGif (5.4.4) - - Toast (4.0.0) + - Toast (4.1.0) - uni_links (0.0.1): - Flutter - UnstoppableDomainsResolution (4.0.0): @@ -262,8 +262,8 @@ EXTERNAL SOURCES: SPEC CHECKSUMS: barcode_scan2: 0af2bb63c81b4565aab6cd78278e4c0fa136dbb0 BigInt: f668a80089607f521586bbe29513d708491ef2f7 - connectivity_plus: 413a8857dd5d9f1c399a39130850d02fe0feaf7e - CryptoSwift: d3d18dc357932f7e6d580689e065cf1f176007c1 + connectivity_plus: bf0076dd84a130856aa636df1c71ccaff908fa1d + CryptoSwift: b9c701d6f5011df23794dbf7f2e480a77835d83d cw_haven: b3e54e1fbe7b8e6fda57a93206bc38f8e89b898a cw_monero: 4cf3b96f2da8e95e2ef7d6703dd4d2c509127b7d cw_shared_external: 2972d872b8917603478117c9957dfca611845a92 @@ -287,19 +287,19 @@ SPEC CHECKSUMS: path_provider_foundation: 3784922295ac71e43754bd15e0653ccfd36a147c permission_handler_apple: e76247795d700c14ea09e3a2d8855d41ee80a2e6 ReachabilitySwift: 985039c6f7b23a1da463388634119492ff86c825 - SDWebImage: 2aea163b50bfcb569a2726b6a754c54a4506fcf6 + SDWebImage: a3ba0b8faac7228c3c8eadd1a55c9c9fe5e16457 sensitive_clipboard: d4866e5d176581536c27bb1618642ee83adca986 share_plus: 056a1e8ac890df3e33cb503afffaf1e9b4fbae68 shared_preferences_foundation: b4c3b4cddf1c21f02770737f147a3f5da9d39695 - SwiftProtobuf: 40bd808372cb8706108f22d28f8ab4a6b9bc6989 + SwiftProtobuf: 407a385e97fd206c4fbe880cc84123989167e0d1 SwiftyGif: 93a1cc87bf3a51916001cf8f3d63835fb64c819f - Toast: 91b396c56ee72a5790816f40d3a94dd357abc196 + Toast: ec33c32b8688982cecc6348adeae667c1b9938da uni_links: d97da20c7701486ba192624d99bffaaffcfc298a UnstoppableDomainsResolution: c3c67f4d0a5e2437cb00d4bd50c2e00d6e743841 url_launcher_ios: bbd758c6e7f9fd7b5b1d4cde34d2b95fcce5e812 wakelock_plus: 8b09852c8876491e4b6d179e17dfe2a0b5f60d47 workmanager: 0afdcf5628bbde6924c21af7836fed07b42e30e6 -PODFILE CHECKSUM: 09df1114e7c360f55770d35a79356bf5446e0100 +PODFILE CHECKSUM: fcb1b8418441a35b438585c9dd8374e722e6c6ca COCOAPODS: 1.12.1 diff --git a/ios/Runner.xcodeproj/project.pbxproj b/ios/Runner.xcodeproj/project.pbxproj index 1da5bc4bc..7a8b99b49 100644 --- a/ios/Runner.xcodeproj/project.pbxproj +++ b/ios/Runner.xcodeproj/project.pbxproj @@ -377,7 +377,7 @@ "$(PROJECT_DIR)/Flutter", ); INFOPLIST_FILE = Runner/Info.plist; - IPHONEOS_DEPLOYMENT_TARGET = 11.0; + IPHONEOS_DEPLOYMENT_TARGET = 12.0; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", @@ -523,7 +523,7 @@ "$(PROJECT_DIR)/Flutter", ); INFOPLIST_FILE = Runner/Info.plist; - IPHONEOS_DEPLOYMENT_TARGET = 11.0; + IPHONEOS_DEPLOYMENT_TARGET = 12.0; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", @@ -561,7 +561,7 @@ "$(PROJECT_DIR)/Flutter", ); INFOPLIST_FILE = Runner/Info.plist; - IPHONEOS_DEPLOYMENT_TARGET = 11.0; + IPHONEOS_DEPLOYMENT_TARGET = 12.0; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", @@ -607,9 +607,4 @@ /* End XCConfigurationList section */ }; rootObject = 97C146E61CF9000F007C117D /* Project object */; - SystemCapabilities = { - com.apple.BackgroundModes = { - enabled = 1; - }; - }; } diff --git a/lib/bitcoin/cw_bitcoin.dart b/lib/bitcoin/cw_bitcoin.dart index f9c20d45e..b36421608 100644 --- a/lib/bitcoin/cw_bitcoin.dart +++ b/lib/bitcoin/cw_bitcoin.dart @@ -181,11 +181,28 @@ class CWBitcoin extends Bitcoin { } @override - BitcoinReceivePageOption getSelectedAddressType(Object wallet) { + ReceivePageOption getSelectedAddressType(Object wallet) { final bitcoinWallet = wallet as ElectrumWallet; return BitcoinReceivePageOption.fromType(bitcoinWallet.walletAddresses.addressPageType); } @override - List getBitcoinReceivePageOptions() => BitcoinReceivePageOption.all; + List getBitcoinReceivePageOptions() => BitcoinReceivePageOption.all; + + @override + BitcoinAddressType getBitcoinAddressType(ReceivePageOption option) { + switch (option) { + case BitcoinReceivePageOption.p2pkh: + return P2pkhAddressType.p2pkh; + case BitcoinReceivePageOption.p2sh: + return P2shAddressType.p2wpkhInP2sh; + case BitcoinReceivePageOption.p2tr: + return SegwitAddresType.p2tr; + case BitcoinReceivePageOption.p2wsh: + return SegwitAddresType.p2wsh; + case BitcoinReceivePageOption.p2wpkh: + default: + return SegwitAddresType.p2wpkh; + } + } } diff --git a/lib/core/address_validator.dart b/lib/core/address_validator.dart index 84fcb9e2e..ad2c761a3 100644 --- a/lib/core/address_validator.dart +++ b/lib/core/address_validator.dart @@ -295,6 +295,16 @@ class AddressValidator extends TextValidator { case CryptoCurrency.sol: return '([^0-9a-zA-Z]|^)[1-9A-HJ-NP-Za-km-z]{43,44}([^0-9a-zA-Z]|\$)'; default: + if (type.tag == CryptoCurrency.eth.title) { + return '0x[0-9a-zA-Z]{42}'; + } + if (type.tag == CryptoCurrency.maticpoly.tag) { + return '0x[0-9a-zA-Z]{42}'; + } + if (type.tag == CryptoCurrency.sol.title) { + return '([^0-9a-zA-Z]|^)[1-9A-HJ-NP-Za-km-z]{43,44}([^0-9a-zA-Z]|\$)'; + } + return null; } } diff --git a/lib/entities/parse_address_from_domain.dart b/lib/entities/parse_address_from_domain.dart index 3ebc08c55..bab0ef51d 100644 --- a/lib/entities/parse_address_from_domain.dart +++ b/lib/entities/parse_address_from_domain.dart @@ -51,7 +51,8 @@ class AddressResolver { } final match = RegExp(addressPattern).firstMatch(raw); - return match?.group(0)?.replaceAllMapped(RegExp('[^0-9a-zA-Z]|bitcoincash:|nano_'), (Match match) { + return match?.group(0)?.replaceAllMapped(RegExp('[^0-9a-zA-Z]|bitcoincash:|nano_'), + (Match match) { String group = match.group(0)!; if (group.startsWith('bitcoincash:') || group.startsWith('nano_')) { return group; @@ -68,7 +69,7 @@ class AddressResolver { return emailRegex.hasMatch(address); } - + // TODO: refactor this to take Crypto currency instead of ticker, or at least pass in the tag as well Future resolve(BuildContext context, String text, String ticker) async { try { if (text.startsWith('@') && !text.substring(1).contains('@')) { @@ -76,7 +77,8 @@ class AddressResolver { final formattedName = text.substring(1); final twitterUser = await TwitterApi.lookupUserByName(userName: formattedName); final addressFromBio = extractAddressByType( - raw: twitterUser.description, type: CryptoCurrency.fromString(ticker)); + raw: twitterUser.description, + type: CryptoCurrency.fromString(ticker, walletCurrency: wallet.currency)); if (addressFromBio != null) { return ParsedAddress.fetchTwitterAddress( address: addressFromBio, @@ -87,8 +89,9 @@ class AddressResolver { final pinnedTweet = twitterUser.pinnedTweet?.text; if (pinnedTweet != null) { - final addressFromPinnedTweet = - extractAddressByType(raw: pinnedTweet, type: CryptoCurrency.fromString(ticker)); + final addressFromPinnedTweet = extractAddressByType( + raw: pinnedTweet, + type: CryptoCurrency.fromString(ticker, walletCurrency: wallet.currency)); if (addressFromPinnedTweet != null) { return ParsedAddress.fetchTwitterAddress( address: addressFromPinnedTweet, @@ -108,11 +111,11 @@ class AddressResolver { final userName = subText.substring(0, hostNameIndex); final mastodonUser = - await MastodonAPI.lookupUserByUserName(userName: userName, apiHost: hostName); + await MastodonAPI.lookupUserByUserName(userName: userName, apiHost: hostName); if (mastodonUser != null) { - String? addressFromBio = - extractAddressByType(raw: mastodonUser.note, type: CryptoCurrency.fromString(ticker)); + String? addressFromBio = extractAddressByType( + raw: mastodonUser.note, type: CryptoCurrency.fromString(ticker)); if (addressFromBio != null) { return ParsedAddress.fetchMastodonAddress( @@ -122,7 +125,7 @@ class AddressResolver { profileName: mastodonUser.username); } else { final pinnedPosts = - await MastodonAPI.getPinnedPosts(userId: mastodonUser.id, apiHost: hostName); + await MastodonAPI.getPinnedPosts(userId: mastodonUser.id, apiHost: hostName); if (pinnedPosts.isNotEmpty) { final userPinnedPostsText = pinnedPosts.map((item) => item.content).join('\n'); @@ -150,7 +153,7 @@ class AddressResolver { } } if (text.hasOnlyEmojis) { - if(settingsStore.lookupsYatService) { + if (settingsStore.lookupsYatService) { if (walletType != WalletType.haven) { final addresses = await yatService.fetchYatAddress(text, ticker); return ParsedAddress.fetchEmojiAddress(addresses: addresses, name: text); @@ -166,7 +169,7 @@ class AddressResolver { } if (unstoppableDomains.any((domain) => name.trim() == domain)) { - if(settingsStore.lookupsUnstoppableDomains) { + if (settingsStore.lookupsUnstoppableDomains) { final address = await fetchUnstoppableDomainAddress(text, ticker); return ParsedAddress.fetchUnstoppableDomainAddress(address: address, name: text); } @@ -182,7 +185,7 @@ class AddressResolver { } if (formattedName.contains(".")) { - if(settingsStore.lookupsOpenAlias) { + if (settingsStore.lookupsOpenAlias) { final txtRecord = await OpenaliasRecord.lookupOpenAliasRecord(formattedName); if (txtRecord != null) { final record = await OpenaliasRecord.fetchAddressAndName( @@ -201,7 +204,11 @@ class AddressResolver { String? addressFromBio = extractAddressByType( raw: nostrUserData.about, type: CryptoCurrency.fromString(ticker)); if (addressFromBio != null) { - return ParsedAddress.nostrAddress(address: addressFromBio, name: text); + return ParsedAddress.nostrAddress( + address: addressFromBio, + name: text, + profileImageUrl: nostrUserData.picture, + profileName: nostrUserData.name); } } } diff --git a/lib/entities/parsed_address.dart b/lib/entities/parsed_address.dart index d87deb9e8..fc8ab2440 100644 --- a/lib/entities/parsed_address.dart +++ b/lib/entities/parsed_address.dart @@ -119,11 +119,17 @@ class ParsedAddress { ); } - factory ParsedAddress.nostrAddress({required String address, required String name}) { + factory ParsedAddress.nostrAddress( + {required String address, + required String name, + required String profileImageUrl, + required String profileName}) { return ParsedAddress( addresses: [address], name: name, parseFrom: ParseFrom.nostr, + profileImageUrl: profileImageUrl, + profileName: profileName, ); } diff --git a/lib/src/screens/dashboard/pages/address_page.dart b/lib/src/screens/dashboard/pages/address_page.dart index 7b7c84c28..0d7c4f11c 100644 --- a/lib/src/screens/dashboard/pages/address_page.dart +++ b/lib/src/screens/dashboard/pages/address_page.dart @@ -1,3 +1,4 @@ +import 'package:cake_wallet/bitcoin/bitcoin.dart'; import 'package:cake_wallet/src/screens/new_wallet/widgets/select_button.dart'; import 'package:cake_wallet/themes/extensions/keyboard_theme.dart'; import 'package:cake_wallet/di.dart'; @@ -6,7 +7,6 @@ import 'package:cake_wallet/src/screens/monero_accounts/monero_account_list_page import 'package:cake_wallet/anonpay/anonpay_donation_link_info.dart'; import 'package:cake_wallet/entities/preferences_key.dart'; import 'package:cw_core/receive_page_option.dart'; -import 'package:cw_bitcoin/bitcoin_receive_page_option.dart'; import 'package:cake_wallet/src/screens/dashboard/widgets/present_receive_option_picker.dart'; import 'package:cake_wallet/src/widgets/gradient_background.dart'; import 'package:cake_wallet/src/widgets/keyboard_done_button.dart'; @@ -28,7 +28,6 @@ import 'package:keyboard_actions/keyboard_actions.dart'; import 'package:mobx/mobx.dart'; import 'package:shared_preferences/shared_preferences.dart'; import 'package:cake_wallet/themes/extensions/balance_page_theme.dart'; -import 'package:bitcoin_base/bitcoin_base.dart'; class AddressPage extends BasePage { AddressPage({ @@ -230,22 +229,10 @@ class AddressPage extends BasePage { ); } break; - case BitcoinReceivePageOption.p2pkh: - addressListViewModel.setAddressType(P2pkhAddressType.p2pkh); - break; - case BitcoinReceivePageOption.p2sh: - addressListViewModel.setAddressType(P2shAddressType.p2wpkhInP2sh); - break; - case BitcoinReceivePageOption.p2wpkh: - addressListViewModel.setAddressType(SegwitAddresType.p2wpkh); - break; - case BitcoinReceivePageOption.p2tr: - addressListViewModel.setAddressType(SegwitAddresType.p2tr); - break; - case BitcoinReceivePageOption.p2wsh: - addressListViewModel.setAddressType(SegwitAddresType.p2wsh); - break; default: + if (addressListViewModel.type == WalletType.bitcoin) { + addressListViewModel.setAddressType(bitcoin!.getBitcoinAddressType(option)); + } } }); diff --git a/lib/src/screens/send/widgets/extract_address_from_parsed.dart b/lib/src/screens/send/widgets/extract_address_from_parsed.dart index 42e646d58..eb997c11b 100644 --- a/lib/src/screens/send/widgets/extract_address_from_parsed.dart +++ b/lib/src/screens/send/widgets/extract_address_from_parsed.dart @@ -53,6 +53,8 @@ Future extractAddressFromParsed( title = S.of(context).address_detected; content = S.of(context).extracted_address_content('${parsedAddress.name} (Nostr NIP-05)'); address = parsedAddress.addresses.first; + profileImageUrl = parsedAddress.profileImageUrl; + profileName = parsedAddress.profileName; break; case ParseFrom.yatRecord: if (parsedAddress.name.isEmpty) { diff --git a/lib/src/widgets/base_alert_dialog.dart b/lib/src/widgets/base_alert_dialog.dart index e9ef522df..b251e4b45 100644 --- a/lib/src/widgets/base_alert_dialog.dart +++ b/lib/src/widgets/base_alert_dialog.dart @@ -1,4 +1,3 @@ -import 'package:cake_wallet/src/screens/receive/widgets/header_tile.dart'; import 'package:cake_wallet/themes/extensions/cake_text_theme.dart'; import 'dart:ui'; import 'package:cake_wallet/src/widgets/section_divider.dart'; diff --git a/lib/store/settings_store.dart b/lib/store/settings_store.dart index fe6c98826..6c91d73f3 100644 --- a/lib/store/settings_store.dart +++ b/lib/store/settings_store.dart @@ -790,9 +790,16 @@ abstract class SettingsStoreBase with Store { final exchangeStatus = ExchangeApiMode.deserialize( raw: sharedPreferences.getInt(PreferencesKey.exchangeStatusKey) ?? ExchangeApiMode.enabled.raw); + final bool isNewInstall = sharedPreferences.getBool(PreferencesKey.isNewInstall) ?? true; + final int defaultTheme; + if (isNewInstall) { + defaultTheme = isMoneroOnly ? ThemeList.moneroDarkTheme.raw : ThemeList.brightTheme.raw; + } else { + defaultTheme = ThemeType.bright.index; + } final savedTheme = initialTheme ?? ThemeList.deserialize( - raw: sharedPreferences.getInt(PreferencesKey.currentTheme) ?? (isMoneroOnly ? ThemeList.moneroDarkTheme.raw : ThemeList.cakeDarkTheme.raw)); + raw: sharedPreferences.getInt(PreferencesKey.currentTheme) ?? defaultTheme); final actionListDisplayMode = ObservableList(); actionListDisplayMode.addAll(deserializeActionlistDisplayModes( sharedPreferences.getInt(PreferencesKey.displayActionListModeKey) ?? defaultActionsMode)); @@ -1151,7 +1158,8 @@ abstract class SettingsStoreBase with Store { raw: sharedPreferences.getInt(PreferencesKey.exchangeStatusKey) ?? ExchangeApiMode.enabled.raw); currentTheme = ThemeList.deserialize( - raw: sharedPreferences.getInt(PreferencesKey.currentTheme) ?? (isMoneroOnly ? ThemeList.moneroDarkTheme.raw : ThemeList.cakeDarkTheme.raw)); + raw: sharedPreferences.getInt(PreferencesKey.currentTheme) ?? + (isMoneroOnly ? ThemeList.moneroDarkTheme.raw : ThemeList.brightTheme.raw)); actionlistDisplayMode = ObservableList(); actionlistDisplayMode.addAll(deserializeActionlistDisplayModes( sharedPreferences.getInt(PreferencesKey.displayActionListModeKey) ?? defaultActionsMode)); diff --git a/lib/twitter/twitter_api.dart b/lib/twitter/twitter_api.dart index bf6298dae..5acb00e2a 100644 --- a/lib/twitter/twitter_api.dart +++ b/lib/twitter/twitter_api.dart @@ -32,7 +32,10 @@ class TwitterApi { } final Map responseJSON = jsonDecode(response.body) as Map; - if (responseJSON['errors'] != null) { + if (responseJSON['errors'] != null && + !responseJSON['errors'][0]['detail'] + .toString() + .contains("Could not find tweet with pinned_tweet_id")) { throw Exception(responseJSON['errors'][0]['detail']); } @@ -40,20 +43,24 @@ class TwitterApi { } static Tweet? _getPinnedTweet(Map responseJSON) { - final tweetId = responseJSON['data']['pinned_tweet_id'] as String?; - if (tweetId == null || responseJSON['includes'] == null) return null; + try { + final tweetId = responseJSON['data']['pinned_tweet_id'] as String?; + if (tweetId == null || responseJSON['includes'] == null) return null; - final tweetIncludes = List.from(responseJSON['includes']['tweets'] as List); - final pinnedTweetData = tweetIncludes.firstWhere( - (tweet) => tweet['id'] == tweetId, - orElse: () => null, - ) as Map?; + final tweetIncludes = List.from(responseJSON['includes']['tweets'] as List); + final pinnedTweetData = tweetIncludes.firstWhere( + (tweet) => tweet['id'] == tweetId, + orElse: () => null, + ) as Map?; - if (pinnedTweetData == null) return null; + if (pinnedTweetData == null) return null; - final pinnedTweetText = - (pinnedTweetData['note_tweet']?['text'] ?? pinnedTweetData['text']) as String; + final pinnedTweetText = + (pinnedTweetData['note_tweet']?['text'] ?? pinnedTweetData['text']) as String; - return Tweet(id: tweetId, text: pinnedTweetText); + return Tweet(id: tweetId, text: pinnedTweetText); + } catch (e) { + return null; + } } } diff --git a/lib/view_model/exchange/exchange_trade_view_model.dart b/lib/view_model/exchange/exchange_trade_view_model.dart index 93877a525..0d40ae240 100644 --- a/lib/view_model/exchange/exchange_trade_view_model.dart +++ b/lib/view_model/exchange/exchange_trade_view_model.dart @@ -162,9 +162,14 @@ abstract class ExchangeTradeViewModelBase with Store { wallet.currency == CryptoCurrency.maticpoly && tradesStore.trade!.from.tag == CryptoCurrency.maticpoly.tag; + bool _isSplToken() => + wallet.currency == CryptoCurrency.sol && + tradesStore.trade!.from.tag == CryptoCurrency.sol.title; + return tradesStore.trade!.from == wallet.currency || tradesStore.trade!.provider == ExchangeProviderDescription.xmrto || _isEthToken() || - _isPolygonToken(); + _isPolygonToken() || + _isSplToken(); } } diff --git a/macos/Flutter/GeneratedPluginRegistrant.swift b/macos/Flutter/GeneratedPluginRegistrant.swift index 05996a674..75a78404f 100644 --- a/macos/Flutter/GeneratedPluginRegistrant.swift +++ b/macos/Flutter/GeneratedPluginRegistrant.swift @@ -5,7 +5,7 @@ import FlutterMacOS import Foundation -import connectivity_plus_macos +import connectivity_plus import cw_monero import device_info_plus import devicelocale diff --git a/macos/Podfile.lock b/macos/Podfile.lock index fcbe1d733..106a8a652 100644 --- a/macos/Podfile.lock +++ b/macos/Podfile.lock @@ -1,5 +1,5 @@ PODS: - - connectivity_plus_macos (0.0.1): + - connectivity_plus (0.0.1): - FlutterMacOS - ReachabilitySwift - cw_monero (0.0.1): @@ -51,7 +51,7 @@ PODS: - FlutterMacOS DEPENDENCIES: - - connectivity_plus_macos (from `Flutter/ephemeral/.symlinks/plugins/connectivity_plus_macos/macos`) + - connectivity_plus (from `Flutter/ephemeral/.symlinks/plugins/connectivity_plus/macos`) - cw_monero (from `Flutter/ephemeral/.symlinks/plugins/cw_monero/macos`) - device_info_plus (from `Flutter/ephemeral/.symlinks/plugins/device_info_plus/macos`) - devicelocale (from `Flutter/ephemeral/.symlinks/plugins/devicelocale/macos`) @@ -73,8 +73,8 @@ SPEC REPOS: - ReachabilitySwift EXTERNAL SOURCES: - connectivity_plus_macos: - :path: Flutter/ephemeral/.symlinks/plugins/connectivity_plus_macos/macos + connectivity_plus: + :path: Flutter/ephemeral/.symlinks/plugins/connectivity_plus/macos cw_monero: :path: Flutter/ephemeral/.symlinks/plugins/cw_monero/macos device_info_plus: @@ -105,8 +105,8 @@ EXTERNAL SOURCES: :path: Flutter/ephemeral/.symlinks/plugins/wakelock_plus/macos SPEC CHECKSUMS: - connectivity_plus_macos: f6e86fd000e971d361e54b5afcadc8c8fa773308 - cw_monero: f8b7f104508efba2591548e76b5c058d05cba3f0 + connectivity_plus: 18d3c32514c886e046de60e9c13895109866c747 + cw_monero: ec03de55a19c4a2b174ea687e0f4202edc716fa4 device_info_plus: 5401765fde0b8d062a2f8eb65510fb17e77cf07f devicelocale: 9f0f36ac651cabae2c33f32dcff4f32b61c38225 flutter_inappwebview_macos: 9600c9df9fdb346aaa8933812009f8d94304203d @@ -123,6 +123,6 @@ SPEC CHECKSUMS: url_launcher_macos: d2691c7dd33ed713bf3544850a623080ec693d95 wakelock_plus: 4783562c9a43d209c458cb9b30692134af456269 -PODFILE CHECKSUM: 5107934592df7813b33d744aebc8ddc6b5a5445f +PODFILE CHECKSUM: 65ec1541137fb5b35d00490dec1bb48d4d9586bb COCOAPODS: 1.12.1 diff --git a/pubspec_base.yaml b/pubspec_base.yaml index 758287601..d4bf981cd 100644 --- a/pubspec_base.yaml +++ b/pubspec_base.yaml @@ -55,7 +55,7 @@ dependencies: basic_utils: ^5.6.1 get_it: ^7.2.0 # connectivity: ^3.0.3 - connectivity_plus: ^2.3.5 + connectivity_plus: ^5.0.2 keyboard_actions: ^4.0.1 another_flushbar: ^1.12.29 archive: ^3.3.0 @@ -107,6 +107,10 @@ dependencies: polyseed: ^0.0.2 nostr_tools: ^1.0.9 solana: ^0.30.1 + bitcoin_base: + git: + url: https://github.com/cake-tech/bitcoin_base.git + ref: cake-update-v1 dev_dependencies: flutter_test: diff --git a/scripts/android/app_env.sh b/scripts/android/app_env.sh index 9251ec31a..fa3701fa7 100644 --- a/scripts/android/app_env.sh +++ b/scripts/android/app_env.sh @@ -15,15 +15,15 @@ TYPES=($MONERO_COM $CAKEWALLET $HAVEN) APP_ANDROID_TYPE=$1 MONERO_COM_NAME="Monero.com" -MONERO_COM_VERSION="1.10.3" -MONERO_COM_BUILD_NUMBER=75 +MONERO_COM_VERSION="1.11.0" +MONERO_COM_BUILD_NUMBER=77 MONERO_COM_BUNDLE_ID="com.monero.app" MONERO_COM_PACKAGE="com.monero.app" MONERO_COM_SCHEME="monero.com" CAKEWALLET_NAME="Cake Wallet" CAKEWALLET_VERSION="4.14.0" -CAKEWALLET_BUILD_NUMBER=193 +CAKEWALLET_BUILD_NUMBER=196 CAKEWALLET_BUNDLE_ID="com.cakewallet.cake_wallet" CAKEWALLET_PACKAGE="com.cakewallet.cake_wallet" CAKEWALLET_SCHEME="cakewallet" diff --git a/scripts/ios/app_env.sh b/scripts/ios/app_env.sh index 47d80013c..31f0b9548 100644 --- a/scripts/ios/app_env.sh +++ b/scripts/ios/app_env.sh @@ -13,13 +13,13 @@ TYPES=($MONERO_COM $CAKEWALLET $HAVEN) APP_IOS_TYPE=$1 MONERO_COM_NAME="Monero.com" -MONERO_COM_VERSION="1.10.3" -MONERO_COM_BUILD_NUMBER=73 +MONERO_COM_VERSION="1.11.0" +MONERO_COM_BUILD_NUMBER=75 MONERO_COM_BUNDLE_ID="com.cakewallet.monero" CAKEWALLET_NAME="Cake Wallet" CAKEWALLET_VERSION="4.14.0" -CAKEWALLET_BUILD_NUMBER=213 +CAKEWALLET_BUILD_NUMBER=215 CAKEWALLET_BUNDLE_ID="com.fotolockr.cakewallet" HAVEN_NAME="Haven" diff --git a/scripts/macos/app_env.sh b/scripts/macos/app_env.sh index 49edd9acb..4dec47f40 100755 --- a/scripts/macos/app_env.sh +++ b/scripts/macos/app_env.sh @@ -16,13 +16,13 @@ if [ -n "$1" ]; then fi MONERO_COM_NAME="Monero.com" -MONERO_COM_VERSION="1.0.3" -MONERO_COM_BUILD_NUMBER=5 +MONERO_COM_VERSION="1.1.0" +MONERO_COM_BUILD_NUMBER=7 MONERO_COM_BUNDLE_ID="com.cakewallet.monero" CAKEWALLET_NAME="Cake Wallet" -CAKEWALLET_VERSION="1.6.3" -CAKEWALLET_BUILD_NUMBER=53 +CAKEWALLET_VERSION="1.7.0" +CAKEWALLET_BUILD_NUMBER=55 CAKEWALLET_BUNDLE_ID="com.fotolockr.cakewallet" if ! [[ " ${TYPES[*]} " =~ " ${APP_MACOS_TYPE} " ]]; then diff --git a/tool/configure.dart b/tool/configure.dart index fb1647e13..3c1587a98 100644 --- a/tool/configure.dart +++ b/tool/configure.dart @@ -61,6 +61,7 @@ Future main(List args) async { Future generateBitcoin(bool hasImplementation) async { final outputFile = File(bitcoinOutputPath); const bitcoinCommonHeaders = """ +import 'package:cw_core/receive_page_option.dart'; import 'package:cw_core/unspent_transaction_output.dart'; import 'package:cw_core/wallet_credentials.dart'; import 'package:cw_core/wallet_info.dart'; @@ -70,7 +71,8 @@ import 'package:cw_core/unspent_coins_info.dart'; import 'package:cw_core/wallet_service.dart'; import 'package:cake_wallet/view_model/send/output.dart'; import 'package:cw_core/wallet_type.dart'; -import 'package:hive/hive.dart';"""; +import 'package:hive/hive.dart'; +import 'package:bitcoin_base/bitcoin_base.dart';"""; const bitcoinCWHeaders = """ import 'package:cw_bitcoin/bitcoin_receive_page_option.dart'; import 'package:cw_bitcoin/electrum_wallet.dart'; @@ -83,7 +85,6 @@ import 'package:cw_bitcoin/bitcoin_amount_format.dart'; import 'package:cw_bitcoin/bitcoin_address_record.dart'; import 'package:cw_bitcoin/bitcoin_transaction_credentials.dart'; import 'package:cw_bitcoin/litecoin_wallet_service.dart'; -import 'package:bitcoin_base/bitcoin_base.dart'; import 'package:mobx/mobx.dart'; """; const bitcoinCwPart = "part 'cw_bitcoin.dart';"; @@ -143,8 +144,9 @@ abstract class Bitcoin { TransactionPriority getLitecoinTransactionPrioritySlow(); Future setAddressType(Object wallet, dynamic option); - BitcoinReceivePageOption getSelectedAddressType(Object wallet); - List getBitcoinReceivePageOptions(); + ReceivePageOption getSelectedAddressType(Object wallet); + List getBitcoinReceivePageOptions(); + BitcoinAddressType getBitcoinAddressType(ReceivePageOption option); } """; @@ -906,6 +908,7 @@ import 'package:cw_core/wallet_credentials.dart'; import 'package:cw_core/wallet_info.dart'; import 'package:cw_core/wallet_service.dart'; import 'package:hive/hive.dart'; +import 'package:solana/solana.dart'; """; const solanaCWHeaders = """ @@ -916,7 +919,6 @@ import 'package:cw_solana/solana_wallet_service.dart'; import 'package:cw_solana/solana_transaction_info.dart'; import 'package:cw_solana/solana_transaction_credentials.dart'; import 'package:cw_solana/solana_wallet_creation_credentials.dart'; -import 'package:solana/solana.dart'; """; const solanaCwPart = "part 'cw_solana.dart';"; const solanaContent = """ @@ -944,10 +946,10 @@ abstract class Solana { List outputs, { required CryptoCurrency currency, }); - List getSPLTokenCurrencies(WalletBase wallet); + List getSPLTokenCurrencies(WalletBase wallet); Future addSPLToken(WalletBase wallet, CryptoCurrency token); Future deleteSPLToken(WalletBase wallet, CryptoCurrency token); - Future getSPLToken(WalletBase wallet, String contractAddress); + Future getSPLToken(WalletBase wallet, String contractAddress); CryptoCurrency assetOfTransaction(WalletBase wallet, TransactionInfo transaction); double getTransactionAmountRaw(TransactionInfo transactionInfo);