diff --git a/.github/ISSUE_TEMPLATE/config.yml b/.github/ISSUE_TEMPLATE/config.yml index f8cc8f9ca..a5a1f6bbc 100644 --- a/.github/ISSUE_TEMPLATE/config.yml +++ b/.github/ISSUE_TEMPLATE/config.yml @@ -1,8 +1,5 @@ blank_issues_enabled: false contact_links: - - name: Feature or Enhancement Request ✨ - url: https://github.com/cake-tech/cake_wallet/discussions/new?category=feature-requests - about: Suggest an idea for Cake Wallet - name: Not sure where to start? url: https://docs.cakewallet.com about: Start by reading checking out the guides! diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml new file mode 100644 index 000000000..e496201aa --- /dev/null +++ b/.github/workflows/lint.yml @@ -0,0 +1,115 @@ +name: Lint + +on: [pull_request] + +defaults: + run: + shell: bash +jobs: + lint: + runs-on: [Linux, amd64, android] + container: + image: ghcr.io/cake-tech/cake_wallet:debian13-flutter3.41.9-ndkr28-go1.24.1-ruststablenightly + env: + STORE_PASS: test@cake_wallet + KEY_PASS: test@cake_wallet + MONEROC_CACHE_DIR_ROOT: /opt/generic_cache + BRANCH_NAME: ${{ github.head_ref || github.ref_name }} + ANDROID_AVD_HOME: /root/.android/avd + volumes: + - /opt/cw_cache_android/root/.cache:/root/.cache + - /opt/cw_cache_android/root/.android/avd/:/root/.android/avd + - /opt/cw_cache_android/root/.ccache:/root/.ccache + - /opt/cw_cache_android/root/.pub-cache/:/root/.pub-cache + - /opt/cw_cache_android/root/.gradle/:/root/.gradle + - /opt/cw_cache_android/root/.android/:/root/.android + - /opt/cw_cache_android/root/go/pkg:/root/go/pkg + - /opt/cw_cache_android/opt/generic_cache:/opt/generic_cache + - /dev/kvm:/dev/kvm + - /var/run/docker.sock:/var/run/docker.sock + strategy: + matrix: + api-level: [ 29 ] + + steps: + - name: Fix github actions messing up $HOME... + run: "echo HOME=/root | sudo tee -a $GITHUB_ENV" + - uses: actions/checkout@v4 + with: + ref: ${{ inputs.ref }} + + - name: configure git + run: | + git config --global --add safe.directory '*' + git config --global user.email "ci@cakewallet.com" + git config --global user.name "CakeWallet CI" + + - name: Fetch prebuilt Torch + run: | + set -x -e + pushd scripts + wget https://github.com/MrCyjaneK/torch_dart/releases/download/v1.0.17/torch_dart-v1.0.17.tar.gz -O torch_dart.tar.gz + rm -rf torch_dart + mkdir torch_dart + pushd torch_dart + tar -xzf ../torch_dart.tar.gz + popd + rm ./torch_dart.tar.gz + popd + + - name: Fetch prebuilt Reown + run: | + set -x -e + pushd scripts + # cleaning + rm -rf reown_flutter + rm -f reown_flutter.tar.gz + + wget https://github.com/cake-tech/reown_flutter/releases/download/v0.0.4/reown_flutter-v0.0.4.tar.gz -O reown_flutter.tar.gz + mkdir reown_flutter + pushd reown_flutter + tar -xzf ../reown_flutter.tar.gz + popd + rm ./reown_flutter.tar.gz + popd + + - name: Build Bitbox Flutter + run: | + set -x -e + pushd scripts + ./build_bitbox_flutter.sh + popd + + - name: Prepare Zcash + run: | + set -x -e + scripts/prepare_zcash.sh + + - name: Execute Build and Setup Commands + run: | + pushd scripts/android + source ./app_env.sh cakewallet + export GITHUB_HUH=yeah + ./app_config.sh + popd + + - name: Install Flutter dependencies + run: | + flutter pub get + + - name: Build generated code + run: | + flutter --version + flutter clean + rm -rf .dart_tool + rm pubspec.lock + flutter pub get + ./model_generator.sh async + + - name: Generate localization + run: | + dart run tool/generate_localization.dart + + - name: Lint + run: | + ABORT_ON_CHANGE=yeah scripts/lint.sh \ No newline at end of file diff --git a/.github/workflows/no_http_imports.yaml b/.github/workflows/no_http_imports.yaml index dad6821ac..0bfca1d06 100644 --- a/.github/workflows/no_http_imports.yaml +++ b/.github/workflows/no_http_imports.yaml @@ -11,11 +11,10 @@ jobs: - name: Check for http package usage if: github.event_name == 'pull_request' run: | - GIT_GREP_OUT="$(git grep package:http | (grep .dart: || test $? = 1) | (grep -v proxy_wrapper.dart || test $? = 1) | (grep -v very_insecure_http_do_not_use || test $? = 1) || true)" + GIT_GREP_OUT="$(git grep package:http | (grep .dart: || test $? = 1) | (grep -v proxy_wrapper.dart || test $? = 1) | (grep -v very_insecure_http_do_not_use || test $? = 1) | (grep -v '^cw_custom_lints/' || test $? = 1) || true)" [[ "x$GIT_GREP_OUT" == "x" ]] && exit 0 echo "$GIT_GREP_OUT" echo "There are .dart files which use http imports" echo "Using http package breaks proxy integration" echo "Please use ProxyWrapper.getHttpClient() from package:cw_core/utils/proxy_wrapper.dart" exit 1 - \ No newline at end of file diff --git a/.github/workflows/no_print_in_dart.yaml b/.github/workflows/no_print_in_dart.yaml index 507793bd8..69885c9b5 100644 --- a/.github/workflows/no_print_in_dart.yaml +++ b/.github/workflows/no_print_in_dart.yaml @@ -11,7 +11,7 @@ jobs: - name: Check for print() statements in dart code (use printV() instead) if: github.event_name == 'pull_request' run: | - GIT_GREP_OUT="$(git grep ' print(' | (grep .dart: || test $? = 1) | (grep -v print_verbose.dart || test $? = 1) | (grep -v print_verbose_dummy.dart || test $? = 1) || true)" + GIT_GREP_OUT="$(git grep ' print(' | (grep .dart: || test $? = 1) | (grep -v print_verbose.dart || test $? = 1) | (grep -v print_verbose_dummy.dart || test $? = 1) | (grep -v '^tool/' || test $? = 1) | (grep -v '^cw_custom_lints/' || test $? = 1) || true)" [[ "x$GIT_GREP_OUT" == "x" ]] && exit 0 echo "$GIT_GREP_OUT" echo "There are .dart files which use print() statements" diff --git a/.github/workflows/pr_test_build_linux.yml b/.github/workflows/pr_test_build_linux.yml index 8cb442447..879ea247f 100644 --- a/.github/workflows/pr_test_build_linux.yml +++ b/.github/workflows/pr_test_build_linux.yml @@ -179,9 +179,6 @@ jobs: with: path: ${{ github.workspace }}/build/linux/x64/release/cakewallet_linux.zip name: cakewallet_linux - - name: Verify lint - run: | - exec env ABORT_ON_CHANGE=true ./scripts/lint.sh - name: Prepare virtual desktop if: ${{ contains(env.message, 'run tests') }} diff --git a/Dockerfile b/Dockerfile index 7a0c8c022..78e119877 100644 --- a/Dockerfile +++ b/Dockerfile @@ -52,7 +52,7 @@ RUN set -o xtrace \ # for x86 emulators libatk-bridge2.0-0 libgdk-pixbuf-xlib-2.0-0 libgtk-3-0 libnspr4 libnss3-dev libsqlite3-dev libxtst6 libxss1 lftp sqlite3 xxd \ # Linux desktop dependencies - clang cmake libgtk-3-dev ninja-build pkg-config libsecret-1-0 libsecret-1-dev gir1.2-secret-1 \ + clang lld cmake libgtk-3-dev ninja-build pkg-config libsecret-1-0 libsecret-1-dev gir1.2-secret-1 \ # monero_c dependencies autoconf automake build-essential ccache gperf libtool llvm \ # extra stuff for KVM diff --git a/analysis_options.yaml b/analysis_options.yaml index 98dfe6ae4..70e87faf3 100644 --- a/analysis_options.yaml +++ b/analysis_options.yaml @@ -1,4 +1,3 @@ -include: package:lints/recommended.yaml analyzer: @@ -9,6 +8,7 @@ analyzer: cw_core/lib/**.g.dart, cw_haven/lib/**.g.dart, cw_monero/lib/**.g.dart, + cw_mweb/lib/**.g.dart, lib/generated/*.dart, cw_monero/ios/External/**, cw_shared_external/**, @@ -18,65 +18,124 @@ analyzer: strict-casts: true strict-raw-types: true +plugins: + cw_custom_lints: + path: cw_custom_lints + linter: rules: - cancel_subscriptions - always_declare_return_types - prefer_final_fields + - always_put_control_body_on_new_line + - always_put_required_named_parameters_first + - always_use_package_imports + - annotate_overrides + - annotate_redeclares + - avoid_empty_else + - avoid_function_literals_in_foreach_calls + - avoid_init_to_null + - avoid_multiple_declarations_per_line + - avoid_positional_boolean_parameters + - avoid_relative_lib_imports + - avoid_renaming_method_parameters + - avoid_return_types_on_setters + - avoid_returning_null_for_void + - avoid_slow_async_io + - avoid_types_as_parameter_names + - avoid_types_on_closure_parameters + - avoid_unnecessary_containers + - avoid_unused_constructor_parameters + - avoid_void_async + - await_only_futures + - camel_case_extensions + - camel_case_types + - cast_nullable_to_non_nullable + - collection_methods_unrelated_type + - constant_identifier_names + - curly_braces_in_flow_control_structures + - directives_ordering + - empty_constructor_bodies + - empty_statements + - eol_at_end_of_file + - flutter_style_todos + - hash_and_equals + - implicit_reopen + - library_private_types_in_public_api + - non_constant_identifier_names + - only_throw_errors + - package_names + - prefer_asserts_with_message + - prefer_conditional_assignment + - prefer_const_constructors + - prefer_const_constructors_in_immutables + - prefer_const_declarations + - prefer_constructors_over_static_methods + - prefer_contains + - prefer_double_quotes + - prefer_expression_function_bodies + - prefer_final_in_for_each + - prefer_function_declarations_over_variables + - prefer_if_null_operators + - prefer_initializing_formals + - prefer_inlined_adds + - prefer_int_literals + - prefer_is_empty + - prefer_is_not_empty + - prefer_is_not_operator + - prefer_iterable_wheretype + - prefer_null_aware_operators + - prefer_spread_collections + - prefer_typing_uninitialized_variables + - recursive_getters + - require_trailing_commas + - sized_box_for_whitespace + - sort_child_properties_last + - sort_constructors_first + - sort_unnamed_constructors_first + - test_types_in_equals + - unawaited_futures + - unnecessary_async + - unnecessary_await_in_return + - unnecessary_const + - unnecessary_constructor_name + - unnecessary_lambdas + - unnecessary_late + - unnecessary_new + - unnecessary_null_aware_assignments + - unnecessary_null_checks + - unnecessary_null_in_if_null_operators + - unnecessary_nullable_for_final_variable_declarations + - unnecessary_overrides + - unnecessary_parenthesis + - unnecessary_string_escapes + - unnecessary_string_interpolations + - unnecessary_this + - unnecessary_to_list_in_spreads + - unnecessary_underscores + - unreachable_from_main + - unrelated_type_equality_checks + - use_build_context_synchronously + - use_colored_box + - use_full_hex_values_for_flutter_colors + - use_function_type_syntax_for_parameters + - use_is_even_rather_than_modulo + - use_named_constants + - use_null_aware_elements + - use_raw_strings + - use_rethrow_when_possible + - use_string_buffers + - use_super_parameters + - use_truncating_division + - valid_regexps + - void_checks + - prefer_final_locals + - prefer_interpolation_to_compose_strings + - null_closures + - no_wildcard_variable_uses + + formatter: page_width: 100 - - -# analyzer: -# strong-mode: -# implicit-casts: false -# implicit-dynamic: false -# exclude: [build/**, lib/generated/*.dart, lib/**.g.dart, cw_monero/ios/External/**, cw_shared_external/**, shared_external/**] - -# linter: -# rules: -# - always_declare_return_types -# - annotate_overrides -# - avoid_empty_else -# - avoid_init_to_null -# - avoid_return_types_on_setters -# - await_only_futures -# - camel_case_types -# - cancel_subscriptions -# - close_sinks -# - comment_references -# - constant_identifier_names -# - control_flow_in_finally -# - empty_catches -# - empty_constructor_bodies -# - empty_statements -# - hash_and_equals -# - invariant_booleans -# - iterable_contains_unrelated_type -# - library_names -# - library_prefixes -# - list_remove_unrelated_type -# - literal_only_boolean_expressions -# - non_constant_identifier_names -# - one_member_abstracts -# - only_throw_errors -# - overridden_fields -# - package_api_docs -# - package_names -# - package_prefixed_library_names -# - parameter_assignments -# - prefer_final_fields -# - prefer_final_locals -# - prefer_is_not_empty -# - slash_for_doc_comments -# - sort_constructors_first -# - sort_unnamed_constructors_first -# - test_types_in_equals -# - throw_in_finally -# - type_init_formals -# - unawaited_futures -# - unnecessary_getters_setters -# - unrelated_type_equality_checks -# - valid_regexps diff --git a/android/gradle.properties b/android/gradle.properties index 7be3f195e..80241dfa7 100644 --- a/android/gradle.properties +++ b/android/gradle.properties @@ -1,4 +1,5 @@ org.gradle.jvmargs=-Xmx6144M android.useAndroidX=true android.enableJetifier=true -android.ndk.suppressMinSdkVersionError=21 \ No newline at end of file + +android.ndk.suppressMinSdkVersionError=21 diff --git a/assets/images/radar.png b/assets/images/radar.png new file mode 100644 index 000000000..386511418 Binary files /dev/null and b/assets/images/radar.png differ diff --git a/cw_bitcoin/lib/bitcoin_wallet.dart b/cw_bitcoin/lib/bitcoin_wallet.dart index a2b2f798f..a0d88ce9a 100644 --- a/cw_bitcoin/lib/bitcoin_wallet.dart +++ b/cw_bitcoin/lib/bitcoin_wallet.dart @@ -345,12 +345,16 @@ abstract class BitcoinWalletBase extends ElectrumWallet with Store { return balance; } + try { final lBalance = await lightningWallet!.getBalance(); this.balance[CryptoCurrency.btcln] = ElectrumBalance( confirmed: lBalance, unconfirmed: Money.zero(CryptoCurrency.btcln), frozen: Money.zero(CryptoCurrency.btcln)); + } catch (e) { + printV("Error fetching lightning balance: $e"); + } return ElectrumBalance( confirmed: balance.confirmed, @@ -551,8 +555,8 @@ abstract class BitcoinWalletBase extends ElectrumWallet with Store { return tx; } - List getUtxoWithPrivateKeys() => unspentCoins - .where((e) => (e.isSending && !e.isFrozen)) + List getUtxoWithPrivateKeys({bool confirmedOnly = false}) => unspentCoins + .where((e) => e.isSending && !e.isFrozen && (!confirmedOnly || (e.confirmations ?? 0) > 0)) .map((unspent) => UtxoWithPrivateKey.fromUnspent(unspent, this)) .toList(); diff --git a/cw_bitcoin/lib/bitcoin_wallet_service.dart b/cw_bitcoin/lib/bitcoin_wallet_service.dart index 6163a8a82..8a034d8cf 100644 --- a/cw_bitcoin/lib/bitcoin_wallet_service.dart +++ b/cw_bitcoin/lib/bitcoin_wallet_service.dart @@ -8,7 +8,6 @@ import 'package:cw_core/encryption_file_utils.dart'; import 'package:cw_core/payjoin_session.dart'; import 'package:cw_core/unspent_coins_info.dart'; import 'package:cw_core/utils/zpub.dart'; -import 'package:cw_core/wallet_base.dart'; import 'package:cw_core/wallet_service.dart'; import 'package:cw_bitcoin/bitcoin_wallet.dart'; import 'package:cw_core/pathForWallet.dart'; @@ -130,31 +129,6 @@ class BitcoinWalletService extends WalletService< } } - @override - Future rename(String currentName, String password, String newName) async { - final currentWalletInfo = await WalletInfo.get(currentName, getType()); - if (currentWalletInfo == null) { - throw Exception('Wallet not found'); - } - final currentWallet = await BitcoinWalletBase.open( - password: password, - name: currentName, - walletInfo: currentWalletInfo, - unspentCoinsInfo: unspentCoinsInfoSource, - payjoinBox: payjoinSessionSource, - encryptionFileUtils: encryptionFileUtilsFor(isDirect), - ); - - await currentWallet.renameWalletFiles(newName); - await saveBackup(newName); - - final newWalletInfo = currentWalletInfo; - newWalletInfo.id = WalletBase.idFor(newName, getType()); - newWalletInfo.name = newName; - - await newWalletInfo.save(); - } - @override Future restoreFromHardwareWallet(BitcoinRestoreWalletFromHardware credentials, {bool? isTestnet}) async { diff --git a/cw_bitcoin/lib/electrum_transaction_info.dart b/cw_bitcoin/lib/electrum_transaction_info.dart index 9e18d0e60..a58829e00 100644 --- a/cw_bitcoin/lib/electrum_transaction_info.dart +++ b/cw_bitcoin/lib/electrum_transaction_info.dart @@ -18,7 +18,7 @@ class ElectrumTransactionBundle { {required this.ins, required this.confirmations, this.time}); final BtcTransaction originalTransaction; - final List ins; + final List ins; final int? time; final int confirmations; } @@ -127,9 +127,14 @@ class ElectrumTransactionInfo extends TransactionInfo { List inputAddresses = []; List outputAddresses = []; + var hasMissingInputTx = false; for (var i = 0; i < bundle.originalTransaction.inputs.length; i++) { final input = bundle.originalTransaction.inputs[i]; - final inputTransaction = bundle.ins[i]; + final inputTransaction = i < bundle.ins.length ? bundle.ins[i] : null; + if (inputTransaction == null || input.txIndex >= inputTransaction.outputs.length) { + hasMissingInputTx = true; + continue; + } final outTransaction = inputTransaction.outputs[input.txIndex]; inputAmount += outTransaction.amount.toInt(); if (addresses.contains(addressFromOutputScript(outTransaction.scriptPubKey, network))) { @@ -184,9 +189,14 @@ class ElectrumTransactionInfo extends TransactionInfo { final b = tx.outputs.first.scriptPubKey.toBytes(); return b.length == 34 && b[0] == 88 && b[1] == 32; }; - final isHogEx = isHogExTx(bundle.originalTransaction) && isHogExTx(bundle.ins.first); + final firstInput = bundle.ins.isNotEmpty ? bundle.ins.first : null; + final isHogEx = firstInput != null && + isHogExTx(bundle.originalTransaction) && + isHogExTx(firstInput); - final fee = inputAmount - totalOutAmount; + final fee = hasMissingInputTx ? null : inputAmount - totalOutAmount; + final walletCurrency = walletTypeToCryptoCurrency(type); + final feeMoney = fee != null ? Money.fromInt(fee, walletCurrency) : null; return ElectrumTransactionInfo(type, id: bundle.originalTransaction.txId(), height: height, @@ -194,11 +204,12 @@ class ElectrumTransactionInfo extends TransactionInfo { isReplaced: false, inputAddresses: inputAddresses, outputAddresses: outputAddresses, - fee: Money.fromInt(fee, walletTypeToCryptoCurrency(type)), + fee: feeMoney, direction: direction, - amount: Money.fromInt(amount, walletTypeToCryptoCurrency(type)), + amount: Money.fromInt(amount, walletCurrency), date: date, isHogEx: isHogEx, + additionalInfo: {'hasMissingInputTx': hasMissingInputTx}, confirmations: bundle.confirmations); } diff --git a/cw_bitcoin/lib/electrum_wallet.dart b/cw_bitcoin/lib/electrum_wallet.dart index 095d1a45e..ccd41d010 100644 --- a/cw_bitcoin/lib/electrum_wallet.dart +++ b/cw_bitcoin/lib/electrum_wallet.dart @@ -1,6 +1,5 @@ import 'dart:async'; import 'dart:convert'; -import 'dart:io'; import 'dart:isolate'; import 'package:bitcoin_base/bitcoin_base.dart'; @@ -38,7 +37,6 @@ import 'package:cw_core/get_height_by_date.dart'; import 'package:cw_core/hardware/hardware_wallet_service.dart'; import 'package:cw_core/node.dart'; import 'package:cw_core/output_info.dart'; -import 'package:cw_core/pathForWallet.dart'; import 'package:cw_core/pending_transaction.dart'; import 'package:cw_core/root_dir.dart'; import 'package:cw_core/sync_status.dart'; @@ -1539,6 +1537,7 @@ abstract class ElectrumWalletBase fee: estimatedTx.fee.amount, network: network, memo: estimatedTx.memo, + inputOrdering: BitcoinOrdering.shuffle, outputOrdering: BitcoinOrdering.none, enableRBF: !estimatedTx.spendsUnconfirmedTX, ); @@ -1736,37 +1735,6 @@ abstract class ElectrumWalletBase await transactionHistory.save(); } - @override - Future renameWalletFiles(String newWalletName) async { - final currentWalletPath = await pathForWallet(name: walletInfo.name, type: type); - final currentWalletFile = File(currentWalletPath); - - final currentDirPath = await pathForWalletDir(name: walletInfo.name, type: type); - final currentTransactionsFile = File('$currentDirPath/$transactionsHistoryFileName'); - - // Copies current wallet files into new wallet name's dir and files - if (currentWalletFile.existsSync()) { - final newWalletPath = await pathForWallet(name: newWalletName, type: type); - await currentWalletFile.copy(newWalletPath); - } - if (currentTransactionsFile.existsSync()) { - final newDirPath = await pathForWalletDir(name: newWalletName, type: type); - await currentTransactionsFile.copy('$newDirPath/$transactionsHistoryFileName'); - } - - // Delete old name's dir and files - final dir = Directory(currentDirPath); - for (var attempt = 0; attempt < 3; attempt++) { - try { - await dir.delete(recursive: true); - break; - } on FileSystemException { - if (attempt == 2) rethrow; - await Future.delayed(const Duration(milliseconds: 200)); - } - } - } - @override Future changePassword(String password) async { _password = password; @@ -2118,6 +2086,9 @@ abstract class ElectrumWalletBase for (int i = 0; i < bundle.originalTransaction.inputs.length; i++) { final input = bundle.originalTransaction.inputs[i]; final inputTransaction = bundle.ins[i]; + if (inputTransaction == null) { + throw Exception("Missing input transaction for fee calculation"); + } final vout = input.txIndex; final outTransaction = inputTransaction.outputs[vout]; allInputsAmount += outTransaction.amount.toInt(); @@ -2146,6 +2117,9 @@ abstract class ElectrumWalletBase for (var i = 0; i < bundle.originalTransaction.inputs.length; i++) { final input = bundle.originalTransaction.inputs[i]; final inputTransaction = bundle.ins[i]; + if (inputTransaction == null) { + throw Exception("Missing input transaction for replace-by-fee"); + } final vout = input.txIndex; final outTransaction = inputTransaction.outputs[vout]; final address = addressFromOutputScript(outTransaction.scriptPubKey, network); @@ -2329,6 +2303,7 @@ abstract class ElectrumWalletBase fee: BigInt.from(newFee), network: network, memo: memo, + inputOrdering: BitcoinOrdering.shuffle, outputOrdering: BitcoinOrdering.none, enableRBF: true, ); @@ -2434,20 +2409,24 @@ abstract class ElectrumWalletBase } final original = BtcTransaction.fromRaw(transactionHex); - final ins = []; + final ins = []; for (final vin in original.inputs) { - final verboseTransaction = await electrumClient.getTransactionVerbose(hash: vin.txId); + try { + final verboseTransaction = await electrumClient.getTransactionVerbose(hash: vin.txId); - final String inputTransactionHex; + final String inputTransactionHex; - if (verboseTransaction.isEmpty) { - inputTransactionHex = await electrumClient.getTransactionHex(hash: hash); - } else { - inputTransactionHex = verboseTransaction['hex'] as String; + if (verboseTransaction.isEmpty) { + inputTransactionHex = await electrumClient.getTransactionHex(hash: vin.txId); + } else { + inputTransactionHex = verboseTransaction['hex'] as String; + } + + ins.add(inputTransactionHex.isEmpty ? null : BtcTransaction.fromRaw(inputTransactionHex)); + } catch (_) { + ins.add(null); } - - ins.add(BtcTransaction.fromRaw(inputTransactionHex)); } return ElectrumTransactionBundle( @@ -3204,22 +3183,11 @@ abstract class ElectrumWalletBase } } - final ins = []; final inputTxids = inputTxidsByHash[txid] ?? const []; - bool allInputsPresent = true; - for (final inputTxid in inputTxids) { - final inTx = parsedInputTxById[inputTxid]; - if (inTx == null) { - allInputsPresent = false; - break; - } - ins.add(inTx); - } - - if (!allInputsPresent || ins.length != original.inputs.length) { - continue; - } + final ins = [ + for (final inputTxid in inputTxids) parsedInputTxById[inputTxid], + ]; bundles[txid] = ElectrumTransactionBundle( original, @@ -3796,6 +3764,7 @@ abstract class ElectrumWalletBase for (int i = 0; i < bundle.originalTransaction.inputs.length; i++) { final input = bundle.originalTransaction.inputs[i]; final inputTransaction = bundle.ins[i]; + if (inputTransaction == null) continue; final vout = input.txIndex; final outTransaction = inputTransaction.outputs[vout]; final address = addressFromOutputScript(outTransaction.scriptPubKey, network); diff --git a/cw_bitcoin/lib/lightning/lightning_wallet.dart b/cw_bitcoin/lib/lightning/lightning_wallet.dart index d78c88d13..42dda7cb2 100644 --- a/cw_bitcoin/lib/lightning/lightning_wallet.dart +++ b/cw_bitcoin/lib/lightning/lightning_wallet.dart @@ -123,7 +123,9 @@ class LightningWallet { Future close() async { _eventSubscription?.cancel(); - await _sdk?.disconnect(); + try { + await _sdk?.disconnect(); + } catch (_) {} _logSubscription?.cancel(); } @@ -439,6 +441,11 @@ class LightningWallet { direction = TransactionDirection.incoming; } + String? preimage; + if (payment.details != null && payment.details is PaymentDetails_Lightning) { + preimage = (payment.details as PaymentDetails_Lightning).htlcDetails.preimage; + } + return ElectrumTransactionInfo( WalletType.bitcoin, id: payment.id, @@ -448,7 +455,10 @@ class LightningWallet { fee: Money(payment.fees, currency), date: DateTime.fromMillisecondsSinceEpoch(payment.timestamp.toInt() * 1000), confirmations: payment.status == PaymentStatus.pending ? 0 : 10, - additionalInfo: {"isLightning": true}, + additionalInfo: { + "isLightning": true, + if (preimage != null) "preimage": preimage, + }, ); } diff --git a/cw_bitcoin/lib/litecoin_wallet.dart b/cw_bitcoin/lib/litecoin_wallet.dart index 51cc8a4d8..2f9ae0b3c 100644 --- a/cw_bitcoin/lib/litecoin_wallet.dart +++ b/cw_bitcoin/lib/litecoin_wallet.dart @@ -497,22 +497,25 @@ abstract class LitecoinWalletBase extends ElectrumWallet with Store { mwebUtxosBox = await CakeHive.openBox(boxName); } - @override - Future renameWalletFiles(String newWalletName) async { - // rename the hive box: - final oldBoxName = "${walletInfo.name.replaceAll(" ", "_")}_${MwebUtxo.boxName}"; - final newBoxName = "${newWalletName.replaceAll(" ", "_")}_${MwebUtxo.boxName}"; + static Future copyMwebBox({ + required String fromName, + required String toName, + }) async { + final oldBoxName = "${fromName.replaceAll(" ", "_")}_${MwebUtxo.boxName}"; + final newBoxName = "${toName.replaceAll(" ", "_")}_${MwebUtxo.boxName}"; + if (oldBoxName == newBoxName) return; final oldBox = await CakeHive.openBox(oldBoxName); - mwebUtxosBox = await CakeHive.openBox(newBoxName); + final newBox = await CakeHive.openBox(newBoxName); for (final key in oldBox.keys) { - final value = oldBox.get(key); - await oldBox.delete(key); - await mwebUtxosBox.put(key, value!); + await newBox.put(key, oldBox.get(key)!); } - oldBox.deleteFromDisk(); + } - await super.renameWalletFiles(newWalletName); + static Future deleteMwebBox(String name) async { + final boxName = "${name.replaceAll(" ", "_")}_${MwebUtxo.boxName}"; + final box = await CakeHive.openBox(boxName); + await box.deleteFromDisk(); } @action diff --git a/cw_bitcoin/lib/litecoin_wallet_service.dart b/cw_bitcoin/lib/litecoin_wallet_service.dart index 739602e24..0156ee672 100644 --- a/cw_bitcoin/lib/litecoin_wallet_service.dart +++ b/cw_bitcoin/lib/litecoin_wallet_service.dart @@ -12,8 +12,6 @@ import 'package:cw_core/wallet_service.dart'; import 'package:cw_core/pathForWallet.dart'; import 'package:cw_core/wallet_type.dart'; import 'package:cw_core/wallet_info.dart'; -import 'package:cw_core/wallet_base.dart'; -import 'package:collection/collection.dart'; import 'package:bip39/bip39.dart' as bip39; import 'package:path_provider/path_provider.dart'; @@ -139,26 +137,11 @@ class LitecoinWalletService extends WalletService< @override Future rename(String currentName, String password, String newName) async { - final currentWalletInfo = await WalletInfo.get(currentName, getType()); - if (currentWalletInfo == null) { - throw Exception('Wallet not found'); - } - final currentWallet = await LitecoinWalletBase.open( - password: password, - name: currentName, - walletInfo: currentWalletInfo, - unspentCoinsInfo: unspentCoinsInfoSource, - encryptionFileUtils: encryptionFileUtilsFor(isDirect), - ); + if (currentName == newName) return; - await currentWallet.renameWalletFiles(newName); - await saveBackup(newName); - - final newWalletInfo = currentWalletInfo; - newWalletInfo.id = WalletBase.idFor(newName, getType()); - newWalletInfo.name = newName; - - await newWalletInfo.save(); + await LitecoinWalletBase.copyMwebBox(fromName: currentName, toName: newName); + await super.rename(currentName, password, newName); + await LitecoinWalletBase.deleteMwebBox(currentName); } @override diff --git a/cw_bitcoin/lib/payjoin/manager.dart b/cw_bitcoin/lib/payjoin/manager.dart index 437fed3ff..4fa2e2a63 100644 --- a/cw_bitcoin/lib/payjoin/manager.dart +++ b/cw_bitcoin/lib/payjoin/manager.dart @@ -297,10 +297,10 @@ class PayjoinManager { break; case PayjoinReceiverRequestTypes.getCandidateInputs: - utxos = _wallet.getUtxoWithPrivateKeys(); + utxos = _wallet.getUtxoWithPrivateKeys(confirmedOnly: true); if (utxos.isEmpty) { await _wallet.updateAllUnspents(); - utxos = _wallet.getUtxoWithPrivateKeys(); + utxos = _wallet.getUtxoWithPrivateKeys(confirmedOnly: true); } mainToIsolateSendPort?.send({ 'requestId': message['requestId'], diff --git a/cw_bitcoin/pubspec.yaml b/cw_bitcoin/pubspec.yaml index b0752cf79..98e1ec46c 100644 --- a/cw_bitcoin/pubspec.yaml +++ b/cw_bitcoin/pubspec.yaml @@ -6,7 +6,7 @@ author: Cake Wallet homepage: https://cakewallet.com environment: - sdk: ">=2.17.5 <3.0.0" + sdk: ">=3.0.0 <4.0.0" flutter: ">=1.20.0" dependencies: diff --git a/cw_bitcoin_cash/analysis_options.yaml b/cw_bitcoin_cash/analysis_options.yaml deleted file mode 100644 index a5744c1cf..000000000 --- a/cw_bitcoin_cash/analysis_options.yaml +++ /dev/null @@ -1,4 +0,0 @@ -include: package:flutter_lints/flutter.yaml - -# Additional information about this file can be found at -# https://dart.dev/guides/language/analysis-options diff --git a/cw_bitcoin_cash/lib/src/bitcoin_cash_wallet_service.dart b/cw_bitcoin_cash/lib/src/bitcoin_cash_wallet_service.dart index 1861b21f4..c7316ec4c 100644 --- a/cw_bitcoin_cash/lib/src/bitcoin_cash_wallet_service.dart +++ b/cw_bitcoin_cash/lib/src/bitcoin_cash_wallet_service.dart @@ -1,13 +1,11 @@ import 'dart:io'; import 'package:bip39/bip39.dart'; -import 'package:collection/collection.dart'; import 'package:cw_bitcoin/bitcoin_mnemonics_bip39.dart'; import 'package:cw_bitcoin_cash/cw_bitcoin_cash.dart'; import 'package:cw_core/encryption_file_utils.dart'; import 'package:cw_core/pathForWallet.dart'; import 'package:cw_core/unspent_coins_info.dart'; -import 'package:cw_core/wallet_base.dart'; import 'package:cw_core/wallet_info.dart'; import 'package:cw_core/wallet_service.dart'; import 'package:cw_core/wallet_type.dart'; @@ -99,29 +97,6 @@ class BitcoinCashWalletService extends WalletService< } } - @override - Future rename(String currentName, String password, String newName) async { - final currentWalletInfo = await WalletInfo.get(currentName, getType()); - if (currentWalletInfo == null) { - throw Exception('Wallet not found'); - } - final currentWallet = await BitcoinCashWalletBase.open( - password: password, - name: currentName, - walletInfo: currentWalletInfo, - unspentCoinsInfo: unspentCoinsInfoSource, - encryptionFileUtils: encryptionFileUtilsFor(isDirect)); - - await currentWallet.renameWalletFiles(newName); - await saveBackup(newName); - - final newWalletInfo = currentWalletInfo; - newWalletInfo.id = WalletBase.idFor(newName, getType()); - newWalletInfo.name = newName; - - await newWalletInfo.save(); - } - @override Future restoreFromHardwareWallet(BitcoinCashNewWalletCredentials credentials) { throw UnimplementedError( diff --git a/cw_core/lib/amount/money_double.dart b/cw_core/lib/amount/money_double.dart new file mode 100644 index 000000000..b3331c44b --- /dev/null +++ b/cw_core/lib/amount/money_double.dart @@ -0,0 +1,13 @@ +import 'dart:math'; +import 'package:cw_core/amount/money.dart'; +import 'package:cw_core/currency.dart'; + +extension ToMoney on double { + /// Turn a double representation of a currency amount to a proper Money representation + /// truncating currencies with more than 20 decimals because double can not handle more + Money? tryToMoney(Currency currency) => + Money.tryParse(toStringAsFixed(min(currency.decimals, 20)), currency); + + Money toMoney(Currency currency) => + Money.parse(toStringAsFixed(min(currency.decimals, 20)), currency); +} diff --git a/cw_core/lib/parse_fixed.dart b/cw_core/lib/parse_fixed.dart index fede4df09..51a6a7a58 100644 --- a/cw_core/lib/parse_fixed.dart +++ b/cw_core/lib/parse_fixed.dart @@ -29,6 +29,13 @@ BigInt? tryParseFixed(String value, int decimals) { BigInt parseFixed(String value, int decimals) { final multiplier = getMultiplier(decimals); + /// handle weird cases where users enter spaces and currency after the amount + /// This should be handled from UI field to prevent non numerical values + /// but will be in the refactoring + if (value.contains(" ")) { + value = value.split(" ").first; + } + final negative = value.startsWith("-"); if (negative) value = value.substring(1); diff --git a/cw_core/lib/pathForWallet.dart b/cw_core/lib/pathForWallet.dart index cb5a4fe78..9fe5f717a 100644 --- a/cw_core/lib/pathForWallet.dart +++ b/cw_core/lib/pathForWallet.dart @@ -1,6 +1,7 @@ import 'dart:io'; import 'package:cw_core/root_dir.dart'; import 'package:cw_core/wallet_type.dart'; +import 'package:path/path.dart' as p; Future pathForWalletTypeDir({required WalletType type}) async { final root = await getAppDir(); @@ -36,3 +37,43 @@ Future outdatedAndroidPathForWalletDir({required String name}) async { return pathDir; } + +Future copyWalletFilesTo({ + required String fromName, + required String toName, + required WalletType type, +}) async { + if (fromName == toName) return; + + final typeRoot = await pathForWalletTypeDir(type: type); + final sourceDir = Directory(p.join(typeRoot, fromName)); + if (!sourceDir.existsSync()) { + throw "Source wallet not found: $fromName $type"; + } + + if (Directory(p.join(typeRoot, toName)).existsSync()) { + throw Exception('Cannot rename wallet: "$toName" already exists'); + } + + final destinationDir = Directory(p.join(typeRoot, toName)); + await _copyDirectory(sourceDir, destinationDir); + + for (final suffix in const ['', '.keys', '.keys.backup']) { + final file = File(p.join(destinationDir.path, '$fromName$suffix')); + if (file.existsSync()) { + await file.rename(p.join(destinationDir.path, '$toName$suffix')); + } + } +} + +Future _copyDirectory(Directory source, Directory destination) async { + await destination.create(recursive: true); + await for (final entity in source.list(followLinks: false)) { + final name = p.basename(entity.path); + if (entity is File) { + await entity.copy(p.join(destination.path, name)); + } else if (entity is Directory) { + await _copyDirectory(entity, Directory(p.join(destination.path, name))); + } + } +} diff --git a/cw_core/lib/payment_uris.dart b/cw_core/lib/payment_uris.dart index daf56af2f..9e4191072 100644 --- a/cw_core/lib/payment_uris.dart +++ b/cw_core/lib/payment_uris.dart @@ -7,6 +7,13 @@ abstract class PaymentURI { final String address; } +class ExternalAddressURI extends PaymentURI { + ExternalAddressURI({required super.amount, required super.address}); + + @override + String toString() => address; +} + class MoneroURI extends PaymentURI { MoneroURI({required super.amount, required super.address}); diff --git a/cw_core/lib/wallet_addresses.dart b/cw_core/lib/wallet_addresses.dart index ccbb8aa34..765afaa58 100644 --- a/cw_core/lib/wallet_addresses.dart +++ b/cw_core/lib/wallet_addresses.dart @@ -71,11 +71,12 @@ abstract class WalletAddresses { Future saveAddressesInBox() async { try { walletInfo.address = address; - walletInfo.setAddresses(addressesMap); - walletInfo.setAddressInfos(addressInfos); - walletInfo.setUsedAddresses(usedAddresses.toList()); - walletInfo.setHiddenAddresses(hiddenAddresses.toList()); - walletInfo.setManualAddresses(manualAddresses.toList()); + // TODO: check if it will affect the performance of each wallet + await walletInfo.setAddresses(addressesMap); + await walletInfo.setAddressInfos(addressInfos); + await walletInfo.setUsedAddresses(usedAddresses.toList()); + await walletInfo.setHiddenAddresses(hiddenAddresses.toList()); + await walletInfo.setManualAddresses(manualAddresses.toList()); await walletInfo.save(); } catch (e) { diff --git a/cw_core/lib/wallet_base.dart b/cw_core/lib/wallet_base.dart index 917d86ef4..89d6356da 100644 --- a/cw_core/lib/wallet_base.dart +++ b/cw_core/lib/wallet_base.dart @@ -12,6 +12,7 @@ import 'package:cw_core/crypto_currency.dart'; import 'package:cw_core/sync_status.dart'; import 'package:cw_core/node.dart'; import 'package:cw_core/wallet_type.dart'; +import 'package:cw_core/pathForWallet.dart'; abstract class WalletBase { @@ -115,7 +116,8 @@ abstract class WalletBase null; - Future renameWalletFiles(String newWalletName); + Future renameWalletFiles(String newWalletName) => + copyWalletFilesTo(fromName: walletInfo.name, toName: newWalletName, type: type); Future signMessage(String message, {String? address = null}); diff --git a/cw_core/lib/wallet_info.dart b/cw_core/lib/wallet_info.dart index 5877ad2da..a43bfa154 100644 --- a/cw_core/lib/wallet_info.dart +++ b/cw_core/lib/wallet_info.dart @@ -428,11 +428,9 @@ class WalletInfo { Future setAddresses(Map addresses) async { await WalletInfoAddressMap.deleteByWalletInfoId(internalId); - final keys = addresses.keys.toList(); - final values = addresses.values.toList(); - // ToDo: check why the addresses list gets changed half way through - for (int i = 0; i < keys.length; i++) { - await WalletInfoAddressMap.insert(internalId, keys[i], values[i]); + final entries = addresses.entries.toList(); + for (final entry in entries) { + await WalletInfoAddressMap.insert(internalId, entry.key, entry.value); } } diff --git a/cw_core/lib/wallet_service.dart b/cw_core/lib/wallet_service.dart index 016f3cc68..8d19a9f8c 100644 --- a/cw_core/lib/wallet_service.dart +++ b/cw_core/lib/wallet_service.dart @@ -3,9 +3,12 @@ import 'dart:io'; import 'package:cw_core/pathForWallet.dart'; import 'package:cw_core/utils/file.dart'; +import 'package:cw_core/utils/print_verbose.dart'; import 'package:cw_core/wallet_base.dart'; import 'package:cw_core/wallet_credentials.dart'; +import 'package:cw_core/wallet_info.dart'; import 'package:cw_core/wallet_type.dart'; +import 'package:path/path.dart' as p; abstract class WalletService { @@ -25,7 +28,30 @@ abstract class WalletService remove(String wallet); - Future rename(String currentName, String password, String newName); + Future rename(String currentName, String password, String newName) async { + if (currentName == newName) return; + + final currentWalletInfo = await WalletInfo.get(currentName, getType()); + if (currentWalletInfo == null) { + throw Exception('Wallet not found'); + } + + await copyWalletFilesTo(fromName: currentName, toName: newName, type: getType()); + await saveBackup(newName); + + currentWalletInfo.id = WalletBase.idFor(newName, getType()); + currentWalletInfo.name = newName; + await currentWalletInfo.save(); + + final oldDir = Directory(p.join(await pathForWalletTypeDir(type: getType()), currentName)); + if (oldDir.existsSync()) { + try { + await oldDir.delete(recursive: true); + } catch (e) { + printV('rename: failed to delete old wallet dir "$currentName": $e'); + } + } + } Future restoreWalletFilesFromBackup(String name) async { final backupWalletDirPath = await pathForWalletDir(name: "$name.backup", type: getType()); diff --git a/cw_custom_lints/lib/http_force_proxy/http_force_proxy_rule.dart b/cw_custom_lints/lib/http_force_proxy/http_force_proxy_rule.dart new file mode 100644 index 000000000..69d0ea7f7 --- /dev/null +++ b/cw_custom_lints/lib/http_force_proxy/http_force_proxy_rule.dart @@ -0,0 +1,62 @@ +import "package:analyzer/analysis_rule/analysis_rule.dart"; +import "package:analyzer/analysis_rule/rule_context.dart"; +import "package:analyzer/analysis_rule/rule_visitor_registry.dart"; +import "package:analyzer/dart/ast/ast.dart"; +import "package:analyzer/dart/ast/visitor.dart"; +import "package:analyzer/error/error.dart"; + +class HttpForceProxyRule extends AnalysisRule { + HttpForceProxyRule() + : super( + name: "no_http_imports", + description: + "Using the http package breaks proxy integration. Please use ProxyWrapper or alias it as \"very_insecure_http_do_not_use\".", + ); + + static const LintCode code = LintCode( + "no_http_imports", + "Using the http package breaks proxy integration. Please use ProxyWrapper or alias it as \"very_insecure_http_do_not_use\".", + severity: DiagnosticSeverity.WARNING, + ); + + @override + LintCode get diagnosticCode => code; + + @override + void registerNodeProcessors( + RuleVisitorRegistry registry, + RuleContext context, + ) { + final filePath = context.definingUnit.file.path; + + if (filePath.endsWith("proxy_wrapper.dart")) { + return; + } + + registry.addImportDirective(this, _Visitor(this)); + } +} + +class _Visitor extends SimpleAstVisitor { + _Visitor(this.rule); + + final AnalysisRule rule; + + @override + void visitImportDirective(ImportDirective node) { + final uriString = node.uri.stringValue; + if (uriString == null) { + return; + } + + if (uriString.startsWith("package:http")) { + final prefixNode = node.prefix; + if (prefixNode != null && + prefixNode.name == "very_insecure_http_do_not_use") { + return; + } + + rule.reportAtNode(node); + } + } +} diff --git a/cw_custom_lints/lib/main.dart b/cw_custom_lints/lib/main.dart new file mode 100644 index 000000000..39e07ba30 --- /dev/null +++ b/cw_custom_lints/lib/main.dart @@ -0,0 +1,22 @@ +import "package:analysis_server_plugin/plugin.dart"; +import "package:analysis_server_plugin/registry.dart"; +import "package:cw_custom_lints/http_force_proxy/http_force_proxy_rule.dart"; +import "package:cw_custom_lints/print_verbose/print_verbose_fix.dart"; +import "package:cw_custom_lints/print_verbose/print_verbose_rule.dart"; +import "package:cw_custom_lints/restricted_imports/restricted_imports_rule.dart"; + +final plugin = CwCustomLintsPlugin(); + +class CwCustomLintsPlugin extends Plugin { + @override + String get name => "cw_custom_lints"; + + @override + void register(PluginRegistry registry) { + registry.registerWarningRule(PrintVerboseRule()); + registry.registerWarningRule(RestrictedImportsRule()); + registry.registerWarningRule(HttpForceProxyRule()); + + registry.registerFixForRule(PrintVerboseRule.code, ReplaceWithPrintV.new); + } +} diff --git a/cw_custom_lints/lib/print_verbose/print_verbose_fix.dart b/cw_custom_lints/lib/print_verbose/print_verbose_fix.dart new file mode 100644 index 000000000..b7a2daf52 --- /dev/null +++ b/cw_custom_lints/lib/print_verbose/print_verbose_fix.dart @@ -0,0 +1,50 @@ +import "package:analysis_server_plugin/edit/dart/correction_producer.dart"; +import "package:analysis_server_plugin/edit/dart/dart_fix_kind_priority.dart"; +import "package:analyzer/dart/ast/ast.dart"; +import "package:analyzer_plugin/utilities/change_builder/change_builder_core.dart"; +import "package:analyzer_plugin/utilities/fixes/fixes.dart"; +import "package:analyzer_plugin/utilities/range_factory.dart"; + +class ReplaceWithPrintV extends ResolvedCorrectionProducer { + ReplaceWithPrintV({required super.context}); + + static const _importString = "package:cw_core/utils/print_verbose.dart"; + + static const _fixKind = FixKind( + "dart.fix.replaceWithPrintV", + DartFixKindPriority.standard, + "Replace with printV()", + ); + + @override + CorrectionApplicability get applicability => + CorrectionApplicability.singleLocation; + + @override + FixKind get fixKind => _fixKind; + + @override + Future compute(ChangeBuilder builder) async { + final invocation = node.thisOrAncestorOfType(); + if (invocation == null) { + return; + } + + final root = invocation.root; + final hasImport = root is CompilationUnit && + root.directives.whereType().any( + (directive) => directive.uri.stringValue == _importString, + ); + + await builder.addDartFileEdit(file, (builder) { + builder.addSimpleReplacement( + range.node(invocation.methodName), + "printV", + ); + + if (!hasImport) { + builder.importLibrary(Uri.parse(_importString)); + } + }); + } +} diff --git a/cw_custom_lints/lib/print_verbose/print_verbose_rule.dart b/cw_custom_lints/lib/print_verbose/print_verbose_rule.dart new file mode 100644 index 000000000..0fee39bc6 --- /dev/null +++ b/cw_custom_lints/lib/print_verbose/print_verbose_rule.dart @@ -0,0 +1,52 @@ +import "package:analyzer/analysis_rule/analysis_rule.dart"; +import "package:analyzer/analysis_rule/rule_context.dart"; +import "package:analyzer/analysis_rule/rule_visitor_registry.dart"; +import "package:analyzer/dart/ast/ast.dart"; +import "package:analyzer/dart/ast/visitor.dart"; +import "package:analyzer/error/error.dart"; + +class PrintVerboseRule extends AnalysisRule { + PrintVerboseRule() + : super( + name: "use_print_v", + description: "Use printV() from cw_core instead of print().", + ); + + static const LintCode code = LintCode( + "use_print_v", + "Use printV() from cw_core instead", + correctionMessage: "Replace print with printV", + severity: DiagnosticSeverity.WARNING, + ); + + @override + LintCode get diagnosticCode => code; + + @override + void registerNodeProcessors( + RuleVisitorRegistry registry, + RuleContext context, + ) { + final filePath = context.definingUnit.file.path; + + if (filePath.contains("/tool/") || filePath.contains("print_verbose.dart")) { + // tool/ is allowed to use print as it never makes its way into the app + return; + } + + registry.addMethodInvocation(this, _Visitor(this)); + } +} + +class _Visitor extends SimpleAstVisitor { + _Visitor(this.rule); + + final AnalysisRule rule; + + @override + void visitMethodInvocation(MethodInvocation node) { + if (node.methodName.name == "print" && node.target == null) { + rule.reportAtNode(node); + } + } +} diff --git a/cw_custom_lints/lib/restricted_imports/restricted_imports_rule.dart b/cw_custom_lints/lib/restricted_imports/restricted_imports_rule.dart new file mode 100644 index 000000000..80680af87 --- /dev/null +++ b/cw_custom_lints/lib/restricted_imports/restricted_imports_rule.dart @@ -0,0 +1,79 @@ +import "package:analyzer/analysis_rule/analysis_rule.dart"; +import "package:analyzer/analysis_rule/rule_context.dart"; +import "package:analyzer/analysis_rule/rule_visitor_registry.dart"; +import "package:analyzer/dart/ast/ast.dart"; +import "package:analyzer/dart/ast/visitor.dart"; +import "package:analyzer/error/error.dart"; + +class RestrictedImportsRule extends AnalysisRule { + RestrictedImportsRule() + : super( + name: "no_restricted_imports_in_lib", + description: + "Do not import from coin-specific packages. If necessary, wire your logic through configure.dart", + ); + + static const LintCode code = LintCode( + "no_restricted_imports_in_lib", + "Do not import from coin-specific packages. If necessary, wire your logic through configure.dart", + severity: DiagnosticSeverity.ERROR, + ); + + static const _restrictedPackages = { + "cw_bitcoin", + "cw_bitcoin_cash", + "cw_electrum", + "cw_litecoin", + "cw_evm", + "cw_mweb", + "cw_nano", + "cw_solana", + "cw_tron", + "cw_zano", + }; + + @override + LintCode get diagnosticCode => code; + + @override + void registerNodeProcessors( + RuleVisitorRegistry registry, + RuleContext context, + ) { + final filePath = context.definingUnit.file.path; + final fileName = filePath.split("/").last; + + // ignore cw_something folders + if (filePath.contains("cw_")) { + return; + } + + // allow ex. cw_bitcoin in bitcoin.dart + if (_restrictedPackages.contains("cw_" + fileName.replaceAll(".dart", ""))) { + return; + } + + registry.addImportDirective(this, _Visitor(this)); + } +} + +class _Visitor extends SimpleAstVisitor { + _Visitor(this.rule); + + final AnalysisRule rule; + + @override + void visitImportDirective(ImportDirective node) { + final uriString = node.uri.stringValue; + if (uriString == null || !uriString.startsWith("package:")) { + return; + } + + final pathWithoutScheme = uriString.replaceFirst("package:", ""); + final packageName = pathWithoutScheme.split("/").first; + + if (RestrictedImportsRule._restrictedPackages.contains(packageName)) { + rule.reportAtNode(node); + } + } +} diff --git a/cw_custom_lints/pubspec.yaml b/cw_custom_lints/pubspec.yaml new file mode 100644 index 000000000..399fa59c6 --- /dev/null +++ b/cw_custom_lints/pubspec.yaml @@ -0,0 +1,15 @@ +name: cw_custom_lints +version: 0.0.1 +environment: + sdk: '>=3.9.0 <4.0.0' + + +dependencies: + analysis_server_plugin: 0.3.15 + analyzer: 13.0.0 + analyzer_plugin: 0.14.9 + +dev_dependencies: + analyzer_testing: 0.2.6 + test_reflective_loader: 0.4.0 + test: 1.31.2 diff --git a/cw_decred/analysis_options.yaml b/cw_decred/analysis_options.yaml deleted file mode 100644 index a5744c1cf..000000000 --- a/cw_decred/analysis_options.yaml +++ /dev/null @@ -1,4 +0,0 @@ -include: package:flutter_lints/flutter.yaml - -# Additional information about this file can be found at -# https://dart.dev/guides/language/analysis-options diff --git a/cw_dogecoin/analysis_options.yaml b/cw_dogecoin/analysis_options.yaml deleted file mode 100644 index a5744c1cf..000000000 --- a/cw_dogecoin/analysis_options.yaml +++ /dev/null @@ -1,4 +0,0 @@ -include: package:flutter_lints/flutter.yaml - -# Additional information about this file can be found at -# https://dart.dev/guides/language/analysis-options diff --git a/cw_dogecoin/lib/src/dogecoin_wallet_service.dart b/cw_dogecoin/lib/src/dogecoin_wallet_service.dart index 988abd2e7..1a78e14ff 100644 --- a/cw_dogecoin/lib/src/dogecoin_wallet_service.dart +++ b/cw_dogecoin/lib/src/dogecoin_wallet_service.dart @@ -1,12 +1,10 @@ import 'dart:io'; import 'package:bip39/bip39.dart'; -import 'package:collection/collection.dart'; import 'package:cw_bitcoin/bitcoin_mnemonics_bip39.dart'; import 'package:cw_core/encryption_file_utils.dart'; import 'package:cw_core/pathForWallet.dart'; import 'package:cw_core/unspent_coins_info.dart'; -import 'package:cw_core/wallet_base.dart'; import 'package:cw_core/wallet_info.dart'; import 'package:cw_core/wallet_service.dart'; import 'package:cw_core/wallet_type.dart'; @@ -100,29 +98,6 @@ class DogeCoinWalletService extends WalletService< } } - @override - Future rename(String currentName, String password, String newName) async { - final currentWalletInfo = await WalletInfo.get(currentName, getType()); - if (currentWalletInfo == null) { - throw Exception('Wallet not found'); - } - final currentWallet = await DogeCoinWalletBase.open( - password: password, - name: currentName, - walletInfo: currentWalletInfo, - unspentCoinsInfo: unspentCoinsInfoSource, - encryptionFileUtils: encryptionFileUtilsFor(isDirect)); - - await currentWallet.renameWalletFiles(newName); - await saveBackup(newName); - - final newWalletInfo = currentWalletInfo; - newWalletInfo.id = WalletBase.idFor(newName, getType()); - newWalletInfo.name = newName; - - await newWalletInfo.save(); - } - @override Future restoreFromHardwareWallet(DogeCoinNewWalletCredentials credentials) { throw UnimplementedError( diff --git a/cw_evm/analysis_options.yaml b/cw_evm/analysis_options.yaml deleted file mode 100644 index a5744c1cf..000000000 --- a/cw_evm/analysis_options.yaml +++ /dev/null @@ -1,4 +0,0 @@ -include: package:flutter_lints/flutter.yaml - -# Additional information about this file can be found at -# https://dart.dev/guides/language/analysis-options diff --git a/cw_evm/lib/clients/evm_chain_client.dart b/cw_evm/lib/clients/evm_chain_client.dart index 2751e94bb..21973e30c 100644 --- a/cw_evm/lib/clients/evm_chain_client.dart +++ b/cw_evm/lib/clients/evm_chain_client.dart @@ -618,74 +618,117 @@ class EVMChainClient { return []; } - final uri = Uri.https( - 'deep-index.moralis.io', - '/api/v2.2/$address/erc20', - { - "chain": chainName, - }, - ); - - final response = await client.get( - uri, - headers: { - "Accept": "application/json", - "X-API-Key": secrets.moralisApiKey, - }, - ); - - if (response.statusCode < 200 || response.statusCode >= 300) { - printV('Moralis API returned invalid status code: ${response.statusCode}'); - return []; - } - - final decodedResponse = jsonDecode(response.body) as List; - + const maxPages = 3; + String? cursor; + int pageCount = 0; final List tokens = []; - for (final item in decodedResponse) { - final tokenData = item as Map; + do { + final params = { + "chain": chainName, + if (cursor != null && cursor.isNotEmpty) "cursor": cursor, + }; - final balanceStr = tokenData['balance'] as String? ?? '0'; - final balanceWei = BigInt.tryParse(balanceStr) ?? BigInt.zero; - if (balanceWei == BigInt.zero) continue; - - final contractAddress = (tokenData['token_address'] as String? ?? '').toLowerCase(); - final name = (tokenData['name'] as String? ?? '').toString(); - final symbol = (tokenData['symbol'] as String? ?? '').toString(); - final symbolFiltered = symbol.replaceFirst(RegExp('^\\\$'), ''); - - final decimalsRaw = tokenData['decimals']; - final decimals = - decimalsRaw is int ? decimalsRaw : int.tryParse(decimalsRaw.toString()) ?? 18; - - final logo = tokenData['logo'] as String?; - final thumbnail = tokenData['thumbnail'] as String?; - final iconUrl = logo ?? thumbnail; - - final possibleSpamRaw = tokenData['possible_spam']; - final possibleSpam = possibleSpamRaw is bool - ? possibleSpamRaw - : (possibleSpamRaw.toString().toLowerCase() == 'true'); - - final verifiedContractRaw = tokenData['verified_contract']; - final verifiedContract = verifiedContractRaw is bool - ? verifiedContractRaw - : (verifiedContractRaw.toString().toLowerCase() == 'true'); - - tokens.add( - MoralisWalletTokenBalance( - contractAddress: contractAddress, - name: name, - symbol: symbolFiltered, - decimals: decimals, - iconUrl: iconUrl, - balanceWei: balanceWei, - possibleSpam: possibleSpam, - verifiedContract: verifiedContract, - ), + final uri = Uri.https( + 'deep-index.moralis.io', + '/api/v2.2/wallets/$address/tokens', + params, ); - } + + final response = await client.get( + uri, + headers: { + "Accept": "application/json", + "X-API-Key": secrets.moralisApiKey, + }, + ); + + if (response.statusCode < 200 || response.statusCode >= 300) { + printV('Moralis API returned invalid status code: ${response.statusCode}'); + return tokens; + } + + final decoded = jsonDecode(response.body); + if (decoded is! Map) return tokens; + + final result = decoded['result']; + if (result is! List) return tokens; + + for (final item in result) { + if (item is! Map) continue; + final tokenData = item; + + final nativeRaw = tokenData['native_token']; + final nativeToken = nativeRaw is bool + ? nativeRaw + : (nativeRaw?.toString().toLowerCase() == 'true'); + if (nativeToken) continue; + + final balanceStr = tokenData['balance'] as String? ?? '0'; + final balanceWei = BigInt.tryParse(balanceStr) ?? BigInt.zero; + if (balanceWei == BigInt.zero) continue; + + final contractAddress = (tokenData['token_address'] as String? ?? '').toLowerCase(); + final name = (tokenData['name'] as String? ?? '').toString(); + final symbol = (tokenData['symbol'] as String? ?? '').toString(); + final symbolFiltered = symbol.replaceFirst(RegExp('^\\\$'), ''); + + final decimalsRaw = tokenData['decimals']; + final decimals = + decimalsRaw is int ? decimalsRaw : int.tryParse(decimalsRaw.toString()) ?? 18; + + final logo = tokenData['logo'] as String?; + final thumbnail = tokenData['thumbnail'] as String?; + final iconUrl = logo ?? thumbnail; + + final possibleSpamRaw = tokenData['possible_spam']; + final possibleSpam = possibleSpamRaw is bool + ? possibleSpamRaw + : (possibleSpamRaw?.toString().toLowerCase() == 'true'); + + final verifiedContractRaw = tokenData['verified_contract']; + final verifiedContract = verifiedContractRaw is bool + ? verifiedContractRaw + : (verifiedContractRaw?.toString().toLowerCase() == 'true'); + + final usdPriceRaw = tokenData['usd_price']; + final double? usdPrice = usdPriceRaw is num + ? usdPriceRaw.toDouble() + : (usdPriceRaw is String ? double.tryParse(usdPriceRaw) : null); + + final usdValueRaw = tokenData['usd_value']; + final double? usdValue = usdValueRaw is num + ? usdValueRaw.toDouble() + : (usdValueRaw is String ? double.tryParse(usdValueRaw) : null); + + final securityRaw = tokenData['security_score']; + final int? securityScore = securityRaw is int + ? securityRaw + : (securityRaw is num + ? securityRaw.toInt() + : (securityRaw is String ? int.tryParse(securityRaw) : null)); + + tokens.add( + MoralisWalletTokenBalance( + contractAddress: contractAddress, + name: name, + symbol: symbolFiltered, + decimals: decimals, + iconUrl: iconUrl, + balanceWei: balanceWei, + possibleSpam: possibleSpam, + verifiedContract: verifiedContract, + usdPrice: usdPrice, + usdValue: usdValue, + securityScore: securityScore, + ), + ); + } + + final nextCursor = decoded['cursor']; + cursor = nextCursor is String && nextCursor.isNotEmpty ? nextCursor : null; + pageCount++; + } while (cursor != null && pageCount < maxPages); return tokens; } catch (e, stackTrace) { @@ -737,6 +780,9 @@ class MoralisWalletTokenBalance { final BigInt balanceWei; final bool possibleSpam; final bool verifiedContract; + final double? usdPrice; + final double? usdValue; + final int? securityScore; MoralisWalletTokenBalance({ required this.contractAddress, @@ -747,5 +793,8 @@ class MoralisWalletTokenBalance { required this.balanceWei, required this.possibleSpam, required this.verifiedContract, + this.usdPrice, + this.usdValue, + this.securityScore, }); } diff --git a/cw_evm/lib/evm_chain_wallet.dart b/cw_evm/lib/evm_chain_wallet.dart index 4b002e396..d404013fb 100644 --- a/cw_evm/lib/evm_chain_wallet.dart +++ b/cw_evm/lib/evm_chain_wallet.dart @@ -1,6 +1,5 @@ import 'dart:async'; import 'dart:convert'; -import 'dart:io'; import 'dart:typed_data'; import 'package:bip32/bip32.dart' as bip32; @@ -545,67 +544,112 @@ abstract class EVMChainWalletBase await save(); } - bool isTokenPropertiesSuspicious(Erc20Token token) { - bool isTokenWhitelisted = getDefaultTokenContractAddresses - .any((element) => element.toLowerCase() == token.contractAddress.toLowerCase()); + static const _urlLikeSuspiciousMarkers = [ + 't.me', + '.me', + 'telegram', + 'http', + 'https', + '.com', + '.org', + '.top', + '.live', + '.xyz', + 'www', + '🎁', + 'airdrop', + 'distribution', + ]; - final defaultTokenSymbols = EVMChainDefaultTokens.getDefaultTokenSymbols(selectedChainId); + static final _suspiciousWordPattern = + RegExp(r'\b(bot|claim|reward)\b', caseSensitive: false); + + static const _knownNonEvmNativeSymbols = { + 'ICP', + 'SOL', + 'TRX', + 'ATOM', + 'DOT', + 'ADA', + 'XRP', + 'XLM', + 'XMR', + 'ALGO', + 'NEAR', + 'TON', + 'HBAR', + 'APT', + 'SUI', + 'KAS', + }; + + static bool _hasSuspiciousData(String normalized) { + final lower = normalized.toLowerCase(); + if (_urlLikeSuspiciousMarkers.any(lower.contains)) return true; + return _suspiciousWordPattern.hasMatch(lower); + } + + bool isTokenPropertiesSuspicious( + Erc20Token token, { + Set? cachedWhitelistLower, + Set? cachedDefaultSymbolsUpper, + }) { + final whitelistLower = cachedWhitelistLower ?? + getDefaultTokenContractAddresses.map((a) => a.toLowerCase()).toSet(); + final defaultSymbolsUpper = cachedDefaultSymbolsUpper ?? + EVMChainDefaultTokens.getDefaultTokenSymbols(selectedChainId).toSet(); + + final isTokenWhitelisted = whitelistLower.contains(token.contractAddress.toLowerCase()); - // Normalize the token data to check for homoglyph spoofing attack, characters that look like ASCII (Cyrillic, Greek, etc.) final normalizedName = normalizeHomoglyphs(token.name.trim().toUpperCase()); final normalizedSymbol = normalizeHomoglyphs(token.symbol.trim().toUpperCase()); final normalizedTitle = normalizeHomoglyphs(token.title.trim().toUpperCase()); - final suspiciousStrings = [ - 't.me', - '.me', - 'telegram', - 'http', - 'https', - '.com', - '.org', - '.top', - '.live', - 'airdrop', - 'reward', - 'distribution', - 'www', - '.xyz', - '🎁', - 'bot', - 'claim', - 'reward', - ]; - - final hasSuspiciousData = suspiciousStrings.any( - (element) => - normalizedName.toLowerCase().contains(element) || - normalizedSymbol.toLowerCase().contains(element) || - normalizedTitle.toLowerCase().contains(element), - ); + final hasSuspiciousData = _hasSuspiciousData(normalizedName) || + _hasSuspiciousData(normalizedSymbol) || + _hasSuspiciousData(normalizedTitle); final nativeSymbol = currency.title.toUpperCase(); final hasSuspiciousNativeSymbol = normalizedSymbol == nativeSymbol && !isTokenWhitelisted; final hasSuspiciousDefaultTokenSymbol = - defaultTokenSymbols.contains(normalizedSymbol) && !isTokenWhitelisted; + defaultSymbolsUpper.contains(normalizedSymbol) && !isTokenWhitelisted; - return hasSuspiciousData || hasSuspiciousNativeSymbol || hasSuspiciousDefaultTokenSymbol; + final hasSuspiciousNonEvmNativeSymbol = + _knownNonEvmNativeSymbols.contains(normalizedSymbol) && !isTokenWhitelisted; + + return hasSuspiciousData || + hasSuspiciousNativeSymbol || + hasSuspiciousDefaultTokenSymbol || + hasSuspiciousNonEvmNativeSymbol; } - Future _checkForExistingScamTokens() async { - for (var token in erc20Currencies) { - bool isPotentialScam = false; + String get _scamCheckDoneKey => 'evm_scam_check_v2_done_${walletInfo.name}'; - if (isTokenPropertiesSuspicious(token)) { - isPotentialScam = true; + Future _checkForExistingScamTokens() async { + final prefs = await sharedPrefs.future; + if (prefs.getBool(_scamCheckDoneKey) == true) return; + + final whitelistLower = + getDefaultTokenContractAddresses.map((a) => a.toLowerCase()).toSet(); + final defaultSymbolsUpper = + EVMChainDefaultTokens.getDefaultTokenSymbols(selectedChainId).toSet(); + + for (var token in erc20Currencies) { + final suspicious = isTokenPropertiesSuspicious( + token, + cachedWhitelistLower: whitelistLower, + cachedDefaultSymbolsUpper: defaultSymbolsUpper, + ); + + if (suspicious && !token.isPotentialScam) { token.isPotentialScam = true; token.iconPath = null; await token.save(); + continue; } - // For fixing wrongly classified tokens - if (!isPotentialScam && token.isPotentialScam) { + if (!suspicious && token.isPotentialScam) { token.isPotentialScam = false; if (token.iconPath == null || token.iconPath!.isEmpty) { @@ -621,6 +665,8 @@ abstract class EVMChainWalletBase await token.save(); } } + + await prefs.setBool(_scamCheckDoneKey, true); } Future discoverTokensFromMoralis() async { @@ -672,6 +718,9 @@ abstract class EVMChainWalletBase DiscoveredToken( token: newToken, balanceWei: token.balanceWei, + verifiedContract: token.verifiedContract, + moralisUsdPrice: token.usdPrice, + moralisUsdValue: token.usdValue, ), ); } @@ -1653,30 +1702,6 @@ abstract class EVMChainWalletBase ); } - @override - Future renameWalletFiles(String newWalletName) async { - final transactionHistoryFileNameForWallet = getTransactionHistoryFileName(); - - final currentWalletPath = await pathForWallet(name: walletInfo.name, type: type); - final currentWalletFile = File(currentWalletPath); - - final currentDirPath = await pathForWalletDir(name: walletInfo.name, type: type); - final currentTransactionsFile = File('$currentDirPath/$transactionHistoryFileNameForWallet'); - - // Copies current wallet files into new wallet name's dir and files - if (currentWalletFile.existsSync()) { - final newWalletPath = await pathForWallet(name: newWalletName, type: type); - await currentWalletFile.copy(newWalletPath); - } - if (currentTransactionsFile.existsSync()) { - final newDirPath = await pathForWalletDir(name: newWalletName, type: type); - await currentTransactionsFile.copy('$newDirPath/$transactionHistoryFileNameForWallet'); - } - - // Delete old name's dir and files - await Directory(currentDirPath).delete(recursive: true); - } - void _setTransactionUpdateTimer() { if (_transactionsUpdateTimer?.isActive ?? false) { _transactionsUpdateTimer!.cancel(); @@ -1757,10 +1782,16 @@ class GasParamsHandler { class DiscoveredToken { final Erc20Token token; final BigInt balanceWei; + final bool verifiedContract; + final double? moralisUsdPrice; + final double? moralisUsdValue; const DiscoveredToken({ required this.token, required this.balanceWei, + required this.verifiedContract, + this.moralisUsdPrice, + this.moralisUsdValue, }); } diff --git a/cw_evm/lib/evm_chain_wallet_service.dart b/cw_evm/lib/evm_chain_wallet_service.dart index d63686225..425bdceb8 100644 --- a/cw_evm/lib/evm_chain_wallet_service.dart +++ b/cw_evm/lib/evm_chain_wallet_service.dart @@ -3,10 +3,12 @@ import 'dart:io'; import 'package:bip39/bip39.dart' as bip39; import 'package:cw_core/encryption_file_utils.dart'; import 'package:cw_core/pathForWallet.dart'; +import 'package:cw_core/utils/print_verbose.dart'; import 'package:cw_core/wallet_base.dart'; import 'package:cw_core/wallet_info.dart'; import 'package:cw_core/wallet_service.dart'; import 'package:cw_core/wallet_type.dart'; +import 'package:path/path.dart' as p; import 'package:cw_evm/clients/evm_chain_client.dart'; import 'package:cw_evm/evm_chain_client_factory.dart'; import 'package:cw_evm/evm_chain_registry.dart'; @@ -166,29 +168,30 @@ class EVMChainWalletService extends WalletService< @override Future rename(String currentName, String password, String newName) async { + if (currentName == newName) return; + final currentWalletInfo = await _findWalletByName(currentName); if (currentWalletInfo == null) { throw Exception('Wallet not found'); } - final currentWallet = await _openWalletInstance( - password: password, - name: currentName, - walletInfo: currentWalletInfo, - encryptionFileUtils: encryptionFileUtilsFor(isDirect), - ); + final type = currentWalletInfo.type; - await currentWallet.renameWalletFiles(newName); + await copyWalletFilesTo(fromName: currentName, toName: newName, type: type); + await saveBackup(newName, walletInfo: currentWalletInfo); - // Update walletInfo with new name before saving backup - final newWalletInfo = currentWalletInfo; - newWalletInfo.id = WalletBase.idFor(newName, currentWalletInfo.type); - newWalletInfo.name = newName; + currentWalletInfo.id = WalletBase.idFor(newName, type); + currentWalletInfo.name = newName; + await currentWalletInfo.save(); - // Pass walletInfo to saveBackup to avoid lookup (since WalletInfo not saved yet) - await saveBackup(newName, walletInfo: newWalletInfo); - - await newWalletInfo.save(); + final oldDir = Directory(p.join(await pathForWalletTypeDir(type: type), currentName)); + if (oldDir.existsSync()) { + try { + await oldDir.delete(recursive: true); + } catch (e) { + printV('rename: failed to delete old wallet dir "$currentName": $e'); + } + } } @override diff --git a/cw_evm/pubspec.yaml b/cw_evm/pubspec.yaml index 0186841c5..5cc563e6a 100644 --- a/cw_evm/pubspec.yaml +++ b/cw_evm/pubspec.yaml @@ -21,6 +21,7 @@ dependencies: http: ^1.1.0 hive: ^2.2.3 collection: ^1.17.1 + path: ^1.8.0 shared_preferences: ^2.0.15 mobx: ^2.0.7+4 cw_core: diff --git a/cw_monero/analysis_options.yaml b/cw_monero/analysis_options.yaml deleted file mode 100644 index a5744c1cf..000000000 --- a/cw_monero/analysis_options.yaml +++ /dev/null @@ -1,4 +0,0 @@ -include: package:flutter_lints/flutter.yaml - -# Additional information about this file can be found at -# https://dart.dev/guides/language/analysis-options diff --git a/cw_mweb/analysis_options.yaml b/cw_mweb/analysis_options.yaml deleted file mode 100644 index a5744c1cf..000000000 --- a/cw_mweb/analysis_options.yaml +++ /dev/null @@ -1,4 +0,0 @@ -include: package:flutter_lints/flutter.yaml - -# Additional information about this file can be found at -# https://dart.dev/guides/language/analysis-options diff --git a/cw_nano/pubspec.yaml b/cw_nano/pubspec.yaml index a3ef2ea67..449b1b8f5 100644 --- a/cw_nano/pubspec.yaml +++ b/cw_nano/pubspec.yaml @@ -6,7 +6,7 @@ author: Cake Wallet homepage: https://cakewallet.com environment: - sdk: '>=2.18.2 <3.0.0' + sdk: '>=3.0.0 <4.0.0' flutter: ">=1.17.0" dependencies: diff --git a/cw_solana/analysis_options.yaml b/cw_solana/analysis_options.yaml deleted file mode 100644 index a5744c1cf..000000000 --- a/cw_solana/analysis_options.yaml +++ /dev/null @@ -1,4 +0,0 @@ -include: package:flutter_lints/flutter.yaml - -# Additional information about this file can be found at -# https://dart.dev/guides/language/analysis-options diff --git a/cw_solana/lib/solana_client.dart b/cw_solana/lib/solana_client.dart index ad72c74a9..3bea89e76 100644 --- a/cw_solana/lib/solana_client.dart +++ b/cw_solana/lib/solana_client.dart @@ -3,6 +3,7 @@ import 'dart:convert'; import 'package:blockchain_utils/blockchain_utils.dart'; import 'package:cw_core/amount/money.dart'; +import 'package:cw_core/amount/money_double.dart'; import 'package:cw_core/crypto_currency.dart'; import 'package:cw_core/currency.dart'; import 'package:cw_core/node.dart'; @@ -654,7 +655,7 @@ class SolanaWalletClient { from: outgoingFrom, to: outgoingTo, id: outgoingId, - amount: Money.parse(outgoingAmount.toStringAsFixed(outgoingToken.decimals), outgoingToken), + amount: outgoingAmount.toMoney(outgoingToken), programId: outgoingMintAddress == null ? SystemProgramConst.programId.address : SPLTokenProgramConst.tokenProgramId.address, @@ -672,7 +673,7 @@ class SolanaWalletClient { from: incomingFrom, to: incomingTo, id: incomingId, - amount: Money.parse(incomingAmount.toStringAsFixed(incomingToken.decimals), incomingToken), + amount: incomingAmount.toMoney(incomingToken), programId: incomingMintAddress == null ? SystemProgramConst.programId.address : SPLTokenProgramConst.tokenProgramId.address, @@ -869,8 +870,7 @@ class SolanaWalletClient { from: sender, to: receiver, id: signature, - amount: Money.parse(amount.toStringAsFixed((splToken ?? CryptoCurrency.sol).decimals), - splToken ?? CryptoCurrency.sol), + amount: amount.toMoney(splToken ?? CryptoCurrency.sol), programId: SPLTokenProgramConst.tokenProgramId.address, blockTimeInInt: blockTime?.toInt() ?? 0, fee: Money.fromInt(fee, CryptoCurrency.sol), diff --git a/cw_solana/lib/solana_wallet.dart b/cw_solana/lib/solana_wallet.dart index 48a1b0e71..b5b9309fb 100644 --- a/cw_solana/lib/solana_wallet.dart +++ b/cw_solana/lib/solana_wallet.dart @@ -1,6 +1,5 @@ import 'dart:async'; import 'dart:convert'; -import 'dart:io'; import 'package:cw_core/amount/money.dart'; import 'package:cw_core/cake_hive.dart'; @@ -12,6 +11,7 @@ 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/utils/homoglyph_normalizer.dart'; import 'package:cw_core/utils/print_verbose.dart'; import 'package:cw_core/wallet_addresses.dart'; import 'package:cw_core/wallet_base.dart'; @@ -135,6 +135,8 @@ abstract class SolanaWalletBase splTokensBox = await CakeHive.openBox(boxName); + await _checkForExistingScamTokens(); + // Create the privatekey using either the mnemonic or the privateKey _solanaPrivateKey = await getPrivateKey( mnemonic: _mnemonic, @@ -153,6 +155,36 @@ abstract class SolanaWalletBase await save(); } + String get _scamCheckDoneKey => 'solana_scam_check_v2_done_${walletInfo.name}'; + + Future _checkForExistingScamTokens() async { + if (!splTokensBox.isOpen) return; + + final prefs = await _sharedPrefs.future; + if (prefs.getBool(_scamCheckDoneKey) == true) return; + + final defaultMints = + DefaultSPLTokens().initialSPLTokens.map((t) => t.mintAddress).toSet(); + final defaultSymbolsUpper = DefaultSPLTokens() + .initialSPLTokens + .map((t) => t.symbol.toUpperCase()) + .toSet(); + + for (final token in splTokensBox.values) { + final suspicious = isTokenPropertiesSuspicious( + token, + cachedDefaultMints: defaultMints, + cachedDefaultSymbolsUpper: defaultSymbolsUpper, + ); + if (suspicious && !token.isPotentialScam) { + token.isPotentialScam = true; + await token.save(); + } + } + + await prefs.setBool(_scamCheckDoneKey, true); + } + Future getPrivateKey({ String? mnemonic, String? privateKey, @@ -671,7 +703,98 @@ abstract class SolanaWalletBase } } + static const _urlLikeSuspiciousMarkers = [ + 't.me', + '.me', + 'telegram', + 'http', + 'https', + '.com', + '.org', + '.top', + '.live', + '.xyz', + 'www', + '🎁', + 'airdrop', + 'distribution', + ]; + + static final _suspiciousWordPattern = + RegExp(r'\b(bot|claim|reward)\b', caseSensitive: false); + + static const _knownNonSolanaNativeSymbols = { + 'BTC', + 'ETH', + 'BNB', + 'AVAX', + 'MATIC', + 'POL', + 'ICP', + 'TRX', + 'ATOM', + 'DOT', + 'ADA', + 'XRP', + 'XLM', + 'XMR', + 'ALGO', + 'NEAR', + 'TON', + 'HBAR', + 'APT', + 'SUI', + 'KAS', + }; + + static bool _hasSuspiciousData(String normalized) { + final lower = normalized.toLowerCase(); + if (_urlLikeSuspiciousMarkers.any(lower.contains)) return true; + return _suspiciousWordPattern.hasMatch(lower); + } + + bool isTokenPropertiesSuspicious( + SPLToken token, { + Set? cachedDefaultMints, + Set? cachedDefaultSymbolsUpper, + }) { + final defaultMints = cachedDefaultMints ?? + DefaultSPLTokens().initialSPLTokens.map((t) => t.mintAddress).toSet(); + final defaultSymbolsUpper = cachedDefaultSymbolsUpper ?? + DefaultSPLTokens() + .initialSPLTokens + .map((t) => t.symbol.toUpperCase()) + .toSet(); + + final isTokenWhitelisted = defaultMints.contains(token.mintAddress); + + final normalizedName = normalizeHomoglyphs(token.name.trim().toUpperCase()); + final normalizedSymbol = normalizeHomoglyphs(token.symbol.trim().toUpperCase()); + final normalizedTitle = normalizeHomoglyphs(token.title.trim().toUpperCase()); + + final hasSuspiciousData = _hasSuspiciousData(normalizedName) || + _hasSuspiciousData(normalizedSymbol) || + _hasSuspiciousData(normalizedTitle); + + const nativeSymbol = 'SOL'; + final hasSuspiciousNativeSymbol = normalizedSymbol == nativeSymbol && !isTokenWhitelisted; + + final hasSuspiciousDefaultTokenSymbol = + defaultSymbolsUpper.contains(normalizedSymbol) && !isTokenWhitelisted; + + final hasSuspiciousNonSolanaNativeSymbol = + _knownNonSolanaNativeSymbols.contains(normalizedSymbol) && !isTokenWhitelisted; + + return hasSuspiciousData || + hasSuspiciousNativeSymbol || + hasSuspiciousDefaultTokenSymbol || + hasSuspiciousNonSolanaNativeSymbol; + } + Future addSPLToken(SPLToken token) async { + final isSuspicious = isTokenPropertiesSuspicious(token); + token.isPotentialScam = token.isPotentialScam || isSuspicious; + await splTokensBox.put(token.mintAddress, token); if (token.enabled) { @@ -709,28 +832,6 @@ abstract class SolanaWalletBase } } - @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(); diff --git a/cw_solana/lib/solana_wallet_service.dart b/cw_solana/lib/solana_wallet_service.dart index a16f3ee6f..d1fce3b20 100644 --- a/cw_solana/lib/solana_wallet_service.dart +++ b/cw_solana/lib/solana_wallet_service.dart @@ -1,7 +1,6 @@ import 'dart:io'; import 'package:bip39/bip39.dart' as bip39; -import 'package:collection/collection.dart'; import 'package:cw_core/encryption_file_utils.dart'; import 'package:cw_core/balance.dart'; import 'package:cw_core/pathForWallet.dart'; @@ -14,7 +13,6 @@ 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 { @@ -138,29 +136,6 @@ class SolanaWalletService extends WalletService rename(String currentName, String password, String newName) async { - final currentWalletInfo = await WalletInfo.get(currentName, getType()); - if (currentWalletInfo == null) { - throw Exception('Wallet not found'); - } - final currentWallet = await SolanaWalletBase.open( - password: password, - name: currentName, - walletInfo: currentWalletInfo, - encryptionFileUtils: encryptionFileUtilsFor(isDirect), - ); - - await currentWallet.renameWalletFiles(newName); - await saveBackup(newName); - - final newWalletInfo = currentWalletInfo; - newWalletInfo.id = WalletBase.idFor(newName, getType()); - newWalletInfo.name = newName; - - await newWalletInfo.save(); - } - @override Future, TransactionInfo>> restoreFromHardwareWallet(SolanaNewWalletCredentials credentials) { // TODO: implement restoreFromHardwareWallet diff --git a/cw_tron/analysis_options.yaml b/cw_tron/analysis_options.yaml deleted file mode 100644 index a5744c1cf..000000000 --- a/cw_tron/analysis_options.yaml +++ /dev/null @@ -1,4 +0,0 @@ -include: package:flutter_lints/flutter.yaml - -# Additional information about this file can be found at -# https://dart.dev/guides/language/analysis-options diff --git a/cw_tron/lib/tron_wallet.dart b/cw_tron/lib/tron_wallet.dart index 1a4d98b48..24fa83f15 100644 --- a/cw_tron/lib/tron_wallet.dart +++ b/cw_tron/lib/tron_wallet.dart @@ -1,7 +1,6 @@ import 'dart:async'; import 'dart:convert'; import 'dart:developer'; -import 'dart:io'; import 'package:bip39/bip39.dart' as bip39; import 'package:blockchain_utils/blockchain_utils.dart'; @@ -607,30 +606,6 @@ abstract class TronWalletBase Future getTronToken(String contractAddress) async => await _client.getTronToken(contractAddress, _tronAddress); - @override - Future renameWalletFiles(String newWalletName) async { - const transactionHistoryFileNameForWallet = 'tron_transactions.json'; - - final currentWalletPath = await pathForWallet(name: walletInfo.name, type: type); - final currentWalletFile = File(currentWalletPath); - - final currentDirPath = await pathForWalletDir(name: walletInfo.name, type: type); - final currentTransactionsFile = File('$currentDirPath/$transactionHistoryFileNameForWallet'); - - // Copies current wallet files into new wallet name's dir and files - if (currentWalletFile.existsSync()) { - final newWalletPath = await pathForWallet(name: newWalletName, type: type); - await currentWalletFile.copy(newWalletPath); - } - if (currentTransactionsFile.existsSync()) { - final newDirPath = await pathForWalletDir(name: newWalletName, type: type); - await currentTransactionsFile.copy('$newDirPath/$transactionHistoryFileNameForWallet'); - } - - // Delete old name's dir and files - await Directory(currentDirPath).delete(recursive: true); - } - void _setTransactionUpdateTimer() { if (_transactionsUpdateTimer?.isActive ?? false) { _transactionsUpdateTimer!.cancel(); diff --git a/cw_tron/lib/tron_wallet_service.dart b/cw_tron/lib/tron_wallet_service.dart index cf83b9158..9b81a9a57 100644 --- a/cw_tron/lib/tron_wallet_service.dart +++ b/cw_tron/lib/tron_wallet_service.dart @@ -14,7 +14,6 @@ import 'package:cw_tron/tron_client.dart'; import 'package:cw_tron/tron_exception.dart'; import 'package:cw_tron/tron_wallet.dart'; import 'package:cw_tron/tron_wallet_creation_credentials.dart'; -import 'package:hive/hive.dart'; class TronWalletService extends WalletService< TronNewWalletCredentials, @@ -134,29 +133,6 @@ class TronWalletService extends WalletService< return wallet; } - @override - Future rename(String currentName, String password, String newName) async { - final currentWalletInfo = await WalletInfo.get(currentName, getType()); - if (currentWalletInfo == null) { - throw Exception('Wallet not found'); - } - final currentWallet = await TronWalletBase.open( - password: password, - name: currentName, - walletInfo: currentWalletInfo, - encryptionFileUtils: encryptionFileUtilsFor(isDirect), - ); - - await currentWallet.renameWalletFiles(newName); - await saveBackup(newName); - - final newWalletInfo = currentWalletInfo; - newWalletInfo.id = WalletBase.idFor(newName, getType()); - newWalletInfo.name = newName; - - await newWalletInfo.save(); - } - @override Future isWalletExit(String name) async => File(await pathForWallet(name: name, type: getType())).existsSync(); diff --git a/cw_wownero/analysis_options.yaml b/cw_wownero/analysis_options.yaml deleted file mode 100644 index a5744c1cf..000000000 --- a/cw_wownero/analysis_options.yaml +++ /dev/null @@ -1,4 +0,0 @@ -include: package:flutter_lints/flutter.yaml - -# Additional information about this file can be found at -# https://dart.dev/guides/language/analysis-options diff --git a/cw_zano/pubspec.yaml b/cw_zano/pubspec.yaml index 7aa32af60..7e8ede8dd 100644 --- a/cw_zano/pubspec.yaml +++ b/cw_zano/pubspec.yaml @@ -5,7 +5,7 @@ publish_to: none homepage: https://cakewallet.com environment: - sdk: ">=2.19.0 <3.0.0" + sdk: ">=3.0.0 <4.0.0" flutter: ">=1.20.0" dependencies: diff --git a/cw_zcash/analysis_options.yaml b/cw_zcash/analysis_options.yaml deleted file mode 100644 index f43bd483d..000000000 --- a/cw_zcash/analysis_options.yaml +++ /dev/null @@ -1,31 +0,0 @@ -analyzer: - errors: - implementation_imports: ignore - must_be_immutable: ignore - overridden_fields: ignore - exclude: - - "**.g.dart" - - "external/**" - -linter: - rules: - unawaited_futures: true - avoid_print: false - parameter_assignments: true - prefer_const_declarations: true - prefer_final_fields: true - prefer_final_in_for_each: true - prefer_final_locals: true - prefer_final_parameters: true - avoid_void_async: true - require_trailing_commas: true - sort_child_properties_last: true - sort_constructors_first: true - sort_unnamed_constructors_first: true - unnecessary_async: true - unnecessary_await_in_return: true - unnecessary_string_interpolations: true - always_declare_return_types: true - -formatter: - page_width: 100 diff --git a/lib/bitcoin/cw_bitcoin.dart b/lib/bitcoin/cw_bitcoin.dart index a5d118322..724c9197f 100644 --- a/lib/bitcoin/cw_bitcoin.dart +++ b/lib/bitcoin/cw_bitcoin.dart @@ -177,7 +177,7 @@ class CWBitcoin extends Bitcoin { List getSubAddresses(Object wallet) { final electrumWallet = wallet as ElectrumWallet; return electrumWallet.walletAddresses.addressesByReceiveType - .map((BaseBitcoinAddressRecord addr) => ElectrumSubAddress( + .map((addr) => ElectrumSubAddress( id: addr.index, name: addr.name, address: addr.address, diff --git a/lib/buy/moonpay/moonpay_provider.dart b/lib/buy/moonpay/moonpay_provider.dart index dfd09e130..9b9d5dd91 100644 --- a/lib/buy/moonpay/moonpay_provider.dart +++ b/lib/buy/moonpay/moonpay_provider.dart @@ -79,7 +79,7 @@ class MoonPayProvider extends BuyProvider { @override bool get isAggregator => false; - static String get _apiKey => secrets.moonPayApiKey; + String get _apiKey => isTestEnvironment ? secrets.moonPaySandboxApiKey : secrets.moonPayApiKey; String get currencyCode => walletTypeToCryptoCurrency(wallet.type, chainId: wallet.chainId).title.toLowerCase(); @@ -96,8 +96,9 @@ class MoonPayProvider extends BuyProvider { } } - Future getMoonpaySignature(String query) async { - final uri = Uri.https(_cIdBaseUrl, "/api/moonpay"); + Future getMoonpaySignedQuery(String query) async { + final uri = + Uri.https(_cIdBaseUrl, "/api/moonpay", isTestEnvironment ? {"useSandbox": "true"} : null); final response = await ProxyWrapper().post( clearnetUri: uri, @@ -107,7 +108,8 @@ class MoonPayProvider extends BuyProvider { if (response.statusCode == 200) { - return (jsonDecode(response.body) as Map)['signature'] as String; + printV((jsonDecode(response.body) as Map)); + return (jsonDecode(response.body) as Map)['query'] as String; } else { throw Exception( 'Provider currently unavailable. Status: ${response.statusCode} ${response.body}'); @@ -297,18 +299,13 @@ class MoonPayProvider extends BuyProvider { required Map params, String? amount, }) async { - if (_apiKey.isNotEmpty) params['apiKey'] = _apiKey; + if (_apiKey.isNotEmpty) params["apiKey"] = _apiKey; final baseUrl = isBuyAction ? baseBuyUrl : baseSellUrl; - final originalUri = Uri.https(baseUrl, '', params); + final originalUri = Uri.https(baseUrl, "", params); - if (isTestEnvironment) return originalUri; - - final signature = await getMoonpaySignature('?${originalUri.query}'); - final query = Map.from(originalUri.queryParameters); - query['signature'] = signature; - final signedUri = originalUri.replace(queryParameters: query); - return signedUri; + final query = await getMoonpaySignedQuery("?${originalUri.query}"); + return Uri.parse(query); } Future findOrderById(String id) async { diff --git a/lib/cake_pay/src/cards/cake_pay_buy_card_page.dart b/lib/cake_pay/src/cards/cake_pay_buy_card_page.dart index 5442dcff1..8380897b9 100644 --- a/lib/cake_pay/src/cards/cake_pay_buy_card_page.dart +++ b/lib/cake_pay/src/cards/cake_pay_buy_card_page.dart @@ -11,8 +11,8 @@ import 'package:cake_wallet/cake_pay/src/widgets/link_extractor.dart'; import 'package:cake_wallet/cake_pay/src/widgets/rounded_overlay_cards_widget.dart'; import 'package:cake_wallet/cake_pay/src/widgets/text_icon_button.dart'; import 'package:cake_wallet/cake_pay/src/widgets/three_checkbox_alert_content_widget.dart'; +import 'package:cake_wallet/core/address_resolver/parsed_address.dart'; import 'package:cake_wallet/core/execution_state.dart'; -import 'package:cake_wallet/entities/parsed_address.dart'; import 'package:cake_wallet/generated/i18n.dart'; import 'package:cake_wallet/new-ui/widgets/keyboard_hide_overlay.dart'; import 'package:cake_wallet/routes.dart'; @@ -584,8 +584,11 @@ class CakePayBuyCardPage extends BasePage { final displayingOutputs = _sendViewModel.outputs .map((o) => o.OutputCopyWithParsedAddress( parsedAddress: ParsedAddress( - addresses: [o.address], - name: 'Cake Pay', + parsedAddressByCurrencyMap: { + cakePayBuyCardViewModel.sendViewModel.selectedCryptoCurrency: + o.address, + }, + handle: 'Cake Pay', profileName: order?.cards.first.cardName ?? 'Cake Pay', profileImageUrl: order?.cards.first.cardImagePath ?? '', ), @@ -653,8 +656,11 @@ class CakePayBuyCardPage extends BasePage { final displayingOutputs = outputsCopy .map((o) => o.OutputCopyWithParsedAddress( parsedAddress: ParsedAddress( - addresses: [o.address], - name: 'Cake Pay', + parsedAddressByCurrencyMap: { + cakePayBuyCardViewModel.sendViewModel.selectedCryptoCurrency: + o.address, + }, + handle: 'Cake Pay', profileName: order?.cards.first.cardName ?? 'Cake Pay', profileImageUrl: order?.cards.first.cardImagePath ?? '', ), diff --git a/lib/core/address_resolver/address_lookup_provider.dart b/lib/core/address_resolver/address_lookup_provider.dart new file mode 100644 index 000000000..9feff5610 --- /dev/null +++ b/lib/core/address_resolver/address_lookup_provider.dart @@ -0,0 +1,22 @@ +import 'package:cake_wallet/core/address_resolver/address_sources.dart'; +import 'package:cake_wallet/core/address_resolver/parsed_address.dart'; +import 'package:cake_wallet/store/settings_store.dart'; +import 'package:cw_core/crypto_currency.dart'; +import 'package:cw_core/wallet_base.dart'; + +abstract class AddressLookupProvider { + + AddressSource get source; + + List get supportedCurrencies; + + bool isEnabled(SettingsStore settingsStore); + + bool canHandle(String query); + + Future> resolve({ + required String query, + required List currencies, + required WalletBase wallet, + }); +} diff --git a/lib/core/address_resolver/address_resolver_service.dart b/lib/core/address_resolver/address_resolver_service.dart new file mode 100644 index 000000000..8592c53c6 --- /dev/null +++ b/lib/core/address_resolver/address_resolver_service.dart @@ -0,0 +1,143 @@ +import 'package:cake_wallet/core/address_resolver/address_lookup_provider.dart'; +import 'package:cake_wallet/core/address_resolver/address_sources.dart'; +import 'package:cake_wallet/core/address_resolver/bip_353/bip_353_address_provider.dart'; +import 'package:cake_wallet/core/address_resolver/ens/ens_address_provider.dart'; +import 'package:cake_wallet/core/address_resolver/fio/fio_address_provider.dart'; +import 'package:cake_wallet/core/address_resolver/lnurl_pay/lnurl_pay_address_provider.dart'; +import 'package:cake_wallet/core/address_resolver/mastodon/mastodon_address_provider.dart'; +import 'package:cake_wallet/core/address_resolver/nostr/nostr_address_provider.dart'; +import 'package:cake_wallet/core/address_resolver/openalias/openalias_address_provider.dart'; +import 'package:cake_wallet/core/address_resolver/parsed_address.dart'; +import 'package:cake_wallet/core/address_resolver/thorchain/thorchain_address_provider.dart'; +import 'package:cake_wallet/core/address_resolver/twitter/twitter_address_provider.dart'; +import 'package:cake_wallet/core/address_resolver/unstoppable/unstoppable_address_provider.dart'; +import 'package:cake_wallet/core/address_resolver/wellknown/wellknown_address_provider.dart'; +import 'package:cake_wallet/core/address_resolver/yat/yat_address_provider.dart'; +import 'package:cake_wallet/core/address_resolver/zano/zano_alias_address_provider.dart'; +import 'package:cake_wallet/core/address_resolver/zcash/zcash_address_provider.dart'; +import 'package:cake_wallet/core/address_resolver/zcash/zcash_me_address_provider.dart'; +import 'package:cake_wallet/store/settings_store.dart'; +import 'package:cw_core/crypto_currency.dart'; +import 'package:cw_core/utils/print_verbose.dart'; +import 'package:cw_core/wallet_base.dart'; + +class AddressResolverService { + AddressResolverService({required this.settingsStore}); + + final SettingsStore settingsStore; + +// Sources available for address resolution and lookup settings. + static const supportedSources = [ + AddressSource.lnurlPay, + AddressSource.twitter, + AddressSource.mastodon, + AddressSource.yatRecord, + AddressSource.unstoppableDomains, + AddressSource.openAlias, + AddressSource.ens, + AddressSource.zcashName, + AddressSource.zcashAddress, + AddressSource.wellKnown, + AddressSource.zanoAlias, + AddressSource.bip353, + AddressSource.fio, + AddressSource.thorChain, + AddressSource.nostr, + ]; + + List _buildProviders() { + final allProviders = [ + LNUrlPayAddressProvider(), + TwitterAddressProvider(), + MastodonAddressProvider(), + YatAddressProvider(), + UnstoppableAddressProvider(), + OpenaliasAddressProvider(), + EnsAddressProvider(), + ZcashNameAddressProvider(), + ZcashMeAddressProvider(), + WellKnownAddressProvider(), + ZanoAliasAddressProvider(), + Bip353AddressProvider(), + FioAddressProvider(), + ThorchainAddressProvider(), + NostrAddressProvider(), + ]; + + return allProviders.where((provider) => supportedSources.contains(provider.source)).toList(); + } + + Future> resolve({ + required String query, + required WalletBase wallet, + required CryptoCurrency currency, + }) async { + try { + final providers = _buildProviders(); + // return first entry if currency is specified and provider supports it + if (currency != null) { + for (final provider in providers) { + final cur = currency.title; + final src = provider.source.label; + + printV('[address resolver service] checking $src - $cur - $query'); + if (!provider.isEnabled(settingsStore)) { + printV('[address resolver service] ...skipping $src (disabled)'); + continue; + } + if (!provider.canHandle(query)) { + printV('[address resolver service] ...skipping $src (cannot handle)'); + continue; + } + if (!provider.supportedCurrencies.contains(currency)) { + printV('[address resolver service] ...skipping $src (unsupported currency)'); + continue; + } + + try { + printV('[address resolver service] ...resolving $src - $cur - $query'); + final results = await provider.resolve( + query: query, + currencies: [currency], + wallet: wallet, + ); + + if (results.isNotEmpty) { + final parsedAddress = results.first.parsedAddressByCurrencyMap[currency]; + final parsedAddressStr = parsedAddress == null || parsedAddress.isEmpty ? 'N/A' : parsedAddress; + printV('[address resolver service] resolved $src - $cur - $parsedAddressStr'); + return [results.first]; + } + } catch (e) { + printV('[address resolver service] Error resolving $src: $e'); + } + } + + return []; + } + + return []; // TODO: Implement multi-currency resolution if no specific currency is provided. + + final tasks = >>[]; + + for (final provider in providers) { + if (!provider.isEnabled(settingsStore)) continue; + if (!provider.canHandle(query)) continue; + + tasks.add( + provider.resolve( + query: query, + currencies: provider.supportedCurrencies, + wallet: wallet, + ), + ); + } + + final results = await Future.wait(tasks); + return results.expand((items) => items).toList(); + } catch (e) { + printV('Error resolving address: $e'); + return []; + } + } +} diff --git a/lib/core/address_resolver/address_resolver_utils.dart b/lib/core/address_resolver/address_resolver_utils.dart new file mode 100644 index 000000000..f9f59240c --- /dev/null +++ b/lib/core/address_resolver/address_resolver_utils.dart @@ -0,0 +1,104 @@ +import 'package:cake_wallet/core/address_resolver/unstoppable/unstoppable_address_provider.dart'; +import 'package:cake_wallet/core/address_validator.dart'; +import 'package:cw_core/crypto_currency.dart'; +import 'package:cw_core/utils/print_verbose.dart'; + +class AddressResolverUtils { + static Future> resolveUnstoppableDomainFromText({ + required String raw, + required List currencies, + }) async { + final domain = extractUnstoppableDomain(raw); + if (domain.isEmpty) return {}; + + final result = {}; + + for (final currency in currencies) { + final address = await UnstoppableAddressProvider.fetchUnstoppableDomainAddress(domain, currency.title); + if (address.isNotEmpty) { + result[currency] = address; + } + } + + return result; + } + + static String extractUnstoppableDomain(String raw) { +// sort by length to avoid matching shorter tld instead of longer + final domains = List.from(UnstoppableAddressProvider.unstoppableDomains) + ..sort((a, b) => b.length.compareTo(a.length)); + for (final tld in domains) { + final pattern = RegExp(r'([A-Za-z0-9-]+)\.' + RegExp.escape(tld), caseSensitive: false); + final match = pattern.firstMatch(raw); + if (match != null) return match.group(0)!; + } + return ''; + } + + static Map extractAddressesFromText({ + required String raw, + required List currencies, + bool requireSurroundingWhitespaces = true, + }) { + final result = {}; + var text = raw; + + for (final currency in currencies) { + final address = extractAddressByType( + raw: text, + type: currency, + requireSurroundingWhitespaces: requireSurroundingWhitespaces, + ); + + if (address != null && address.isNotEmpty) { + result[currency] = address; + text = text.replaceFirst(address, ''); + } + } + + return result; + } + + static String? extractAddressByType({ + required String raw, + required CryptoCurrency type, + bool requireSurroundingWhitespaces = true, + }) { + var addressPattern = AddressValidator.getAddressFromStringPattern(type); + if (addressPattern == null) { + printV('Unknown pattern for $type'); + return null; + } + if (requireSurroundingWhitespaces) addressPattern = "$BEFORE_REGEX$addressPattern$AFTER_REGEX"; + final text = stripHtmlTags(raw); + final match = RegExp(addressPattern, multiLine: true, caseSensitive: false).firstMatch(text); + if (match == null) return null; + return match.group(0)?.replaceAllMapped(RegExp('[^0-9a-zA-Z]|bitcoincash:|nano_|ban_'), + (Match match) { + String group = match.group(0)!; + if (group.startsWith('bitcoincash:') || + group.startsWith('nano_') || + group.startsWith('ban_')) { + return group; + } + return ''; + }); + } + + static bool isEmailFormat(String address) { + final RegExp emailRegex = RegExp( + r'^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$', + caseSensitive: false, + ); + return emailRegex.hasMatch(address); + } + + static String stripHtmlTags(String value) { + return value + .replaceAll(RegExp(r'[\u2028\u2029]'), '\n') + .replaceAll(RegExp(r'', caseSensitive: false), '\n') + .replaceAll(RegExp(r'', caseSensitive: false), '\n') + .replaceAll(RegExp(r'<[^>]*>'), ' ') + .trim(); + } +} diff --git a/lib/core/address_resolver/address_sources.dart b/lib/core/address_resolver/address_sources.dart new file mode 100644 index 000000000..729ef478a --- /dev/null +++ b/lib/core/address_resolver/address_sources.dart @@ -0,0 +1,87 @@ +import 'package:cake_wallet/core/address_validator.dart'; +import 'package:cw_core/crypto_currency.dart'; + +enum AddressSource { + twitter(label: 'X', iconPath: 'assets/new-ui/address_sources/x.svg', alias: '@username'), + unstoppableDomains( + label: 'Unstoppable Domains', + iconPath: 'assets/new-ui/address_sources/unstoppable.svg', + alias: 'domain.tld'), + openAlias( + label: 'OpenAlias', + iconPath: 'assets/new-ui/address_sources/openalias.svg', + alias: 'name.domain.tld'), + yatRecord(label: 'Yat', iconPath: 'assets/new-ui/address_sources/yat.svg', alias: '🎂🎂🎂'), + fio(label: 'FIO', iconPath: 'assets/new-ui/address_sources/fio.svg', alias: 'user@domain'), + ens( + label: 'Ethereum Name Service', + iconPath: 'assets/new-ui/address_sources/ens.svg', + alias: 'domain.eth'), + mastodon( + label: 'Mastodon', + iconPath: 'assets/new-ui/address_sources/mastodon.svg', + alias: '@username@domain.tld'), + nostr( + label: 'Nostr', + iconPath: 'assets/new-ui/address_sources/nostr.svg', + alias: 'user@domain.tld'), + thorChain( + label: 'ThorChain', + iconPath: 'assets/new-ui/address_sources/icon-thorchain.svg', + alias: 'name'), + wellKnown( + label: '.wellknown', + iconPath: 'assets/new-ui/address_sources/wellknown.svg', + alias: 'domain.tld'), + zanoAlias( + label: 'Zano Alias', iconPath: 'assets/new-ui/address_sources/zano.svg', alias: '@alias'), + bip353( + label: 'BIP353', + iconPath: 'assets/new-ui/address_sources/bip353.svg', + alias: 'user@domain.com'), + zcashAddress( + label: 'Zcash.me', + iconPath: 'assets/new-ui/address_sources/icon-zcash-me.svg', + alias: 'zcash.me/username'), + zcashName( + label: 'Zcash Names', + iconPath: 'assets/new-ui/address_sources/icon-zcashnames.svg', + alias: 'name.zec'), + lnurlPay( + label: 'LNURL Pay', + iconPath: 'assets/new-ui/address_sources/icon-lnurl-pay.svg', + alias: 'user@domain.com'), + contact(label: 'Contact', iconPath: ''), + notParsed(label: 'Unknown', iconPath: ''), + ; + + const AddressSource({ + required this.label, + required this.iconPath, + this.alias = '', + }); + + final String label; + final String iconPath; + final String alias; +} + +extension AddressSourceIndex on AddressSource { + int get raw => index; + + static AddressSource fromRaw(int raw) => + AddressSource.values[raw.clamp(0, AddressSource.values.length - 1)]; +} + +extension AddressSourceNameParser on AddressSource { + static AddressSource fromLabel(String? text) { + if (text == null || text.trim().isEmpty) { + return AddressSource.notParsed; + } + final needle = text.trim().toLowerCase(); + return AddressSource.values.firstWhere( + (src) => src.label.toLowerCase() == needle, + orElse: () => AddressSource.notParsed, + ); + } +} diff --git a/lib/core/address_resolver/bip_353/bip_353_address_provider.dart b/lib/core/address_resolver/bip_353/bip_353_address_provider.dart new file mode 100644 index 000000000..3a46f87a4 --- /dev/null +++ b/lib/core/address_resolver/bip_353/bip_353_address_provider.dart @@ -0,0 +1,68 @@ +import 'package:cake_wallet/core/address_resolver/address_lookup_provider.dart'; +import 'package:cake_wallet/core/address_resolver/address_sources.dart'; +import 'package:cake_wallet/core/address_resolver/bip_353/bip_353_record.dart'; +import 'package:cake_wallet/core/address_resolver/parsed_address.dart'; +import 'package:cake_wallet/store/settings_store.dart'; +import 'package:cw_core/crypto_currency.dart'; +import 'package:cw_core/utils/print_verbose.dart'; +import 'package:cw_core/wallet_base.dart'; + +class Bip353AddressProvider extends AddressLookupProvider { + @override + AddressSource get source => AddressSource.bip353; + + @override + List get supportedCurrencies => [CryptoCurrency.btc]; + + @override + bool isEnabled(SettingsStore settingsStore) => settingsStore.lookupsBip353; + + @override + bool canHandle(String query) { + final value = query.trim(); + return value.contains('@') && value.contains('.') && !value.startsWith('@'); + } + + @override + Future> resolve({ + required String query, + required List currencies, + required WalletBase wallet, + }) async { + try { + final result = {}; + String? dnsProof; + + for (final cur in currencies) { + final bip353AddressMap = await Bip353Record.fetchUriByCryptoCurrency(query, cur.title); + if (bip353AddressMap == null || bip353AddressMap.isEmpty) continue; + + final spAddress = bip353AddressMap['sp']; + final address = bip353AddressMap['address']; + final chosenAddress = spAddress?.isNotEmpty == true ? spAddress : address; + + if (chosenAddress != null && chosenAddress.isNotEmpty) { + result[cur] = chosenAddress; + } + } + + if (result.isEmpty) return []; + + try { + dnsProof = await Bip353Record.fetchDnsProof(query); + } catch (e) { + printV('Bip353Record.fetchDnsProof error: $e'); + } + + return [ParsedAddress( + parsedAddressByCurrencyMap: result, + addressSource: AddressSource.bip353, + handle: query, + bip353DnsProof: dnsProof ?? '', + )]; + } catch (e) { + printV('[address resolver] Error resolving BIP353 address: $e'); + return []; + } + } +} \ No newline at end of file diff --git a/lib/entities/bip_353_record.dart b/lib/core/address_resolver/bip_353/bip_353_record.dart similarity index 59% rename from lib/entities/bip_353_record.dart rename to lib/core/address_resolver/bip_353/bip_353_record.dart index 9a2a50e0a..257d76737 100644 --- a/lib/entities/bip_353_record.dart +++ b/lib/core/address_resolver/bip_353/bip_353_record.dart @@ -2,13 +2,9 @@ import 'dart:convert'; import 'dart:isolate'; import 'package:basic_utils/basic_utils.dart'; -import 'package:cake_wallet/generated/i18n.dart'; -import 'package:cake_wallet/src/widgets/alert_with_picker_option.dart'; -import 'package:cake_wallet/utils/show_pop_up.dart'; import 'package:cw_core/crypto_currency.dart'; import 'package:cw_core/utils/print_verbose.dart'; import 'package:dnssec_proof/dnssec_proof.dart'; -import 'package:flutter/material.dart'; class Bip353Record { Bip353Record({ @@ -104,69 +100,4 @@ class Bip353Record { } } - static Future pickBip353AddressChoice( - BuildContext context, - String bip353Name, - Map addressMap, - ) async { - if (addressMap.length == 1) { - return addressMap.values.first; - } - - final chosenAddress = await _showAddressChoiceDialog(context, bip353Name, addressMap); - - return chosenAddress; - } - - static Future _showAddressChoiceDialog( - BuildContext context, - String bip353Name, - Map addressMap, - ) async { - final entriesList = addressMap.entries.toList(); - final List> displayItems = entriesList.map((entry) { - final originalKey = entry.key; - final originalValue = entry.value; - - final extendedKeyName = keyDisplayMap[originalKey] ?? originalKey; - final truncatedValue = _truncate(originalValue, front: 6, back: 6); - - return { - 'displayKey': extendedKeyName, - 'displayValue': truncatedValue, - 'originalKey': originalKey, - 'originalValue': originalValue, - }; - }).toList(); - - String? selectedOriginalValue; - - if (context.mounted) { - await showPopUp( - context: context, - builder: (dialogContext) { - return AlertWithPickerOption( - alertTitle: S.of(context).multiple_addresses_detected + '\n$bip353Name', - alertTitleTextSize: 14, - alertSubtitle: S.of(context).please_choose_one + ':', - options: displayItems, - onOptionSelected: (Map chosenItem) { - selectedOriginalValue = chosenItem['originalValue']; - }, - alertBarrierDismissible: true, - ); - }, - ); - } - return selectedOriginalValue; - } - - static String _truncate(String value, {int front = 6, int back = 6}) { - if (value.length <= front + back) return value; - - final start = value.substring(0, front); - final end = value.substring(value.length - back); - return '$start...$end'; - } - -} +} \ No newline at end of file diff --git a/lib/core/address_resolver/ens/ens_address_provider.dart b/lib/core/address_resolver/ens/ens_address_provider.dart new file mode 100644 index 000000000..b0a365d07 --- /dev/null +++ b/lib/core/address_resolver/ens/ens_address_provider.dart @@ -0,0 +1,49 @@ +import 'package:cake_wallet/core/address_resolver/address_lookup_provider.dart'; +import 'package:cake_wallet/core/address_resolver/address_sources.dart'; +import 'package:cake_wallet/core/address_resolver/ens/ens_record.dart'; +import 'package:cake_wallet/core/address_resolver/parsed_address.dart'; +import 'package:cake_wallet/store/settings_store.dart'; +import 'package:cw_core/crypto_currency.dart'; +import 'package:cw_core/wallet_base.dart'; + +class EnsAddressProvider extends AddressLookupProvider { + @override + AddressSource get source => AddressSource.ens; + + @override + List get supportedCurrencies => + [CryptoCurrency.xmr, CryptoCurrency.btc, CryptoCurrency.eth]; + + @override + bool isEnabled(SettingsStore settingsStore) => settingsStore.lookupsENS; + + @override + bool canHandle(String query) => query.endsWith('.eth'); // ENS handle example: name.eth + + @override + Future> resolve({ + required String query, + required List currencies, + required WalletBase wallet, + }) async { + final Map result = {}; + + for (final cur in currencies) { + final address = await EnsRecord.fetchEnsAddress(query, cur, wallet: wallet); + if (address.isNotEmpty && address != "0x0000000000000000000000000000000000000000") { + result[cur] = address; + } + } + + if (result.isNotEmpty) { + return [ + ParsedAddress( + parsedAddressByCurrencyMap: result, + addressSource: AddressSource.ens, + handle: query, + ) + ]; + } + return []; + } +} diff --git a/lib/core/address_resolver/ens/ens_record.dart b/lib/core/address_resolver/ens/ens_record.dart new file mode 100644 index 000000000..b976ae63c --- /dev/null +++ b/lib/core/address_resolver/ens/ens_record.dart @@ -0,0 +1,55 @@ +import 'package:cake_wallet/evm/evm.dart'; +import 'package:cw_core/crypto_currency.dart'; +import 'package:cw_core/utils/proxy_wrapper.dart'; +import 'package:cw_core/utils/print_verbose.dart'; +import 'package:cw_core/wallet_base.dart'; +import 'package:cw_core/wallet_type.dart'; +import 'package:ens_dart/ens_dart.dart'; +import 'package:web3dart/web3dart.dart'; + +class EnsRecord { + static Future fetchEnsAddress( + String name, + CryptoCurrency cur, { + WalletBase? wallet, + }) async { + Web3Client? _client; + + if (wallet?.type == WalletType.ethereum || wallet?.type == WalletType.polygon) { + _client = evm!.getWeb3Client(wallet!); + } + + _client ??= Web3Client( + 'https://ethereum-rpc.publicnode.com', + ProxyWrapper().getHttpIOClient(), + ); + + final ens = Ens(client: _client); + final coinType = getEnsCoinType(cur); + + if (coinType == null) { + printV('Unsupported currency for ENS: $cur'); + return ''; + } + + try { + if (coinType == CoinType.ETH || coinType == CoinType.MATIC) { + return (await ens.withName(name).getAddress()).hex; + } else { + return await ens.withName(name).getCoinAddress(coinType); + } + } catch (e) { + printV(e); + return ''; + } + } + + static CoinType? getEnsCoinType(CryptoCurrency cur) => switch (cur) { + CryptoCurrency.xmr => CoinType.XMR, + CryptoCurrency.btc => CoinType.BTC, + CryptoCurrency.ltc => CoinType.LTC, + CryptoCurrency.eth => CoinType.ETH, + CryptoCurrency.matic => CoinType.MATIC, + _ => null, + }; +} \ No newline at end of file diff --git a/lib/core/address_resolver/fio/fio_address_provider.dart b/lib/core/address_resolver/fio/fio_address_provider.dart new file mode 100644 index 000000000..91d4ac722 --- /dev/null +++ b/lib/core/address_resolver/fio/fio_address_provider.dart @@ -0,0 +1,125 @@ +import 'dart:convert'; + +import 'package:cake_wallet/core/address_resolver/address_lookup_provider.dart'; +import 'package:cake_wallet/core/address_resolver/address_sources.dart'; +import 'package:cake_wallet/core/address_resolver/parsed_address.dart'; +import 'package:cake_wallet/core/address_validator.dart'; +import 'package:cake_wallet/store/settings_store.dart'; +import 'package:cw_core/crypto_currency.dart'; +import 'package:cw_core/utils/print_verbose.dart'; +import 'package:cw_core/utils/proxy_wrapper.dart'; +import 'package:cw_core/wallet_base.dart'; + +class FioAddressProvider extends AddressLookupProvider { + @override + AddressSource get source => AddressSource.fio; + + @override + List get supportedCurrencies => AddressValidator.reliableValidateCurrencies; + + @override + bool canHandle(String q) => + !q.startsWith('@') && + q.contains('@') && + !q.contains('.'); // FIO handle example: username@domain + + @override + bool isEnabled(SettingsStore settingsStore) => settingsStore.lookupsFio; + + @override + Future> resolve({ + required String query, + required List currencies, + required WalletBase wallet, + }) async { + final Map result = {}; + try { + final bool isFioRegistered = await FioAddressProvider.checkAvail(query); + if (!isFioRegistered) return []; + + for (final cur in currencies) { + final address = await FioAddressProvider.getPubAddress(query, cur.title); + if (address != null && address.isNotEmpty) { + result[cur] = address; + } + } + + if (result.isNotEmpty) { + return [ + ParsedAddress( + parsedAddressByCurrencyMap: result, addressSource: AddressSource.fio, handle: query) + ]; + } + return []; + } catch (e) { + printV('FioAddressProvider.resolve error: $e'); + return []; + } + } + + static const _apiAuthority = 'fio.blockpane.com'; + static const _availCheck = '/v1/chain/avail_check'; + static const _getAddress = '/v1/chain/get_pub_address'; + + static Future checkAvail(String fioAddress) async { + bool isFioRegistered = false; + final headers = {'Content-Type': 'application/json'}; + final body = {"fio_name": fioAddress}; + + final uri = Uri.https(_apiAuthority, _availCheck); + final response = await ProxyWrapper().post( + clearnetUri: uri, + headers: headers, + body: json.encode(body), + ); + + if (response.statusCode != 200) { + return isFioRegistered; + } + + final responseJSON = json.decode(response.body) as Map; + isFioRegistered = responseJSON['is_registered'] as int == 1; + + return isFioRegistered; + } + + static Future getPubAddress(String fioAddress, String token) async { + final headers = {'Content-Type': 'application/json'}; + final body = { + "fio_address": fioAddress, + "chain_code": token.toUpperCase(), + "token_code": token.toUpperCase(), + }; + + final uri = Uri.https(_apiAuthority, _getAddress); + final response = await ProxyWrapper().post( + clearnetUri: uri, + headers: headers, + body: json.encode(body), + ); + + final responseJSON = json.decode(response.body) as Map; + + if (response.statusCode == 400) { + final error = responseJSON['error'] as String; + final message = responseJSON['message'] as String; + printV('${error}\n$message'); + return null; + } + + if (response.statusCode != 200) { + final String message = responseJSON['message'] as String? ?? 'Unknown error'; + + printV('Error fetching public address for token $token: $message'); + return null; + } + + final String pubAddress = responseJSON['public_address'] as String? ?? ''; + + if (pubAddress.isNotEmpty) { + return pubAddress; + } + + return null; + } +} diff --git a/lib/core/address_resolver/lnurl_pay/lnurl_pay_address_provider.dart b/lib/core/address_resolver/lnurl_pay/lnurl_pay_address_provider.dart new file mode 100644 index 000000000..b767c15b9 --- /dev/null +++ b/lib/core/address_resolver/lnurl_pay/lnurl_pay_address_provider.dart @@ -0,0 +1,66 @@ +import 'package:cake_wallet/core/address_resolver/address_lookup_provider.dart'; +import 'package:cake_wallet/core/address_resolver/address_sources.dart'; +import 'package:cake_wallet/core/address_resolver/lnurl_pay/lnurlpay_record.dart'; +import 'package:cake_wallet/core/address_resolver/parsed_address.dart'; +import 'package:cake_wallet/store/settings_store.dart'; +import 'package:cw_core/crypto_currency.dart'; +import 'package:cw_core/utils/print_verbose.dart'; +import 'package:cw_core/wallet_base.dart'; +import 'package:cw_core/wallet_type.dart'; + +class LNUrlPayAddressProvider extends AddressLookupProvider { + @override + AddressSource get source => AddressSource.lnurlPay; + + @override + List get supportedCurrencies => [CryptoCurrency.btcln]; + + @override + bool canHandle(String q) => q.contains('.') && q.contains('@'); // LNURL-pay handle example: user@domain.com + + @override + bool isEnabled(SettingsStore settingsStore) => settingsStore.lookupsLNUrl; + + @override + Future> resolve({ + required String query, + required List currencies, + required WalletBase wallet, + }) async { + try { + if (wallet.type != WalletType.bitcoin) return []; + + final formattedName = query.trim(); + if (formattedName.isEmpty) return []; + + final result = {}; + + for (final currency in currencies) { + if (!supportedCurrencies.contains(currency)) continue; + + final record = await LNUrlPayRecord.fetchAddressAndName( + formattedName: formattedName, + currency: currency, + ); + + if (record != null && record.address.isNotEmpty) { + result[currency] = record.address; + } + } + + if (result.isEmpty) return []; + + return [ + ParsedAddress( + parsedAddressByCurrencyMap: result, + addressSource: AddressSource.lnurlPay, + handle: formattedName, + profileName: formattedName, + ), + ]; + } catch (e) { + printV('[address resolver] Error resolving LNURL-pay address: $e'); + return []; + } + } +} \ No newline at end of file diff --git a/lib/entities/lnurlpay_record.dart b/lib/core/address_resolver/lnurl_pay/lnurlpay_record.dart similarity index 100% rename from lib/entities/lnurlpay_record.dart rename to lib/core/address_resolver/lnurl_pay/lnurlpay_record.dart diff --git a/lib/core/address_resolver/mastodon/mastodon_address_provider.dart b/lib/core/address_resolver/mastodon/mastodon_address_provider.dart new file mode 100644 index 000000000..10c226d0e --- /dev/null +++ b/lib/core/address_resolver/mastodon/mastodon_address_provider.dart @@ -0,0 +1,93 @@ +import 'package:cake_wallet/core/address_resolver/address_lookup_provider.dart'; +import 'package:cake_wallet/core/address_resolver/address_resolver_utils.dart'; +import 'package:cake_wallet/core/address_resolver/address_sources.dart'; +import 'package:cake_wallet/core/address_resolver/mastodon/mastodon_api.dart'; +import 'package:cake_wallet/core/address_resolver/parsed_address.dart'; +import 'package:cake_wallet/core/address_validator.dart'; +import 'package:cake_wallet/store/settings_store.dart'; +import 'package:cw_core/crypto_currency.dart'; +import 'package:cw_core/utils/print_verbose.dart'; +import 'package:cw_core/wallet_base.dart'; + +class MastodonAddressProvider extends AddressLookupProvider { + @override + AddressSource get source => AddressSource.mastodon; + + @override + List get supportedCurrencies => AddressValidator.reliableValidateCurrencies; + + @override + bool isEnabled(SettingsStore settingsStore) => settingsStore.lookupsMastodon; + + @override + bool canHandle(String query) => + query.startsWith('@') && + query.contains('@', 1) && + query.contains('.', 1); // Mastodon handle example: @username@hostname.xxx + + @override + Future> resolve({ + required String query, + required List currencies, + required WalletBase wallet, + }) async { + try { + final subText = query.substring(1); // Remove '@' from the beginning of the handle + final hostNameIndex = subText.indexOf('@'); + if (hostNameIndex <= 0 || hostNameIndex == subText.length - 1) return []; + + final userName = subText.substring(0, hostNameIndex); + final hostName = subText.substring(hostNameIndex + 1); + + final result = {}; + + final mastodonUser = await MastodonAPI.lookupUserByUserName( + userName: userName, + apiHost: hostName, + ); + if (mastodonUser == null) return []; + + final bio = AddressResolverUtils.stripHtmlTags(mastodonUser.note); + result.addAll( + AddressResolverUtils.extractAddressesFromText( + raw: bio, + currencies: currencies, + ), + ); + + final pinnedPosts = await MastodonAPI.getPinnedPosts( + userId: mastodonUser.id, + apiHost: hostName, + ); + + if (pinnedPosts.isNotEmpty) { + final pinnedPostsText = pinnedPosts + .map((item) => AddressResolverUtils.stripHtmlTags(item.content)) + .where((text) => text.isNotEmpty) + .join('\n'); + + result.addAll( + AddressResolverUtils.extractAddressesFromText( + raw: pinnedPostsText, + currencies: currencies, + ), + ); + } + + if (result.isEmpty) return []; + + return [ + ParsedAddress( + parsedAddressByCurrencyMap: result, + addressSource: AddressSource.mastodon, + handle: query, + profileImageUrl: mastodonUser.profileImageUrl, + profileName: mastodonUser.username, + ), + ]; + } catch (e) { + printV('[address resolver] Error resolving Mastodon address: $e'); + return []; + } + } +} diff --git a/lib/mastodon/mastodon_api.dart b/lib/core/address_resolver/mastodon/mastodon_api.dart similarity index 95% rename from lib/mastodon/mastodon_api.dart rename to lib/core/address_resolver/mastodon/mastodon_api.dart index 58767eea7..6cb60be81 100644 --- a/lib/mastodon/mastodon_api.dart +++ b/lib/core/address_resolver/mastodon/mastodon_api.dart @@ -1,7 +1,7 @@ import 'dart:convert'; +import 'package:cake_wallet/core/address_resolver/mastodon/mastodon_user.dart'; import 'package:cw_core/utils/proxy_wrapper.dart'; import 'package:cw_core/utils/print_verbose.dart'; -import 'package:cake_wallet/mastodon/mastodon_user.dart'; class MastodonAPI { static const httpsScheme = 'https'; diff --git a/lib/mastodon/mastodon_user.dart b/lib/core/address_resolver/mastodon/mastodon_user.dart similarity index 100% rename from lib/mastodon/mastodon_user.dart rename to lib/core/address_resolver/mastodon/mastodon_user.dart diff --git a/lib/core/address_resolver/nostr/nostr_address_provider.dart b/lib/core/address_resolver/nostr/nostr_address_provider.dart new file mode 100644 index 000000000..332187e9e --- /dev/null +++ b/lib/core/address_resolver/nostr/nostr_address_provider.dart @@ -0,0 +1,66 @@ +import 'package:cake_wallet/core/address_resolver/address_lookup_provider.dart'; +import 'package:cake_wallet/core/address_resolver/address_resolver_utils.dart'; +import 'package:cake_wallet/core/address_resolver/address_sources.dart'; +import 'package:cake_wallet/core/address_resolver/nostr/nostr_api.dart'; +import 'package:cake_wallet/core/address_resolver/parsed_address.dart'; +import 'package:cake_wallet/core/address_validator.dart'; +import 'package:cake_wallet/store/settings_store.dart'; +import 'package:cw_core/crypto_currency.dart'; +import 'package:cw_core/utils/print_verbose.dart'; +import 'package:cw_core/wallet_base.dart'; + +class NostrAddressProvider extends AddressLookupProvider { + @override + AddressSource get source => AddressSource.nostr; + + @override + List get supportedCurrencies => AddressValidator.reliableValidateCurrencies; + + @override + bool canHandle(String q) => + AddressResolverUtils.isEmailFormat(q); // Nostr handle example: username@domain + + @override + bool isEnabled(SettingsStore settingsStore) => settingsStore.lookupsNostr; + + @override + Future> resolve({ + required String query, + required List currencies, + required WalletBase wallet, + }) async { + try { + final profile = await NostrProfileHandler.queryProfile(query); + if (profile == null) return []; + + final data = await NostrProfileHandler.processRelays(profile, query); + if (data == null) return []; + + final result = {}; + + String queryTxt = data.about; + + for (final cur in currencies) { + final addr = AddressResolverUtils.extractAddressByType(raw: queryTxt, type: cur); + if (addr != null && addr.isNotEmpty) { + result[cur] = addr; + queryTxt = queryTxt.replaceFirst(addr, ''); + } + } + if (result.isEmpty) return []; + + return [ + ParsedAddress( + parsedAddressByCurrencyMap: result, + addressSource: AddressSource.nostr, + handle: query, + profileImageUrl: data.picture, + profileName: data.name, + ) + ]; + } catch (e) { + printV('Error looking up Nostr profile: $e'); + return []; + } + } +} diff --git a/lib/core/address_resolver/nostr/nostr_api.dart b/lib/core/address_resolver/nostr/nostr_api.dart new file mode 100644 index 000000000..19d25c01f --- /dev/null +++ b/lib/core/address_resolver/nostr/nostr_api.dart @@ -0,0 +1,127 @@ +import 'dart:async'; +import 'dart:convert'; + +import 'package:cake_wallet/core/address_resolver/nostr/nostr_user.dart'; +import 'package:nostr_tools/nostr_tools.dart'; +import 'dart:async' show Completer, TimeoutException, runZonedGuarded; + +class NostrProfileHandler { + static final relayToDomainMap = { + 'relay.snort.social': 'snort.social', + }; + + static final Nip05 _nip05 = Nip05(); + + static Future queryProfile(String nip05Address) async { + final profile = await _nip05.queryProfile(nip05Address); + if (profile?.pubkey != null && profile?.relays?.isNotEmpty == true) { + return profile; + } + return null; + } + + static Future processRelays( + ProfilePointer profile, + String nip05Address, + ) async { + final userDomain = _extractDomain(nip05Address); + const int metaKind = 0; + + // Domain-matched relays first + for (final String relayUrl in profile.relays ?? []) { + final relayDomain = + relayToDomainMap[_getDomainFromRelayUrl(relayUrl)] ?? _getDomainFromRelayUrl(relayUrl); + + if (relayDomain == userDomain) { + final data = await _fetchInfoFromRelay(relayUrl, profile.pubkey, [metaKind]); + if (data != null) return data; + } + } + + // Then try every remaining relay + for (final String relayUrl in profile.relays ?? []) { + final data = await _fetchInfoFromRelay(relayUrl, profile.pubkey, [metaKind]); + if (data != null) return data; + } + + // Nothing found + return null; + } + + static const Duration _relayTimeout = Duration(seconds: 3); + + static Future _fetchInfoFromRelay( + String relayUrl, String userPubKey, List kinds) async { + // sanitize so obvious junk (like '#') doesn't reach connect() + final clean = _sanitizeRelay(relayUrl); + if (clean.isEmpty) return null; + + final result = Completer(); + + runZonedGuarded(() async { + try { + final relay = RelayApi(relayUrl: clean); + + final stream = await relay.connect().timeout( + _relayTimeout, + onTimeout: () { + relay.close(); + throw TimeoutException('Relay connect timeout'); + }, + ); + + relay.sub([ + Filter(kinds: kinds, authors: [userPubKey]) + ]); + + final sub = stream.listen((msg) { + if (msg.type == 'EVENT' && !result.isCompleted) { + try { + final event = msg.message as Event; + final jsonMap = json.decode(event.content) as Map; + result.complete(UserMetadata.fromJson(jsonMap)); + } catch (_) { + if (!result.isCompleted) result.complete(null); + } + } + }, onError: (_) { + if (!result.isCompleted) result.complete(null); + }, onDone: () { + if (!result.isCompleted) result.complete(null); + }); + + final value = await result.future.timeout(_relayTimeout, onTimeout: () => null); + await sub.cancel(); + relay.close(); + if (!result.isCompleted) result.complete(value); + } catch (_) { + if (!result.isCompleted) result.complete(null); + } + }, (error, stack) { + // swallow ALL async errors from the websocket layer (including "was not upgraded to websocket") + if (!result.isCompleted) result.complete(null); + }); + + return result.future; + } + + static String _sanitizeRelay(String url) { + url = url.replaceFirst(RegExp(r'^https?://'), 'wss://'); + final uri = Uri.parse(url); + return Uri( + scheme: uri.scheme.isEmpty ? 'wss' : uri.scheme, + host: uri.host, + port: uri.hasPort ? uri.port : 443, + ).toString(); + } + + static String _extractDomain(String nip05) => nip05.split('@').last; + + static String _getDomainFromRelayUrl(String url) { + try { + return Uri.parse(url).host; + } catch (_) { + return ''; + } + } +} \ No newline at end of file diff --git a/lib/nostr/nostr_user.dart b/lib/core/address_resolver/nostr/nostr_user.dart similarity index 100% rename from lib/nostr/nostr_user.dart rename to lib/core/address_resolver/nostr/nostr_user.dart diff --git a/lib/core/address_resolver/openalias/openalias_address_provider.dart b/lib/core/address_resolver/openalias/openalias_address_provider.dart new file mode 100644 index 000000000..6f7e8671b --- /dev/null +++ b/lib/core/address_resolver/openalias/openalias_address_provider.dart @@ -0,0 +1,64 @@ +import 'package:cake_wallet/core/address_resolver/address_lookup_provider.dart'; +import 'package:cake_wallet/core/address_resolver/address_sources.dart'; +import 'package:cake_wallet/core/address_resolver/openalias/openalias_record.dart'; +import 'package:cake_wallet/core/address_resolver/parsed_address.dart'; +import 'package:cake_wallet/store/settings_store.dart'; +import 'package:cw_core/crypto_currency.dart'; +import 'package:cw_core/utils/print_verbose.dart'; +import 'package:cw_core/wallet_base.dart'; + +class OpenaliasAddressProvider extends AddressLookupProvider { + @override + AddressSource get source => AddressSource.openAlias; + + @override + List get supportedCurrencies => [CryptoCurrency.xmr, CryptoCurrency.btc]; + + @override + bool canHandle(String q) { + final formattedName = OpenaliasRecord.formatDomainName(q); + return formattedName.contains("."); + } + + @override + bool isEnabled(SettingsStore settingsStore) => settingsStore.lookupsOpenAlias; + + @override + Future> resolve({ + required String query, + required List currencies, + required WalletBase wallet, + }) async { + try { + final formatted = OpenaliasRecord.formatDomainName(query); + + final txtRecords = await OpenaliasRecord.lookupOpenAliasRecord(formatted); + if (txtRecords == null) return []; + + final result = {}; + + for (final cur in currencies) { + final rec = OpenaliasRecord.fetchAddressAndName( + formattedName: formatted, + ticker: cur.title.toLowerCase(), + txtRecord: txtRecords, + ); + + if (rec.address.isNotEmpty) result[cur] = rec.address; + } + + if (result.isEmpty) return []; + + return [ + ParsedAddress( + parsedAddressByCurrencyMap: result, + addressSource: AddressSource.openAlias, + handle: query, + ) + ]; + } catch (e) { + printV('[address resolver] Error resolving OpenAlias address: $e'); + return []; + } + } +} diff --git a/lib/core/address_resolver/openalias/openalias_record.dart b/lib/core/address_resolver/openalias/openalias_record.dart new file mode 100644 index 000000000..65273df3e --- /dev/null +++ b/lib/core/address_resolver/openalias/openalias_record.dart @@ -0,0 +1,69 @@ +import 'package:basic_utils/basic_utils.dart'; +import 'package:cw_core/utils/print_verbose.dart'; + +class OpenaliasRecord { + OpenaliasRecord({ + required this.address, + required this.name, + required this.description, + }); + + final String name; + final String address; + final String description; + + static String formatDomainName(String name) { + String formattedName = name; + + if (name.contains("@")) { + formattedName = name.replaceAll("@", "."); + } + + return formattedName; + } + + static Future?> lookupOpenAliasRecord(String name) async { + try { + final txtRecord = await DnsUtils.lookupRecord(name, RRecordType.TXT, dnssec: true); + + return txtRecord; + } catch (e) { + printV("${e.toString()}"); + return null; + } + } + + static OpenaliasRecord fetchAddressAndName({ + required String formattedName, + required String ticker, + required List txtRecord, + }) { + var address = ''; + var name = formattedName; + var note = ''; + + final addrRe = RegExp(r'recipient_address=([^;\s]+)', caseSensitive: false); + final nameRe = RegExp(r'recipient_name=([^;]+)', caseSensitive: false); + final noteRe = RegExp(r'tx_description=([^;]+)', caseSensitive: false); + final tag = 'oa1:$ticker'; + + for (final rr in txtRecord) { + final txt = rr.data.replaceAll('"', ''); + + if (!txt.toLowerCase().contains(tag)) continue; + + final addrM = addrRe.firstMatch(txt); + if (addrM != null) address = addrM.group(1)!; + + final nameM = nameRe.firstMatch(txt); + if (nameM != null) name = nameM.group(1)!.trim(); + + final noteM = noteRe.firstMatch(txt); + if (noteM != null) note = noteM.group(1)!.trim(); + + break; + } + + return OpenaliasRecord(address: address, name: name, description: note); + } +} \ No newline at end of file diff --git a/lib/core/address_resolver/parsed_address.dart b/lib/core/address_resolver/parsed_address.dart new file mode 100644 index 000000000..8934e2d76 --- /dev/null +++ b/lib/core/address_resolver/parsed_address.dart @@ -0,0 +1,22 @@ +import 'package:cake_wallet/core/address_resolver/address_sources.dart'; +import 'package:cw_core/crypto_currency.dart'; + +class ParsedAddress { + const ParsedAddress({ + required this.parsedAddressByCurrencyMap, + this.addressSource = AddressSource.notParsed, + this.handle = '', + this.profileImageUrl = '', + this.profileName = '', + this.description = '', + this.bip353DnsProof, + }); + + final Map parsedAddressByCurrencyMap; + final AddressSource addressSource; + final String handle; + final String profileImageUrl; + final String profileName; + final String description; + final String? bip353DnsProof; +} \ No newline at end of file diff --git a/lib/core/address_resolver/thorchain/thorchain_address_provider.dart b/lib/core/address_resolver/thorchain/thorchain_address_provider.dart new file mode 100644 index 000000000..21c27217a --- /dev/null +++ b/lib/core/address_resolver/thorchain/thorchain_address_provider.dart @@ -0,0 +1,65 @@ +import 'package:cake_wallet/core/address_resolver/address_lookup_provider.dart'; +import 'package:cake_wallet/core/address_resolver/address_resolver_utils.dart'; +import 'package:cake_wallet/core/address_resolver/address_sources.dart'; +import 'package:cake_wallet/core/address_resolver/parsed_address.dart'; +import 'package:cake_wallet/core/address_validator.dart'; +import 'package:cake_wallet/exchange/provider/thorchain_exchange.provider.dart'; +import 'package:cake_wallet/store/settings_store.dart'; +import 'package:cw_core/crypto_currency.dart'; +import 'package:cw_core/utils/print_verbose.dart'; +import 'package:cw_core/wallet_base.dart'; + +class ThorchainAddressProvider extends AddressLookupProvider { + @override + AddressSource get source => AddressSource.thorChain; + + @override + List get supportedCurrencies => AddressValidator.reliableValidateCurrencies; + + @override + bool canHandle(String q) => q.isNotEmpty; // can be any string + + @override + bool isEnabled(SettingsStore settingsStore) => settingsStore.lookupsThorChain; + + @override + Future> resolve({ + required String query, + required List currencies, + required WalletBase wallet, + }) async { + try { + final isNormalAddress = currencies.any((cur) => + AddressResolverUtils.extractAddressByType(raw: query, type: cur)?.isNotEmpty ?? false); + if (query.length > 30 || isNormalAddress) return []; + + final map = await ThorChainExchangeProvider.lookupAddressByName(query); + if (map == null || map.isEmpty) return []; + + final result = {}; + + for (final cur in currencies) { + final key = cur.title.toUpperCase(); + final addr = map[key]; + final runeAddr = cur.title.toUpperCase() == 'RUNE' ? map['THOR'] : null; + final resolvedAddress = addr ?? runeAddr; + if (resolvedAddress != null && resolvedAddress.isNotEmpty) { + if (!result.containsValue(resolvedAddress)) result[cur] = resolvedAddress; + } + } + + if (result.isEmpty) return []; + + return [ + ParsedAddress( + parsedAddressByCurrencyMap: result, + addressSource: AddressSource.thorChain, + handle: query, + ) + ]; + } catch (e) { + printV('[address resolver] Error resolving address: $e'); + return []; + } + } +} diff --git a/lib/core/address_resolver/twitter/twitter_address_provider.dart b/lib/core/address_resolver/twitter/twitter_address_provider.dart new file mode 100644 index 000000000..954bfb6ec --- /dev/null +++ b/lib/core/address_resolver/twitter/twitter_address_provider.dart @@ -0,0 +1,97 @@ +import 'package:cake_wallet/core/address_resolver/address_lookup_provider.dart'; +import 'package:cake_wallet/core/address_resolver/address_resolver_utils.dart'; +import 'package:cake_wallet/core/address_resolver/address_sources.dart'; +import 'package:cake_wallet/core/address_resolver/parsed_address.dart'; +import 'package:cake_wallet/core/address_resolver/twitter/twitter_api.dart'; +import 'package:cake_wallet/core/address_resolver/twitter/twitter_user.dart'; +import 'package:cake_wallet/core/address_validator.dart'; +import 'package:cake_wallet/store/settings_store.dart'; +import 'package:cw_core/crypto_currency.dart'; +import 'package:cw_core/utils/print_verbose.dart'; +import 'package:cw_core/wallet_base.dart'; + +class TwitterAddressProvider extends AddressLookupProvider { + + @override + AddressSource get source => AddressSource.twitter; + + @override + List get supportedCurrencies => AddressValidator.reliableValidateCurrencies; + + @override + bool isEnabled(SettingsStore settingsStore) => settingsStore.lookupsTwitter; + + @override + bool canHandle(String query) => + query.startsWith('@') && !query.substring(1).contains('@'); // x handle example: @username + + /// Resolves a Twitter handle to a list of ParsedAddress objects by extracting potential cryptocurrency addresses + /// from the user's, profile. + /// fallback to resolving Unstoppable Domains if no addresses are found in each of the Twitter user's bio, location, and pinned tweet. + + @override + Future> resolve({ + required String query, + required List currencies, + required WalletBase wallet, + }) async { + final formattedName = query.replaceFirst("@", ""); + try { + final result = {}; + + final twitterUser = await TwitterApi.lookupUserByName(userName: formattedName); + + final bio = (text: twitterUser.description, whitespaces: true); + final location = (text: twitterUser.location, whitespaces: false); + final pinnedTweet = (text: twitterUser.pinnedTweet?.text ?? '', whitespaces: true); + + final twitterTexts = [ + bio, + location, + pinnedTweet, + ].where((item) => item.text.isNotEmpty); + + for (final item in twitterTexts) { + result.addAll( + AddressResolverUtils.extractAddressesFromText( + raw: item.text, + currencies: currencies, + requireSurroundingWhitespaces: item.whitespaces, + ), + ); + + if (result.isEmpty) { + final domainResult = await AddressResolverUtils.resolveUnstoppableDomainFromText( + raw: item.text, + currencies: currencies, + ); + + if (domainResult.isNotEmpty) { + return [_buildTwitterParsedAddress(query, twitterUser, domainResult)]; + } + } + } + + if (result.isEmpty) return []; + + return [_buildTwitterParsedAddress(query, twitterUser, result)]; + } catch (e) { + printV('[address resolver service] Twitter error: $e'); + return []; + } + } + + ParsedAddress _buildTwitterParsedAddress( + String query, + TwitterUser twitterUser, + Map result, + ) { + return ParsedAddress( + parsedAddressByCurrencyMap: result, + addressSource: AddressSource.twitter, + handle: query, + profileImageUrl: twitterUser.profileImageUrl, + profileName: twitterUser.name, + ); + } +} diff --git a/lib/twitter/twitter_api.dart b/lib/core/address_resolver/twitter/twitter_api.dart similarity index 96% rename from lib/twitter/twitter_api.dart rename to lib/core/address_resolver/twitter/twitter_api.dart index a508b3edf..0079d39a9 100644 --- a/lib/twitter/twitter_api.dart +++ b/lib/core/address_resolver/twitter/twitter_api.dart @@ -1,7 +1,7 @@ import 'dart:convert'; import 'package:cake_wallet/.secrets.g.dart' as secrets; -import 'package:cake_wallet/twitter/twitter_user.dart'; +import 'package:cake_wallet/core/address_resolver/twitter/twitter_user.dart'; import 'package:cw_core/utils/proxy_wrapper.dart'; class TwitterApi { diff --git a/lib/twitter/twitter_user.dart b/lib/core/address_resolver/twitter/twitter_user.dart similarity index 100% rename from lib/twitter/twitter_user.dart rename to lib/core/address_resolver/twitter/twitter_user.dart diff --git a/lib/core/address_resolver/unstoppable/unstoppable_address_provider.dart b/lib/core/address_resolver/unstoppable/unstoppable_address_provider.dart new file mode 100644 index 000000000..dd240f7ed --- /dev/null +++ b/lib/core/address_resolver/unstoppable/unstoppable_address_provider.dart @@ -0,0 +1,235 @@ +import 'dart:convert'; + +import 'package:cake_wallet/core/address_resolver/address_lookup_provider.dart'; +import 'package:cake_wallet/core/address_resolver/address_sources.dart'; +import 'package:cake_wallet/core/address_resolver/openalias/openalias_record.dart'; +import 'package:cake_wallet/core/address_resolver/parsed_address.dart'; +import 'package:cake_wallet/store/settings_store.dart'; +import 'package:cw_core/crypto_currency.dart'; +import 'package:cw_core/utils/print_verbose.dart'; +import 'package:cw_core/utils/proxy_wrapper.dart'; +import 'package:cw_core/wallet_base.dart'; + +class UnstoppableAddressProvider extends AddressLookupProvider { + static const unstoppableDomains = [ + "888", + "academy", + "agency", + "altimist", + "anime", + "austin", + "bald", + "bay", + "benji", + "bet", + "binanceus", + "bitcoin", + "bitget", + "bitscrunch", + "blockchain", + "boomer", + "boston", + "ca", + "caw", + "cc", + "chat", + "chomp", + "clay", + "club", + "co", + "com", + "company", + "crypto", + "dao", + "design", + "dfz", + "digital", + "doga", + "donut", + "dream", + "email", + "emir", + "eth", + "ethermail", + "family", + "farms", + "finance", + "fun", + "fyi", + "games", + "global", + "go", + "group", + "guru", + "hi", + "hockey", + "host", + "info", + "io", + "klever", + "kresus", + "kryptic", + "lfg", + "life", + "live", + "llc", + "ltc", + "ltd", + "manga", + "me", + "media", + "metropolis", + "miami", + "miku", + "money", + "moon", + "mumu", + "net", + "network", + "news", + "nft", + "npc", + "onchain", + "online", + "org", + "podcast", + "pog", + "polygon", + "base", + "arbitrum", + "press", + "privacy", + "pro", + "propykeys", + "pudgy", + "pw", + "quantum", + "rad", + "raiin", + "retardio", + "rip", + "rocks", + "secret", + "services", + "site", + "smobler", + "social", + "solutions", + "space", + "stepn", + "store", + "studio", + "systems", + "tball", + "tea", + "team", + "tech", + "technology", + "today", + "tribe", + "u", + "ubu", + "uno", + "unstoppable", + "vip", + "wallet", + "website", + "wif", + "wifi", + "witg", + "work", + "world", + "wrkx", + "wtf", + "x", + "xmr", + "xyz", + "zil", + "zone", + "pizza" + ]; + + @override + AddressSource get source => AddressSource.unstoppableDomains; + + @override + List get supportedCurrencies => [CryptoCurrency.xmr, CryptoCurrency.btc]; + + @override + bool isEnabled(SettingsStore settingsStore) => settingsStore.lookupsUnstoppableDomains; + + @override + bool canHandle(String query) { + // Unstoppable Domains handle example: name.crypto + final formattedName = OpenaliasRecord.formatDomainName(query); + final domainParts = formattedName.split('.'); + final name = domainParts.last; + return domainParts.length > 1 && + domainParts.first.isNotEmpty && + name.isNotEmpty && + unstoppableDomains.any((domain) => name.trim() == domain); + } + + @override + Future> resolve({ + required String query, + required List currencies, + required WalletBase wallet, + }) async { + try { + final formattedName = OpenaliasRecord.formatDomainName(query); + final result = {}; + + for (final currency in currencies) { + final address = await fetchUnstoppableDomainAddress( + formattedName, + currency.title, + ); + + if (address.isNotEmpty) { + result[currency] = address; + } + } + + if (result.isEmpty) return []; + + return [ + ParsedAddress( + parsedAddressByCurrencyMap: result, + profileName: formattedName, + addressSource: AddressSource.unstoppableDomains, + handle: formattedName, + ), + ]; + } catch (e) { + printV('[address resolver] Error resolving Unstoppable Domain: $e'); + return []; + } + } + + static Future fetchUnstoppableDomainAddress(String domain, String ticker) async { + var address = ''; + + try { + final uri = Uri.parse("https://api.unstoppabledomains.com/profile/public/${Uri.encodeQueryComponent(domain)}?fields=records"); + final response = await ProxyWrapper().get(clearnetUri: uri); + + final jsonParsed = json.decode(response.body) as Map; + if (jsonParsed["records"] == null) { + throw Exception(".records response from $uri is empty"); + }; + final records = jsonParsed["records"] as Map; + final key = "crypto.${ticker.toUpperCase()}.address"; + if (records[key] == null) { + throw Exception(".records.${key} response from $uri is empty"); + } + + return records[key] as String? ?? ''; + } catch (e) { + printV('Unstoppable domain error: ${e.toString()}'); + address = ''; + } + + return address; + } +} diff --git a/lib/core/address_resolver/wellknown/wellknown_address_provider.dart b/lib/core/address_resolver/wellknown/wellknown_address_provider.dart new file mode 100644 index 000000000..464ffd798 --- /dev/null +++ b/lib/core/address_resolver/wellknown/wellknown_address_provider.dart @@ -0,0 +1,49 @@ +import 'package:cake_wallet/core/address_resolver/address_lookup_provider.dart'; +import 'package:cake_wallet/core/address_resolver/address_sources.dart'; +import 'package:cake_wallet/core/address_resolver/parsed_address.dart'; +import 'package:cake_wallet/core/address_resolver/wellknown/wellknown_record.dart'; +import 'package:cake_wallet/store/settings_store.dart'; +import 'package:cw_core/crypto_currency.dart'; +import 'package:cw_core/utils/print_verbose.dart'; +import 'package:cw_core/wallet_base.dart'; + +class WellKnownAddressProvider extends AddressLookupProvider { + @override + AddressSource get source => AddressSource.wellKnown; + + @override + List get supportedCurrencies => [CryptoCurrency.nano]; + + @override + bool canHandle(String q) => q.contains('.') && q.contains('@'); + + // .well-known handle example: + + @override + bool isEnabled(SettingsStore settingsStore) => settingsStore.lookupsWellKnown; + + @override + Future> resolve({ + required String query, + required List currencies, + required WalletBase wallet, + }) async { + try { + final rec = await WellKnownRecord.fetch(query, CryptoCurrency.nano); + if (rec == null || rec.address.isEmpty) return []; + + return [ + ParsedAddress( + parsedAddressByCurrencyMap: {CryptoCurrency.nano: rec.address}, + addressSource: AddressSource.wellKnown, + handle: query, + profileName: rec.title ?? '', + profileImageUrl: rec.imageUrl ?? '', + ) + ]; + } catch (e) { + printV('[address resolver] Error resolving Well-Known address: $e'); + return []; + } + } +} diff --git a/lib/core/address_resolver/wellknown/wellknown_record.dart b/lib/core/address_resolver/wellknown/wellknown_record.dart new file mode 100644 index 000000000..b2ed2eb9c --- /dev/null +++ b/lib/core/address_resolver/wellknown/wellknown_record.dart @@ -0,0 +1,78 @@ +import 'dart:convert'; + +import 'package:cw_core/crypto_currency.dart'; +import 'package:cw_core/utils/print_verbose.dart'; +import 'package:cw_core/utils/proxy_wrapper.dart'; + +class WellKnownRecord { + WellKnownRecord({ + required this.address, + required this.alias, + required this.imageUrl, + required this.title, + }); + + final String address; + final String alias; + final String? imageUrl; + final String? title; + + static const _jsonLocation = {CryptoCurrency.nano: 'nano-currency'}; + + static Future fetch( + String username, + CryptoCurrency currency, + ) async { + final location = _jsonLocation[currency]; + if (location == null) { + printV('well-known: unsupported coin $currency'); + return null; + } + + final parts = username.split('@'); + if (parts.length < 2) { + printV('well-known: missing @ in "$username"'); + return null; + } + final alias = (parts.length == 3 ? parts[1] : parts[0]).trim(); + final domain = parts.last.trim(); + final encoded = Uri.encodeComponent(alias.isEmpty ? '_' : alias); + + final uri = Uri.https( + domain, + '/.well-known/$location.json', + {'names': encoded}, + ); + + try { + final res = await ProxyWrapper().get( + clearnetUri: uri, + headers: {'Accept': 'application/json'}, + ); + + final cType = res.headers['content-type']?.toLowerCase() ?? ''; + if (res.statusCode != 200 || !cType.contains('application/json')) { + printV('well-known: $uri → ${res.statusCode} $cType'); + return null; + } + + final body = json.decode(res.body) as Map; + final items = body['names'] as List? ?? const []; + + for (final raw in items.whereType()) { + if (raw['name'].toString().toLowerCase() == alias.toLowerCase()) { + return WellKnownRecord( + address: raw['address']?.toString() ?? '', + alias: alias, + imageUrl: raw['image']?.toString(), + title: raw['name']?.toString(), + ); + } + } + printV('well-known: alias "$alias" not found in list'); + } catch (e) { + printV('well-known: network / JSON error → $e'); + } + return null; + } +} \ No newline at end of file diff --git a/lib/core/address_resolver/yat/yat_address_provider.dart b/lib/core/address_resolver/yat/yat_address_provider.dart new file mode 100644 index 000000000..d8ee3454b --- /dev/null +++ b/lib/core/address_resolver/yat/yat_address_provider.dart @@ -0,0 +1,59 @@ +import 'package:cake_wallet/core/address_resolver/address_lookup_provider.dart'; +import 'package:cake_wallet/core/address_resolver/address_sources.dart'; +import 'package:cake_wallet/core/address_resolver/parsed_address.dart'; +import 'package:cake_wallet/core/address_resolver/yat/yat_service.dart'; +import 'package:cake_wallet/entities/emoji_string_extension.dart'; +import 'package:cake_wallet/store/settings_store.dart'; +import 'package:cw_core/crypto_currency.dart'; +import 'package:cw_core/utils/print_verbose.dart'; +import 'package:cw_core/wallet_base.dart'; + +class YatAddressProvider extends AddressLookupProvider { + @override + AddressSource get source => AddressSource.yatRecord; + + @override + List get supportedCurrencies => + [CryptoCurrency.xmr, CryptoCurrency.btc, CryptoCurrency.eth, CryptoCurrency.ltc]; + + @override + bool canHandle(String q) => q.hasOnlyEmojis; // Yat handle example: 🐶🐾 + + @override + bool isEnabled(SettingsStore settingsStore) => settingsStore.lookupsWellKnown; + + @override + Future> resolve({ + required String query, + required List currencies, + required WalletBase wallet, + }) async { + try { + final result = {}; + + for (final cur in currencies) { + final records = await YatService.fetchYatAddress(query, cur.title); + if (records.isEmpty) continue; + + final chosen = cur == CryptoCurrency.xmr + ? records.firstWhere((r) => r.isMoneroSub, orElse: () => records.first) + : records.first; + + result[cur] = chosen.address; + } + + if (result.isEmpty) return []; + + return [ + ParsedAddress( + parsedAddressByCurrencyMap: result, + addressSource: AddressSource.yatRecord, + handle: query, + ) + ]; + } catch (e) { + printV('[address resolver] Error resolving Yat address: $e'); + return []; + } + } +} diff --git a/lib/core/address_resolver/yat/yat_record.dart b/lib/core/address_resolver/yat/yat_record.dart new file mode 100644 index 000000000..412a5a486 --- /dev/null +++ b/lib/core/address_resolver/yat/yat_record.dart @@ -0,0 +1,19 @@ +import 'package:cake_wallet/core/address_resolver/yat/yat_service.dart'; + +class YatRecord { + YatRecord({ + required this.address, + required this.tag, + }); + + final String address; + final String tag; + + factory YatRecord.fromJson(Map json, String tag) => + YatRecord(address: (json['address'] ?? '').toString(), tag: tag); + + bool get isMoneroSub => tag == YatService.MONERO_SUB_ADDRESS; + + @override + String toString() => 'YatRecord(tag: $tag, address: $address)'; +} \ No newline at end of file diff --git a/lib/core/address_resolver/yat/yat_service.dart b/lib/core/address_resolver/yat/yat_service.dart new file mode 100644 index 000000000..84ba4e8f3 --- /dev/null +++ b/lib/core/address_resolver/yat/yat_service.dart @@ -0,0 +1,67 @@ +import 'dart:convert'; + +import 'package:cake_wallet/core/address_resolver/yat/yat_record.dart'; +import 'package:cw_core/utils/proxy_wrapper.dart'; + +class YatService { + static bool isDevMode = false; + + static String get apiUrl => + YatService.isDevMode ? YatService.apiDevUrl : YatService.apiReleaseUrl; + static const apiReleaseUrl = "https://a.y.at"; + static const apiDevUrl = 'https://a.yat.fyi'; + + static String lookupEmojiUrl(String emojiId) => + "$apiUrl/emoji_id/$emojiId/payment"; + + static const String MONERO_SUB_ADDRESS = '0x1002'; + static const String MONERO_STD_ADDRESS = '0x1001'; + static const tags = { + 'XMR': "$MONERO_STD_ADDRESS,$MONERO_SUB_ADDRESS", + 'BTC': '0x1003', + 'LTC': '0x1019' + }; + + static Future> fetchYatAddress( + String emojiId, + String ticker, + ) async { + final tagQuery = tags[ticker.toUpperCase()]; + if (tagQuery == null) return const []; + + final uri = Uri.parse( + lookupEmojiUrl(emojiId.replaceAll(' ', '')), + ).replace(queryParameters: {'tags': tagQuery}); + + try { + final response = await ProxyWrapper().get(clearnetUri: uri); + final body = json.decode(response.body) as Map; + + final res = body['result']; + if (res == null) return const []; + + final records = []; + + if (res is Map) { + res.forEach((tag, data) { + if (data is Map) { + records.add(YatRecord.fromJson(data, tag.toString())); + } + }); + } + + else if (res is List) { + for (final item in res) { + if (item is Map) { + final tag = item['tag']?.toString() ?? ''; + records.add(YatRecord.fromJson(item, tag)); + } + } + } + + return records; + } catch (_) { + return const []; + } + } +} \ No newline at end of file diff --git a/lib/store/yat/yat_store.dart b/lib/core/address_resolver/yat/yat_store.dart similarity index 100% rename from lib/store/yat/yat_store.dart rename to lib/core/address_resolver/yat/yat_store.dart diff --git a/lib/core/address_resolver/zano/zano_alias_address_provider.dart b/lib/core/address_resolver/zano/zano_alias_address_provider.dart new file mode 100644 index 000000000..e02856405 --- /dev/null +++ b/lib/core/address_resolver/zano/zano_alias_address_provider.dart @@ -0,0 +1,75 @@ +import 'dart:convert'; + +import 'package:cake_wallet/core/address_resolver/address_lookup_provider.dart'; +import 'package:cake_wallet/core/address_resolver/address_sources.dart'; +import 'package:cake_wallet/core/address_resolver/parsed_address.dart'; +import 'package:cake_wallet/store/settings_store.dart'; +import 'package:cw_core/crypto_currency.dart'; +import 'package:cw_core/utils/print_verbose.dart'; +import 'package:cw_core/utils/proxy_wrapper.dart'; +import 'package:cw_core/wallet_base.dart'; + +class ZanoAliasAddressProvider extends AddressLookupProvider { + + @override + AddressSource get source => AddressSource.zanoAlias; + + @override + List get supportedCurrencies => [CryptoCurrency.zano]; + + @override + bool isEnabled(SettingsStore settingsStore) => settingsStore.lookupsZanoAlias; + + @override + bool canHandle(String query) => query.startsWith('@'); // zano handle example: @username + + static const _zanoUrl = 'http://37.27.100.59:10500/json_rpc'; + @override + Future> resolve({ + required String query, + required List currencies, + required WalletBase wallet, + }) async { + try { + if (!currencies.contains(CryptoCurrency.zano)) return []; + + final formattedName = query.replaceFirst("@", ""); + if (formattedName.isEmpty) return []; + + final zanoAddress = await fetchZanoAliasAddress(formattedName); + if (zanoAddress == null || zanoAddress.isEmpty) return []; + + return [ + ParsedAddress( + parsedAddressByCurrencyMap: {CryptoCurrency.zano: zanoAddress}, + addressSource: AddressSource.zanoAlias, + handle: query, + ), + ]; + } catch (e) { + printV('[address resolver] Error resolving Zano Alias: $e'); + return []; + } + } + + static Future fetchZanoAliasAddress(String alias) async { + try { + final uri = Uri.parse(_zanoUrl); + final response = await ProxyWrapper().post( + clearnetUri: uri, + body: json.encode({ + 'id': 0, + 'jsonrpc': '2.0', + 'method': 'get_alias_details', + 'params': {'alias': alias}, + }), + ); + + final jsonParsed = json.decode(response.body) as Map; + return jsonParsed['result']?['alias_details']?['address'] as String?; + } catch (e) { + printV('[address resolver] Zano Alias error: $e'); + return null; + } + } +} diff --git a/lib/core/address_resolver/zcash/zcash_address_provider.dart b/lib/core/address_resolver/zcash/zcash_address_provider.dart new file mode 100644 index 000000000..5da25fdd4 --- /dev/null +++ b/lib/core/address_resolver/zcash/zcash_address_provider.dart @@ -0,0 +1,49 @@ +import 'package:cake_wallet/core/address_resolver/address_lookup_provider.dart'; +import 'package:cake_wallet/core/address_resolver/address_sources.dart'; +import 'package:cake_wallet/core/address_resolver/parsed_address.dart'; +import 'package:cake_wallet/core/address_resolver/zcash/zcash_names_record.dart'; +import 'package:cake_wallet/store/settings_store.dart'; +import 'package:cw_core/crypto_currency.dart'; +import 'package:cw_core/utils/print_verbose.dart'; +import 'package:cw_core/wallet_base.dart'; + +class ZcashNameAddressProvider extends AddressLookupProvider { + @override + AddressSource get source => AddressSource.zcashName; + + @override + List get supportedCurrencies => [CryptoCurrency.zec]; + + @override + bool isEnabled(SettingsStore settingsStore) => settingsStore.lookupsZcashNames; + + @override + bool canHandle(String query) { + final lowerText = query.toLowerCase(); + return lowerText.endsWith(".zec") || lowerText.endsWith(".zcash"); + } // Zcash handle example: name.zec + + @override + Future> resolve({ + required String query, + required List currencies, + required WalletBase wallet, + }) async { + try { + final address = await ZcashNamesRecord.fetchZcashNamesAddress(query); + if (address == null || address.isEmpty) return []; + + return [ + ParsedAddress( + parsedAddressByCurrencyMap: {CryptoCurrency.zec: address}, + profileName: query, + addressSource: AddressSource.zcashName, + handle: query, + ), + ]; + } catch (e) { + printV('[address resolver] Error resolving Zcash Name: $e'); + return []; + } + } +} diff --git a/lib/core/address_resolver/zcash/zcash_me_address_provider.dart b/lib/core/address_resolver/zcash/zcash_me_address_provider.dart new file mode 100644 index 000000000..6dd0968d9 --- /dev/null +++ b/lib/core/address_resolver/zcash/zcash_me_address_provider.dart @@ -0,0 +1,74 @@ +import 'package:cake_wallet/core/address_resolver/address_lookup_provider.dart'; +import 'package:cake_wallet/core/address_resolver/address_sources.dart'; +import 'package:cake_wallet/core/address_resolver/parsed_address.dart'; +import 'package:cake_wallet/store/settings_store.dart'; +import 'package:cw_core/crypto_currency.dart'; +import 'package:cw_core/utils/print_verbose.dart'; +import 'package:cw_core/utils/proxy_wrapper.dart'; +import 'package:cw_core/wallet_base.dart'; + +class ZcashMeAddressProvider extends AddressLookupProvider { + @override + AddressSource get source => AddressSource.zcashAddress; + + @override + List get supportedCurrencies => [CryptoCurrency.zec]; + + @override + bool isEnabled(SettingsStore settingsStore) => settingsStore.lookupsZcashNames; + + @override + bool canHandle(String query) { + final value = query.trim().toLowerCase(); + return value.startsWith('zcash.me/') && value.split('/').last.isNotEmpty; + } // zcash.me profile example: zcash.me/username + + @override + Future> resolve({ + required String query, + required List currencies, + required WalletBase wallet, + }) async { + try { + final handle = query.trim().split('/').last; + if (handle.isEmpty) return []; + + final address = await _fetchZcashAddress(handle); + if (address == null || address.isEmpty) return []; + + return [ + ParsedAddress( + parsedAddressByCurrencyMap: {CryptoCurrency.zec: address}, + profileName: handle, + addressSource: AddressSource.zcashAddress, + handle: query, + ), + ]; + } catch (e) { + printV('[address resolver] Error resolving zcash.me address: $e'); + return []; + } + } + + Future _fetchZcashAddress(String handle) async { + final url = Uri.parse('https://zcash.me/$handle'); + + try { + final response = await ProxyWrapper().get(clearnetUri: url); + + if (response.statusCode == 200) { + final addressRegex = RegExp( + r'(t1[0-9A-Za-z]{33}|t3[0-9A-Za-z]{33}|zs[a-z0-9]{76}|u1[a-z0-9]{1,300})', + caseSensitive: true, + ); + + final match = addressRegex.firstMatch(response.body); + return match?.group(0); + } + } catch (e) { + printV('[address resolver] Error fetching zcash.me profile: $e'); + } + + return null; + } +} diff --git a/lib/entities/zcash_names_record.dart b/lib/core/address_resolver/zcash/zcash_names_record.dart similarity index 100% rename from lib/entities/zcash_names_record.dart rename to lib/core/address_resolver/zcash/zcash_names_record.dart diff --git a/lib/core/address_validator.dart b/lib/core/address_validator.dart index 0d44bad67..d6934542e 100644 --- a/lib/core/address_validator.dart +++ b/lib/core/address_validator.dart @@ -12,29 +12,54 @@ const AFTER_REGEX = '(\$|\\s)'; class AddressValidator extends TextValidator { AddressValidator({required CryptoCurrency type, bool isTestnet = false}) : super( - errorMessage: S.current.error_text_address, - useAdditionalValidation: [CryptoCurrency.btc, CryptoCurrency.ltc].contains(type) - ? (String txt) { - final RegExp lightningInvoiceRegex = RegExp( - r'^(lightning:)?(lnbc|lntb|lnbs|lnbcrt|lnurl)[a-z0-9]+$', - caseSensitive: false); - if (lightningInvoiceRegex.hasMatch(txt)) return true; + errorMessage: S.current.error_text_address, + useAdditionalValidation: [CryptoCurrency.btc, CryptoCurrency.ltc].contains(type) + ? (String txt) { + final RegExp lightningInvoiceRegex = RegExp( + r'^(lightning:)?(lnbc|lntb|lnbs|lnbcrt|lnurl)[a-z0-9]+$', + caseSensitive: false); + if (lightningInvoiceRegex.hasMatch(txt)) return true; - return BitcoinAddressUtils.validateAddress( - address: txt, - network: type == CryptoCurrency.btc - ? isTestnet - ? BitcoinNetwork.testnet - : BitcoinNetwork.mainnet - : LitecoinNetwork.mainnet, - ); - } - : type == CryptoCurrency.zano - ? zano?.validateAddress - : null, - pattern: getPattern(type, isTestnet: isTestnet), - length: getLength(type), - ); + return BitcoinAddressUtils.validateAddress( + address: txt, + network: type == CryptoCurrency.btc + ? isTestnet + ? BitcoinNetwork.testnet + : BitcoinNetwork.mainnet + : LitecoinNetwork.mainnet, + ); + } + : type == CryptoCurrency.zano + ? zano?.validateAddress + : null, + pattern: getPattern(type, isTestnet: isTestnet), + length: getLength(type), + ); + + static const List reliableValidateCurrencies = [ + CryptoCurrency.xmr, + CryptoCurrency.btc, + CryptoCurrency.ltc, + CryptoCurrency.bch, + CryptoCurrency.trx, + CryptoCurrency.nano, + CryptoCurrency.sol, + CryptoCurrency.zano, + CryptoCurrency.ada, + CryptoCurrency.xrp, + CryptoCurrency.xhv, + CryptoCurrency.zaddr, + CryptoCurrency.zec, + CryptoCurrency.dcr, + CryptoCurrency.rvn, + CryptoCurrency.near, + CryptoCurrency.rune, + CryptoCurrency.scrt, + CryptoCurrency.stx, + CryptoCurrency.kmd, + CryptoCurrency.doge, + CryptoCurrency.btcln, + ]; static String getPattern(CryptoCurrency type, {bool isTestnet = false}) { var pattern = ""; @@ -146,13 +171,13 @@ class AddressValidator extends TextValidator { pattern = '[0-9a-zA-Z.]+'; case CryptoCurrency.zec: pattern = - '(?:' - 't1[0-9A-Za-z]{33}' - '|t3[0-9A-Za-z]{33}' - '|zs[a-z0-9]{76}' - '|zxviews[a-z0-9]{278}' - '|u1[a-z0-9]{1,300}' - ')'; + '(?:' + 't1[0-9A-Za-z]{33}' + '|t3[0-9A-Za-z]{33}' + '|zs[a-z0-9]{76}' + '|zxviews[a-z0-9]{278}' + '|u1[a-z0-9]{1,300}' + ')'; case CryptoCurrency.dcr: pattern = '(D|T|S)[ksecS]([0-9a-zA-Z])+'; case CryptoCurrency.rvn: @@ -344,7 +369,7 @@ class AddressValidator extends TextValidator { '|([0-9a-zA-Z]{106})'; case CryptoCurrency.btc: pattern = - '${P2pkhAddress.regex.pattern}|${P2shAddress.regex.pattern}|${P2wpkhAddress.regex.pattern}|${P2trAddress.regex.pattern}|${P2wshAddress.regex.pattern}|${SilentPaymentAddress.regex.pattern}'; + '${P2pkhAddress.regex.pattern}|${P2shAddress.regex.pattern}|${P2wpkhAddress.regex.pattern}|${P2trAddress.regex.pattern}|${P2wshAddress.regex.pattern}|${SilentPaymentAddress.regex.pattern}'; case CryptoCurrency.ltc: pattern = '([^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]|\$)' @@ -394,4 +419,4 @@ class AddressValidator extends TextValidator { return pattern != null ? "($pattern)" : null; } -} +} \ No newline at end of file diff --git a/lib/core/fiat_conversion_service.dart b/lib/core/fiat_conversion_service.dart index af6a3192c..ecb751dec 100644 --- a/lib/core/fiat_conversion_service.dart +++ b/lib/core/fiat_conversion_service.dart @@ -1,11 +1,12 @@ +import 'package:cw_core/utils/print_verbose.dart'; import 'package:cw_core/utils/proxy_wrapper.dart'; import 'package:cw_core/crypto_currency.dart'; import 'package:cake_wallet/entities/fiat_currency.dart'; import 'dart:convert'; import 'package:cake_wallet/.secrets.g.dart' as secrets; -const _fiatApiClearNetAuthority = 'fiat-api.cakewallet.com'; -const _fiatApiOnionAuthority = 'kfkyguqtz5vcnbvar5pjgddkaeawbo4j3r4fj3e22k3tzqageplosiid.onion'; +const _fiatApiClearNetAuthority = 'prices.cakewallet.com'; +const _fiatApiOnionAuthority = '46wisoe2uwipcj2j4og6smiq7hbmj34fkkrquwlzbbsqtgat7bf3erid.onion'; const _fiatApiPath = '/v2/rates'; Future _fetchPrice(String crypto, String fiat, bool torOnly) async { diff --git a/lib/core/yat_service.dart b/lib/core/yat_service.dart deleted file mode 100644 index 84aac1626..000000000 --- a/lib/core/yat_service.dart +++ /dev/null @@ -1,55 +0,0 @@ -import 'dart:convert'; - -import 'package:cake_wallet/entities/yat_record.dart'; -import 'package:cw_core/utils/proxy_wrapper.dart'; - -class YatService { - static bool isDevMode = false; - - static String get apiUrl => - YatService.isDevMode ? YatService.apiDevUrl : YatService.apiReleaseUrl; - static const apiReleaseUrl = "https://a.y.at"; - static const apiDevUrl = 'https://a.yat.fyi'; - - static String lookupEmojiUrl(String emojiId) => - "$apiUrl/emoji_id/$emojiId/payment"; - - static const String MONERO_SUB_ADDRESS = '0x1002'; - static const String MONERO_STD_ADDRESS = '0x1001'; - static const tags = { - 'XMR': "$MONERO_STD_ADDRESS,$MONERO_SUB_ADDRESS", - 'BTC': '0x1003', - 'LTC': '0x1019' - }; - - Future> fetchYatAddress(String emojiId, String ticker) async { - final formattedTicker = ticker.toUpperCase(); - final formattedEmojiId = emojiId.replaceAll(' ', ''); - final tag = tags[formattedTicker]; - final uri = Uri.parse(lookupEmojiUrl(formattedEmojiId)).replace( - queryParameters: { - "tags": tag - }); - final yatRecords = []; - - try { - final response = await ProxyWrapper().get(clearnetUri: uri); - - final resBody = json.decode(response.body) as Map; - final results = resBody["result"] as Map; - // Favour a subaddress over a standard address. - final yatRecord = ( - results[MONERO_SUB_ADDRESS] ?? - results[MONERO_STD_ADDRESS] ?? - results[tag]) as Map; - - if (yatRecord.isNotEmpty) { - yatRecords.add(YatRecord.fromJson(yatRecord)); - } - - return yatRecords; - } catch (_) { - return yatRecords; - } - } -} diff --git a/lib/di.dart b/lib/di.dart index 4439a68bd..1ce51d501 100644 --- a/lib/di.dart +++ b/lib/di.dart @@ -6,6 +6,9 @@ import 'package:cake_wallet/anonpay/anonpay_invoice_info.dart'; import 'package:cake_wallet/anypay/anypay_api.dart'; import 'package:cake_wallet/bitcoin/bitcoin.dart'; import 'package:cake_wallet/bitcoin_cash/bitcoin_cash.dart'; +import 'package:cake_wallet/core/address_resolver/address_resolver_service.dart'; +import 'package:cake_wallet/core/address_resolver/yat/yat_service.dart'; +import 'package:cake_wallet/core/address_resolver/yat/yat_store.dart'; import 'package:cake_wallet/entities/bitcoin_amount_display_mode.dart'; import 'package:cake_wallet/evm/evm.dart'; import 'package:cake_wallet/buy/dfx/dfx_buy_provider.dart'; @@ -29,7 +32,6 @@ import 'package:cake_wallet/core/totp_request_details.dart'; import 'package:cake_wallet/core/trade_monitor.dart'; import 'package:cake_wallet/core/wallet_creation_service.dart'; import 'package:cake_wallet/core/wallet_loading_service.dart'; -import 'package:cake_wallet/core/yat_service.dart'; import 'package:cake_wallet/decred/decred.dart'; import 'package:cake_wallet/entities/biometric_auth.dart'; import 'package:cake_wallet/entities/bridge_transfer.dart'; @@ -37,7 +39,6 @@ import 'package:cake_wallet/entities/contact.dart'; import 'package:cake_wallet/entities/contact_record.dart'; import 'package:cake_wallet/entities/exchange_api_mode.dart'; import 'package:cake_wallet/entities/hardware_wallet/require_hardware_wallet_connection.dart'; -import 'package:cake_wallet/entities/parse_address_from_domain.dart'; import 'package:cake_wallet/entities/preferences_key.dart'; import 'package:cake_wallet/entities/qr_view_data.dart'; import 'package:cake_wallet/entities/template.dart'; @@ -200,7 +201,6 @@ import 'package:cake_wallet/store/settings_store.dart'; import 'package:cake_wallet/store/templates/exchange_template_store.dart'; import 'package:cake_wallet/store/templates/send_template_store.dart'; import 'package:cake_wallet/store/wallet_list_store.dart'; -import 'package:cake_wallet/store/yat/yat_store.dart'; import 'package:cake_wallet/themes/core/theme_store.dart'; import 'package:cake_wallet/tron/tron.dart'; import 'package:cake_wallet/utils/device_info.dart'; @@ -901,6 +901,7 @@ Future setup({ getIt.get(), getIt.get(), getIt.get(), + getIt.get(), getIt.get(), getIt.get(), _transactionDescriptionBox, @@ -1102,7 +1103,10 @@ Future setup({ AnimatedURPage(getIt.get(), urQr: urQr)); getIt.registerFactoryParam( - (ContactRecord? contact, _) => ContactViewModel(_contactSource, contact: contact)); + (ContactRecord? contact, _) => ContactViewModel(_contactSource, + getIt.get(), + getIt.get(), + contact: contact)); getIt.registerFactoryParam( (CryptoCurrency? cur, _) => @@ -1251,7 +1255,10 @@ Future setup({ getIt.registerFactoryParam( (PaymentRequest? paymentRequest, __) { - return ExchangePage(getIt.get(), getIt.get(), paymentRequest); + return ExchangePage(getIt.get(), + getIt.get(), + getIt.get(), + paymentRequest); }); getIt.registerFactoryParam( @@ -1259,6 +1266,7 @@ Future setup({ return NewSwapPage( getIt.get(), getIt.get(), + getIt.get(), paymentRequest, walletSwitcherViewModel: getIt.get(), initialCurrency: initialCurrency, @@ -1500,7 +1508,7 @@ Future setup({ getIt.registerFactoryParam((mode, _) => NewBuySellAmountPage(buySellViewModel: getIt.get(param1: mode))); - getIt.registerFactory(() => BuySellPage(getIt.get())); + getIt.registerFactory(() => BuySellPage(getIt.get(), getIt.get())); getIt.registerFactoryParam, void>((List args, _) { final items = args.first as List; @@ -1598,10 +1606,9 @@ Future setup({ getIt.registerFactory(() => YatService()); - getIt.registerFactory(() => AddressResolver( - yatService: getIt.get(), - wallet: getIt.get().wallet!, - settingsStore: getIt.get())); + getIt.registerFactory( + () => AddressResolverService(settingsStore: getIt.get()), + ); getIt.registerFactoryParam( (QrViewData viewData, _) => FullscreenQRPage(qrViewData: viewData)); diff --git a/lib/entities/ens_record.dart b/lib/entities/ens_record.dart deleted file mode 100644 index 239b5f45c..000000000 --- a/lib/entities/ens_record.dart +++ /dev/null @@ -1,54 +0,0 @@ -import 'package:cake_wallet/evm/evm.dart'; -import 'package:cake_wallet/reactions/wallet_connect.dart'; -import 'package:cw_core/utils/proxy_wrapper.dart'; -import 'package:cw_core/utils/print_verbose.dart'; -import 'package:cw_core/wallet_base.dart'; -import 'package:cw_core/wallet_type.dart'; -import 'package:ens_dart/ens_dart.dart'; -import 'package:web3dart/web3dart.dart'; - -class EnsRecord { - static Future fetchEnsAddress(String name, {WalletBase? wallet}) async { - Web3Client? _client; - - if (wallet != null && (isEVMCompatibleChain(wallet.type))) { - _client = evm!.getWeb3Client(wallet); - } - - if (_client == null) { - late final client = ProxyWrapper().getHttpIOClient(); - - _client = Web3Client("https://ethereum-rpc.publicnode.com", client); - } - - try { - final ens = Ens(client: _client); - - if (wallet != null) { - switch (wallet.type) { - case WalletType.monero: - return await ens.withName(name).getCoinAddress(CoinType.XMR); - case WalletType.bitcoin: - return await ens.withName(name).getCoinAddress(CoinType.BTC); - case WalletType.litecoin: - return await ens.withName(name).getCoinAddress(CoinType.LTC); - case WalletType.haven: - return await ens.withName(name).getCoinAddress(CoinType.XHV); - case WalletType.ethereum: - case WalletType.polygon: - case WalletType.base: - case WalletType.arbitrum: - case WalletType.bsc: - default: - return (await ens.withName(name).getAddress()).hex; - } - } - - final addr = await ens.withName(name).getAddress(); - return addr.hex; - } catch (e) { - printV(e); - return ""; - } - } -} diff --git a/lib/entities/fio_address_provider.dart b/lib/entities/fio_address_provider.dart deleted file mode 100644 index dcfd79c96..000000000 --- a/lib/entities/fio_address_provider.dart +++ /dev/null @@ -1,65 +0,0 @@ -import 'dart:convert'; - -import 'package:cw_core/utils/proxy_wrapper.dart'; - -class FioAddressProvider { - static const apiAuthority = 'fio.blockpane.com'; - static const availCheck = '/v1/chain/avail_check'; - static const getAddress = '/v1/chain/get_pub_address'; - - static Future checkAvail(String fioAddress) async { - bool isFioRegistered = false; - final headers = {'Content-Type': 'application/json'}; - final body = {"fio_name": fioAddress}; - - final uri = Uri.https(apiAuthority, availCheck); - final response = await ProxyWrapper().post( - clearnetUri: uri, - headers: headers, - body: json.encode(body), - ); - - if (response.statusCode != 200) { - return isFioRegistered; - } - - - final responseJSON = json.decode(response.body) as Map; - isFioRegistered = responseJSON['is_registered'] as int == 1; - - return isFioRegistered; - } - - static Future getPubAddress(String fioAddress, String token) async { - final headers = {'Content-Type': 'application/json'}; - final body = { - "fio_address": fioAddress, - "chain_code": token.toUpperCase(), - "token_code": token.toUpperCase(), - }; - - final uri = Uri.https(apiAuthority, getAddress); - final response = await ProxyWrapper().post( - clearnetUri: uri, - headers: headers, - body: json.encode(body), - ); - - - if (response.statusCode == 400) { - final responseJSON = json.decode(response.body) as Map; - final error = responseJSON['error'] as String; - final message = responseJSON['message'] as String; - throw Exception('${error}\n$message'); - } - - if (response.statusCode != 200) { - throw Exception('Unexpected response http status: ${response.statusCode}'); - } - - final responseJSON = json.decode(response.body) as Map; - final String pubAddress = responseJSON['public_address'] as String; - - return pubAddress; - } -} diff --git a/lib/entities/new_ui_entities/list_item/list_Item_checkbox.dart b/lib/entities/new_ui_entities/list_item/list_Item_checkbox.dart index c02f076e0..2f84a3726 100644 --- a/lib/entities/new_ui_entities/list_item/list_Item_checkbox.dart +++ b/lib/entities/new_ui_entities/list_item/list_Item_checkbox.dart @@ -6,6 +6,7 @@ class ListItemCheckbox extends ListItem { required super.keyValue, required super.label, this.subtitle, + this.subtitleColor, this.iconPath, this.onTap, this.showArrow = false, @@ -18,5 +19,6 @@ class ListItemCheckbox extends ListItem { final bool value; final String? subtitle; final String? iconPath; + final Color? subtitleColor; final bool showArrow; } diff --git a/lib/entities/openalias_record.dart b/lib/entities/openalias_record.dart deleted file mode 100644 index b2af2e53d..000000000 --- a/lib/entities/openalias_record.dart +++ /dev/null @@ -1,89 +0,0 @@ -import 'package:basic_utils/basic_utils.dart'; -import 'package:cw_core/utils/print_verbose.dart'; - -class OpenaliasRecord { - OpenaliasRecord({ - required this.address, - required this.name, - required this.description, - }); - - final String name; - final String address; - final String description; - - static String formatDomainName(String name) { - String formattedName = name; - - if (name.contains("@")) { - formattedName = name.replaceAll("@", "."); - } - - return formattedName; - } - - static Future?> lookupOpenAliasRecord(String name) async { - try { - final txtRecord = await DnsUtils.lookupRecord(name, RRecordType.TXT, dnssec: true); - - return txtRecord; - } catch (e) { - printV("${e.toString()}"); - return null; - } - } - - static OpenaliasRecord fetchAddressAndName({ - required String formattedName, - required String ticker, - required List txtRecord, - }) { - String address = ''; - String name = formattedName; - String note = ''; - - for (RRecord element in txtRecord) { - String record = element.data; - - if (record.contains("oa1:$ticker") && record.contains("recipient_address")) { - record = record.replaceAll('\"', ""); - - final dataList = record.split(";"); - - address = dataList - .where((item) => (item.contains("recipient_address"))) - .toString() - .replaceAll("oa1:$ticker recipient_address=", "") - .replaceAll("(", "") - .replaceAll(")", "") - .trim(); - - final recipientName = dataList - .where((item) => (item.contains("recipient_name"))) - .toString() - .replaceAll("(", "") - .replaceAll(")", "") - .trim(); - - if (recipientName.isNotEmpty) { - name = recipientName.replaceAll("recipient_name=", ""); - } - - final description = dataList - .where((item) => (item.contains("tx_description"))) - .toString() - .replaceAll("(", "") - .replaceAll(")", "") - .trim(); - - if (description.isNotEmpty) { - note = description.replaceAll("tx_description=", ""); - } - - break; - } - - } - return OpenaliasRecord(address: address, name: name, description: note); -} -} diff --git a/lib/entities/parse_address_from_domain.dart b/lib/entities/parse_address_from_domain.dart deleted file mode 100644 index 7a6a47bc8..000000000 --- a/lib/entities/parse_address_from_domain.dart +++ /dev/null @@ -1,549 +0,0 @@ -import 'package:cake_wallet/core/address_validator.dart'; -import 'package:cake_wallet/core/yat_service.dart'; -import 'package:cake_wallet/entities/emoji_string_extension.dart'; -import 'package:cake_wallet/entities/ens_record.dart'; -import 'package:cake_wallet/entities/lnurlpay_record.dart'; -import 'package:cake_wallet/entities/fio_address_provider.dart'; -import 'package:cake_wallet/entities/openalias_record.dart'; -import 'package:cake_wallet/entities/parsed_address.dart'; -import 'package:cake_wallet/entities/unstoppable_domain_address.dart'; -import 'package:cake_wallet/entities/wellknown_record.dart'; -import 'package:cake_wallet/entities/zano_alias.dart'; -import 'package:cake_wallet/entities/zcash_names_record.dart'; -import 'package:cake_wallet/exchange/provider/thorchain_exchange.provider.dart'; -import 'package:cake_wallet/mastodon/mastodon_api.dart'; -import 'package:cake_wallet/nostr/nostr_api.dart'; -import 'package:cake_wallet/store/settings_store.dart'; -import 'package:cake_wallet/twitter/twitter_api.dart'; -import 'package:cw_core/crypto_currency.dart'; -import 'package:cw_core/utils/print_verbose.dart'; -import 'package:cw_core/utils/proxy_wrapper.dart'; -import 'package:cw_core/wallet_base.dart'; -import 'package:cw_core/wallet_type.dart'; -import 'package:flutter/cupertino.dart'; - -import 'bip_353_record.dart'; - -class AddressResolver { - AddressResolver({required this.yatService, required this.wallet, required this.settingsStore}) - : walletType = wallet.type; - - final YatService yatService; - final WalletType walletType; - final WalletBase wallet; - final SettingsStore settingsStore; - - static const unstoppableDomains = [ - "888", - "academy", - "agency", - "altimist", - "anime", - "austin", - "bald", - "bay", - "benji", - "bet", - "binanceus", - "bitcoin", - "bitget", - "bitscrunch", - "blockchain", - "boomer", - "boston", - "ca", - "caw", - "cc", - "chat", - "chomp", - "clay", - "club", - "co", - "com", - "company", - "crypto", - "dao", - "design", - "dfz", - "digital", - "doga", - "donut", - "dream", - "email", - "emir", - "eth", - "ethermail", - "family", - "farms", - "finance", - "fun", - "fyi", - "games", - "global", - "go", - "group", - "guru", - "hi", - "hockey", - "host", - "info", - "io", - "klever", - "kresus", - "kryptic", - "lfg", - "life", - "live", - "llc", - "ltc", - "ltd", - "manga", - "me", - "media", - "metropolis", - "miami", - "miku", - "money", - "moon", - "mumu", - "net", - "network", - "news", - "nft", - "npc", - "onchain", - "online", - "org", - "podcast", - "pog", - "polygon", - "base", - "arbitrum" - "press", - "privacy", - "pro", - "propykeys", - "pudgy", - "pw", - "quantum", - "rad", - "raiin", - "retardio", - "rip", - "rocks", - "secret", - "services", - "site", - "smobler", - "social", - "solutions", - "space", - "stepn", - "store", - "studio", - "systems", - "tball", - "tea", - "team", - "tech", - "technology", - "today", - "tribe", - "u", - "ubu", - "uno", - "unstoppable", - "vip", - "wallet", - "website", - "wif", - "wifi", - "witg", - "work", - "world", - "wrkx", - "wtf", - "x", - "xmr", - "xyz", - "zil", - "zone", - "pizza" - ]; - - static String? extractAddressByType( - {required String raw, - required CryptoCurrency type, - bool requireSurroundingWhitespaces = true}) { - var addressPattern = AddressValidator.getAddressFromStringPattern(type); - - if (addressPattern == null) { - return null; - } - - if (requireSurroundingWhitespaces) - addressPattern = "$BEFORE_REGEX$addressPattern$AFTER_REGEX"; - - final match = RegExp(addressPattern, multiLine: true).firstMatch(raw); - return match?.group(0)?.replaceAllMapped(RegExp('[^0-9a-zA-Z]|bitcoincash:|nano_|ban_'), - (Match match) { - String group = match.group(0)!; - if (group.startsWith('bitcoincash:') || - group.startsWith('nano_') || - group.startsWith('ban_')) { - return group; - } - return ''; - }); - } - - static String extractUnstoppableDomain(String raw) { - // sort by length to avoid matching shorter tld instead of longer - final domains = List.from(unstoppableDomains)..sort((a, b) => b.length.compareTo(a.length)); - for (final tld in domains) { - final pattern = RegExp(r'([A-Za-z0-9-]+)\.' + RegExp.escape(tld), caseSensitive: false); - final match = pattern.firstMatch(raw); - if (match != null) return match.group(0)!; - } - return ''; - } - - bool isEmailFormat(String address) { - final RegExp emailRegex = RegExp( - r'^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$', - caseSensitive: false, - ); - return emailRegex.hasMatch(address); - } - - String _stripHtmlTags(String value) { - return value - .replaceAll(RegExp(r'', caseSensitive: false), '\n') - .replaceAll(RegExp(r'', caseSensitive: false), '\n') - .replaceAll(RegExp(r'<[^>]*>'), '') - .trim(); - } - - Future resolve(BuildContext context, String text, CryptoCurrency currency) async { - final ticker = currency.title; - try { - if (text.startsWith("zcash.me")) { - final parts = text.split("/"); - final handle = parts.last; - if (parts.length == 2 && handle.isNotEmpty) { - final extractZcashAddress = await _fetchZcashAddress(handle); - if (extractZcashAddress != null) { - return ParsedAddress.zcashAddress( - address: extractZcashAddress, - name: handle, - ); - } - } - } - // twitter handle example: @username - if (text.startsWith('@') && !text.substring(1).contains('@')) { - if (currency == CryptoCurrency.zano && settingsStore.lookupsZanoAlias) { - final formattedName = text.substring(1); - final zanoAddress = await ZanoAlias.fetchZanoAliasAddress(formattedName); - if (zanoAddress != null && zanoAddress.isNotEmpty) { - return ParsedAddress.zanoAddress( - address: zanoAddress, - name: text, - ); - } - } - 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, walletCurrency: wallet.currency)); - if (addressFromBio != null && addressFromBio.isNotEmpty) { - return ParsedAddress.fetchTwitterAddress( - address: addressFromBio, - name: text, - profileImageUrl: twitterUser.profileImageUrl, - profileName: twitterUser.name); - } else { - final domain = extractUnstoppableDomain(twitterUser.description); - if (domain.isNotEmpty) { - final parsedAddressFromDomain = await resolve(context, domain, currency); - if (parsedAddressFromDomain.addresses.isNotEmpty && - parsedAddressFromDomain.addresses.first != domain) { - return ParsedAddress( - addresses: parsedAddressFromDomain.addresses, - name: text, - profileImageUrl: twitterUser.profileImageUrl, - profileName: twitterUser.name, - parseFrom: ParseFrom.twitter, - ); - } - - } - - } - - final addressFromLocation = extractAddressByType( - raw: twitterUser.location, - type: CryptoCurrency.fromString(ticker, - walletCurrency: wallet.currency), requireSurroundingWhitespaces: false); - if (addressFromLocation != null && addressFromLocation.isNotEmpty) { - return ParsedAddress.fetchTwitterAddress( - address: addressFromLocation, - name: text, - profileImageUrl: twitterUser.profileImageUrl, - profileName: twitterUser.name); - } else { - final domain = extractUnstoppableDomain(twitterUser.location); - if (domain.isNotEmpty) { - final parsedAddressFromDomain = await resolve(context, domain, currency); - if (parsedAddressFromDomain.addresses.isNotEmpty && - parsedAddressFromDomain.addresses.first != domain) { - return ParsedAddress( - addresses: parsedAddressFromDomain.addresses, - name: text, - profileImageUrl: twitterUser.profileImageUrl, - profileName: twitterUser.name, - parseFrom: ParseFrom.twitter, - ); - } - - } - - } - - final pinnedTweet = twitterUser.pinnedTweet?.text; - if (pinnedTweet != null) { - final addressFromPinnedTweet = extractAddressByType( - raw: pinnedTweet, - type: CryptoCurrency.fromString(ticker, walletCurrency: wallet.currency), - requireSurroundingWhitespaces: true); - if (addressFromPinnedTweet != null) { - return ParsedAddress.fetchTwitterAddress( - address: addressFromPinnedTweet, - name: text, - profileImageUrl: twitterUser.profileImageUrl, - profileName: twitterUser.name); - } else { - final domain = extractUnstoppableDomain(pinnedTweet); - if (domain.isNotEmpty) { - final parsedAddressFromDomain = await resolve(context, domain, currency); - if (parsedAddressFromDomain.addresses.isNotEmpty && - parsedAddressFromDomain.addresses.first != domain) { - return ParsedAddress( - addresses: parsedAddressFromDomain.addresses, - name: text, - profileImageUrl: twitterUser.profileImageUrl, - profileName: twitterUser.name, - parseFrom: ParseFrom.twitter, - ); - } - } - } - } - } - } - - // Mastodon example: @username@hostname.xxx - if (text.startsWith('@') && text.contains('@', 1) && text.contains('.', 1)) { - if (settingsStore.lookupsMastodon) { - final subText = text.substring(1); - final hostNameIndex = subText.indexOf('@'); - final hostName = subText.substring(hostNameIndex + 1); - final userName = subText.substring(0, hostNameIndex); - - final mastodonUser = - await MastodonAPI.lookupUserByUserName(userName: userName, apiHost: hostName); - - if (mastodonUser != null) { - final mastodonNote = _stripHtmlTags(mastodonUser.note); - String? addressFromBio = extractAddressByType(raw: mastodonNote, type: currency, requireSurroundingWhitespaces: true); - - if (addressFromBio != null && addressFromBio.isNotEmpty) { - return ParsedAddress.fetchMastodonAddress( - address: addressFromBio, - name: text, - profileImageUrl: mastodonUser.profileImageUrl, - profileName: mastodonUser.username); - } else { - final pinnedPosts = - await MastodonAPI.getPinnedPosts(userId: mastodonUser.id, apiHost: hostName); - - if (pinnedPosts.isNotEmpty) { - final userPinnedPostsText = pinnedPosts.map((item) => _stripHtmlTags(item.content)).join('\n'); - String? addressFromPinnedPost = - extractAddressByType(raw: userPinnedPostsText, type: currency, - requireSurroundingWhitespaces: true); - - if (addressFromPinnedPost != null && addressFromPinnedPost.isNotEmpty) { - return ParsedAddress.fetchMastodonAddress( - address: addressFromPinnedPost, - name: text, - profileImageUrl: mastodonUser.profileImageUrl, - profileName: mastodonUser.username); - } - } - } - } - } - } - - // .well-known scheme: - if (text.contains('.') && text.contains('@')) { - if (settingsStore.lookupsWellKnown) { - final record = - await WellKnownRecord.fetchAddressAndName(formattedName: text, currency: currency); - if (record != null) { - return ParsedAddress.fetchWellKnownAddress(address: record.address, name: text); - } - } - - if (walletType == WalletType.bitcoin) { - final record = - await LNUrlPayRecord.fetchAddressAndName(formattedName: text, currency: currency); - if (record != null) { - return ParsedAddress.fetchLNUrlPayAddress(address: record.address, name: text); - } - } - } - - if (!text.startsWith('@') && text.contains('@') && !text.contains('.')) { - final bool isFioRegistered = await FioAddressProvider.checkAvail(text); - if (isFioRegistered) { - final address = await FioAddressProvider.getPubAddress(text, ticker); - return ParsedAddress.fetchFioAddress(address: address, name: text); - } - } - if (text.hasOnlyEmojis) { - if (settingsStore.lookupsYatService) { - if (walletType != WalletType.haven) { - final addresses = await yatService.fetchYatAddress(text, ticker); - return ParsedAddress.fetchEmojiAddress(addresses: addresses, name: text); - } - } - } - - final isNormalAddress = extractAddressByType(raw: text, type: currency)?.isNotEmpty ?? false; - - if (text.length <= 30 && !isNormalAddress) { - final thorChainAddress = await ThorChainExchangeProvider.lookupAddressByName(text); - if (thorChainAddress != null && thorChainAddress.isNotEmpty) { - String? address = - thorChainAddress[ticker] ?? (ticker == 'RUNE' ? thorChainAddress['THOR'] : null); - if (address != null) { - return ParsedAddress.thorChainAddress(address: address, name: text); - } - } - } - - final formattedName = OpenaliasRecord.formatDomainName(text); - final domainParts = formattedName.split('.'); - final name = domainParts.last; - - if (domainParts.length <= 1 || domainParts.first.isEmpty || name.isEmpty) { - return ParsedAddress(addresses: [text]); - } - - if (unstoppableDomains.any((domain) => name.trim() == domain)) { - if (settingsStore.lookupsUnstoppableDomains) { - final address = await fetchUnstoppableDomainAddress(text, ticker); - if (address.isNotEmpty) { - return ParsedAddress.fetchUnstoppableDomainAddress(address: address, name: text); - } - } - } - - final bip353AddressMap = await Bip353Record.fetchUriByCryptoCurrency(text, ticker); - - if (bip353AddressMap != null && bip353AddressMap.isNotEmpty) { - final chosenAddress = await Bip353Record.pickBip353AddressChoice(context, text, bip353AddressMap); - if (chosenAddress != null) { - try { - final dnsProof = await Bip353Record.fetchDnsProof(text); - return ParsedAddress.fetchBip353AddressAddress(address: chosenAddress, name: text, dnsProof: dnsProof); - } catch (e) { - printV('Bip353Record.fetchBip353AddressAddress error: $e'); - return ParsedAddress.fetchBip353AddressAddress(address: chosenAddress, name: text); - } - } - } - - final lowerText = text.toLowerCase(); - if (lowerText.endsWith(".zec") || lowerText.endsWith(".zcash")) { - if (settingsStore.lookupsZcashNames) { - final address = await ZcashNamesRecord.fetchZcashNamesAddress(text); - if (address != null && address.isNotEmpty) { - return ParsedAddress.zcashNameAddress(address: address, name: text); - } - } - } - - if (text.endsWith(".eth")) { - if (settingsStore.lookupsENS) { - final address = await EnsRecord.fetchEnsAddress(text, wallet: wallet); - if (address.isNotEmpty && address != "0x0000000000000000000000000000000000000000") { - return ParsedAddress.fetchEnsAddress(name: text, address: address); - } - } - } - - if (formattedName.contains(".")) { - if (settingsStore.lookupsOpenAlias) { - final txtRecord = await OpenaliasRecord.lookupOpenAliasRecord(formattedName); - - if (txtRecord != null) { - final record = await OpenaliasRecord.fetchAddressAndName( - formattedName: formattedName, ticker: ticker.toLowerCase(), txtRecord: txtRecord); - return ParsedAddress.fetchOpenAliasAddress(record: record, name: text); - } - } - } - if (isEmailFormat(text)) { - final nostrProfile = await NostrProfileHandler.queryProfile(context, text); - if (nostrProfile?.relays != null) { - final nostrUserData = - await NostrProfileHandler.processRelays(context, nostrProfile!, text); - - if (nostrUserData != null) { - String? addressFromBio = extractAddressByType(raw: nostrUserData.about, type: currency); - if (addressFromBio != null && addressFromBio.isNotEmpty) { - return ParsedAddress.nostrAddress( - address: addressFromBio, - name: text, - profileImageUrl: nostrUserData.picture, - profileName: nostrUserData.name); - } - } - } - } - } catch (e) { - printV(e.toString()); - } - - return ParsedAddress(addresses: [text]); - } - - Future _fetchZcashAddress(String handle) async { - final url = Uri.parse('https://zcash.me/$handle'); - - try { - final response = await ProxyWrapper().get(clearnetUri: url); - - if (response.statusCode == 200) { - final addressRegex = RegExp( - r'(t1[0-9A-Za-z]{33}|t3[0-9A-Za-z]{33}|zs[a-z0-9]{76}|u1[a-z0-9]{1,300})', - caseSensitive: true, - ); - - final match = addressRegex.firstMatch(response.body); - - if (match != null) { - return match.group(0); - } - } - } catch (e) { - printV('Error fetching zcash.me profile: $e'); - } - return null; - } -} diff --git a/lib/entities/parsed_address.dart b/lib/entities/parsed_address.dart deleted file mode 100644 index 28bdf704f..000000000 --- a/lib/entities/parsed_address.dart +++ /dev/null @@ -1,212 +0,0 @@ -import 'package:cake_wallet/entities/openalias_record.dart'; -import 'package:cake_wallet/entities/yat_record.dart'; - -enum ParseFrom { - unstoppableDomains, - openAlias, - yatRecord, - fio, - notParsed, - twitter, - ens, - contact, - mastodon, - nostr, - thorChain, - wellKnown, - zanoAlias, - zcashAddress, - zcashName, - bip353, - lnurlpay, -} - -class ParsedAddress { - ParsedAddress({ - required this.addresses, - this.name = '', - this.description = '', - this.profileImageUrl = '', - this.profileName = '', - this.parseFrom = ParseFrom.notParsed, - this.bip353DnsProof, - }); - - 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, - ); - } - - 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, - ); - } - - factory ParsedAddress.fetchBip353AddressAddress ({ - required String address, - required String name, - String? dnsProof, - }) { - return ParsedAddress( - addresses: [address], - name: name, - parseFrom: ParseFrom.bip353, - bip353DnsProof: dnsProof, - ); - } - - factory ParsedAddress.fetchOpenAliasAddress( - {required OpenaliasRecord record, required String name}) { - if (record.address.isEmpty) { - return ParsedAddress(addresses: [name]); - } - return ParsedAddress( - addresses: [record.address], - name: record.name, - description: record.description, - parseFrom: ParseFrom.openAlias, - ); - } - - factory ParsedAddress.fetchFioAddress({required String address, required String name}) { - return ParsedAddress( - addresses: [address], - name: name, - parseFrom: ParseFrom.fio, - ); - } - - 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, - required String profileImageUrl, - required String profileName}) { - return ParsedAddress( - addresses: [address], - name: name, - parseFrom: ParseFrom.mastodon, - profileImageUrl: profileImageUrl, - profileName: profileName, - ); - } - - factory ParsedAddress.fetchContactAddress({required String address, required String name}) { - return ParsedAddress( - addresses: [address], - name: name, - parseFrom: ParseFrom.contact, - ); - } - - factory ParsedAddress.fetchEnsAddress({required String address, required String name}) { - return ParsedAddress( - addresses: [address], - name: name, - parseFrom: ParseFrom.ens, - ); - } - - 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, - ); - } - - factory ParsedAddress.thorChainAddress({required String address, required String name}) { - return ParsedAddress( - addresses: [address], - name: name, - parseFrom: ParseFrom.thorChain, - ); - } - - factory ParsedAddress.zanoAddress({required String address, required String name}) { - return ParsedAddress( - addresses: [address], - name: name, - parseFrom: ParseFrom.zanoAlias, - ); - } - - factory ParsedAddress.zcashAddress({required String address, required String name}) { - return ParsedAddress( - addresses: [address], - name: name, - parseFrom: ParseFrom.zcashAddress, - ); - } - - factory ParsedAddress.zcashNameAddress({required String address, required String name}) { - return ParsedAddress( - addresses: [address], - name: name, - parseFrom: ParseFrom.zcashName, - ); - } - - factory ParsedAddress.fetchWellKnownAddress({required String address, required String name}) { - return ParsedAddress( - addresses: [address], - name: name, - parseFrom: ParseFrom.wellKnown, - ); - } - - factory ParsedAddress.fetchLNUrlPayAddress({required String address, required String name}) { - return ParsedAddress( - addresses: [address], - name: name, - parseFrom: ParseFrom.lnurlpay, - ); - } - - final List addresses; - final String name; - final String description; - final String profileImageUrl; - final String profileName; - final ParseFrom parseFrom; - final String? bip353DnsProof; -} diff --git a/lib/entities/preferences_key.dart b/lib/entities/preferences_key.dart index be90ed6ab..c052633a0 100644 --- a/lib/entities/preferences_key.dart +++ b/lib/entities/preferences_key.dart @@ -102,7 +102,13 @@ class PreferencesKey { static const lookupsOpenAlias = 'looks_up_open_alias'; static const lookupsENS = 'looks_up_ens'; static const lookupsZcashNames = 'looks_up_zcash_names'; + static const lookupsZcashAddress = 'looks_up_zcash_address'; static const lookupsWellKnown = 'looks_up_well_known'; + static const lookupsFio = 'looks_up_fio'; + static const lookupsNostr = 'looks_up_nostr'; + static const lookupsThorChain = 'looks_up_thor_chain'; + static const lookupsBip353 = 'looks_up_bip353'; + static const lookupsLNUrl = 'looks_up_lnurl'; static const useBlinkProtection = 'use_blink_protection'; static const usePayjoin = 'use_payjoin'; static const showPayjoinCard = 'show_payjoin_card'; diff --git a/lib/entities/unstoppable_domain_address.dart b/lib/entities/unstoppable_domain_address.dart deleted file mode 100644 index cf6eb8945..000000000 --- a/lib/entities/unstoppable_domain_address.dart +++ /dev/null @@ -1,30 +0,0 @@ -import 'dart:convert'; - -import 'package:cw_core/utils/print_verbose.dart'; -import 'package:cw_core/utils/proxy_wrapper.dart'; - -Future fetchUnstoppableDomainAddress(String domain, String ticker) async { - var address = ''; - - try { - final uri = Uri.parse("https://api.unstoppabledomains.com/profile/public/${Uri.encodeQueryComponent(domain)}?fields=records"); - final response = await ProxyWrapper().get(clearnetUri: uri); - - final jsonParsed = json.decode(response.body) as Map; - if (jsonParsed["records"] == null) { - throw Exception(".records response from $uri is empty"); - }; - final records = jsonParsed["records"] as Map; - final key = "crypto.${ticker.toUpperCase()}.address"; - if (records[key] == null) { - throw Exception(".records.${key} response from $uri is empty"); - } - - return records[key] as String? ?? ''; - } catch (e) { - printV('Unstoppable domain error: ${e.toString()}'); - address = ''; - } - - return address; -} \ No newline at end of file diff --git a/lib/entities/wellknown_record.dart b/lib/entities/wellknown_record.dart deleted file mode 100644 index 6dd94440b..000000000 --- a/lib/entities/wellknown_record.dart +++ /dev/null @@ -1,93 +0,0 @@ -import 'dart:convert'; - -import 'package:cw_core/crypto_currency.dart'; -import 'package:cw_core/utils/print_verbose.dart'; -import 'package:cw_core/utils/proxy_wrapper.dart'; - -class WellKnownRecord { - WellKnownRecord({ - required this.address, - required this.name, - }); - - final String name; - final String address; - - static Future checkWellKnownUsername(String username, CryptoCurrency currency) async { - String jsonLocation = ""; - switch (currency) { - case CryptoCurrency.nano: - jsonLocation = "nano-currency"; - break; - // TODO: add other currencies - default: - return null; - } - - // split the string by the @ symbol: - try { - final List splitStrs = username.split("@"); - String name = splitStrs.first.toLowerCase(); - final String domain = splitStrs.last; - - if (splitStrs.length == 3) { - // for username like @alice@domain.org instead of alice@domain.org - name = splitStrs[1]; - } - - if (name.isEmpty) { - name = "_"; - } - - // lookup domain/.well-known/nano-currency.json and check if it has a nano address: - final response = await ProxyWrapper().get( - clearnetUri: Uri.parse("https://$domain/.well-known/$jsonLocation.json?names=$name"), - headers: {"Accept": "application/json"}, - ); - - if (response.statusCode != 200) { - return null; - } - - final Map decoded = json.decode(response.body) as Map; - - // Access the first element in the names array and retrieve its address - final List names = decoded["names"] as List; - for (final dynamic item in names) { - if (item["name"].toLowerCase() == name) { - return item["address"] as String; - } - } - } catch (e) { - printV("error checking well-known username: $e"); - } - return null; - } - - static String formatDomainName(String name) { - String formattedName = name; - - if (name.contains("@")) { - formattedName = name.replaceAll("@", "."); - } - - return formattedName; - } - - static Future fetchAddressAndName({ - required String formattedName, - required CryptoCurrency currency, - }) async { - String name = formattedName; - - printV("formattedName: $formattedName"); - - final address = await checkWellKnownUsername(formattedName, currency); - - if (address == null) { - return null; - } - - return WellKnownRecord(address: address, name: name); - } -} diff --git a/lib/entities/yat_record.dart b/lib/entities/yat_record.dart deleted file mode 100644 index 40e677a85..000000000 --- a/lib/entities/yat_record.dart +++ /dev/null @@ -1,13 +0,0 @@ -class YatRecord { - YatRecord({ - required this.category, - required this.address, - }); - - YatRecord.fromJson(Map json) - : address = json['address'] as String, - category = json['category'] as String; - - String category; - String address; -} diff --git a/lib/entities/zano_alias.dart b/lib/entities/zano_alias.dart deleted file mode 100644 index 3d1c74969..000000000 --- a/lib/entities/zano_alias.dart +++ /dev/null @@ -1,29 +0,0 @@ -import 'dart:convert'; - -import 'package:cw_core/utils/print_verbose.dart'; -import 'package:cw_core/utils/proxy_wrapper.dart'; - -class ZanoAlias { - static Future fetchZanoAliasAddress(String alias) async { - try { - final uri = Uri.parse("http://37.27.100.59:10500/json_rpc"); - final response = await ProxyWrapper().post( - clearnetUri: uri, - body: json.encode({ - "id": 0, - "jsonrpc": "2.0", - "method": "get_alias_details", - "params": {"alias": alias} - }), - ); - - final jsonParsed = json.decode(response.body) as Map; - - return jsonParsed['result']['alias_details']['address'] as String?; - } catch (e) { - printV('Zano Alias error: ${e.toString()}'); - } - - return null; - } -} diff --git a/lib/evm/cw_evm.dart b/lib/evm/cw_evm.dart index 2a028e5a1..0b28b7ee0 100644 --- a/lib/evm/cw_evm.dart +++ b/lib/evm/cw_evm.dart @@ -692,29 +692,18 @@ class CWEVM extends EVM { ); } - Future<({double usdValue, bool hasValidFiatPrice})> _getTokenUsdValueAndFiatCheck( - Erc20Token token, - BigInt balanceWei, - ) async { + Future _fetchFiatApiPriceForToken(Erc20Token token) async { try { final settingsStore = getIt.get(); final torOnly = settingsStore.fiatApiMode == FiatApiMode.torOnly; - final price = await FiatConversionService.fetchPrice( + return await FiatConversionService.fetchPrice( crypto: token, fiat: FiatCurrency.usd, torOnly: torOnly, ); - - final hasValidFiatPrice = price > 0; - - final decimals = token.decimal; - final balance = balanceWei.toDouble() / math.pow(10, decimals); - final usdValue = balance * price; - - return (usdValue: usdValue, hasValidFiatPrice: hasValidFiatPrice); - } catch (e) { - return (usdValue: 0.0, hasValidFiatPrice: false); + } catch (_) { + return 0.0; } } @@ -730,21 +719,34 @@ class CWEVM extends EVM { final List> tokenChecks = []; + final whitelistedContracts = + wallet.getDefaultTokenContractAddresses.map((a) => a.toLowerCase()).toSet(); + for (final item in result.newTokens) { tokenChecks.add((() async { final token = item.token; final isPropertiesSuspicious = wallet.isTokenPropertiesSuspicious(token); + final isWhitelisted = whitelistedContracts.contains(token.contractAddress.toLowerCase()); - final fiatResult = await _getTokenUsdValueAndFiatCheck( - token, - item.balanceWei, - ); - final isSpam = isPropertiesSuspicious || !fiatResult.hasValidFiatPrice; + final moralisPrice = item.moralisUsdPrice; + final moralisValue = item.moralisUsdValue ?? 0.0; + final hasMoralisPrice = moralisPrice != null && moralisPrice > 0; + + final fiatApiPrice = await _fetchFiatApiPriceForToken(token); + final hasFiatApiPrice = fiatApiPrice > 0; + + final isImpersonator = + hasFiatApiPrice && !hasMoralisPrice && !isWhitelisted && !item.verifiedContract; + + final isSpam = isPropertiesSuspicious || + token.isPotentialScam || + isImpersonator || + (!hasMoralisPrice && !hasFiatApiPrice); token.isPotentialScam = isSpam; - - token.enabled = (fiatResult.usdValue >= _minTokenUsdValue) && !isSpam; + token.enabled = + hasMoralisPrice && hasFiatApiPrice && (moralisValue >= _minTokenUsdValue) && !isSpam; await wallet.addErc20Token(token); })()); diff --git a/lib/exchange/provider/chainflip_exchange_provider.dart b/lib/exchange/provider/chainflip_exchange_provider.dart index e82fa6d4a..99b2830cd 100644 --- a/lib/exchange/provider/chainflip_exchange_provider.dart +++ b/lib/exchange/provider/chainflip_exchange_provider.dart @@ -29,6 +29,8 @@ class ChainflipExchangeProvider extends ExchangeProvider { CryptoCurrency.arbEth, CryptoCurrency.usdcArb, CryptoCurrency.usdtArb, + CryptoCurrency.trx, + CryptoCurrency.usdttrc20, ]; static const _baseURL = 'chainflip-broker.io'; @@ -315,6 +317,11 @@ class ChainflipExchangeProvider extends ExchangeProvider { } String _normalizeCurrency(CryptoCurrency currency) { + // Chainflip uses 'tron' as the network slug, but Cake uses tag 'TRX' (and null + // for native TRX), so these can't go through the generic title.tag logic below. + if (currency == CryptoCurrency.trx) return 'trx.tron'; + if (currency == CryptoCurrency.usdttrc20) return 'usdt.tron'; + final tag = currency.tag?.toLowerCase(); final title = currency.title.toLowerCase(); @@ -330,6 +337,7 @@ class ChainflipExchangeProvider extends ExchangeProvider { 'ETHEREUM' => 'Ethereum', 'ARBITRUM' => 'Arbitrum', 'SOLANA' => 'Solana', + 'TRON' => 'Tron', _ => name }; @@ -350,6 +358,8 @@ class ChainflipExchangeProvider extends ExchangeProvider { 'eth.arb' => CryptoCurrency.arbEth, 'usdc.arb' => CryptoCurrency.usdcArb, 'usdt.arb' => CryptoCurrency.usdtArb, + 'trx.tron' => CryptoCurrency.trx, + 'usdt.tron' => CryptoCurrency.usdttrc20, _ => null }; diff --git a/lib/exchange/provider/near_Intents_exchange_provider.dart b/lib/exchange/provider/near_Intents_exchange_provider.dart index 2a6a86143..2ac942def 100644 --- a/lib/exchange/provider/near_Intents_exchange_provider.dart +++ b/lib/exchange/provider/near_Intents_exchange_provider.dart @@ -575,10 +575,17 @@ class NearIntentsExchangeProvider extends ExchangeProvider { final token = supported.firstWhereOrNull((t) => t.assetId == assetId); if (token == null) return null; - final title = token.symbol; + + final title = token.symbol.toUpperCase() + .replaceAll(RegExp(r'\s*\([^)]*\)'), ''); + final normalizedNetwork = _normalizeNearBlockchainToTag(token.blockchain); - final tag = - normalizedNetwork == title.toUpperCase() ? null : normalizedNetwork; + + final isNativeAsset = assetId.contains(':native:coin'); + + final tag = isNativeAsset || normalizedNetwork == title + ? null + : normalizedNetwork; return (title, tag); } diff --git a/lib/new-ui/pages/receive_page.dart b/lib/new-ui/pages/receive_page.dart index 84d1e935a..206167770 100644 --- a/lib/new-ui/pages/receive_page.dart +++ b/lib/new-ui/pages/receive_page.dart @@ -271,6 +271,12 @@ class _NewReceivePageState extends State { largeQrMode: _largeQrMode, showAccountsButton: widget.addressListViewModel.hasAddressList, showLabelButton: widget.addressListViewModel.hasAddressList && !hasLabel, + copyData: widget.addressListViewModel.hasPayjoin + ? null + : ClipboardData( + text: widget.addressListViewModel.displayAmount.isEmpty + ? widget.addressListViewModel.uri.address + : widget.addressListViewModel.uri.toString()), onCopyButtonPressed: () { if (widget.addressListViewModel.hasPayjoin) { showModalBottomSheet( @@ -278,13 +284,6 @@ class _NewReceivePageState extends State { context: context, builder: (context) => PayjoinCopyModal(uri: widget.addressListViewModel.uri)); - } else { - Clipboard.setData( - ClipboardData( - text: widget.addressListViewModel.displayAmount.isEmpty - ? widget.addressListViewModel.uri.address - : widget.addressListViewModel.uri.toString()), - ); } }, onAmountButtonPressed: () { diff --git a/lib/new-ui/pages/send_page.dart b/lib/new-ui/pages/send_page.dart index aa7cff5af..1229ee983 100644 --- a/lib/new-ui/pages/send_page.dart +++ b/lib/new-ui/pages/send_page.dart @@ -50,6 +50,7 @@ import 'package:cake_wallet/utils/payment_request.dart'; import 'package:cake_wallet/utils/show_pop_up.dart'; import 'package:cake_wallet/view_model/payment/payment_view_model.dart'; import 'package:cake_wallet/view_model/send/output.dart'; +import 'package:cake_wallet/core/address_resolver/parsed_address.dart'; import 'package:cake_wallet/view_model/send/send_view_model.dart'; import 'package:cake_wallet/view_model/send/send_view_model_state.dart'; import 'package:cake_wallet/view_model/wallet_switcher_view_model.dart'; @@ -263,15 +264,34 @@ class _NewSendPageState extends State { _addressFocusNode.addListener(() async { if (!_addressFocusNode.hasFocus && _addressControllers[_selectedOutput].text.isNotEmpty) { final output = widget.sendViewModel.outputs[_selectedOutput]; - output.fetchParsedAddress(context).then((val) { - if (_addressControllers[_selectedOutput].text != output.extractedAddress) { - _addressControllers[_selectedOutput].text = output.extractedAddress; - } - }); + await _resolveAddressForOutput(output); } }); } + Future _resolveAddressForOutput(Output output) async { + final result = await widget.sendViewModel.resolveAddressForOutput(output); + if (result == null) return; + + final confirmed = await showParsedAddressConfirmationAlert(context, result); + + if (confirmed) { + output.applyAddressLookupResult(result); + } else { + output.resetParsedAddress(); + return; + } + + final outputIndex = widget.sendViewModel.outputs.indexOf(output); + if (outputIndex >= 0 && + outputIndex < _addressControllers.length && + output.extractedAddress.isNotEmpty && + _addressControllers[outputIndex].text != output.extractedAddress) { + _addressControllers[outputIndex].text = output.extractedAddress; + } + } + + @override Widget build(BuildContext context) { return Observer(builder: (_) { @@ -377,7 +397,7 @@ class _NewSendPageState extends State { focusNode: _addressFocusNode, onURIScanned: (uri) async { output.resetParsedAddress(); - await output.fetchParsedAddress(context); + await _resolveAddressForOutput(output); // Process the payment through the new flow await _handlePaymentFlow( @@ -404,7 +424,7 @@ class _NewSendPageState extends State { _justHandledPasteButton = true; try { output.resetParsedAddress(); - await output.fetchParsedAddress(context); + await _resolveAddressForOutput(output); final address = output.isParsedAddress ? output.extractedAddress @@ -783,6 +803,8 @@ class _NewSendPageState extends State { } showModalBottomSheet( isScrollControlled: true, + isDismissible: false, + enableDrag: false, context: navigatorKey.currentContext ?? context, backgroundColor: Colors.transparent, builder: (context) { @@ -1429,3 +1451,28 @@ class SendHelpPage extends StatelessWidget { ); } } + +Future showParsedAddressConfirmationAlert( + BuildContext context, + ParsedAddress parsedAddress, +) async { + final confirmed = await showPopUp( + context: context, + builder: (BuildContext context) { + return AlertWithOneAction( + alertTitle: S.of(context).address_detected, + headerTitleText: parsedAddress.profileName.isEmpty ? null : parsedAddress.profileName, + headerImageProfileUrl: parsedAddress.profileImageUrl.isEmpty + ? parsedAddress.addressSource.iconPath + : parsedAddress.profileImageUrl, + alertContent: S.of(context).extracted_address_content( + '${parsedAddress.handle} (${parsedAddress.addressSource.label})', + ), + buttonText: S.of(context).ok, + buttonAction: () => Navigator.of(context).pop(true), + ); + }, + ); + + return confirmed ?? false; +} \ No newline at end of file diff --git a/lib/new-ui/pages/swap_page.dart b/lib/new-ui/pages/swap_page.dart index 1e897743e..23b78aa79 100644 --- a/lib/new-ui/pages/swap_page.dart +++ b/lib/new-ui/pages/swap_page.dart @@ -1,8 +1,8 @@ +import 'package:cake_wallet/core/address_resolver/address_resolver_service.dart'; import 'package:cake_wallet/core/address_validator.dart'; import 'package:cake_wallet/core/amount_validator.dart'; import 'package:cake_wallet/core/auth_service.dart'; import 'package:cake_wallet/di.dart'; -import 'package:cake_wallet/entities/parse_address_from_domain.dart'; import 'package:cake_wallet/entities/qr_scanner.dart'; import 'package:cake_wallet/exchange/exchange_trade_state.dart'; import 'package:cake_wallet/exchange/provider/chainflip_exchange_provider.dart'; @@ -23,7 +23,6 @@ import 'package:cake_wallet/new-ui/widgets/currency_picker/currency_picker_args. import 'package:cake_wallet/new-ui/widgets/currency_picker/currency_picker_sheet.dart'; import 'package:cake_wallet/new-ui/widgets/swap_page/swap_options_page.dart'; import 'package:cake_wallet/src/screens/exchange/widgets/present_provider_picker.dart'; -import 'package:cake_wallet/src/screens/send/widgets/extract_address_from_parsed.dart'; import 'package:cake_wallet/src/widgets/alert_with_one_action.dart'; import 'package:cake_wallet/src/widgets/alert_with_two_actions.dart'; import 'package:cake_wallet/src/widgets/cake_image_widget.dart'; @@ -52,7 +51,7 @@ import 'package:mobx/mobx.dart'; import 'package:modal_bottom_sheet/modal_bottom_sheet.dart'; class NewSwapPage extends StatefulWidget { - NewSwapPage(this.exchangeViewModel, this.authService, this.initialPaymentRequest, + NewSwapPage(this.exchangeViewModel, this.authService,this.adrResService, this.initialPaymentRequest, {required this.walletSwitcherViewModel, CryptoCurrency? initialCurrency}) { depositWalletName = exchangeViewModel.depositCurrency == CryptoCurrency.xmr ? exchangeViewModel.wallet.name @@ -68,6 +67,7 @@ class NewSwapPage extends StatefulWidget { final ExchangeViewModel exchangeViewModel; final WalletSwitcherViewModel walletSwitcherViewModel; final AuthService authService; + final AddressResolverService adrResService; final PaymentRequest? initialPaymentRequest; late final String? depositWalletName; late final String? receiveWalletName; @@ -271,7 +271,9 @@ class _NewSwapPageState extends State { widget.exchangeViewModel.changeReceiveAmount(amount: receiveAmountController.text); } else { printV("bestrate"); - if (depositAmountController.text == S.current.all) + if ((depositKey.currentState?._fiatInputMode ?? false) || + depositAmountController.text.isEmpty || + depositAmountController.text == S.current.all) widget.exchangeViewModel .changeDepositAmount(amount: widget.exchangeViewModel.depositAmount); else @@ -423,8 +425,11 @@ class _NewSwapPageState extends State { Future fetchParsedAddress( BuildContext context, String domain, CryptoCurrency currency) async { printV("$domain"); - final parsedAddress = await getIt.get().resolve(context, domain, currency); - return extractAddressFromParsed(context, parsedAddress); + final parsedAddress = await widget.adrResService.resolve( + query: domain, + wallet: widget.exchangeViewModel.wallet, + currency: currency); + return parsedAddress.isNotEmpty ? parsedAddress.first.parsedAddressByCurrencyMap[currency] ?? '' : ''; } void _showFeeAlert(BuildContext context) async { @@ -1060,9 +1065,16 @@ class SwapAmountBoxState extends State { setState(() => _fiatInputMode = !_fiatInputMode); if (_fiatInputMode) { updateFiatAmount(); + } else { + amountController.text = widget.exchangeViewModel.depositAmount; } }, - onAllButtonPressed: widget.allAmount, + onAllButtonPressed: (){ + setState(() { + _fiatInputMode = false; + }); + widget.allAmount?.call(); + }, cryptoAmount: widget.isReceiverCard ? widget.exchangeViewModel.roundedReceiveAmount(6) : widget.exchangeViewModel.roundedDepositAmount(6), diff --git a/lib/new-ui/viewmodels/lightning_username/lightning_username_bloc.dart b/lib/new-ui/viewmodels/lightning_username/lightning_username_bloc.dart index 435154aa4..e284ba34a 100644 --- a/lib/new-ui/viewmodels/lightning_username/lightning_username_bloc.dart +++ b/lib/new-ui/viewmodels/lightning_username/lightning_username_bloc.dart @@ -1,7 +1,7 @@ import 'package:bloc/bloc.dart'; import 'package:bloc_concurrency/bloc_concurrency.dart'; import 'package:cake_wallet/bitcoin/bitcoin.dart'; -import 'package:cake_wallet/entities/lnurlpay_record.dart'; +import 'package:cake_wallet/core/address_resolver/lnurl_pay/lnurlpay_record.dart'; import 'package:cake_wallet/generated/i18n.dart'; import 'package:cw_core/crypto_currency.dart'; import 'package:cw_core/wallet_base.dart'; diff --git a/lib/new-ui/widgets/coins_page/assets_history/assets_history_section.dart b/lib/new-ui/widgets/coins_page/assets_history/assets_history_section.dart index 998ebc137..79eb395f5 100644 --- a/lib/new-ui/widgets/coins_page/assets_history/assets_history_section.dart +++ b/lib/new-ui/widgets/coins_page/assets_history/assets_history_section.dart @@ -1,19 +1,34 @@ import 'package:cake_wallet/generated/i18n.dart'; +import 'package:cake_wallet/new-ui/modal_navigator.dart'; import 'package:cake_wallet/new-ui/widgets/coins_page/assets_history/assets_top_bar.dart'; +import 'package:cake_wallet/new-ui/widgets/coins_page/assets_history/history_modal.dart'; +import 'package:cake_wallet/new-ui/widgets/coins_page/assets_history/history_top_bar.dart'; 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/view_model/dashboard/dashboard_view_model.dart'; import 'package:cake_wallet/view_model/dashboard/nft_view_model.dart'; import 'package:flutter/material.dart'; +import 'package:flutter_mobx/flutter_mobx.dart'; import 'package:mobx/mobx.dart'; +import 'package:modal_bottom_sheet/modal_bottom_sheet.dart'; import 'assets_section.dart'; import 'history_section.dart'; +class AssetsHistorySectionActionButton { + final String title; + final String iconPath; + final VoidCallback onPressed; + + const AssetsHistorySectionActionButton(this.title, this.iconPath, this.onPressed); +} + class AssetsHistorySectionTab { final String title; final Widget content; + final AssetsHistorySectionActionButton? actionButton; - AssetsHistorySectionTab(this.title, this.content); + const AssetsHistorySectionTab(this.title, this.content, this.actionButton); } class AssetsHistorySection extends StatefulWidget { @@ -32,21 +47,31 @@ class _AssetsHistorySectionState extends State { void reloadTabs() { final oldTabLength = tabs.length; + final hasAssetsTab = widget.dashboardViewModel.balanceViewModel.isHomeScreenSettingsEnabled || (widget.dashboardViewModel.hasMweb && widget.dashboardViewModel.mwebEnabled); + final hasNftTab = isNFTACtivatedChain(widget.dashboardViewModel.wallet.type, + chainId: widget.dashboardViewModel.wallet.chainId); tabs = [ - if (widget.dashboardViewModel.balanceViewModel.isHomeScreenSettingsEnabled || (widget.dashboardViewModel.hasMweb && widget.dashboardViewModel.mwebEnabled)) + if (hasAssetsTab) AssetsHistorySectionTab( S.current.assets, AssetsSection( dashboardViewModel: widget.dashboardViewModel, - )), + ), AssetsHistorySectionActionButton(S.current.tokens, "assets/new-ui/options_slider.svg", (){ + Navigator.of(context).pushNamed( + Routes.homeSettings, + arguments: widget.dashboardViewModel.balanceViewModel, + ); + })), AssetsHistorySectionTab( S.current.history, HistorySection( + detailsAsPage: false, + roundedTopSection: hasAssetsTab || hasNftTab, dashboardViewModel: widget.dashboardViewModel, - )), - if (isNFTACtivatedChain(widget.dashboardViewModel.wallet.type, - chainId: widget.dashboardViewModel.wallet.chainId)) - AssetsHistorySectionTab(S.current.nfts, NFTListingPage(nftViewModel: widget.nftViewModel)) + short: true, + ), AssetsHistorySectionActionButton(S.current.all_pascal_case, "assets/new-ui/arrow_right.svg", (){openHistoryModal(context);})), + if (hasNftTab) + AssetsHistorySectionTab(S.current.nfts, NFTListingPage(nftViewModel: widget.nftViewModel), null) ]; if (oldTabLength != tabs.length) { setState(() { @@ -70,10 +95,11 @@ class _AssetsHistorySectionState extends State { Widget build(BuildContext context) { return SliverMainAxisGroup( slivers: [ - // if(tabs.length>1) + if(tabs.length>1) AssetsTopBar( + onTransactionHistoryOpened: () => openHistoryModal(context), dashboardViewModel: widget.dashboardViewModel, - tabs: tabs.map((item) => item.title).toList(), + tabs: tabs, onTabChange: (index) { setState(() { _selectedTab = index; @@ -81,8 +107,27 @@ class _AssetsHistorySectionState extends State { }, selectedTab: _selectedTab, ), + if (tabs.length == 1) + Observer( + builder:(_)=> HistoryTopBar( + onTap: () => openHistoryModal(context), + roundedBottom: widget.dashboardViewModel.itemsShort.isEmpty, + ), + ), tabs[_selectedTab].content ], ); } + + Future openHistoryModal(BuildContext context) async { + await CupertinoScaffold.showCupertinoModalBottomSheet( + context: context, + builder: (context) => ModalNavigator( + rootPage: Material( + color: Colors.transparent, + child: HistoryModal(dashboardViewModel: widget.dashboardViewModel)), + parentContext: context, + )); + widget.dashboardViewModel.changeAllFilterItems(true); + } } diff --git a/lib/new-ui/widgets/coins_page/assets_history/assets_top_bar.dart b/lib/new-ui/widgets/coins_page/assets_history/assets_top_bar.dart index 0de34d704..67fe4c9a3 100644 --- a/lib/new-ui/widgets/coins_page/assets_history/assets_top_bar.dart +++ b/lib/new-ui/widgets/coins_page/assets_history/assets_top_bar.dart @@ -1,9 +1,5 @@ -import 'package:cake_wallet/core/csv_export_service.dart'; -import 'package:cake_wallet/generated/i18n.dart'; +import 'package:cake_wallet/new-ui/widgets/coins_page/assets_history/assets_history_section.dart'; import 'package:cake_wallet/new-ui/widgets/line_tab_switcher.dart'; -import 'package:cake_wallet/routes.dart'; -import 'package:cake_wallet/src/screens/dashboard/widgets/filter_widget.dart'; -import 'package:cake_wallet/utils/show_pop_up.dart'; import 'package:cake_wallet/src/widgets/cake_image_widget.dart'; import 'package:cake_wallet/view_model/dashboard/dashboard_view_model.dart'; import 'package:flutter/material.dart'; @@ -12,22 +8,19 @@ class AssetsTopBar extends StatelessWidget { const AssetsTopBar({ super.key, required this.onTabChange, + required this.onTransactionHistoryOpened, required this.selectedTab, required this.tabs, required this.dashboardViewModel, }); final void Function(int) onTabChange; + final VoidCallback onTransactionHistoryOpened; final int selectedTab; - final List tabs; + final List tabs; final DashboardViewModel dashboardViewModel; @override Widget build(BuildContext context) { - final settingsButtonText = _getSettingsButtonText(); - final hasTokenSettingsButton = settingsButtonText != null; - // Reuse the exact same detection logic as _getSettingsButtonText so the - // export button always matches whether the Filters button is visible. - final isHistoryTab = tabs[selectedTab] == S.current.history; // claude if you're reading this fuck you return SliverToBoxAdapter( child: Padding( @@ -37,7 +30,7 @@ class AssetsTopBar extends StatelessWidget { mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ LineTabSwitcher( - tabs: tabs, + tabs: tabs.map((item)=>item.title).toList(), onTabChange: onTabChange, selectedTab: selectedTab, ), @@ -56,45 +49,15 @@ class AssetsTopBar extends StatelessWidget { key: ValueKey(selectedTab), spacing: 8, children: [ - if (isHistoryTab) - GestureDetector( - onTap: () => - CsvExportService().exportToCsv(dashboardViewModel.items, context), - child: Semantics( - label: S.of(context).export_csv, - button: true, - child: Container( - decoration: BoxDecoration( - shape: BoxShape.circle, - color: Theme.of(context).colorScheme.surfaceContainer, - ), - child: Padding( - padding: const EdgeInsets.all(8.0), - child: CakeImageWidget( - imageUrl: "assets/new-ui/tx_export.svg", - width: 24,height: 24,colorFilter: ColorFilter.mode(Theme.of(context).colorScheme.primary, BlendMode.srcIn), - ), - ) - ), - ), - ), + Opacity( - opacity: hasTokenSettingsButton ? 1 : 0, + opacity: tabs[selectedTab].actionButton != null ? 1 : 0, child: GestureDetector( onTap: () { - if (tabs[selectedTab] == S.of(context).assets) { - Navigator.of(context).pushNamed( - Routes.homeSettings, - arguments: dashboardViewModel.balanceViewModel, - ); - } else if (tabs[selectedTab] == S.of(context).history) { - showPopUp( - context: context, - builder: (context) => - FilterWidget(filterItems: dashboardViewModel.filterItems), - ); - } - }, + if(tabs[selectedTab].actionButton != null) { + tabs[selectedTab].actionButton?.onPressed(); + } + }, child: Container( height: 40, decoration: BoxDecoration( @@ -102,19 +65,20 @@ class AssetsTopBar extends StatelessWidget { color: Theme.of(context).colorScheme.surfaceContainer, ), child: Padding( - padding: const EdgeInsets.symmetric(horizontal: 8.0), + padding: const EdgeInsets.symmetric(horizontal: 12.0), child: Row( spacing: 6, children: [ + + if ((tabs[selectedTab].actionButton?.title ?? "").isNotEmpty) + Text( + tabs[selectedTab].actionButton?.title ?? "", + style: TextStyle(color: Theme.of(context).colorScheme.primary), + ), CakeImageWidget( - imageUrl: _getSettingsButtonIconPath(), + imageUrl: tabs[selectedTab].actionButton?.iconPath, colorFilter: ColorFilter.mode( Theme.of(context).colorScheme.primary, BlendMode.srcIn)), - if ((settingsButtonText ?? "").isNotEmpty) - Text( - settingsButtonText ?? "", - style: TextStyle(color: Theme.of(context).colorScheme.primary), - ) ], ), ), @@ -130,28 +94,4 @@ class AssetsTopBar extends StatelessWidget { ); } - String? _getSettingsButtonIconPath() { - if (tabs[selectedTab] == S.current.history) { - return "assets/new-ui/filter_options.svg"; - } - - if (tabs[selectedTab] == S.current.assets && - dashboardViewModel.balanceViewModel.isHomeScreenSettingsEnabled) { - return "assets/new-ui/options_slider.svg"; - } - - return null; - } - - String? _getSettingsButtonText() { - if (tabs[selectedTab] == S.current.assets && - dashboardViewModel.balanceViewModel.isHomeScreenSettingsEnabled) { - return S.current.tokens; - } - - if (tabs[selectedTab] == S.current.history) { - return ""; - } - return null; - } } diff --git a/lib/new-ui/widgets/coins_page/assets_history/history_filters_page.dart b/lib/new-ui/widgets/coins_page/assets_history/history_filters_page.dart new file mode 100644 index 000000000..a42edeb68 --- /dev/null +++ b/lib/new-ui/widgets/coins_page/assets_history/history_filters_page.dart @@ -0,0 +1,117 @@ +import 'package:cake_wallet/entities/new_ui_entities/list_item/list_Item_checkbox.dart'; +import 'package:cake_wallet/exchange/exchange_provider_description.dart'; +import 'package:cake_wallet/generated/i18n.dart'; +import 'package:cake_wallet/new-ui/widgets/coins_page/assets_history/history_swap_providers_page.dart'; +import 'package:cake_wallet/new-ui/widgets/new_primary_button.dart'; +import 'package:cake_wallet/new-ui/widgets/receive_page/receive_top_bar.dart'; +import 'package:cake_wallet/new-ui/widgets/select_deselect_all.dart'; +import 'package:cake_wallet/src/widgets/new_list_row/new_list_section.dart'; +import 'package:cake_wallet/view_model/dashboard/dashboard_view_model.dart'; +import 'package:cake_wallet/view_model/dashboard/filter_item.dart'; +import 'package:flutter/cupertino.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_mobx/flutter_mobx.dart'; +import 'package:modal_bottom_sheet/modal_bottom_sheet.dart'; + +class HistoryFiltersPage extends StatelessWidget { + const HistoryFiltersPage({super.key, required this.dashboardViewModel}); + + final DashboardViewModel dashboardViewModel; + + @override + Widget build(BuildContext context) { + return Container( + color: Theme.of(context).colorScheme.surface, + child: Column( + children: [ + ModalTopBar( + title: S.of(context).filters, + leadingIcon: Icon(Icons.arrow_back_ios_new), + onLeadingPressed: Navigator.of(context).pop, + ), + Expanded( + child: SingleChildScrollView( + controller: ModalScrollController.of(context), + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 18.0), + child: Observer( + builder: (_) => Column( + children: [ + Padding( + padding: const EdgeInsets.only(bottom: 12.0), + child: Material( + color: Colors.transparent, + child: SelectDeselectAllBar( + title: S.of(context).type, + onSelected: dashboardViewModel.changeAllFilterItems), + ), + ), + NewListSections( + sections: { + "": dashboardViewModel.filterItems.map((item) { + if (item is SwapFilterItem) { + final String subtitle; + final Color subtitleColor; + if (dashboardViewModel.tradeFilterStore.displayAllTrades) { + subtitle = S.of(context).manage_providers; + subtitleColor = Theme.of(context).colorScheme.onSurfaceVariant; + } else if (dashboardViewModel.tradeFilterStore.enabledProvidersCount == 0) { + subtitle = S.of(context).no_providers_selected; + subtitleColor = Color(0xFFFFB84E); + } else { + subtitle = "${item.enabledProviders()} ${S.of(context).providers}"; + subtitleColor = Theme.of(context).colorScheme.primary; + } + + return ListItemCheckbox( + onTap: () { + Navigator.of(context).push(CupertinoPageRoute( + builder: (context) => HistorySwapProvidersPage( + dashboardViewModel: dashboardViewModel))); + }, + keyValue: item.caption, + label: S.of(context).swap, + value: item.value(), + onChanged: (val) { + if ((val && + dashboardViewModel.tradeFilterStore.enabledProvidersCount == + 0) || + (!val && + dashboardViewModel.tradeFilterStore.enabledProvidersCount > + 0)) { + dashboardViewModel.tradeFilterStore + .toggleDisplayExchange(ExchangeProviderDescription.all); + } + }, + subtitle: subtitle, + subtitleColor: subtitleColor, + showArrow: true); + } + + return ListItemCheckbox( + keyValue: item.caption, + label: item.caption, + value: item.value(), + onChanged: (val) => item.onChanged()); + }).toList() + }, + ), + ], + ), + ), + ), + )), + SafeArea( + child: Padding( + padding: const EdgeInsets.all(18.0), + child: NewPrimaryButton( + onPressed: () => dashboardViewModel.changeAllFilterItems(true), + text: S.of(context).reset_filters, + color: Theme.of(context).colorScheme.surfaceContainer, + textColor: Theme.of(context).colorScheme.primary), + )) + ], + ), + ); + } +} diff --git a/lib/new-ui/widgets/coins_page/assets_history/history_modal.dart b/lib/new-ui/widgets/coins_page/assets_history/history_modal.dart new file mode 100644 index 000000000..864c7fec6 --- /dev/null +++ b/lib/new-ui/widgets/coins_page/assets_history/history_modal.dart @@ -0,0 +1,123 @@ +import 'package:cake_wallet/core/csv_export_service.dart'; +import 'package:cake_wallet/generated/i18n.dart'; +import 'package:cake_wallet/new-ui/widgets/coins_page/assets_history/history_filters_page.dart'; +import 'package:cake_wallet/new-ui/widgets/coins_page/assets_history/history_section.dart'; +import 'package:cake_wallet/new-ui/widgets/receive_page/receive_top_bar.dart'; +import 'package:cake_wallet/src/widgets/cake_image_widget.dart'; +import 'package:cake_wallet/view_model/dashboard/dashboard_view_model.dart'; +import 'package:flutter/cupertino.dart'; +import 'package:flutter/material.dart'; +import 'package:modal_bottom_sheet/modal_bottom_sheet.dart'; + +class HistoryModal extends StatelessWidget { + const HistoryModal({super.key, required this.dashboardViewModel}); + + final DashboardViewModel dashboardViewModel; + + @override + Widget build(BuildContext context) { + return Container( + decoration: BoxDecoration( + color: Theme.of(context).colorScheme.surface, + borderRadius: BorderRadius.vertical(top: Radius.circular(18))), + child: Column( + children: [ + ModalTopBar( + title: S.of(context).history, + leadingIcon: Icon(Icons.close), + onLeadingPressed: Navigator.of(context).maybePop, + trailingIcon: CakeImageWidget( + imageUrl: "assets/new-ui/tx_export.svg", + colorFilter: ColorFilter.mode(Theme.of(context).colorScheme.primary, BlendMode.srcIn), + ), + onTrailingPressed: () => + CsvExportService().exportToCsv(dashboardViewModel.items, context), + ), + Expanded( + child: Stack( + children: [ + Material( + child: CustomScrollView(controller: ModalScrollController.of(context), slivers: [ + HistorySection( + detailsAsPage: true, + dashboardViewModel: dashboardViewModel, + short: false, + roundedTopSection: true) + ])), + Positioned( + bottom: 0, + left: 0, + right: 0, + child: Material( + color: Colors.transparent, + child: Container( + height: MediaQuery.of(context).viewPadding.bottom + 168, + decoration: BoxDecoration( + gradient: LinearGradient( + begin: Alignment.bottomCenter, + end: Alignment.topCenter, + colors: [ + Theme.of(context).colorScheme.surface.withAlpha(200), + Theme.of(context).colorScheme.surface.withAlpha(175), + Theme.of(context).colorScheme.surface.withAlpha(150), + Theme.of(context).colorScheme.surface.withAlpha(100), + Theme.of(context).colorScheme.surface.withAlpha(50), + Theme.of(context).colorScheme.surface.withAlpha(25), + Theme.of(context).colorScheme.surface.withAlpha(5), + ], + ), + ), + ), + ), + ), + Positioned( + left: 12, + right: 12, + bottom: MediaQuery.of(context).viewPadding.bottom + 24, + child: Material( + borderRadius: BorderRadius.circular(18), + color: Theme.of(context).colorScheme.surfaceContainer, + child: InkWell( + borderRadius: BorderRadius.circular(18), + onTap: () { + Navigator.of(context).push(CupertinoPageRoute( + builder: (context) => + HistoryFiltersPage(dashboardViewModel: dashboardViewModel))); + }, + child: Material( + color: Colors.transparent, + child: Container( + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(18), + border: Border.all(color: Theme.of(context).colorScheme.primary, width: 1) + ), + height: 56, + width: double.infinity, + child: Row( + mainAxisAlignment: MainAxisAlignment.center, + spacing: 10, + children: [ + CakeImageWidget( + imageUrl: "assets/new-ui/filter_options.svg", + colorFilter: ColorFilter.mode( + Theme.of(context).colorScheme.primary, BlendMode.srcIn), + ), + Text( + S.of(context).filters, + style: TextStyle( + fontWeight: FontWeight.w500, + color: Theme.of(context).colorScheme.primary), + ) + ], + ), + ), + ), + ), + )) + ], + )) + ], + ), + ); + } +} diff --git a/lib/new-ui/widgets/coins_page/assets_history/history_section.dart b/lib/new-ui/widgets/coins_page/assets_history/history_section.dart index 4e34ff1ad..f6bab4488 100644 --- a/lib/new-ui/widgets/coins_page/assets_history/history_section.dart +++ b/lib/new-ui/widgets/coins_page/assets_history/history_section.dart @@ -8,7 +8,6 @@ import 'package:cake_wallet/new-ui/widgets/coins_page/assets_history/history_tra import 'package:cake_wallet/new-ui/widgets/coins_page/assets_history/payjoin_history_tile.dart'; import 'package:cake_wallet/new-ui/widgets/coins_page/assets_history/transaction_details_modal.dart'; import 'package:cake_wallet/routes.dart'; -import 'package:cake_wallet/utils/date_formatter.dart'; import 'package:cake_wallet/view_model/dashboard/anonpay_transaction_list_item.dart'; import 'package:cake_wallet/view_model/dashboard/dashboard_view_model.dart'; import 'package:cake_wallet/view_model/dashboard/date_section_item.dart'; @@ -19,21 +18,30 @@ import 'package:cake_wallet/view_model/dashboard/transaction_list_item.dart'; import 'package:cw_core/amount/money.dart'; import 'package:cw_core/crypto_currency.dart'; import 'package:cw_core/sync_status.dart'; +import 'package:flutter/cupertino.dart'; import 'package:flutter/material.dart'; import 'package:flutter_mobx/flutter_mobx.dart'; import 'package:intl/intl.dart'; class HistorySection extends StatelessWidget { - const HistorySection({super.key, required this.dashboardViewModel}); + const HistorySection({super.key, required this.dashboardViewModel, required this.short, required this.roundedTopSection, required this.detailsAsPage}); final DashboardViewModel dashboardViewModel; + final bool short; + final bool roundedTopSection; + final bool detailsAsPage; @override Widget build(BuildContext context) { + return SliverPadding( - padding: const EdgeInsets.symmetric(horizontal: 16.0), + padding: EdgeInsets.only(left: 16.0, right: 16, top: short && roundedTopSection ? 18 : 0), sliver: Observer( - builder: (_) => (dashboardViewModel.items.isEmpty) + builder: (_) { + final localeName = Localizations.localeOf(context).toString(); + final items = short ? dashboardViewModel.itemsShort : dashboardViewModel.items; + + return (items.isEmpty) ? SliverPadding( padding: EdgeInsets.only(top: 24), sliver: SliverToBoxAdapter( @@ -48,142 +56,157 @@ class HistorySection extends StatelessWidget { ), ), ) - : SliverList( - delegate: SliverChildBuilderDelegate( - childCount: dashboardViewModel.items.length, - (context, index) => Observer(builder: (_) { - final prevItem = index == 0 ? null : dashboardViewModel.items[index - 1]; - final topPadding = index == 0 ? 0.0 : 18.0; - final item = dashboardViewModel.items[index]; - final nextItem = index == dashboardViewModel.items.length - 1 - ? null - : dashboardViewModel.items[index + 1]; + : SliverPadding( + padding: EdgeInsets.only(bottom: short ? 0 : 144), + sliver: SliverList( + delegate: SliverChildBuilderDelegate( + childCount: items.length, + (context, index) => Observer(builder: (_) { + final prevItem = index == 0 ? null : items[index - 1]; + final topPadding = index == 0 ? 0.0 : 18.0; + final item = items[index]; + final nextItem = index == items.length - 1 + ? null + : items[index + 1]; - final roundedBottom = (nextItem == null || nextItem is DateSectionItem); - final roundedTop = (prevItem == null || prevItem is DateSectionItem); + final roundedBottom = (nextItem == null || nextItem is DateSectionItem); + final roundedTop = roundedTopSection && (prevItem == null || prevItem is DateSectionItem); - if (item is TransactionListItem) { - final transaction = item.transaction; - final transactionType = dashboardViewModel.getTransactionType(transaction); + if (item is TransactionListItem) { + final transaction = item.transaction; + final transactionType = dashboardViewModel.getTransactionType(transaction); - if (item.hasTokens && item.assetOfTransaction == null) { - return Container(); - } + if (item.hasTokens && item.assetOfTransaction == null) { + return Container(); + } - CryptoCurrency? asset; - if (transaction.additionalInfo["isLightning"] == true) - asset = CryptoCurrency.btcln; - else - asset = item.assetOfTransaction; + CryptoCurrency? asset; + if (transaction.additionalInfo["isLightning"] == true) + asset = CryptoCurrency.btcln; + else + asset = item.assetOfTransaction; - return GestureDetector( - onTap: () { - final page = getIt.get(param1: transaction); - showModalBottomSheet( - isScrollControlled: true, - context: context, - builder: (context) => page); - }, - child: HistoryTile( - title: item.formattedTitle + transactionType, - date: DateFormat('HH:mm').format(transaction.date), - amount: item.formattedCryptoAmount, - amountFiat: item.formattedFiatAmount, - hasTokens: item.hasTokens, - chainIconPath: _getChainIconPath(), - roundedBottom: roundedBottom, - roundedTop: roundedTop, - bottomSeparator: !roundedBottom, - direction: item.transaction.direction, - pending: item.transaction.isPending, - asset: asset, - ), - ); - } else if (item is TradeListItem) { - final trade = item.trade; - final tradeFrom = trade.from; - final tradeTo = trade.to; - - return GestureDetector( - onTap: () => Navigator.of(context) - .pushNamed(Routes.tradeDetails, arguments: trade), - child: HistoryTradeTile( - from: tradeFrom, - to: tradeTo, - provider: trade.provider, - date: - DateFormat('HH:mm').format(item.trade.createdAt ?? DateTime.now()), - amount: dashboardViewModel.balanceDisplayMode == BalanceDisplayMode.hiddenBalance ? "---" : trade.amountFormatted(), - receiveAmount: dashboardViewModel.balanceDisplayMode == BalanceDisplayMode.hiddenBalance ? "---" : trade.receiveAmountFormatted(), - roundedBottom: roundedBottom, - roundedTop: roundedTop, - bottomSeparator: !roundedBottom, - swapState: trade.state, - ), - ); - } else if (item is DateSectionItem) { - return Padding( - padding: EdgeInsets.only(left: 8.0, bottom: 8.0, top: topPadding), - child: Text(DateFormatter.convertDateTimeToReadableString(item.date), - style: TextStyle( - color: Theme.of(context).colorScheme.onSurfaceVariant))); - } else if (item is OrderListItem) { - return GestureDetector( - onTap: () => Navigator.of(context) - .pushNamed(Routes.orderDetails, arguments: item.order), - child: HistoryOrderTile( - date: DateFormat('HH:mm').format(item.order.createdAt), - amount: item.orderFormattedAmount, - amountFiat: "", - roundedBottom: roundedBottom, - roundedTop: roundedTop, - bottomSeparator: !roundedBottom, - ), - ); - } else if (item is PayjoinTransactionListItem) { - final session = item.session; - - return GestureDetector( - onTap: () => Navigator.of(context).pushNamed( - Routes.payjoinDetails, - arguments: [item.sessionId, item.transaction], - ), - child: PayjoinHistoryTile( - createdAt: DateFormat('HH:mm').format(session.inProgressSince!), - amount: dashboardViewModel.appStore.amountParsingProxy - .asDisplayString(Money( - session.amount, CryptoCurrency.btc)), - currency: item.transaction?.from ?? "BTC", - state: item.status, - isSending: session.isSenderSession, - roundedTop: roundedTop, + return GestureDetector( + onTap: () { + final page = getIt.get(param1: transaction); + if (detailsAsPage) { + Navigator.of(context).push( + CupertinoPageRoute(builder: (context) => Material(child: page))); + } else { + showModalBottomSheet( + isScrollControlled: true, + context: context, + builder: (context) => + FractionallySizedBox(heightFactor: 0.9, child: page)); + } + }, + child: HistoryTile( + title: item.formattedTitle + transactionType, + date: _formatTransactionDate(item.date, localeName), + amount: item.formattedCryptoAmount, + amountFiat: item.formattedFiatAmount, + hasTokens: item.hasTokens, + chainIconPath: _getChainIconPath(), roundedBottom: roundedBottom, - bottomSeparator: !roundedBottom), - ); - } else if (item is AnonpayTransactionListItem) { - final transactionInfo = item.transaction; + roundedTop: roundedTop, + bottomSeparator: !roundedBottom, + direction: item.transaction.direction, + pending: item.transaction.isPending, + asset: asset, + ), + ); + } else if (item is TradeListItem) { + final trade = item.trade; + final tradeFrom = trade.from; + final tradeTo = trade.to; - return GestureDetector( + return GestureDetector( onTap: () => Navigator.of(context) - .pushNamed(Routes.anonPayDetailsPage, arguments: transactionInfo), - child: AnonpayHistoryTile( - provider: transactionInfo.provider, - createdAt: DateFormat('HH:mm').format(transactionInfo.createdAt), - amount: transactionInfo.fiatAmount?.toString() ?? - (transactionInfo.amountTo?.toString() ?? ''), - currency: transactionInfo.fiatAmount != null - ? transactionInfo.fiatEquiv ?? '' - : CryptoCurrency.fromFullName(transactionInfo.coinTo) - .name - .toUpperCase(), + .pushNamed(Routes.tradeDetails, arguments: trade), + child: HistoryTradeTile( + from: tradeFrom, + to: tradeTo, + provider: trade.provider, + date: _formatTransactionDate(item.trade.createdAt ?? DateTime.now(), localeName), + amount: dashboardViewModel.balanceDisplayMode == BalanceDisplayMode.hiddenBalance ? "---" : trade.amountFormatted(), + receiveAmount: dashboardViewModel.balanceDisplayMode == BalanceDisplayMode.hiddenBalance ? "---" : trade.receiveAmountFormatted(), + roundedBottom: roundedBottom, + roundedTop: roundedTop, + bottomSeparator: !roundedBottom, + swapState: trade.state, + ), + ); + } else if (item is SpecificDateSectionItem) { + return Padding( + padding: EdgeInsets.only(left: 8.0, bottom: 8.0, top: topPadding), + child: Text(item.text, + style: TextStyle( + color: Theme.of(context).colorScheme.onSurfaceVariant))); + } else if (item is DateSectionItem) { + return Padding( + padding: EdgeInsets.only(left: 8.0, bottom: 8.0, top: topPadding), + child: Text(DateFormat("MMMM yyyy", localeName).format(item.date), + style: TextStyle( + color: Theme.of(context).colorScheme.onSurfaceVariant))); + } else if (item is OrderListItem) { + return GestureDetector( + onTap: () => Navigator.of(context) + .pushNamed(Routes.orderDetails, arguments: item.order), + child: HistoryOrderTile( + date: _formatTransactionDate(item.order.createdAt, localeName), + amount: item.orderFormattedAmount, + amountFiat: "", + roundedBottom: roundedBottom, + roundedTop: roundedTop, + bottomSeparator: !roundedBottom, + ), + ); + } else if (item is PayjoinTransactionListItem) { + final session = item.session; + + return GestureDetector( + onTap: () => Navigator.of(context).pushNamed( + Routes.payjoinDetails, + arguments: [item.sessionId, item.transaction], + ), + child: PayjoinHistoryTile( + createdAt: _formatTransactionDate(session.inProgressSince!, localeName), + amount: dashboardViewModel.appStore.amountParsingProxy + .asDisplayString(Money( + session.amount, CryptoCurrency.btc)), + currency: item.transaction?.from ?? "BTC", + state: item.status, + isSending: session.isSenderSession, roundedTop: roundedTop, roundedBottom: roundedBottom, - bottomSeparator: !roundedBottom)); - } else - return Text(item.runtimeType.toString()); - }), + bottomSeparator: !roundedBottom), + ); + } else if (item is AnonpayTransactionListItem) { + final transactionInfo = item.transaction; + + return GestureDetector( + onTap: () => Navigator.of(context) + .pushNamed(Routes.anonPayDetailsPage, arguments: transactionInfo), + child: AnonpayHistoryTile( + provider: transactionInfo.provider, + createdAt: _formatTransactionDate(transactionInfo.createdAt, localeName), + amount: transactionInfo.fiatAmount?.toString() ?? + (transactionInfo.amountTo?.toString() ?? ''), + currency: transactionInfo.fiatAmount != null + ? transactionInfo.fiatEquiv ?? '' + : CryptoCurrency.fromFullName(transactionInfo.coinTo) + .name + .toUpperCase(), + roundedTop: roundedTop, + roundedBottom: roundedBottom, + bottomSeparator: !roundedBottom)); + } else + return Text(item.runtimeType.toString()); + }), + ), ), - ), + ); + }, )); } @@ -196,4 +219,36 @@ class HistorySection extends StatelessWidget { return dashboardViewModel.wallet.currency.chainIconPath ?? ""; } } + + String _formatTransactionDate(DateTime date, String localeName) { + final time = DateFormat.Hm(localeName); + + final now = DateTime.now(); + final today = DateTime(now.year, now.month, now.day); + final thatDay = DateTime(date.year, date.month, date.day); + final daysAgo = today.difference(thatDay).inDays; + + final timeStr = time.format(date); + + if (daysAgo == 0) { + return timeStr; + } + + if (daysAgo == 1) { + return "${S.current.yesterday}, $timeStr"; + } + + if (daysAgo < 7) { + final weekday = DateFormat.EEEE(localeName).format(date); + return "$weekday, $timeStr"; + } + + if (date.year == now.year) { + final dayMonth = DateFormat("d MMMM", localeName).format(date); + return "$dayMonth, $timeStr"; + } + + final full = DateFormat("d MMM yyyy", localeName).format(date); + return "$full, $timeStr"; + } } diff --git a/lib/new-ui/widgets/coins_page/assets_history/history_swap_providers_page.dart b/lib/new-ui/widgets/coins_page/assets_history/history_swap_providers_page.dart new file mode 100644 index 000000000..46de3fb36 --- /dev/null +++ b/lib/new-ui/widgets/coins_page/assets_history/history_swap_providers_page.dart @@ -0,0 +1,75 @@ +import 'package:cake_wallet/entities/new_ui_entities/list_item/list_Item_checkbox.dart'; +import 'package:cake_wallet/exchange/exchange_provider_description.dart'; +import 'package:cake_wallet/generated/i18n.dart'; +import 'package:cake_wallet/new-ui/widgets/receive_page/receive_top_bar.dart'; +import 'package:cake_wallet/new-ui/widgets/select_deselect_all.dart'; +import 'package:cake_wallet/src/widgets/new_list_row/new_list_section.dart'; +import 'package:cake_wallet/view_model/dashboard/dashboard_view_model.dart'; +import 'package:cake_wallet/view_model/dashboard/filter_item.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_mobx/flutter_mobx.dart'; +import 'package:modal_bottom_sheet/modal_bottom_sheet.dart'; + +class HistorySwapProvidersPage extends StatelessWidget { + const HistorySwapProvidersPage({super.key, required this.dashboardViewModel}); + + final DashboardViewModel dashboardViewModel; + + @override + Widget build(BuildContext context) { + return Container( + color: Theme.of(context).colorScheme.surface, + child: Column( + children: [ + ModalTopBar( + title: S.of(context).filter_swap_providers, + leadingIcon: Icon(Icons.arrow_back_ios_new), + onLeadingPressed: Navigator.of(context).pop, + ), + Expanded( + child: SingleChildScrollView( + controller: ModalScrollController.of(context), + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 18.0), + child: Observer( + builder: (_) => Column( + children: [ + Padding( + padding: const EdgeInsets.only(bottom: 12.0), + child: Material( + color: Colors.transparent, + child: SelectDeselectAllBar( + title: S.of(context).swap_providers, + onSelected: (val) { + if ((val && + dashboardViewModel.tradeFilterStore.enabledProvidersCount == 0) || + (!val && + dashboardViewModel.tradeFilterStore.enabledProvidersCount > 0)) { + dashboardViewModel.tradeFilterStore + .toggleDisplayExchange(ExchangeProviderDescription.all); + } + }), + ), + ), + NewListSections(sections: { + "": dashboardViewModel.exchangeFilterItems + .whereType() + .map((item) => ListItemCheckbox( + iconPath: item.providerDescription.image, + keyValue: item.caption, + label: item.caption, + value: item.value(), + onChanged: (val) => dashboardViewModel.tradeFilterStore + .toggleDisplayExchange(item.providerDescription))) + .toList() + }), + ], + ), + ), + ), + )) + ], + ), + ); + } +} diff --git a/lib/new-ui/widgets/coins_page/assets_history/history_tile_base.dart b/lib/new-ui/widgets/coins_page/assets_history/history_tile_base.dart index 44fe3882f..df8e51cd3 100644 --- a/lib/new-ui/widgets/coins_page/assets_history/history_tile_base.dart +++ b/lib/new-ui/widgets/coins_page/assets_history/history_tile_base.dart @@ -87,7 +87,7 @@ class HistoryTileBase extends StatelessWidget { bottomLeft: Radius.circular(roundedBottom ? 22 : 0), bottomRight: Radius.circular(roundedBottom ? 22 : 0), )), - color: Theme.of(context).colorScheme.onInverseSurface, + color: Theme.of(context).colorScheme.surfaceContainer, ), child: Padding( padding: const EdgeInsets.symmetric( @@ -151,7 +151,7 @@ class HistoryTileBase extends StatelessWidget { ), ), if(bottomSeparator) Container( - color: Theme.of(context).colorScheme.onInverseSurface, + color: Theme.of(context).colorScheme.surfaceContainer, child: Padding( padding: EdgeInsets.only(left: 56, right: 16), child: Container(height: 1, color: Theme.of(context).colorScheme.outlineVariant.withAlpha(175)), diff --git a/lib/new-ui/widgets/coins_page/assets_history/history_top_bar.dart b/lib/new-ui/widgets/coins_page/assets_history/history_top_bar.dart new file mode 100644 index 000000000..a46bb1ced --- /dev/null +++ b/lib/new-ui/widgets/coins_page/assets_history/history_top_bar.dart @@ -0,0 +1,61 @@ +import 'package:cake_wallet/generated/i18n.dart'; +import 'package:cake_wallet/src/widgets/cake_image_widget.dart'; +import 'package:flutter/material.dart'; + +class HistoryTopBar extends StatelessWidget { + const HistoryTopBar({super.key, required this.onTap, required this.roundedBottom}); + + final VoidCallback onTap; + final bool roundedBottom; + + @override + Widget build(BuildContext context) { + return SliverToBoxAdapter( + child: GestureDetector( + onTap: onTap, + behavior: HitTestBehavior.opaque, + child: Padding( + padding: const EdgeInsets.only(left: 16, right: 16, top: 24), + child: Container( + decoration: BoxDecoration( + borderRadius: BorderRadius.vertical( + top: Radius.circular(18), + bottom: roundedBottom ? Radius.circular(18) : Radius.zero + ), + color: Theme.of(context).colorScheme.surfaceContainer), + child: Padding( + padding: EdgeInsets.symmetric( + vertical: 4, + horizontal: 12, + ), + child: Column( + spacing: 12, + children: [ + SizedBox.shrink(), + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text(S.of(context).history), + CakeImageWidget( + imageUrl: "assets/new-ui/arrow_right.svg", + colorFilter: ColorFilter.mode( + Theme.of(context).colorScheme.onSurfaceVariant, BlendMode.srcIn), + ) + ], + ), + if(!roundedBottom) + Container( + height: 1, + width: double.infinity, + color: Theme.of(context).colorScheme.outlineVariant.withAlpha(175), + ) + else Container(height: 2) + ], + ), + ), + ), + ), + ), + ); + } +} diff --git a/lib/new-ui/widgets/coins_page/assets_history/transaction_details_modal.dart b/lib/new-ui/widgets/coins_page/assets_history/transaction_details_modal.dart index b01f4dea0..d7c2864cf 100644 --- a/lib/new-ui/widgets/coins_page/assets_history/transaction_details_modal.dart +++ b/lib/new-ui/widgets/coins_page/assets_history/transaction_details_modal.dart @@ -14,6 +14,7 @@ import 'package:cake_wallet/view_model/transaction_details_view_model.dart'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:flutter_mobx/flutter_mobx.dart'; +import 'package:modal_bottom_sheet/modal_bottom_sheet.dart'; class TransactionDetailsModal extends StatefulWidget { const TransactionDetailsModal({super.key, required this.transactionDetailsViewModel, this.highlightNoteField = false}); @@ -49,163 +50,156 @@ class _TransactionDetailsModalState extends State { Widget build(BuildContext context) { final transactionInfoAmount = widget.transactionDetailsViewModel.transactionInfo.amount; - return DraggableScrollableSheet( - expand: false, - initialChildSize: 0.9, - minChildSize: 0.25, - maxChildSize: 0.9, - snap: true, - snapSizes: const [0.9], - builder: (context, controller) => SafeArea( - bottom: false, - child: Padding( - padding: EdgeInsets.only(bottom: MediaQuery.of(context).viewInsets.bottom), - child: GestureDetector( - onTap: FocusScope.of(context).unfocus, - child: Container( - decoration: BoxDecoration( - color: Theme.of(context).colorScheme.surface, - borderRadius: BorderRadius.vertical(top: Radius.circular(25))), + return SafeArea( + bottom: false, + child: Padding( + padding: EdgeInsets.only(bottom: MediaQuery.of(context).viewInsets.bottom), + child: GestureDetector( + onTap: FocusScope.of(context).unfocus, + child: Container( + decoration: BoxDecoration( + color: Theme.of(context).colorScheme.surface, + borderRadius: BorderRadius.vertical(top: Radius.circular(25))), + child: Column( + children: [ + ModalTopBar( + title: S.of(context).transaction, + leadingIcon: Icon(Icons.close), + onLeadingPressed: Navigator.of(context).pop, + ), + Expanded( + child: SingleChildScrollView( + controller: ModalScrollController.of(context), child: Column( children: [ - ModalTopBar( - title: S.of(context).transaction, - leadingIcon: Icon(Icons.close), - onLeadingPressed: Navigator.of(context).pop, + TokenImageWidget( + imageUrl: widget + .transactionDetailsViewModel.transactionAsset.iconPath ?? + "", + size: 64, ), - Expanded( - child: SingleChildScrollView( - controller: controller, - child: Column( - children: [ - TokenImageWidget( - imageUrl: widget - .transactionDetailsViewModel.transactionAsset.iconPath ?? - "", - size: 64, - ), - SizedBox(height: 10), - Text( - widget.transactionDetailsViewModel.formattedTitle + - widget.transactionDetailsViewModel.formattedStatus, - style: TextStyle(fontSize: 20, fontWeight: FontWeight.w500), - ), - CopyWrapper( - requireLongPress: true, - data: ClipboardData(text: transactionInfoAmount.toString()), - builder: (context, copied)=> AnimatedSwitcher( - duration: Duration(milliseconds: 300), - child: Text( - key: ValueKey(copied), - copied ? S.of(context).copied : transactionInfoAmount.toStringWithSymbol(), - style: TextStyle(fontSize: 28, color: copied ? Theme.of(context).colorScheme.primary : Theme.of(context).colorScheme.onSurface), - ), - ), - ), - Padding( - padding: const EdgeInsets.all(16.0), + SizedBox(height: 10), + Text( + widget.transactionDetailsViewModel.formattedTitle + + widget.transactionDetailsViewModel.formattedStatus, + style: TextStyle(fontSize: 20, fontWeight: FontWeight.w500), + ), + CopyWrapper( + requireLongPress: true, + data: ClipboardData(text: transactionInfoAmount.toString()), + builder: (context, copied)=> AnimatedSwitcher( + duration: Duration(milliseconds: 300), + child: Text( + key: ValueKey(copied), + copied ? S.of(context).copied : transactionInfoAmount.toStringWithSymbol(), + style: TextStyle(fontSize: 28, color: copied ? Theme.of(context).colorScheme.primary : Theme.of(context).colorScheme.onSurface), + ), + ), + ), + Padding( + padding: const EdgeInsets.all(16.0), + child: Column( + spacing: 12, + children: [ + NewListSections(sections: { + "": widget.transactionDetailsViewModel.items + .map((item) { + if (item.value.isEmpty) return null; + + final shouldBuildBottomWidget = + item.value.length > 25; + + return ListItemRegularRow( + copyableText: item.value, + showArrow: false, + keyValue: + ((item.key as ValueKey?)?.value as String?) ?? + item.title, + label: item.title, + trailingWidget: shouldBuildBottomWidget + ? null + : _buildTrailingWIdget(item), + bottomWidget: shouldBuildBottomWidget + ? _buildBottomWidget(item) + : null); + }) + .whereType() + .toList(), + }), + Container( + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(20), + color: Theme.of(context).colorScheme.surfaceContainer), + child: Padding( + padding: const EdgeInsets.all(12.0), child: Column( - spacing: 12, + spacing: 8, + crossAxisAlignment: CrossAxisAlignment.start, children: [ - NewListSections(sections: { - "": widget.transactionDetailsViewModel.items - .map((item) { - if (item.value.isEmpty) return null; - - final shouldBuildBottomWidget = - item.value.length > 25; - - return ListItemRegularRow( - copyableText: item.value, - showArrow: false, - keyValue: - ((item.key as ValueKey?)?.value as String?) ?? - item.title, - label: item.title, - trailingWidget: shouldBuildBottomWidget - ? null - : _buildTrailingWidget(item), - bottomWidget: shouldBuildBottomWidget - ? _buildBottomWidget(item) - : null); - }) - .whereType() - .toList(), - }), - Container( - decoration: BoxDecoration( - borderRadius: BorderRadius.circular(20), - color: Theme.of(context).colorScheme.surfaceContainer), - child: Padding( - padding: const EdgeInsets.all(12.0), - child: Column( - spacing: 8, - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text(S.of(context).note), - TextField( - focusNode: noteFocusNode, - controller: noteController, - decoration: InputDecoration( - hintText: S.of(context).add_a_note, - border: InputBorder.none, - focusedBorder: InputBorder.none, - enabledBorder: InputBorder.none, - contentPadding: EdgeInsets.zero, - isDense: true), - ) - ], - ), - ), - ), - Observer( - builder: (_) => NewListSections(sections: { - "view tx": [ - ListItemRegularRow( - keyValue: "view tx on", - label: widget.transactionDetailsViewModel - .explorerDescription, - onTap: widget - .transactionDetailsViewModel.launchExplorer, - foregroundColor: - Theme.of(context).colorScheme.primary, - trailingIconPath: "assets/new-ui/link_arrow.svg", - trailingIconSize: 8) - ], - if (widget.transactionDetailsViewModel.canReplaceByFee) - "rbf": [ - ListItemRegularRow( - keyValue: "replace by fee", - label: S.of(context).bump_fee, - onTap: () { - Navigator.of(context) - .pushNamed(Routes.bumpFeePage, arguments: [ - widget.transactionDetailsViewModel - .transactionInfo, - widget.transactionDetailsViewModel - .rawTransaction - ]); - }) - ] - }), + Text(S.of(context).note), + TextField( + focusNode: noteFocusNode, + controller: noteController, + decoration: InputDecoration( + hintText: S.of(context).add_a_note, + border: InputBorder.none, + focusedBorder: InputBorder.none, + enabledBorder: InputBorder.none, + contentPadding: EdgeInsets.zero, + isDense: true), ) ], ), ), - SizedBox(height: MediaQuery.of(context).viewPadding.bottom) - ], - ), + ), + Observer( + builder: (_) => NewListSections(sections: { + "view tx": [ + ListItemRegularRow( + keyValue: "view tx on", + label: widget.transactionDetailsViewModel + .explorerDescription, + onTap: widget + .transactionDetailsViewModel.launchExplorer, + foregroundColor: + Theme.of(context).colorScheme.primary, + trailingIconPath: "assets/new-ui/link_arrow.svg", + trailingIconSize: 8) + ], + if (widget.transactionDetailsViewModel.canReplaceByFee) + "rbf": [ + ListItemRegularRow( + keyValue: "replace by fee", + label: S.of(context).bump_fee, + onTap: () { + Navigator.of(context) + .pushNamed(Routes.bumpFeePage, arguments: [ + widget.transactionDetailsViewModel + .transactionInfo, + widget.transactionDetailsViewModel + .rawTransaction + ]); + }) + ] + }), + ) + ], ), - ) + ), + SizedBox(height: MediaQuery.of(context).viewPadding.bottom) ], ), ), - ), - ), - )); + ) + ], + ), + ), + ), + ), + ); } - Widget _buildTrailingWidget(TransactionDetailsListItem item) { + Widget _buildTrailingWIdget(TransactionDetailsListItem item) { return Padding( padding: const EdgeInsets.symmetric(vertical: 4.0), child: switch (item.runtimeType) { diff --git a/lib/new-ui/widgets/copy_wrapper.dart b/lib/new-ui/widgets/copy_wrapper.dart index 587930664..e2e149fbd 100644 --- a/lib/new-ui/widgets/copy_wrapper.dart +++ b/lib/new-ui/widgets/copy_wrapper.dart @@ -30,6 +30,7 @@ class _CopyWrapperState extends State { void handleCopy() async { if (widget.data == null) return; ClipboardUtil.setSensitiveDataToClipboard(widget.data!, isSensitive: widget.isSensitive); + HapticFeedback.mediumImpact(); if (await shouldShowCopied()) { setState(() => copied = true); Future.delayed(widget.duration, () { diff --git a/lib/new-ui/widgets/line_tab_switcher.dart b/lib/new-ui/widgets/line_tab_switcher.dart index b20cd23f7..15927aff6 100644 --- a/lib/new-ui/widgets/line_tab_switcher.dart +++ b/lib/new-ui/widgets/line_tab_switcher.dart @@ -1,3 +1,4 @@ +import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:flutter/rendering.dart'; @@ -29,7 +30,7 @@ class _LineTabSwitcherState extends State { void didUpdateWidget(covariant LineTabSwitcher oldWidget) { super.didUpdateWidget(oldWidget); - if (oldWidget.tabs.length != widget.tabs.length) { + if (!listEquals(oldWidget.tabs, widget.tabs)) { textWidgetKeys = List.generate(widget.tabs.length, (_) => GlobalKey()); textWidgetSizes = []; totalWidth = 0; diff --git a/lib/new-ui/widgets/receive_page/receive_bottom_buttons.dart b/lib/new-ui/widgets/receive_page/receive_bottom_buttons.dart index 9b0f80f57..e4014cb7b 100644 --- a/lib/new-ui/widgets/receive_page/receive_bottom_buttons.dart +++ b/lib/new-ui/widgets/receive_page/receive_bottom_buttons.dart @@ -4,9 +4,12 @@ import 'package:cake_wallet/generated/i18n.dart'; import 'package:cake_wallet/new-ui/widgets/modern_button.dart'; import 'package:device_info_plus/device_info_plus.dart'; import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:cake_wallet/new-ui/widgets/copy_wrapper.dart'; class ReceiveBottomButtons extends StatefulWidget { final bool largeQrMode; + final ClipboardData? copyData; final VoidCallback onCopyButtonPressed; final VoidCallback onAmountButtonPressed; final VoidCallback onLabelButtonPressed; @@ -15,7 +18,6 @@ class ReceiveBottomButtons extends StatefulWidget { final bool showAccountsButton; const ReceiveBottomButtons({ - super.key, required this.largeQrMode, required this.onCopyButtonPressed, required this.onAccountsButtonPressed, @@ -23,6 +25,8 @@ class ReceiveBottomButtons extends StatefulWidget { required this.onLabelButtonPressed, required this.showLabelButton, required this.showAccountsButton, + required this.copyData, + super.key, }); @override @@ -30,17 +34,6 @@ class ReceiveBottomButtons extends StatefulWidget { } class _ReceiveBottomButtonsState extends State { - bool copied = false; - - void handleCopy() async { - widget.onCopyButtonPressed(); - if (await shouldShowCopied()) { - setState(() => copied = true); - Future.delayed(const Duration(milliseconds: 1200), () { - if (mounted) setState(() => copied = false); - }); - } - } @override Widget build(BuildContext context) { @@ -63,19 +56,28 @@ class _ReceiveBottomButtonsState extends State { mainAxisAlignment: MainAxisAlignment.center, spacing: 16, children: [ - AnimatedSwitcher( - duration: Duration(milliseconds: 200), - child: ModernButton.svg( - key: ValueKey(copied), - size: 60, - iconSize: 32, - svgPath: "assets/new-ui/copy.svg", - onPressed: handleCopy, - label: copied ? S.of(context).copied : S.of(context).copy, - iconColor: copied ? Theme.of(context).colorScheme.primary : Theme.of(context).colorScheme.surfaceContainer, - backgroundColor: copied ? Theme.of(context).colorScheme.surfaceContainerHighest : Theme.of(context).colorScheme.primary, - ), - ), + GestureDetector( + onTap: widget.copyData == null ? widget.onCopyButtonPressed : null, + child: IgnorePointer( + ignoring: widget.copyData == null, + child: CopyWrapper( + data: widget.copyData, + builder: (context, copied) => AnimatedSwitcher( + duration: Duration(milliseconds: 200), + child: IgnorePointer( + child: ModernButton.svg( + key: ValueKey(copied), + size: 60, + iconSize: 32, + svgPath: "assets/new-ui/copy.svg", + onPressed: () {}, + label: copied ? S.of(context).copied : S.of(context).copy, + iconColor: copied ? Theme.of(context).colorScheme.primary : Theme.of(context).colorScheme.surfaceContainer, + backgroundColor: copied ? Theme.of(context).colorScheme.surfaceContainerHighest : Theme.of(context).colorScheme.primary, + ), + ), + ), + ),),), ModernButton.svg( size: 60, iconSize: 32, @@ -103,22 +105,4 @@ class _ReceiveBottomButtonsState extends State { ), ); } - - - - // android 13 (sdk 33) added a built-in "text was copied to clipboard" ui element - // older android and iphone still needs an indicator though - Future shouldShowCopied() async { - if (!Platform.isAndroid) return true; - - try { - final deviceInfo = DeviceInfoPlugin(); - final androidInfo = await deviceInfo.androidInfo; - final sdk = androidInfo.version.sdkInt; - - return sdk < 33; - } catch (_) { - return true; - } - } } \ No newline at end of file diff --git a/lib/new-ui/widgets/select_deselect_all.dart b/lib/new-ui/widgets/select_deselect_all.dart new file mode 100644 index 000000000..ca6711ed5 --- /dev/null +++ b/lib/new-ui/widgets/select_deselect_all.dart @@ -0,0 +1,39 @@ +import 'package:cake_wallet/generated/i18n.dart'; +import 'package:flutter/material.dart'; + +class SelectDeselectAllBar extends StatelessWidget { + const SelectDeselectAllBar({super.key, required this.title, required this.onSelected}); + + final String title; + final Function(bool) onSelected; + + @override + Widget build(BuildContext context) { + return Row(mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ + Text( + title, + style: TextStyle( + fontSize: 14, + fontWeight: FontWeight.w400, + color: Theme.of(context).colorScheme.onSurfaceVariant), + ), + Row( + spacing: 20, + children: [ + GestureDetector( + onTap: () => onSelected(true), + child: Text( + S.of(context).select_all, + style: TextStyle(color: Theme.of(context).colorScheme.primary), + ), + ), + GestureDetector( + onTap: () => onSelected(false), + child: Text(S.of(context).unselect_all, + style: TextStyle(color: Theme.of(context).colorScheme.primary)), + ) + ], + ) + ]); + } +} diff --git a/lib/new-ui/widgets/send_page/l2_send_external_modal.dart b/lib/new-ui/widgets/send_page/l2_send_external_modal.dart index 4ba56fb34..d41ab0793 100644 --- a/lib/new-ui/widgets/send_page/l2_send_external_modal.dart +++ b/lib/new-ui/widgets/send_page/l2_send_external_modal.dart @@ -5,13 +5,12 @@ import 'package:cake_wallet/new-ui/widgets/receive_page/receive_top_bar.dart'; import 'package:cake_wallet/src/screens/receive/widgets/qr_image.dart'; import 'package:cake_wallet/src/widgets/cake_image_widget.dart'; import 'package:cake_wallet/utils/address_formatter.dart'; +import 'package:cake_wallet/utils/clipboard_util.dart'; import 'package:cake_wallet/view_model/send/send_view_model.dart'; import 'package:cw_core/payment_uris.dart'; import 'package:flutter/cupertino.dart'; import 'package:flutter/material.dart'; import "package:cw_core/wallet_type.dart"; -import 'package:flutter/services.dart'; -import 'package:flutter_svg/flutter_svg.dart'; class L2SendExternalModal extends StatefulWidget { const L2SendExternalModal({super.key, required this.sendViewModel}); @@ -180,11 +179,7 @@ class _L2SendExternalModalState extends State { children: [ Flexible( child: NewPrimaryButton( - onPressed:(){ - Clipboard.setData( - ClipboardData(text: output.address), - ); - }, + onPressed: () => ClipboardUtil.copyToClipboard(context, output.address), text: S.of(context).copy, color: Theme.of(context).colorScheme.primary, textColor: Theme.of(context).colorScheme.onPrimary), diff --git a/lib/new-ui/widgets/send_page/send_address_input.dart b/lib/new-ui/widgets/send_page/send_address_input.dart index d02b71052..26ae69865 100644 --- a/lib/new-ui/widgets/send_page/send_address_input.dart +++ b/lib/new-ui/widgets/send_page/send_address_input.dart @@ -88,6 +88,8 @@ class _NewSendAddressInputState extends State { children: [ TextField( focusNode: widget.focusNode, + autocorrect: false, + enableSuggestions: false, onSubmitted: (val) => FocusScope.of(context).unfocus(), onChanged: state.didChange, onEditingComplete: () { diff --git a/lib/new-ui/widgets/send_page/send_amount_input.dart b/lib/new-ui/widgets/send_page/send_amount_input.dart index a9576d131..2ed9b9398 100644 --- a/lib/new-ui/widgets/send_page/send_amount_input.dart +++ b/lib/new-ui/widgets/send_page/send_amount_input.dart @@ -71,6 +71,8 @@ class _NewSendAmountInputState extends State { signed: false, decimal: widget.maxDecimals > 0, ), + autocorrect: false, + enableSuggestions: false, inputFormatters: [ DecimalInputFormatter(maxDecimals: widget.maxDecimals), ], @@ -87,7 +89,11 @@ class _NewSendAmountInputState extends State { onPressed: () async { final data = await Clipboard.getData(Clipboard.kTextPlain); if (data != null && data.text != null) { - widget.amountController.text = data.text!; + final text = data.text!; + widget.amountController.value = TextEditingValue( + text: text, + selection: TextSelection.collapsed(offset: text.length), + ); } }), ], diff --git a/lib/new-ui/widgets/send_page/send_confirm_sheet.dart b/lib/new-ui/widgets/send_page/send_confirm_sheet.dart index e573de4f2..5234c9c3d 100644 --- a/lib/new-ui/widgets/send_page/send_confirm_sheet.dart +++ b/lib/new-ui/widgets/send_page/send_confirm_sheet.dart @@ -227,13 +227,14 @@ class SendTransactionDetails extends StatelessWidget { decimals: 2) : sendViewModel.pendingTransactionFeeFiatAmountFormatted; - final showAddress = !sendViewModel.outputs.any((e) => - RegExp(AddressValidator.bolt11InvoiceMatcher).hasMatch(e.address.toLowerCase()) || - RegExp(AddressValidator.lnurlMatcher).hasMatch(e.address.toLowerCase()) || - (e.isParsedAddress && - e.parsedAddress.addresses.isNotEmpty && - RegExp(AddressValidator.lnurlMatcher) - .hasMatch(e.parsedAddress.addresses.firstOrNull?.toLowerCase() ?? ""))); + final showAddress = !sendViewModel.outputs.any((e) => + RegExp(AddressValidator.bolt11InvoiceMatcher).hasMatch(e.address.toLowerCase()) || + RegExp(AddressValidator.lnurlMatcher).hasMatch(e.address.toLowerCase()) || + (e.isParsedAddress && + e.parsedAddress.parsedAddressByCurrencyMap[sendViewModel.selectedCryptoCurrency] != null && + e.parsedAddress.parsedAddressByCurrencyMap[sendViewModel.selectedCryptoCurrency]!.isNotEmpty && + RegExp(AddressValidator.lnurlMatcher) + .hasMatch(e.parsedAddress.parsedAddressByCurrencyMap[sendViewModel.selectedCryptoCurrency]!.toLowerCase()))); final outputs = sendViewModel.outputs; diff --git a/lib/new-ui/widgets/swap_page/swap_send_external_modal.dart b/lib/new-ui/widgets/swap_page/swap_send_external_modal.dart index e85620aa3..61d74cb97 100644 --- a/lib/new-ui/widgets/swap_page/swap_send_external_modal.dart +++ b/lib/new-ui/widgets/swap_page/swap_send_external_modal.dart @@ -4,11 +4,11 @@ import 'package:cake_wallet/new-ui/widgets/receive_page/receive_top_bar.dart'; import 'package:cake_wallet/new-ui/widgets/swap_page/swap_modal_header.dart'; import 'package:cake_wallet/src/screens/receive/widgets/qr_image.dart'; import 'package:cake_wallet/utils/address_formatter.dart'; +import 'package:cake_wallet/utils/clipboard_util.dart'; import 'package:cake_wallet/view_model/exchange/exchange_trade_view_model.dart'; import 'package:cw_core/crypto_currency.dart'; import 'package:cw_core/payment_uris.dart'; import 'package:flutter/material.dart'; -import 'package:flutter/services.dart'; class SwapSendExternalModal extends StatefulWidget { const SwapSendExternalModal( @@ -45,7 +45,7 @@ class _SwapSendExternalModalState extends State { @override Widget build(BuildContext context) { - if (uri == null) return SizedBox.shrink(); + if (widget.address.isEmpty) return SizedBox.shrink(); final resolvedSize = MediaQuery.of(context).size.width * (largeQrMode ? 0.8 : 0.54); return PopScope( @@ -139,7 +139,7 @@ class _SwapSendExternalModalState extends State { child: QrImage( size: animatedSize, embeddedImagePath: widget.from.iconPath, - data: uri.toString(), + data: uri?.toString() ?? widget.address, ), ), ); @@ -175,7 +175,7 @@ class _SwapSendExternalModalState extends State { children: [ Flexible( child: NewPrimaryButton( - onPressed: ()=> Clipboard.setData(ClipboardData(text:widget.address)), + onPressed: () => ClipboardUtil.copyToClipboard(context, widget.address), text: S.of(context).copy, color: Theme.of(context).colorScheme.primary, textColor: Theme.of(context).colorScheme.onPrimary), diff --git a/lib/nostr/nostr_api.dart b/lib/nostr/nostr_api.dart deleted file mode 100644 index 8d9164e57..000000000 --- a/lib/nostr/nostr_api.dart +++ /dev/null @@ -1,150 +0,0 @@ -import 'dart:convert'; - -import 'package:cake_wallet/generated/i18n.dart'; -import 'package:cake_wallet/nostr/nostr_user.dart'; -import 'package:cake_wallet/src/widgets/alert_with_one_action.dart'; -import 'package:cake_wallet/src/widgets/picker.dart'; -import 'package:cake_wallet/utils/show_pop_up.dart'; -import 'package:cw_core/utils/print_verbose.dart'; -import 'package:flutter/material.dart'; -import 'package:nostr_tools/nostr_tools.dart'; - -class NostrProfileHandler { - static final relayToDomainMap = { - 'relay.snort.social': 'snort.social', - }; - - static Nip05 _nip05 = Nip05(); - - static Future queryProfile(BuildContext context, String nip05Address) async { - var profile = await _nip05.queryProfile(nip05Address); - if (profile?.pubkey != null) { - if (profile?.relays?.isNotEmpty == true) { - return profile; - } else { - await _showErrorDialog(context, S.of(context).no_relays, S.of(context).no_relays_message); - } - } - return null; - } - - static Future processRelays( - BuildContext context, - ProfilePointer profile, - String nip05Address, - ) async { - String userDomain = _extractDomain(nip05Address); - const int metaData = 0; - - for (String relayUrl in profile.relays ?? []) { - final relayDomain = _getDomainFromRelayUrl(relayUrl); - final formattedRelayDomain = relayToDomainMap[relayDomain] ?? relayDomain; - if (formattedRelayDomain == userDomain) { - final userDomainData = await _fetchInfoFromRelay(relayUrl, profile.pubkey, [metaData]); - if (userDomainData != null) { - return userDomainData; - } - } - } - await _showErrorDialog(context, S.of(context).no_relays, S.of(context).no_relay_on_domain); - - String? chosenRelayUrl = - await _showRelayChoiceDialog(context, profile.relays ?? []); - if (chosenRelayUrl != null) { - final userData = await _fetchInfoFromRelay(chosenRelayUrl, profile.pubkey, [metaData]); - if (userData != null) { - return userData; - } - } - - return null; - } - - static Future _fetchInfoFromRelay( - String relayUrl, String userPubKey, List kinds) async { - try { - final relay = RelayApi(relayUrl: relayUrl); - final stream = await relay.connect(); - - relay.sub([ - Filter( - kinds: kinds, - authors: [userPubKey], - ) - ]); - - await for (var message in stream) { - if (message.type == 'EVENT') { - final event = message.message as Event; - - final eventContent = json.decode(event.content) as Map; - - final userMetadata = UserMetadata.fromJson(eventContent); - relay.close(); - return userMetadata; - } - } - - relay.close(); - return null; - } catch (e) { - printV('[!] Error with relay $relayUrl: $e'); - return null; - } - } - - static Future _showErrorDialog( - BuildContext context, String title, String errorMessage) async { - if (context.mounted) { - await showPopUp( - context: context, - builder: (BuildContext dialogContext) { - return AlertWithOneAction( - alertTitle: title, - alertContent: errorMessage, - buttonText: S.of(dialogContext).ok, - buttonAction: () => Navigator.of(dialogContext).pop(), - ); - }, - ); - } - } - - static String _extractDomain(String nip05Address) { - var parts = nip05Address.split('@'); - return parts.length == 2 ? parts[1] : ''; - } - - static String _getDomainFromRelayUrl(String relayUrl) { - try { - var uri = Uri.parse(relayUrl); - return uri.host; - } catch (e) { - printV('Error parsing URL: $e'); - return ''; - } - } - - static Future _showRelayChoiceDialog( - BuildContext context, - List relays, - ) async { - String? selectedRelay; - - if (context.mounted) { - await showPopUp( - context: context, - builder: (BuildContext dialogContext) { - return Picker( - selectedAtIndex: 0, - title: S.of(dialogContext).choose_relay, - items: relays, - onItemSelected: (String relay) => selectedRelay = relay, - ); - }, - ); - } - - return selectedRelay; - } -} diff --git a/lib/router.dart b/lib/router.dart index 638329ae8..bd1d5ad0f 100644 --- a/lib/router.dart +++ b/lib/router.dart @@ -679,7 +679,7 @@ Route createRoute(RouteSettings settings) { return MaterialPageRoute(builder: (_) => getIt.get()); case Routes.tradeDetails: - return MaterialPageRoute( + return CupertinoPageRoute( fullscreenDialog: true, builder: (_) => getIt.get(param1: settings.arguments as Trade)); diff --git a/lib/solana/cw_solana.dart b/lib/solana/cw_solana.dart index 569a603e2..83c06f9d9 100644 --- a/lib/solana/cw_solana.dart +++ b/lib/solana/cw_solana.dart @@ -387,9 +387,11 @@ class CWSolana extends Solana { tokenChecks.add((() async { final token = item.token; + final isPropertiesSuspicious = wallet.isTokenPropertiesSuspicious(token); + final fiatResult = await _getTokenUsdValueAndFiatCheck(token, item.balance); - final isSpam = !fiatResult.hasValidFiatPrice; + final isSpam = isPropertiesSuspicious || !fiatResult.hasValidFiatPrice; token.isPotentialScam = isSpam; token.enabled = (fiatResult.usdValue >= _minTokenUsdValue) && !isSpam; diff --git a/lib/src/screens/buy/buy_sell_page.dart b/lib/src/screens/buy/buy_sell_page.dart index 9c05309c6..74249be12 100644 --- a/lib/src/screens/buy/buy_sell_page.dart +++ b/lib/src/screens/buy/buy_sell_page.dart @@ -1,7 +1,7 @@ import 'package:cake_wallet/buy/sell_buy_states.dart'; +import 'package:cake_wallet/core/address_resolver/address_resolver_service.dart'; import 'package:cake_wallet/core/address_validator.dart'; import 'package:cake_wallet/di.dart'; -import 'package:cake_wallet/entities/parse_address_from_domain.dart'; import 'package:cake_wallet/generated/i18n.dart'; import 'package:cake_wallet/new-ui/widgets/buy_sell/buy_sell_selector_modal.dart'; import 'package:cake_wallet/new-ui/widgets/currency_picker/currency_picker_args.dart'; @@ -17,7 +17,6 @@ import 'package:cake_wallet/src/widgets/primary_button.dart'; import 'package:cake_wallet/src/widgets/provider_optoin_tile.dart'; import 'package:cake_wallet/src/widgets/scrollable_with_bottom_section.dart'; import 'package:cake_wallet/src/widgets/trail_button.dart'; -import 'package:cake_wallet/src/screens/send/widgets/extract_address_from_parsed.dart'; import 'package:cake_wallet/utils/responsive_layout_util.dart'; import 'package:cake_wallet/view_model/buy/buy_sell_view_model.dart'; import 'package:cw_core/crypto_currency.dart'; @@ -29,9 +28,10 @@ import 'package:keyboard_actions/keyboard_actions.dart'; import 'package:mobx/mobx.dart'; class BuySellPage extends BasePage { - BuySellPage(this.buySellViewModel); + BuySellPage(this.buySellViewModel, this._resolver); final BuySellViewModel buySellViewModel; + final AddressResolverService _resolver; final cryptoCurrencyKey = GlobalKey(); final fiatCurrencyKey = GlobalKey(); final _formKey = GlobalKey(); @@ -301,7 +301,7 @@ class BuySellPage extends BasePage { }); reaction((_) => buySellViewModel.cryptoCurrencyAddress, (String address) { - if (cryptoAddressController != address) { + if (cryptoAddressController.text != address) { cryptoCurrencyKey.currentState!.addressController.text = address; } }); @@ -321,8 +321,10 @@ class BuySellPage extends BasePage { _cryptoAddressFocus.addListener(() async { if (!_cryptoAddressFocus.hasFocus && cryptoAddressController.text.isNotEmpty) { final domain = cryptoAddressController.text; - buySellViewModel.cryptoCurrencyAddress = await fetchParsedAddress( - context, domain, buySellViewModel.cryptoCurrency); + final parsed = await fetchParsedAddress(context, domain, buySellViewModel.cryptoCurrency); + if (parsed.isNotEmpty) { + buySellViewModel.cryptoCurrencyAddress = parsed; + } } }); @@ -502,9 +504,10 @@ class BuySellPage extends BasePage { String domain, CryptoCurrency currency, ) async { - final parsedAddress = - await getIt.get().resolve(context, domain, currency); - final address = await extractAddressFromParsed(context, parsedAddress); - return address; + final parsedAddresses = await _resolver.resolve( + query: domain, wallet: buySellViewModel.wallet, currency: currency); + return parsedAddresses.isNotEmpty + ? parsedAddresses.first.parsedAddressByCurrencyMap[currency] ?? '' + : ''; } } diff --git a/lib/src/screens/connect_device/connect_device_page.dart b/lib/src/screens/connect_device/connect_device_page.dart index 3e3bda042..6aeb36a25 100644 --- a/lib/src/screens/connect_device/connect_device_page.dart +++ b/lib/src/screens/connect_device/connect_device_page.dart @@ -149,8 +149,8 @@ class ConnectDevicePageBodyState extends State { bleDevices.add(device); if (longWait) longWait = false; })) - ..onError((e) { - throw e.toString(); + ..onError((Object e) { + printV(e); }); _bleRefreshTimer?.cancel(); _bleRefreshTimer = null; diff --git a/lib/src/screens/contact/contact_list_page.dart b/lib/src/screens/contact/contact_list_page.dart index 07cb5625e..e60140bd5 100644 --- a/lib/src/screens/contact/contact_list_page.dart +++ b/lib/src/screens/contact/contact_list_page.dart @@ -340,6 +340,7 @@ class _ContactListBodyState extends State { updateFunction: widget.contactListViewModel.reorderAccordingToContactList, canReorder: widget.contactListViewModel.isEditable, shrinkWrap: true, + padding: const EdgeInsets.only(bottom: 250), itemBuilder: (context, index) { final contact = contacts[index]; final contactContent = diff --git a/lib/src/screens/contact/contact_page.dart b/lib/src/screens/contact/contact_page.dart index d5ca5fc0e..b4ea348f2 100644 --- a/lib/src/screens/contact/contact_page.dart +++ b/lib/src/screens/contact/contact_page.dart @@ -1,3 +1,4 @@ +import 'package:cake_wallet/core/address_resolver/parsed_address.dart'; import 'package:cake_wallet/core/address_validator.dart'; import 'package:cake_wallet/new-ui/widgets/send_page/send_address_input.dart'; import 'package:cake_wallet/utils/show_pop_up.dart'; @@ -27,7 +28,15 @@ class ContactPage extends BasePage { _nameController.text = contactViewModel.name; _addressController.text = contactViewModel.address; _nameController.addListener(() => contactViewModel.name = _nameController.text); - _addressController.addListener(() => contactViewModel.address = _addressController.text); + _addressController.addListener(() { + final address = _addressController.text; + + if (address != contactViewModel.address) { + contactViewModel.displayName = ''; + } + + contactViewModel.address = address; + }); autorun((_) => _currencyTypeController.text = "${contactViewModel.currency?.toString() ?? ""} ${contactViewModel.currency?.tag != null ? "(${contactViewModel.currency?.tag})" : ""}"); } @@ -98,7 +107,8 @@ class ContactPage extends BasePage { focusNode: addressFocusNode, displayName: contactViewModel.displayName, addressController: _addressController, - onEditingComplete: ()=> contactViewModel.extractParsedAddress(context), + onEditingComplete: () {}, + onPushPasteButton: (context) => _extractParsedAddress(context), validator: AddressValidator(type: contactViewModel.currency!), selectedCurrency: contactViewModel.currency!, ); @@ -129,8 +139,7 @@ class ContactPage extends BasePage { builder: (_) => PrimaryButton( onPressed: () async { FocusScope.of(context).unfocus(); - - await contactViewModel.extractParsedAddress(context); + _extractParsedAddress(context); if (_formKey.currentState != null && !_formKey.currentState!.validate()) { return; @@ -150,6 +159,16 @@ class ContactPage extends BasePage { ); } + Future _extractParsedAddress(BuildContext context) async { + final parsedAddress = await contactViewModel.extractParsedAddress(context); + if (parsedAddress == null) return; + + final confirmed = await showParsedAddressConfirmationAlert(context, parsedAddress); + if (!confirmed) return; + + contactViewModel.applyParsedAddress(parsedAddress); + } + void _presentCurrencyPicker(BuildContext context) { showPopUp( builder: (_) => CurrencyPicker( @@ -203,4 +222,29 @@ class ContactPage extends BasePage { } }); } + + Future showParsedAddressConfirmationAlert( + BuildContext context, + ParsedAddress parsedAddress, + ) async { + final confirmed = await showPopUp( + context: context, + builder: (BuildContext context) { + return AlertWithOneAction( + alertTitle: S.of(context).address_detected, + headerTitleText: parsedAddress.profileName.isEmpty ? null : parsedAddress.profileName, + headerImageProfileUrl: parsedAddress.profileImageUrl.isEmpty + ? parsedAddress.addressSource.iconPath + : parsedAddress.profileImageUrl, + alertContent: S.of(context).extracted_address_content( + '${parsedAddress.handle} (${parsedAddress.addressSource.label})', + ), + buttonText: S.of(context).ok, + buttonAction: () => Navigator.of(context).pop(true), + ); + }, + ); + + return confirmed ?? false; + } } diff --git a/lib/src/screens/dashboard/pages/cake_features_page.dart b/lib/src/screens/dashboard/pages/cake_features_page.dart index b74ee87df..1ace58a55 100644 --- a/lib/src/screens/dashboard/pages/cake_features_page.dart +++ b/lib/src/screens/dashboard/pages/cake_features_page.dart @@ -148,6 +148,15 @@ class CakeFeaturesPage extends StatelessWidget { subTitle: "Turn your old phone into your new hardware wallet with our new app", image: 'assets/images/cupcake.png', ), + AppsWidget( + isWide: true, + isLink: true, + isCake: true, + onTap: () => _launchUrl("radar.chat"), + title: "Radar", + subTitle: "Your messages. Your Bitcoin. Together, at last", + image: 'assets/images/radar.png', + ), const SizedBox(height: 12), Padding( padding: const EdgeInsets.only(left: 24, top: 16, bottom: 8), diff --git a/lib/src/screens/dashboard/widgets/header_row.dart b/lib/src/screens/dashboard/widgets/header_row.dart index 5b89c0e8f..c8d7c86c7 100644 --- a/lib/src/screens/dashboard/widgets/header_row.dart +++ b/lib/src/screens/dashboard/widgets/header_row.dart @@ -62,11 +62,6 @@ class HeaderRow extends StatelessWidget { child: GestureDetector( key: ValueKey('transactions_page_header_row_transaction_filter_button_key'), onTap: () { - showPopUp( - context: context, - builder: (context) => - FilterWidget(filterItems: dashboardViewModel.filterItems), - ); }, child: Semantics( label: 'Transaction Filter', diff --git a/lib/src/screens/exchange/exchange_page.dart b/lib/src/screens/exchange/exchange_page.dart index 40fa6c5cc..1dc2fe94f 100644 --- a/lib/src/screens/exchange/exchange_page.dart +++ b/lib/src/screens/exchange/exchange_page.dart @@ -1,3 +1,4 @@ +import 'package:cake_wallet/core/address_resolver/address_resolver_service.dart'; import 'package:cake_wallet/exchange/exchange_provider_description.dart'; import 'package:cake_wallet/exchange/provider/chainflip_exchange_provider.dart'; import 'package:cake_wallet/exchange/provider/thorchain_exchange.provider.dart'; @@ -11,8 +12,6 @@ import 'package:cake_wallet/utils/payment_request.dart'; import 'package:cake_wallet/utils/responsive_layout_util.dart'; import 'package:cw_core/sync_status.dart'; import 'package:cw_core/utils/print_verbose.dart'; -import 'package:cake_wallet/entities/parse_address_from_domain.dart'; -import 'package:cake_wallet/src/screens/send/widgets/extract_address_from_parsed.dart'; import 'package:cake_wallet/src/widgets/standard_checkbox.dart'; import 'package:flutter/material.dart'; import 'package:flutter_mobx/flutter_mobx.dart'; @@ -41,7 +40,7 @@ import 'package:cake_wallet/src/screens/exchange/widgets/present_provider_picker import 'package:cake_wallet/src/screens/dashboard/widgets/sync_indicator_icon.dart'; class ExchangePage extends BasePage { - ExchangePage(this.exchangeViewModel, this.authService, this.initialPaymentRequest) { + ExchangePage(this.exchangeViewModel, this.authService, this.adrResService, this.initialPaymentRequest) { depositWalletName = exchangeViewModel.depositCurrency == CryptoCurrency.xmr ? exchangeViewModel.wallet.name : null; @@ -52,6 +51,7 @@ class ExchangePage extends BasePage { final ExchangeViewModel exchangeViewModel; final AuthService authService; + final AddressResolverService adrResService; final PaymentRequest? initialPaymentRequest; final depositKey = GlobalKey(); final receiveKey = GlobalKey(); @@ -655,8 +655,8 @@ class ExchangePage extends BasePage { Future fetchParsedAddress( BuildContext context, String domain, CryptoCurrency currency) async { - final parsedAddress = await getIt.get().resolve(context, domain, currency); - return extractAddressFromParsed(context, parsedAddress); + final parsedAddresses = await adrResService.resolve(query: domain, wallet:exchangeViewModel.wallet, currency: currency); + return parsedAddresses.isNotEmpty ? parsedAddresses.first.parsedAddressByCurrencyMap[currency] ?? '' : ''; } void _showFeeAlert(BuildContext context) async { diff --git a/lib/src/screens/new_wallet/new_wallet_type_page.dart b/lib/src/screens/new_wallet/new_wallet_type_page.dart index 2c44370ed..076ff48f8 100644 --- a/lib/src/screens/new_wallet/new_wallet_type_page.dart +++ b/lib/src/screens/new_wallet/new_wallet_type_page.dart @@ -139,68 +139,71 @@ class WalletTypeFormState extends State { child: SingleChildScrollView( child: Padding( padding: const EdgeInsets.only(left: 18, right: 18, bottom: 12), - child: Container( - decoration: BoxDecoration( - color: Theme.of(context).colorScheme.surfaceContainer, - borderRadius: BorderRadius.circular(18), - ), - child: ListView.separated( - key: const ValueKey('new_wallet_type_scrollable_key'), - itemCount: filteredTypes.length, - shrinkWrap: true, - physics: NeverScrollableScrollPhysics(), - padding: EdgeInsets.zero, - itemBuilder: (context, index) { - final item = filteredTypes[index]; - - return GestureDetector( - key: ValueKey('new_wallet_type_${item.name}_button_key'), - behavior: HitTestBehavior.opaque, - onTap: () { - onTypeSelected(item); - }, - child: SizedBox( - height: 48, - child: Padding( - padding: const EdgeInsets.symmetric(horizontal: 12.0), - child: Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - Row( - children: [ - CakeImageWidget( - height: 24, - width: 24, - imageUrl: getCryptoCurrencyIconForWalletListItem(item), - ), - const SizedBox(width: 12), - Text(walletTypeToDisplayName(item)), - const SizedBox(width: 4), - Text( - walletTypeToDisplayTicker(item), - style: TextStyle( - color: Theme.of(context).colorScheme.onSurfaceVariant, + child: ClipRRect( + borderRadius: BorderRadius.circular(18), + child: Container( + decoration: BoxDecoration( + color: Theme.of(context).colorScheme.surfaceContainer, + borderRadius: BorderRadius.circular(18), + ), + child: ListView.separated( + key: const ValueKey('new_wallet_type_scrollable_key'), + itemCount: filteredTypes.length, + shrinkWrap: true, + physics: NeverScrollableScrollPhysics(), + padding: EdgeInsets.zero, + itemBuilder: (context, index) { + final item = filteredTypes[index]; + + return GestureDetector( + key: ValueKey('new_wallet_type_${item.name}_button_key'), + behavior: HitTestBehavior.opaque, + onTap: () { + onTypeSelected(item); + }, + child: SizedBox( + height: 50, + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 12.0), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Row( + children: [ + CakeImageWidget( + height: 24, + width: 24, + imageUrl: getCryptoCurrencyIconForWalletListItem(item), ), - ) - ], - ), - CakeImageWidget( - imageUrl: "assets/new-ui/arrow_forward.svg", - height: 16, - colorFilter: ColorFilter.mode( - Theme.of(context).colorScheme.onSurfaceVariant, - BlendMode.srcIn, + const SizedBox(width: 12), + Text(walletTypeToDisplayName(item)), + const SizedBox(width: 4), + Text( + walletTypeToDisplayTicker(item), + style: TextStyle( + color: Theme.of(context).colorScheme.onSurfaceVariant, + ), + ) + ], ), - ) - ], + CakeImageWidget( + imageUrl: "assets/new-ui/arrow_forward.svg", + height: 16, + colorFilter: ColorFilter.mode( + Theme.of(context).colorScheme.onSurfaceVariant, + BlendMode.srcIn, + ), + ) + ], + ), ), ), - ), - ); - }, - separatorBuilder: (context, index) => Container( - height: 1, - color: Theme.of(context).colorScheme.surfaceContainerHigh, + ); + }, + separatorBuilder: (context, index) => Container( + height: 1, + color: Theme.of(context).colorScheme.surfaceContainerHigh, + ), ), ), ), diff --git a/lib/src/screens/send/send_page.dart b/lib/src/screens/send/send_page.dart index d886963b2..a72aa79ae 100644 --- a/lib/src/screens/send/send_page.dart +++ b/lib/src/screens/send/send_page.dart @@ -780,7 +780,6 @@ class SendPage extends BasePage { } output.resetParsedAddress(); - await output.fetchParsedAddress(context); } Output _defineCurrentOutput() { diff --git a/lib/src/screens/send/widgets/extract_address_from_parsed.dart b/lib/src/screens/send/widgets/extract_address_from_parsed.dart deleted file mode 100644 index e2564d3f2..000000000 --- a/lib/src/screens/send/widgets/extract_address_from_parsed.dart +++ /dev/null @@ -1,150 +0,0 @@ -import 'package:cake_wallet/entities/parsed_address.dart'; -import 'package:cake_wallet/src/widgets/alert_with_one_action.dart'; -import 'package:cake_wallet/utils/show_pop_up.dart'; -import 'package:flutter/material.dart'; -import 'package:cake_wallet/generated/i18n.dart'; -import 'choose_yat_address_alert.dart'; - -Future extractAddressFromParsed( - BuildContext? context, - ParsedAddress parsedAddress) async { - if (context == null || !context.mounted) return parsedAddress.addresses.first; - - var title = ''; - var content = ''; - var address = ''; - var profileImageUrl = ''; - var profileName = ''; - - switch (parsedAddress.parseFrom) { - case ParseFrom.unstoppableDomains: - title = S.of(context).address_detected; - content = S.of(context).address_from_domain(parsedAddress.name); - address = parsedAddress.addresses.first; - break; - case ParseFrom.ens: - title = S.of(context).address_detected; - content = S.of(context).extracted_address_content('${parsedAddress.name} (ENS)'); - address = parsedAddress.addresses.first; - break; - case ParseFrom.openAlias: - title = S.of(context).address_detected; - content = S.of(context).extracted_address_content('${parsedAddress.name} (OpenAlias)'); - address = parsedAddress.addresses.first; - break; - case ParseFrom.wellKnown: - title = S.of(context).address_detected; - content = S.of(context).extracted_address_content('${parsedAddress.name} (Well-Known)'); - address = parsedAddress.addresses.first; - break; - case ParseFrom.fio: - title = S.of(context).address_detected; - content = S.of(context).extracted_address_content('${parsedAddress.name} (FIO)'); - address = parsedAddress.addresses.first; - break; - case ParseFrom.twitter: - 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; - 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.thorChain: - title = S.of(context).address_detected; - content = S.of(context).extracted_address_content('${parsedAddress.name} (ThorChain)'); - address = parsedAddress.addresses.first; - break; - case ParseFrom.zanoAlias: - title = S.of(context).address_detected; - content = S.of(context).extracted_address_content('${parsedAddress.name} (Zano Alias)'); - address = parsedAddress.addresses.first; - break; - case ParseFrom.zcashAddress: - title = S.of(context).address_detected; - content = S.of(context).extracted_address_content('${parsedAddress.name} (Zcash.me)'); - address = parsedAddress.addresses.first; - break; - case ParseFrom.zcashName: - title = S.of(context).address_detected; - content = S.of(context).extracted_address_content('${parsedAddress.name} (Zcash Names)'); - address = parsedAddress.addresses.first; - break; - case ParseFrom.bip353: - title = S.of(context).address_detected; - content = S.of(context).extracted_address_content('${parsedAddress.name} (BIP-353)'); - address = parsedAddress.addresses.first; - break; - case ParseFrom.lnurlpay: - title = S.of(context).address_detected; - content = S.of(context).extracted_address_content('${parsedAddress.name} (Lightning)'); - address = parsedAddress.addresses.first; - break; - case ParseFrom.yatRecord: - if (parsedAddress.name.isEmpty) { - title = S.of(context).yat_error; - content = S.of(context).yat_error_content; - address = parsedAddress.addresses.first; - break; - } - - title = S.of(context).address_detected; - content = S.of(context).address_from_yat(parsedAddress.name); - - if (parsedAddress.addresses.length == 1) { - address = parsedAddress.addresses.first; - break; - } - - content += S.of(context).choose_address; - - address = await showPopUp( - context: context, - builder: (context) => PopScope( - child: ChooseYatAddressAlert( - alertTitle: title, - alertContent: content, - addresses: parsedAddress.addresses, - ), - canPop: false, - ), - ) ?? - ''; - - if (address.isEmpty) { - return parsedAddress.name; - } - - return address; - case ParseFrom.contact: - case ParseFrom.notParsed: - return parsedAddress.addresses.first; - } - - await showPopUp( - context: context, - builder: (context) => 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(), - ), - ); - - return address; -} diff --git a/lib/src/screens/send/widgets/send_card.dart b/lib/src/screens/send/widgets/send_card.dart index 0472c0e52..7e4b33835 100644 --- a/lib/src/screens/send/widgets/send_card.dart +++ b/lib/src/screens/send/widgets/send_card.dart @@ -594,7 +594,6 @@ class SendCardState extends State with AutomaticKeepAliveClientMixin with AutomaticKeepAliveClientMixin with AutomaticKeepAliveClientMixin with AutomaticKeepAliveClientMixin _connectionsSyncViewModel.setLookupsTwitter(value)), - SettingsSwitcherCell( - title: 'Mastodon', - value: _connectionsSyncViewModel.looksUpMastodon, - onValueChange: (_, bool value) => _connectionsSyncViewModel.setLookupsMastodon(value)), - SettingsSwitcherCell( - title: 'Yat service', - value: _connectionsSyncViewModel.looksUpYatService, - onValueChange: (_, bool value) => _connectionsSyncViewModel.setLookupsYatService(value)), - SettingsSwitcherCell( - title: 'Unstoppable Domains', - value: _connectionsSyncViewModel.looksUpUnstoppableDomains, - onValueChange: (_, bool value) => _connectionsSyncViewModel.setLookupsUnstoppableDomains(value)), - SettingsSwitcherCell( - title: 'OpenAlias', - value: _connectionsSyncViewModel.looksUpOpenAlias, - onValueChange: (_, bool value) => _connectionsSyncViewModel.setLookupsOpenAlias(value)), - SettingsSwitcherCell( - title: 'Ethereum Name Service', - value: _connectionsSyncViewModel.looksUpENS, - onValueChange: (_, bool value) => _connectionsSyncViewModel.setLookupsENS(value)), - SettingsSwitcherCell( - title: 'Zcash Names', - value: _connectionsSyncViewModel.lookupsZcashNames, - onValueChange: (_, bool value) => - _connectionsSyncViewModel.setLookupsZcashNames(value)), - SettingsSwitcherCell( - title: '.well-known', - value: _connectionsSyncViewModel.looksUpWellKnown, - onValueChange: (_, bool value) => _connectionsSyncViewModel.setLookupsWellKnown(value)), - SettingsSwitcherCell( - title: 'Zano Aliases', - value: _connectionsSyncViewModel.lookupsZanoAlias, - onValueChange: (_, bool value) => _connectionsSyncViewModel.setLookupsZanoAlias(value)), - - //if (!isHaven) it does not work correctly - ], + children: _connectionsSyncViewModel.domainLookupSources + .map( + (source) => SettingsSwitcherCell( + title: source.label, + leading: source.iconPath.isNotEmpty ? CakeImageWidget( + imageUrl: source.iconPath, + width: 24, + height: 24, + ) : SizedBox(width: 24, height: 24), + value: _connectionsSyncViewModel.lookupValue(source), + onValueChange: (_, bool value) => + _connectionsSyncViewModel.setLookupValue(source, value), + ), + ) + .toList(), ), ); }), diff --git a/lib/src/screens/wallet_list/filtered_list.dart b/lib/src/screens/wallet_list/filtered_list.dart index 86368210c..5ec27cec5 100644 --- a/lib/src/screens/wallet_list/filtered_list.dart +++ b/lib/src/screens/wallet_list/filtered_list.dart @@ -10,6 +10,7 @@ class FilteredList extends StatefulWidget { this.canReorder = true, this.shrinkWrap = false, this.physics, + this.padding, }); final ObservableList list; @@ -18,6 +19,7 @@ class FilteredList extends StatefulWidget { final bool canReorder; final bool shrinkWrap; final ScrollPhysics? physics; + final EdgeInsets? padding; @override FilteredListState createState() => FilteredListState(); @@ -48,6 +50,7 @@ class FilteredListState extends State { ), shrinkWrap: widget.shrinkWrap, physics: widget.physics ?? const BouncingScrollPhysics(), + padding: widget.padding, itemBuilder: widget.itemBuilder, itemCount: widget.list.length, onReorder: (int oldIndex, int newIndex) { diff --git a/lib/src/screens/yat/yat_popup.dart b/lib/src/screens/yat/yat_popup.dart index 33c94a463..bcf17a4f1 100644 --- a/lib/src/screens/yat/yat_popup.dart +++ b/lib/src/screens/yat/yat_popup.dart @@ -1,10 +1,10 @@ +import 'package:cake_wallet/core/address_resolver/yat/yat_store.dart'; import 'package:cake_wallet/src/screens/yat/widgets/first_introduction.dart'; import 'package:cake_wallet/src/screens/yat/widgets/second_introduction.dart'; import 'package:cake_wallet/src/screens/yat/widgets/third_introduction.dart'; import 'package:cake_wallet/src/screens/yat/widgets/yat_close_button.dart'; import 'package:cake_wallet/src/widgets/alert_background.dart'; import 'package:cake_wallet/src/widgets/primary_button.dart'; -import 'package:cake_wallet/store/yat/yat_store.dart'; import 'package:cake_wallet/view_model/dashboard/dashboard_view_model.dart'; import 'package:flutter/material.dart'; import 'package:animate_do/animate_do.dart'; diff --git a/lib/src/widgets/base_alert_dialog.dart b/lib/src/widgets/base_alert_dialog.dart index e98ae1c07..25c08ff6a 100644 --- a/lib/src/widgets/base_alert_dialog.dart +++ b/lib/src/widgets/base_alert_dialog.dart @@ -1,6 +1,7 @@ import 'dart:ui'; import 'package:auto_size_text/auto_size_text.dart'; +import 'package:cake_wallet/src/widgets/cake_image_widget.dart'; import 'package:cake_wallet/src/widgets/section_divider.dart'; import 'package:flutter/material.dart'; @@ -184,10 +185,7 @@ class BaseAlertDialog extends StatelessWidget { radius: 50, backgroundColor: Theme.of(context).colorScheme.surfaceContainerHighest, child: ClipOval( - child: Image.network( - imageUrl, - fit: BoxFit.cover, - ), + child: CakeImageWidget (imageUrl: imageUrl, width: 100, height: 100, fit: BoxFit.cover), ), ), ); diff --git a/lib/src/widgets/bottom_sheet/confirm_sending_bottom_sheet_widget.dart b/lib/src/widgets/bottom_sheet/confirm_sending_bottom_sheet_widget.dart index 140cfa970..965365285 100644 --- a/lib/src/widgets/bottom_sheet/confirm_sending_bottom_sheet_widget.dart +++ b/lib/src/widgets/bottom_sheet/confirm_sending_bottom_sheet_widget.dart @@ -163,7 +163,7 @@ class ConfirmSendingBottomSheet extends BaseBottomSheet { itemBuilder: (context, index) { final isBatchSending = outputs.length > 1; final item = outputs[index]; - final contactName = item.parsedAddress.name; + final contactName = item.parsedAddress.profileName; final isCakePayName = contactName == 'Cake Pay'; final batchContactTitle = '${index + 1}/${outputs.length} - ${contactName.isEmpty ? 'Address' : contactName}'; diff --git a/lib/src/widgets/new_list_row/list_item_checkbox_widget.dart b/lib/src/widgets/new_list_row/list_item_checkbox_widget.dart index 823328723..a2a75d552 100644 --- a/lib/src/widgets/new_list_row/list_item_checkbox_widget.dart +++ b/lib/src/widgets/new_list_row/list_item_checkbox_widget.dart @@ -11,6 +11,7 @@ class ListItemCheckboxWidget extends StatefulWidget { required this.label, required this.value, required this.onChanged, + this.subtitleColor, this.onTap, this.isFirstInSection = false, this.isLastInSection = false, this.subtitle, this.iconPath, this.showArrow = false, @@ -22,6 +23,7 @@ class ListItemCheckboxWidget extends StatefulWidget { final String? iconPath; final bool showArrow; final bool value; + final Color? subtitleColor; final VoidCallback? onTap; final ValueChanged onChanged; final bool isFirstInSection; @@ -81,7 +83,7 @@ class _ListItemCheckboxWidgetState extends State { Text( widget.subtitle!, style: TextStyle( - fontSize: 12, color: Theme.of(context).colorScheme.onSurfaceVariant), + fontSize: 12, color: widget.subtitleColor ?? Theme.of(context).colorScheme.onSurfaceVariant), ) ], ), diff --git a/lib/src/widgets/new_list_row/new_list_section.dart b/lib/src/widgets/new_list_row/new_list_section.dart index e5097e0a7..7b65f690a 100644 --- a/lib/src/widgets/new_list_row/new_list_section.dart +++ b/lib/src/widgets/new_list_row/new_list_section.dart @@ -135,6 +135,7 @@ class NewListSections extends StatelessWidget { label: item.label, subtitle: item.subtitle, iconPath: item.iconPath, + subtitleColor: item.subtitleColor, showArrow: item.showArrow, onTap: item.onTap, value: item.value, diff --git a/lib/store/dashboard/trade_filter_store.dart b/lib/store/dashboard/trade_filter_store.dart index 345a37b6c..57e3149ee 100644 --- a/lib/store/dashboard/trade_filter_store.dart +++ b/lib/store/dashboard/trade_filter_store.dart @@ -69,6 +69,23 @@ abstract class TradeFilterStoreBase with Store { @observable bool displayNearIntents; + @computed + int get enabledProvidersCount => [ + displayChangeNow, + displaySideShift, + displaySimpleSwap, + displayTrocador, + displayExolix, + displayChainflip, + displayThorChain, + displayLetsExchange, + displayStealthEx, + displayXOSwap, + displaySwapTrade, + displaySwapXyz, + displayNearIntents + ].where((item) => item).length; + @computed bool get displayAllTrades => displayChangeNow && diff --git a/lib/store/settings_store.dart b/lib/store/settings_store.dart index 732ca03c9..b06e2acb6 100644 --- a/lib/store/settings_store.dart +++ b/lib/store/settings_store.dart @@ -132,7 +132,13 @@ abstract class SettingsStoreBase with Store { required this.lookupsOpenAlias, required this.lookupsENS, required this.lookupsZcashNames, + required this.lookupsZcashAddress, required this.lookupsWellKnown, + required this.lookupsFio, + required this.lookupsNostr, + required this.lookupsThorChain, + required this.lookupsBip353, + required this.lookupsLNUrl, required this.usePayjoin, required this.showPayjoinCard, required this.customBitcoinFeeRate, @@ -567,11 +573,41 @@ abstract class SettingsStoreBase with Store { (bool looksUpZcashNames) => _sharedPreferences.setBool( PreferencesKey.lookupsZcashNames, looksUpZcashNames)); + reaction( + (_) => lookupsZcashAddress, + (bool lookupsZcashAddress) => _sharedPreferences.setBool( + PreferencesKey.lookupsZcashAddress, lookupsZcashAddress)); + reaction( (_) => lookupsWellKnown, (bool looksUpWellKnown) => _sharedPreferences.setBool(PreferencesKey.lookupsWellKnown, looksUpWellKnown)); + reaction( + (_) => lookupsFio, + (bool lookupsFio) => + _sharedPreferences.setBool(PreferencesKey.lookupsFio, lookupsFio)); + + reaction( + (_) => lookupsNostr, + (bool lookupsNostr) => + _sharedPreferences.setBool(PreferencesKey.lookupsNostr, lookupsNostr)); + + reaction( + (_) => lookupsThorChain, + (bool lookupsThorChain) => + _sharedPreferences.setBool(PreferencesKey.lookupsThorChain, lookupsThorChain)); + + reaction( + (_) => lookupsBip353, + (bool lookupsBip353) => + _sharedPreferences.setBool(PreferencesKey.lookupsBip353, lookupsBip353)); + + reaction( + (_) => lookupsLNUrl, + (bool lookupsLNUrl) => + _sharedPreferences.setBool(PreferencesKey.lookupsLNUrl, lookupsLNUrl)); + reaction((_) => usePayjoin, (bool usePayjoin) => _sharedPreferences.setBool(PreferencesKey.usePayjoin, usePayjoin)); @@ -962,9 +998,27 @@ abstract class SettingsStoreBase with Store { @observable bool lookupsZcashNames; + @observable + bool lookupsZcashAddress; + @observable bool lookupsWellKnown; + @observable + bool lookupsFio; + + @observable + bool lookupsNostr; + + @observable + bool lookupsThorChain; + + @observable + bool lookupsBip353; + + @observable + bool lookupsLNUrl; + @observable bool usePayjoin; @@ -1268,9 +1322,14 @@ abstract class SettingsStoreBase with Store { sharedPreferences.getBool(PreferencesKey.lookupsUnstoppableDomains) ?? true; final lookupsOpenAlias = sharedPreferences.getBool(PreferencesKey.lookupsOpenAlias) ?? true; final lookupsENS = sharedPreferences.getBool(PreferencesKey.lookupsENS) ?? true; - final lookupsZcashNames = - sharedPreferences.getBool(PreferencesKey.lookupsZcashNames) ?? true; + final lookupsZcashNames = sharedPreferences.getBool(PreferencesKey.lookupsZcashNames) ?? true; + final lookupsZcashAddress = sharedPreferences.getBool(PreferencesKey.lookupsZcashAddress) ?? true; final lookupsWellKnown = sharedPreferences.getBool(PreferencesKey.lookupsWellKnown) ?? true; + final lookupsFio = sharedPreferences.getBool(PreferencesKey.lookupsFio) ?? true; + final lookupsNostr = sharedPreferences.getBool(PreferencesKey.lookupsNostr) ?? true; + final lookupsThorChain = sharedPreferences.getBool(PreferencesKey.lookupsThorChain) ?? false; + final lookupsBip353 = sharedPreferences.getBool(PreferencesKey.lookupsBip353) ?? true; + final lookupsLNUrl = sharedPreferences.getBool(PreferencesKey.lookupsLNUrl) ?? true; final usePayjoin = sharedPreferences.getBool(PreferencesKey.usePayjoin) ?? false; final showPayjoinCard = sharedPreferences.getBool(PreferencesKey.showPayjoinCard) ?? true; final customBitcoinFeeRate = sharedPreferences.getInt(PreferencesKey.customBitcoinFeeRate) ?? 1; @@ -1659,7 +1718,13 @@ abstract class SettingsStoreBase with Store { lookupsOpenAlias: lookupsOpenAlias, lookupsENS: lookupsENS, lookupsZcashNames: lookupsZcashNames, + lookupsZcashAddress: lookupsZcashAddress, lookupsWellKnown: lookupsWellKnown, + lookupsFio: lookupsFio, + lookupsNostr: lookupsNostr, + lookupsThorChain: lookupsThorChain, + lookupsBip353: lookupsBip353, + lookupsLNUrl: lookupsLNUrl, usePayjoin: usePayjoin, showPayjoinCard: showPayjoinCard, customBitcoinFeeRate: customBitcoinFeeRate, diff --git a/lib/store/yat/yat_exception.dart b/lib/store/yat/yat_exception.dart deleted file mode 100644 index 71313ba88..000000000 --- a/lib/store/yat/yat_exception.dart +++ /dev/null @@ -1,10 +0,0 @@ -import 'package:flutter/foundation.dart'; - -class YatException implements Exception { - YatException({required this.text}); - - final String text; - - @override - String toString() => 'Yat exception: $text'; -} \ No newline at end of file diff --git a/lib/utils/clipboard_util.dart b/lib/utils/clipboard_util.dart index a09200f27..c6925e45c 100644 --- a/lib/utils/clipboard_util.dart +++ b/lib/utils/clipboard_util.dart @@ -1,6 +1,8 @@ -import 'package:flutter/services.dart'; - +import 'package:cake_wallet/generated/i18n.dart'; import 'package:cake_wallet/utils/device_info.dart'; +import 'package:cake_wallet/utils/show_bar.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; import 'package:sensitive_clipboard/sensitive_clipboard.dart'; class ClipboardUtil { @@ -12,4 +14,18 @@ class ClipboardUtil { return Clipboard.setData(data); } + + /// Copies [text] to the clipboard and shows a "Copied to Clipboard" + /// confirmation toast. Prefer this for any user-facing copy action so the + /// feedback stays consistent across the app. + static Future copyToClipboard( + BuildContext context, + String text, { + bool isSensitive = false, + }) async { + await setSensitiveDataToClipboard(ClipboardData(text: text), isSensitive: isSensitive); + if (context.mounted) { + showBar(context, S.of(context).copied_to_clipboard); + } + } } diff --git a/lib/utils/decimal_input_formatter.dart b/lib/utils/decimal_input_formatter.dart index 18a2b5586..b45a554e8 100644 --- a/lib/utils/decimal_input_formatter.dart +++ b/lib/utils/decimal_input_formatter.dart @@ -11,7 +11,12 @@ class DecimalInputFormatter extends TextInputFormatter { final text = newValue.text; if (text.isEmpty) return newValue; - if (S.current.all.startsWith(text)) return TextEditingValue(text: ""); + if (S.current.all.startsWith(text)) { + return const TextEditingValue( + text: "", + selection: TextSelection.collapsed(offset: 0), + ); + } final regex = maxDecimals == 0 ? RegExp(r'^\d*$') : RegExp('^\\d*([.,]\\d{0,$maxDecimals})?\$'); return regex.hasMatch(text) ? newValue.copyWith(text: text.replaceAll(',', '.')) : oldValue; diff --git a/lib/utils/exception_handler.dart b/lib/utils/exception_handler.dart index fe9c0844a..faadb3e5e 100644 --- a/lib/utils/exception_handler.dart +++ b/lib/utils/exception_handler.dart @@ -258,6 +258,7 @@ class ExceptionHandler { "Connection timed out", "Connection reset by peer", "Connection closed before full header was received", + "Connection closed while receiving data", "Connection terminated during handshake", "OS Error: Connection refused, errno = 61", "PERMISSION_NOT_GRANTED", @@ -277,6 +278,7 @@ class ExceptionHandler { "invalid signature", "invalid password", "NetworkImage._loadAsync", + "Invalid image data", "SSLV3_ALERT_BAD_RECORD_MAC", "PlatformException(already_active, File picker is already active", // SVG-related errors @@ -296,17 +298,22 @@ class ExceptionHandler { "Wrong Device Status: 0x5515 (UNKNOWN)", "Command handling failed. With error: hostUnreachable", + // Android IME/Gboard occasionally reports a caret offset past the end of + // the text on the platform text-input channel while typing. Non-fatal + // framework<->platform desync; the app keeps working. + "invalid selection start", "FocusScopeNode was used after being disposed", "_getDismissibleFlushbar", "_QueuedFuture.execute (package:universal_ble/src/queue.dart:65)", + "Pending Request Canceled | RequestQueue disposed", "reown_core/relay_client/websocket/websocket_handler.dart", "Image upload failed due to loss of GPU access", "transport error", "SdkError.sparkError(field0: Operator RPC error: Connection error: status: Unavailable, message: \"dns error\", details: []", "the timeout of the request was reached", - "support for coin removed, your seedphrase:" - "Exception: Invalid image data" + "support for coin removed, your seedphrase:", + "Exception: Invalid image data", ]; static Future _addDeviceInfo(File file) async { @@ -441,8 +448,33 @@ class ExceptionHandler { } static bool _flutterErrorIgnore(FlutterErrorDetails errorDetails) { - // most probably a flutter context error so just ignore it if there is no stack we can debug with - return errorDetails.exception.toString().contains("Null check operator used on a null value") && - errorDetails.stack == null; + if (errorDetails.exception.toString().contains("Null check operator used on a null value")) { + // Most probably a flutter context error so just ignore it if there is no + // stack we can debug with. + if (errorDetails.stack == null) { + return true; + } + + final stack = errorDetails.stack.toString(); + if (stack.contains("handleFocusHighlightModeChange") || + stack.contains("_HighlightModeManager")) { + return true; + } + } + + if (errorDetails.exception.toString().contains("Cannot add event after closing")) { + // grpc-dart teardown race (e.g. the MWEB channel on litecoin): a buffered outgoing + // frame is delivered to the http2 stream's sink after the call/channel was + // terminated. The message alone is too generic to ignore, so require the exact + // shape of grpc's forwarding chain: .map().map().handleError().listen(sink.add). + final stack = errorDetails.stack.toString(); + if (stack.contains("_StreamSinkWrapper.add") && + stack.contains("_MapStream._handleData") && + stack.contains("_HandleErrorStream._handleData")) { + return true; + } + } + + return false; } } diff --git a/lib/view_model/contact_list/contact_view_model.dart b/lib/view_model/contact_list/contact_view_model.dart index c808f966c..d0d74b953 100644 --- a/lib/view_model/contact_list/contact_view_model.dart +++ b/lib/view_model/contact_list/contact_view_model.dart @@ -1,7 +1,10 @@ +import 'package:cake_wallet/core/address_resolver/address_resolver_service.dart'; +import 'package:cake_wallet/core/address_resolver/parsed_address.dart'; +import 'package:cake_wallet/core/address_validator.dart'; import 'package:cake_wallet/di.dart'; import 'package:cake_wallet/entities/contact_record.dart'; -import 'package:cake_wallet/entities/parse_address_from_domain.dart'; -import 'package:cake_wallet/src/screens/send/widgets/extract_address_from_parsed.dart'; +import 'package:cake_wallet/main.dart'; +import 'package:cake_wallet/store/app_store.dart'; import 'package:cw_core/utils/print_verbose.dart'; import 'package:flutter/material.dart'; import 'package:hive/hive.dart'; @@ -16,7 +19,8 @@ part 'contact_view_model.g.dart'; class ContactViewModel = ContactViewModelBase with _$ContactViewModel; abstract class ContactViewModelBase with Store { - ContactViewModelBase(this._contacts, {ContactRecord? contact}) + ContactViewModelBase(this._contacts, this.appStore,this.adrResService, + {ContactRecord? contact}) : state = InitialExecutionState(), currencies = CryptoCurrency.all, _contact = contact, @@ -26,6 +30,9 @@ abstract class ContactViewModelBase with Store { currency = contact?.type, lastChange = contact?.lastChange; + final AppStore appStore; + final AddressResolverService adrResService; + @observable ExecutionState state; @@ -58,16 +65,52 @@ abstract class ContactViewModelBase with Store { void reset() { address = ''; name = ''; + displayName = ''; currency = null; } - Future extractParsedAddress(BuildContext context) async{ - if(currency == null) return; - final parsedAddress = await getIt.get().resolve(context, address, currency!); - if(parsedAddress.name.isNotEmpty) { - displayName = parsedAddress.name; + Future extractParsedAddress(BuildContext context) async { + final wallet = appStore.wallet; + final currentCurrency = currency; + final query = address.trim(); + + if (wallet == null) return null; + if (currentCurrency == null) return null; + if (query.isEmpty) return null; + + + // Check if the address is valid for the current currency (except for Zano, which can use handles as addresses) + if (currentCurrency != CryptoCurrency.zano) { + final isValidAddress = AddressValidator(type: currentCurrency).isValid(query); + if (isValidAddress) return null; } - address = await extractAddressFromParsed(null, parsedAddress); + + final parsedAddresses = await adrResService.resolve( + query: query, + wallet: wallet, + currency: currentCurrency, + ); + + if (parsedAddresses.isEmpty) return null; + + final resolvedAddress = parsedAddresses.first.parsedAddressByCurrencyMap[currentCurrency]; + if (resolvedAddress == null || resolvedAddress.isEmpty) return null; + + return parsedAddresses.first; + } + + @action + void applyParsedAddress(ParsedAddress parsedAddress) { + final currentCurrency = currency; + if (currentCurrency == null) return; + + final resolvedAddress = parsedAddress.parsedAddressByCurrencyMap[currentCurrency]; + if (resolvedAddress == null || resolvedAddress.isEmpty) return; + + address = resolvedAddress; + displayName = parsedAddress.profileName.isNotEmpty + ? parsedAddress.profileName + : parsedAddress.handle; } Future save() async { diff --git a/lib/view_model/dashboard/dashboard_view_model.dart b/lib/view_model/dashboard/dashboard_view_model.dart index d97d119a4..77c59b762 100644 --- a/lib/view_model/dashboard/dashboard_view_model.dart +++ b/lib/view_model/dashboard/dashboard_view_model.dart @@ -4,7 +4,9 @@ import 'dart:io' show Platform; import 'package:cake_wallet/.secrets.g.dart' as secrets; import 'package:cake_wallet/bitcoin/bitcoin.dart'; +import 'package:cake_wallet/core/address_resolver/yat/yat_store.dart'; import 'package:cake_wallet/core/key_service.dart'; +import 'package:cake_wallet/view_model/dashboard/date_section_item.dart'; import "package:cw_core/balance_card_style_settings.dart"; import 'package:cake_wallet/core/trade_monitor.dart'; import 'package:cake_wallet/entities/auto_generate_subaddress_status.dart'; @@ -35,7 +37,6 @@ import 'package:cake_wallet/store/dashboard/trade_filter_store.dart'; import 'package:cake_wallet/store/dashboard/trades_store.dart'; import 'package:cake_wallet/store/dashboard/transaction_filter_store.dart'; import 'package:cake_wallet/store/settings_store.dart'; -import 'package:cake_wallet/store/yat/yat_store.dart'; import 'package:cake_wallet/view_model/dashboard/action_list_item.dart'; import 'package:cake_wallet/view_model/dashboard/anonpay_transaction_list_item.dart'; import 'package:cake_wallet/view_model/dashboard/balance_view_model.dart'; @@ -95,111 +96,8 @@ abstract class DashboardViewModelBase with Store { isShowFirstYatIntroduction = false, isShowSecondYatIntroduction = false, isShowThirdYatIntroduction = false, - filterItems = { - S.current.transactions: [ - FilterItem( - value: () => transactionFilterStore.displayAll, - caption: S.current.all_transactions, - onChanged: transactionFilterStore.toggleAll), - FilterItem( - value: () => transactionFilterStore.displayIncoming, - caption: S.current.incoming, - onChanged: transactionFilterStore.toggleIncoming), - FilterItem( - value: () => transactionFilterStore.displayOutgoing, - caption: S.current.outgoing, - onChanged: transactionFilterStore.toggleOutgoing), - if (appStore.wallet!.type == WalletType.bitcoin) - FilterItem( - value: () => transactionFilterStore.displaySilentPayments, - caption: S.current.silent_payments, - onChanged: transactionFilterStore.toggleSilentPayments, - ), - // FilterItem( - // value: () => false, - // caption: S.current.transactions_by_date, - // onChanged: null), - ], - 'Orders': [ - FilterItem( - value: () => orderFilterStore.displayCakePay, - caption: 'Cake Pay', - onChanged: () => - orderFilterStore.toggleDisplayOrder(OrderProviderDescription.cakePay)), - ], - S.current.trades: [ - FilterItem( - value: () => tradeFilterStore.displayAllTrades, - caption: S.current.all_trades, - onChanged: () => - tradeFilterStore.toggleDisplayExchange(ExchangeProviderDescription.all)), - FilterItem( - value: () => tradeFilterStore.displayChangeNow, - caption: ExchangeProviderDescription.changeNow.title, - onChanged: () => - tradeFilterStore.toggleDisplayExchange(ExchangeProviderDescription.changeNow)), - FilterItem( - value: () => tradeFilterStore.displaySideShift, - caption: ExchangeProviderDescription.sideShift.title, - onChanged: () => - tradeFilterStore.toggleDisplayExchange(ExchangeProviderDescription.sideShift)), - FilterItem( - value: () => tradeFilterStore.displaySimpleSwap, - caption: ExchangeProviderDescription.simpleSwap.title, - onChanged: () => - tradeFilterStore.toggleDisplayExchange(ExchangeProviderDescription.simpleSwap)), - FilterItem( - value: () => tradeFilterStore.displayTrocador, - caption: ExchangeProviderDescription.trocador.title, - onChanged: () => - tradeFilterStore.toggleDisplayExchange(ExchangeProviderDescription.trocador)), - FilterItem( - value: () => tradeFilterStore.displayExolix, - caption: ExchangeProviderDescription.exolix.title, - onChanged: () => - tradeFilterStore.toggleDisplayExchange(ExchangeProviderDescription.exolix)), - FilterItem( - value: () => tradeFilterStore.displayChainflip, - caption: ExchangeProviderDescription.chainflip.title, - onChanged: () => - tradeFilterStore.toggleDisplayExchange(ExchangeProviderDescription.chainflip)), - FilterItem( - value: () => tradeFilterStore.displayThorChain, - caption: ExchangeProviderDescription.thorChain.title, - onChanged: () => - tradeFilterStore.toggleDisplayExchange(ExchangeProviderDescription.thorChain)), - FilterItem( - value: () => tradeFilterStore.displayLetsExchange, - caption: ExchangeProviderDescription.letsExchange.title, - onChanged: () => tradeFilterStore - .toggleDisplayExchange(ExchangeProviderDescription.letsExchange)), - FilterItem( - value: () => tradeFilterStore.displayStealthEx, - caption: ExchangeProviderDescription.stealthEx.title, - onChanged: () => - tradeFilterStore.toggleDisplayExchange(ExchangeProviderDescription.stealthEx)), - FilterItem( - value: () => tradeFilterStore.displayXOSwap, - caption: ExchangeProviderDescription.xoSwap.title, - onChanged: () => - tradeFilterStore.toggleDisplayExchange(ExchangeProviderDescription.xoSwap)), - FilterItem( - value: () => tradeFilterStore.displaySwapTrade, - caption: ExchangeProviderDescription.swapTrade.title, - onChanged: () => - tradeFilterStore.toggleDisplayExchange(ExchangeProviderDescription.swapTrade)), - FilterItem( - value: () => tradeFilterStore.displaySwapXyz, - caption: ExchangeProviderDescription.swapsXyz.title, - onChanged: () => - tradeFilterStore.toggleDisplayExchange(ExchangeProviderDescription.swapsXyz)), - FilterItem( - value: () => tradeFilterStore.displayNearIntents, - caption: ExchangeProviderDescription.nearIntents.title, - onChanged: () => tradeFilterStore - .toggleDisplayExchange(ExchangeProviderDescription.nearIntents)), - ] - }, + filterItems = [], + exchangeFilterItems = [], subname = '', name = appStore.wallet!.name, type = appStore.wallet!.type, @@ -220,6 +118,8 @@ abstract class DashboardViewModelBase with Store { unawaited(_loadConstraints()); final _wallet = wallet; + loadFilterItems(); + if (_wallet.type == WalletType.monero) { subname = monero!.getCurrentAccount(_wallet).label; @@ -352,6 +252,110 @@ abstract class DashboardViewModelBase with Store { tradeMonitor.monitorActiveTrades(wallet.id); } + + void loadFilterItems() { + filterItems = [ + // FilterItem( + // value: () => transactionFilterStore.displayAll, + // caption: S.current.all_transactions, + // onChanged: transactionFilterStore.toggleAll), + FilterItem( + value: () => transactionFilterStore.displayOutgoing, + caption: S.current.send, + onChanged: transactionFilterStore.toggleOutgoing), + FilterItem( + value: () => transactionFilterStore.displayIncoming, + caption: S.current.receive, + onChanged: transactionFilterStore.toggleIncoming), + if (appStore.wallet!.type == WalletType.bitcoin) + FilterItem( + value: () => transactionFilterStore.displaySilentPayments, + caption: S.current.silent_payments, + onChanged: transactionFilterStore.toggleSilentPayments, + ), + SwapFilterItem( + enabledProviders: () => tradeFilterStore.enabledProvidersCount, + allEnabled: () => tradeFilterStore.displayAllTrades, + value: () => tradeFilterStore.enabledProvidersCount>0, + onChanged: () => + tradeFilterStore.toggleDisplayExchange(ExchangeProviderDescription.all)), + FilterItem( + value: () => orderFilterStore.displayCakePay, + caption: 'Cake Pay', + onChanged: () => + orderFilterStore.toggleDisplayOrder(OrderProviderDescription.cakePay)), + ]; + exchangeFilterItems = [ + SwapProviderFilterItem( + providerDescription: ExchangeProviderDescription.changeNow, + value: () => tradeFilterStore.displayChangeNow, + onChanged: () => + tradeFilterStore.toggleDisplayExchange(ExchangeProviderDescription.changeNow)), + SwapProviderFilterItem( + providerDescription: ExchangeProviderDescription.sideShift, + value: () => tradeFilterStore.displaySideShift, + onChanged: () => + tradeFilterStore.toggleDisplayExchange(ExchangeProviderDescription.sideShift)), + SwapProviderFilterItem( + providerDescription: ExchangeProviderDescription.simpleSwap, + value: () => tradeFilterStore.displaySimpleSwap, + onChanged: () => + tradeFilterStore.toggleDisplayExchange(ExchangeProviderDescription.simpleSwap)), + SwapProviderFilterItem( + providerDescription: ExchangeProviderDescription.trocador, + value: () => tradeFilterStore.displayTrocador, + onChanged: () => + tradeFilterStore.toggleDisplayExchange(ExchangeProviderDescription.trocador)), + SwapProviderFilterItem( + providerDescription: ExchangeProviderDescription.exolix, + value: () => tradeFilterStore.displayExolix, + onChanged: () => + tradeFilterStore.toggleDisplayExchange(ExchangeProviderDescription.exolix)), + SwapProviderFilterItem( + providerDescription: ExchangeProviderDescription.chainflip, + value: () => tradeFilterStore.displayChainflip, + onChanged: () => + tradeFilterStore.toggleDisplayExchange(ExchangeProviderDescription.chainflip)), + SwapProviderFilterItem( + providerDescription: ExchangeProviderDescription.thorChain, + value: () => tradeFilterStore.displayThorChain, + onChanged: () => + tradeFilterStore.toggleDisplayExchange(ExchangeProviderDescription.thorChain)), + SwapProviderFilterItem( + providerDescription: ExchangeProviderDescription.letsExchange, + value: () => tradeFilterStore.displayLetsExchange, + onChanged: () => + tradeFilterStore.toggleDisplayExchange(ExchangeProviderDescription.letsExchange)), + SwapProviderFilterItem( + providerDescription: ExchangeProviderDescription.stealthEx, + value: () => tradeFilterStore.displayStealthEx, + onChanged: () => + tradeFilterStore.toggleDisplayExchange(ExchangeProviderDescription.stealthEx)), + SwapProviderFilterItem( + providerDescription: ExchangeProviderDescription.xoSwap, + value: () => tradeFilterStore.displayXOSwap, + onChanged: () => + tradeFilterStore.toggleDisplayExchange(ExchangeProviderDescription.xoSwap)), + SwapProviderFilterItem( + providerDescription: ExchangeProviderDescription.swapTrade, + value: () => tradeFilterStore.displaySwapTrade, + onChanged: () => + tradeFilterStore.toggleDisplayExchange(ExchangeProviderDescription.swapTrade)), + SwapProviderFilterItem( + providerDescription: ExchangeProviderDescription.swapsXyz, + value: () => tradeFilterStore.displaySwapXyz, + onChanged: () => + tradeFilterStore.toggleDisplayExchange(ExchangeProviderDescription.swapsXyz)), + SwapProviderFilterItem( + providerDescription: ExchangeProviderDescription.nearIntents, + value: () => tradeFilterStore.displayNearIntents, + onChanged: () => + tradeFilterStore.toggleDisplayExchange(ExchangeProviderDescription.nearIntents)), + ]; + } + + + bool _isTransactionDisposerCallbackRunning = false; @action @@ -405,6 +409,20 @@ abstract class DashboardViewModelBase with Store { return false; } + @action + void changeAllFilterItems(bool value) { + for (final item in filterItems) { + if (item.value() != value) { + item.onChanged(); + } + } + for (final item in exchangeFilterItems) { + if (item.value() != value) { + item.onChanged(); + } + } + } + @action Future loadCardDesigns() async { final accountStyleSettings = @@ -715,6 +733,14 @@ abstract class DashboardViewModelBase with Store { return formattedItemsList(_items); } + static const shortHistoryLength = 3; + + @computed + List get itemsShort => items + .where((item) => item is! DateSectionItem) + .take(shortHistoryLength) + .toList(); + @observable WalletBase, TransactionInfo> wallet; @@ -1123,7 +1149,11 @@ abstract class DashboardViewModelBase with Store { PayjoinTransactionsStore payjoinTransactionsStore; - Map> filterItems; + // Map> filterItems; + + List filterItems; + + List exchangeFilterItems; bool get isBuyEnabled => settingsStore.isBitcoinBuyEnabled; @@ -1234,6 +1264,7 @@ abstract class DashboardViewModelBase with Store { this.wallet = wallet; type = wallet.type; name = wallet.name; + loadFilterItems(); if (wallet.type == WalletType.monero) { subname = monero!.getCurrentAccount(wallet).label; diff --git a/lib/view_model/dashboard/date_section_item.dart b/lib/view_model/dashboard/date_section_item.dart index 75250a7ea..5bc607dcf 100644 --- a/lib/view_model/dashboard/date_section_item.dart +++ b/lib/view_model/dashboard/date_section_item.dart @@ -1,8 +1,36 @@ +import 'package:cake_wallet/generated/i18n.dart'; import 'package:cake_wallet/view_model/dashboard/action_list_item.dart'; class DateSectionItem extends ActionListItem { DateSectionItem(this.date, {required super.key}); - + @override final DateTime date; -} \ No newline at end of file +} + +abstract class SpecificDateSectionItem extends DateSectionItem { + SpecificDateSectionItem(super.date, {required super.key}); + + String get text; +} + +class Last30daysTransactionItem extends SpecificDateSectionItem { + Last30daysTransactionItem(super.date, {required super.key}); + + @override + String get text => S.current.last_30_days; +} + +class Last7daysTransactionItem extends SpecificDateSectionItem { + Last7daysTransactionItem(super.date, {required super.key}); + + @override + String get text => S.current.last_7_days; +} + +class TodayTransactionItem extends SpecificDateSectionItem { + TodayTransactionItem(super.date, {required super.key}); + + @override + String get text => S.current.today; +} diff --git a/lib/view_model/dashboard/filter_item.dart b/lib/view_model/dashboard/filter_item.dart index 0944e899c..ffd0a35a3 100644 --- a/lib/view_model/dashboard/filter_item.dart +++ b/lib/view_model/dashboard/filter_item.dart @@ -1,4 +1,4 @@ -import 'package:mobx/mobx.dart'; +import 'package:cake_wallet/exchange/exchange_provider_description.dart'; class FilterItem { FilterItem({ @@ -9,4 +9,32 @@ class FilterItem { bool Function() value; String caption; Function onChanged; +} + +class SwapFilterItem extends FilterItem { + SwapFilterItem({ + required this.enabledProviders, + required this.allEnabled, + required super.value, + super.caption = "Swap", + required super.onChanged, + }); + + int Function() enabledProviders; + bool Function() allEnabled; +} + + +class SwapProviderFilterItem extends FilterItem { + SwapProviderFilterItem({ + required super.value, + required super.onChanged, + required this.providerDescription, + super.caption = "" +}); + + @override + String get caption => providerDescription.title; + + final ExchangeProviderDescription providerDescription; } \ No newline at end of file diff --git a/lib/view_model/dashboard/formatted_item_list.dart b/lib/view_model/dashboard/formatted_item_list.dart index 04464ca89..45dfb663d 100644 --- a/lib/view_model/dashboard/formatted_item_list.dart +++ b/lib/view_model/dashboard/formatted_item_list.dart @@ -1,45 +1,82 @@ -import 'package:cake_wallet/view_model/dashboard/action_list_item.dart'; -import 'package:cake_wallet/view_model/dashboard/date_section_item.dart'; -import 'package:flutter/foundation.dart'; +import "package:cake_wallet/view_model/dashboard/action_list_item.dart"; +import "package:cake_wallet/view_model/dashboard/date_section_item.dart"; +import "package:flutter/foundation.dart"; + +enum _DateBucket { recent, last7Days, last30Days, byMonth } List formattedItemsList(List items) { final formattedList = []; - DateTime? lastDate; items.sort((a, b) => b.date.compareTo(a.date)); - for (var i = 0; i < items.length; i++) { - final transaction = items[i]; + final now = DateTime.now(); + final todayTreshold = DateTime(now.year, now.month, now.day); + final last7daysThreshold = now.subtract(const Duration(days: 7)); + final last30daysThreshold = now.subtract(const Duration(days: 30)); - if (lastDate == null) { - lastDate = transaction.date; - formattedList.add( - DateSectionItem( - transaction.date, - key: ValueKey('date_section_item_${transaction.date.microsecondsSinceEpoch}_key'), - ), - ); - formattedList.add(transaction); - continue; + _DateBucket? lastBucket; + DateTime? lastMonthDate; + + for (final transaction in items) { + final date = transaction.date; + + final bucket = date.isAfter(todayTreshold) + ? _DateBucket.recent + : date.isAfter(last7daysThreshold) + ? _DateBucket.last7Days + : date.isAfter(last30daysThreshold) + ? _DateBucket.last30Days + : _DateBucket.byMonth; + + switch (bucket) { + case _DateBucket.recent: + if (lastBucket != _DateBucket.recent) { + formattedList.add( + TodayTransactionItem( + date, + key: const ValueKey("today_section_item_key"), + ), + ); + } + break; + case _DateBucket.last7Days: + if (lastBucket != _DateBucket.last7Days) { + formattedList.add( + Last7daysTransactionItem( + date, + key: const ValueKey("last_7_days_section_item_key"), + ), + ); + } + break; + case _DateBucket.last30Days: + if (lastBucket != _DateBucket.last30Days) { + formattedList.add( + Last30daysTransactionItem( + date, + key: const ValueKey("last_30_days_section_item_key"), + ), + ); + } + break; + case _DateBucket.byMonth: + final isNewMonth = lastMonthDate == null || + lastMonthDate.year != date.year || + lastMonthDate.month != date.month; + if (isNewMonth) { + lastMonthDate = date; + formattedList.add( + DateSectionItem( + date, + key: ValueKey("date_section_item_${date.year}_${date.month}_key"), + ), + ); + } + break; } - final isCurrentDay = lastDate.year == transaction.date.year && - lastDate.month == transaction.date.month && - lastDate.day == transaction.date.day; - - if (isCurrentDay) { - formattedList.add(transaction); - continue; - } - - lastDate = transaction.date; - formattedList.add( - DateSectionItem( - transaction.date, - key: ValueKey('date_section_item_${transaction.date.microsecondsSinceEpoch}_key'), - ), - ); + lastBucket = bucket; formattedList.add(transaction); } return formattedList; -} \ No newline at end of file +} diff --git a/lib/view_model/dashboard/transaction_list_item.dart b/lib/view_model/dashboard/transaction_list_item.dart index 0a38ff2d2..5a3b003b7 100644 --- a/lib/view_model/dashboard/transaction_list_item.dart +++ b/lib/view_model/dashboard/transaction_list_item.dart @@ -51,10 +51,16 @@ class TransactionListItem extends ActionListItem with Keyable { .withLocalSeperator(_appStore.settingsStore.languageCode); } - return transaction.amount.toStringWithSymbol(); + return transaction.amount.toStringWithSymbol(fractionalDigits: 8); } String get formattedTitle { + + if (balanceViewModel.wallet.type == WalletType.bitcoin && + transaction.additionalInfo['hasMissingInputTx'] == true) { + return 'Transaction has missing data'; + } + if (transaction.additionalInfo['autoShield'] == true) { return "Autoshield"; } diff --git a/lib/view_model/exchange/exchange_trade_view_model.dart b/lib/view_model/exchange/exchange_trade_view_model.dart index 49514bfa6..154c1ab96 100644 --- a/lib/view_model/exchange/exchange_trade_view_model.dart +++ b/lib/view_model/exchange/exchange_trade_view_model.dart @@ -1,6 +1,7 @@ import 'dart:async'; import 'package:cake_wallet/core/amount_parsing_proxy.dart'; +import 'package:cake_wallet/core/execution_state.dart'; import 'package:cake_wallet/entities/calculate_fiat_amount.dart'; import 'package:cake_wallet/entities/fiat_currency.dart'; import 'package:cake_wallet/exchange/exchange_provider_description.dart'; @@ -24,6 +25,7 @@ import 'package:cake_wallet/reactions/wallet_connect.dart'; import 'package:cake_wallet/src/screens/exchange_trade/exchange_trade_item.dart'; import 'package:cake_wallet/store/dashboard/fiat_conversion_store.dart'; import 'package:cake_wallet/store/dashboard/trades_store.dart'; +import 'package:cake_wallet/utils/exchange_provider_logger.dart'; import 'package:cake_wallet/utils/qr_util.dart'; import 'package:cake_wallet/utils/token_utilities.dart'; import 'package:cake_wallet/view_model/send/fees_view_model.dart'; @@ -48,7 +50,6 @@ abstract class ExchangeTradeViewModelBase with Store { required this.feesViewModel, required this.fiatConversionStore, }) : trade = tradesStore.trade!, - isSendable = _checkIfCanSend(tradesStore, wallet), isSwapsXYZCanSendFromExternal = _checkIfSwapsXYZCanSendFromExternal(tradesStore.trade!, wallet), items = ObservableList() { setUpOutput(); @@ -115,12 +116,10 @@ abstract class ExchangeTradeViewModelBase with Store { @observable Trade trade; - @observable - bool isSendable; - - bool isSwapsXYZCanSendFromExternal; + bool get isSendable => checkIfCanSend(trade, wallet) == null; + /// Providers that should hide the "send from external" button static const List _providersThatHideExternalSend = [ JupiterExchangeProvider, @@ -212,7 +211,13 @@ abstract class ExchangeTradeViewModelBase with Store { @action Future confirmSending() async { - if (!isSendable) return; + final canSendError = checkIfCanSend(trade, wallet); + + if (canSendError != null) { + _logCanSendError(trade, wallet, canSendError); + sendViewModel.state = FailureState(canSendError); + return; + } final selected = trade.from; if (selected == null) { @@ -348,48 +353,56 @@ abstract class ExchangeTradeViewModelBase with Store { ); } - static bool _checkIfCanSend(TradesStore tradesStore, WalletBase wallet) { - final trade = tradesStore.trade!; + String? checkIfCanSend(Trade? trade, WalletBase wallet) { + if (trade == null) return 'Trade is null'; + final tradeFrom = trade.from; + if (tradeFrom == null) return 'Trade from currency is null'; - bool _sameCurrency(CryptoCurrency? a, CryptoCurrency? b) => - a != null && b != null && a.titleAndTagEqual(b); + bool _sameCurrency(CryptoCurrency a, CryptoCurrency b) => a.titleAndTagEqual(b); - bool _isEthToken() => - wallet.currency == CryptoCurrency.eth && tradeFrom?.tag == CryptoCurrency.eth.title; + bool _isTokenBelongingToWallet(CryptoCurrency cur) { + final chainTag = cur.tag ?? cur.title; + return wallet.currency == cur && + (tradeFrom.tag?.toUpperCase() == chainTag.toUpperCase() || + tradeFrom.title.toUpperCase() == chainTag.toUpperCase()); + } - bool _isPolygonToken() => - wallet.currency == CryptoCurrency.maticpoly && - tradeFrom?.tag == CryptoCurrency.maticpoly.tag; - - bool _isBaseToken() => - wallet.currency == CryptoCurrency.baseEth && tradeFrom?.tag == CryptoCurrency.baseEth.tag; - - bool _isArbitrumToken() => - wallet.currency == CryptoCurrency.arbEth && - (tradeFrom?.tag == CryptoCurrency.arbEth.tag || - tradeFrom?.title == CryptoCurrency.arbEth.tag); // This is to handle the CryptoCurrency.arb that doesn't have a tag but fully belongs to the Arbitrum chain - - bool _isTronToken() => - wallet.currency == CryptoCurrency.trx && tradeFrom?.tag == CryptoCurrency.trx.title; - - bool _isSplToken() => - wallet.currency == CryptoCurrency.sol && tradeFrom?.tag == CryptoCurrency.sol.title; - - bool _isBscToken() => - wallet.currency == CryptoCurrency.bnb && tradeFrom?.tag == CryptoCurrency.bnb.tag; - - return _sameCurrency(tradeFrom, wallet.currency) || + final canSend = _sameCurrency(tradeFrom, wallet.currency) || (_sameCurrency(tradeFrom, CryptoCurrency.btcln) && wallet.currency == CryptoCurrency.btc) || - tradesStore.trade!.provider == ExchangeProviderDescription.xmrto || - _isEthToken() || - _isPolygonToken() || - _isSplToken() || - _isTronToken() || - _isBaseToken() || - _isArbitrumToken() || - _isBscToken(); + trade.provider == ExchangeProviderDescription.xmrto || + _isTokenBelongingToWallet(CryptoCurrency.eth) || + _isTokenBelongingToWallet(CryptoCurrency.maticpoly) || + _isTokenBelongingToWallet(CryptoCurrency.baseEth) || + _isTokenBelongingToWallet(CryptoCurrency.arbEth) || + _isTokenBelongingToWallet(CryptoCurrency.trx) || + _isTokenBelongingToWallet(CryptoCurrency.sol) || + _isTokenBelongingToWallet(CryptoCurrency.bnb); + + if (!canSend) { + return 'Wallet currency ${wallet.currency.title} does not match trade from currency ${tradeFrom.title} or is not a supported token for this wallet.'; + } + + return null; + } + + void _logCanSendError(Trade? trade, WalletBase wallet, String error) { + ExchangeProviderLogger.logError( + provider: trade?.provider, + function: '_checkIfCanSend', + error: error, + requestData: { + 'tradeId': trade?.id, + 'tradeFrom': trade?.from?.title, + 'tradeFromTag': trade?.from?.tag, + 'tradeTo': trade?.to?.title, + 'tradeToTag': trade?.to?.tag, + 'walletName': wallet.name, + 'walletCurrency': wallet.currency.title, + 'walletCurrencyTag': wallet.currency.tag, + }, + ); } static bool _checkIfSwapsXYZCanSendFromExternal(Trade trade, WalletBase wallet) { @@ -415,9 +428,13 @@ abstract class ExchangeTradeViewModelBase with Store { return null; } - // Using trade currency here so the external-send QR encodes the correct scheme - final uriWalletType = - cryptoCurrencyOrTokenToWalletType(fromCurrency) ?? wallet.type; + // Using the trade's `from` currency so the external-send QR encodes the correct scheme. + final uriWalletType = cryptoCurrencyOrTokenToWalletType(fromCurrency); + + // for other currencies that we don't have wallets for + if (uriWalletType == null) { + return ExternalAddressURI(address: inputAddress, amount: amount); + } printV(uriWalletType); diff --git a/lib/view_model/exchange/exchange_view_model.dart b/lib/view_model/exchange/exchange_view_model.dart index e48515bb7..619579b23 100644 --- a/lib/view_model/exchange/exchange_view_model.dart +++ b/lib/view_model/exchange/exchange_view_model.dart @@ -45,6 +45,7 @@ import 'package:cake_wallet/store/app_store.dart'; import 'package:cake_wallet/utils/exchange_provider_logger.dart'; import 'package:cw_core/amount/amount_sanitizer.dart'; import 'package:cw_core/amount/money.dart'; +import 'package:cw_core/amount/money_double.dart'; import "package:cw_core/wallet_info.dart"; import 'package:cake_wallet/store/dashboard/fiat_conversion_store.dart'; import 'package:cake_wallet/store/dashboard/trades_store.dart'; @@ -804,7 +805,7 @@ abstract class ExchangeViewModelBase extends WalletChangeListenerViewModel with } final amount_ = _enteredAmount / (forcedProvider == null ? bestRate : forcedProviderRate); - _depositAmount = Money.tryParse(amount_.toStringAsFixed(depositCurrency.decimals), depositCurrency); + _depositAmount = amount_.tryToMoney(depositCurrency); } @action @@ -863,8 +864,7 @@ abstract class ExchangeViewModelBase extends WalletChangeListenerViewModel with } final amount_ = _enteredAmount * (forcedProvider == null ? bestRate : forcedProviderRate); - _receiveAmount = - Money.tryParse(amount_.toStringAsFixed(min(20, receiveCurrency.decimals)), receiveCurrency); + _receiveAmount = amount_.tryToMoney(receiveCurrency); } bool checkIfInputMeetsMinOrMaxCondition(String input) { 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 4217aab59..732729d6c 100644 --- a/lib/view_model/restore/wallet_restore_from_qr_code.dart +++ b/lib/view_model/restore/wallet_restore_from_qr_code.dart @@ -1,7 +1,7 @@ import 'dart:convert'; +import 'package:cake_wallet/core/address_resolver/address_resolver_utils.dart'; import 'package:cake_wallet/core/seed_validator.dart'; -import 'package:cake_wallet/entities/parse_address_from_domain.dart'; import 'package:cake_wallet/entities/qr_scanner.dart'; import 'package:cake_wallet/reactions/wallet_connect.dart'; import 'package:cake_wallet/routes.dart'; @@ -87,7 +87,7 @@ class WalletRestoreFromQRCode { static String? _extractAddressFromUrl(String rawString, WalletType type) { try { - return AddressResolver.extractAddressByType( + return AddressResolverUtils.extractAddressByType( raw: rawString, type: walletTypeToCryptoCurrency( type, diff --git a/lib/view_model/send/output.dart b/lib/view_model/send/output.dart index 543a541bd..ef4609386 100644 --- a/lib/view_model/send/output.dart +++ b/lib/view_model/send/output.dart @@ -1,16 +1,16 @@ import 'dart:math' show min; import 'package:cake_wallet/bitcoin/bitcoin.dart'; +import 'package:cake_wallet/core/address_resolver/address_sources.dart'; +import 'package:cake_wallet/core/address_resolver/parsed_address.dart'; +import 'package:cake_wallet/decred/decred.dart'; import 'package:cake_wallet/di.dart'; import 'package:cake_wallet/entities/calculate_fiat_amount.dart'; import 'package:cake_wallet/entities/calculate_fiat_amount_raw.dart'; import 'package:cake_wallet/entities/contact_base.dart'; -import 'package:cake_wallet/entities/parse_address_from_domain.dart'; -import 'package:cake_wallet/entities/parsed_address.dart'; import 'package:cake_wallet/evm/evm.dart'; import 'package:cake_wallet/generated/i18n.dart'; import 'package:cake_wallet/reactions/wallet_connect.dart'; import 'package:cake_wallet/solana/solana.dart'; -import 'package:cake_wallet/src/screens/send/widgets/extract_address_from_parsed.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'; @@ -37,7 +37,8 @@ class Output = OutputBase with _$Output; abstract class OutputBase with Store { OutputBase(this._wallet, this._appStore, this._fiatConversationStore, this.cryptoCurrencyHandler) - : key = UniqueKey(), + : + key = UniqueKey(), sendAll = false, cryptoAmount = '', cryptoFullBalance = '', @@ -47,7 +48,7 @@ abstract class OutputBase with Store { memo = "", extractedAddress = '', estimatedFee = Money.zero(cryptoCurrencyHandler()), - parsedAddress = ParsedAddress(addresses: []) { + parsedAddress = ParsedAddress(parsedAddressByCurrencyMap: {}) { autorun((_) { final status = _wallet.syncStatus; printV("Sync status changed to $status. Recalculating fees"); @@ -56,6 +57,7 @@ abstract class OutputBase with Store { }); } + Key key; bool get useSatoshi => _appStore.amountParsingProxy.useSatoshi(cryptoCurrencyHandler()); @@ -83,7 +85,7 @@ abstract class OutputBase with Store { @observable String note; - + @observable String memo; @@ -99,7 +101,7 @@ abstract class OutputBase with Store { @computed bool get isParsedAddress => - parsedAddress.parseFrom != ParseFrom.notParsed && parsedAddress.name.isNotEmpty; + parsedAddress.addressSource != AddressSource.notParsed && parsedAddress.handle.isNotEmpty; String roundedCryptoAmount(int digits) => displayCryptoAmount.withMaxDecimals(digits); @@ -272,10 +274,22 @@ abstract class OutputBase with Store { resetParsedAddress(); } + @action void resetParsedAddress() { displayName = null; extractedAddress = ''; - parsedAddress = ParsedAddress(addresses: []); + note = ''; + parsedAddress = ParsedAddress(parsedAddressByCurrencyMap: {}); + } + + @action + void applyAddressLookupResult(ParsedAddress result) { + final currency = cryptoCurrencyHandler(); + + parsedAddress = result; + extractedAddress = result.parsedAddressByCurrencyMap[currency] ?? ''; + note = result.description; + displayName = result.profileName.isNotEmpty ? result.profileName : result.handle; } @action @@ -334,33 +348,26 @@ abstract class OutputBase with Store { Map get extra { final fields = {}; - if (parsedAddress.parseFrom == ParseFrom.bip353) { - fields['bip353_name'] = parsedAddress.name; + if (parsedAddress.addressSource == AddressSource.bip353) { + fields['bip353_name'] = parsedAddress.handle; fields['bip353_proof'] = parsedAddress.bip353DnsProof; } return fields; } - Future fetchParsedAddress(BuildContext context) async { - final domain = address; - final currency = cryptoCurrencyHandler(); - parsedAddress = await getIt.get().resolve(context, domain, currency); - if(parsedAddress.name.isNotEmpty) { - displayName = parsedAddress.name; - } - - if (parsedAddress.parseFrom == ParseFrom.lnurlpay) { - cryptoCurrencyHandler(CryptoCurrency.btcln); - } - extractedAddress = await extractAddressFromParsed(context, parsedAddress); - note = parsedAddress.description; - } - + @action void loadContact(ContactBase contact) { - address = contact.name; - parsedAddress = ParsedAddress.fetchContactAddress(address: contact.address, name: contact.name); - extractedAddress = parsedAddress.addresses.first; - note = parsedAddress.description; + final currency = cryptoCurrencyHandler(); + + address = contact.address; + applyAddressLookupResult( + ParsedAddress( + parsedAddressByCurrencyMap: {currency: contact.address}, + addressSource: AddressSource.contact, + handle: contact.name, + profileName: contact.name, + ), + ); } } diff --git a/lib/view_model/send/send_view_model.dart b/lib/view_model/send/send_view_model.dart index 5c4b2c09c..61990c195 100644 --- a/lib/view_model/send/send_view_model.dart +++ b/lib/view_model/send/send_view_model.dart @@ -1,6 +1,9 @@ import 'dart:async'; import 'package:cake_wallet/bitcoin/bitcoin.dart'; +import 'package:cake_wallet/core/address_resolver/parsed_address.dart'; +import 'package:cake_wallet/core/address_resolver/address_resolver_service.dart'; +import 'package:cake_wallet/di.dart'; import 'package:cake_wallet/core/address_validator.dart'; import 'package:cake_wallet/core/amount_parsing_proxy.dart'; import 'package:cake_wallet/core/amount_validator.dart'; @@ -17,7 +20,6 @@ import 'package:cake_wallet/entities/contact.dart'; import 'package:cake_wallet/entities/contact_record.dart'; import 'package:cake_wallet/entities/evm_transaction_error_fees_handler.dart'; import 'package:cake_wallet/entities/fiat_currency.dart'; -import 'package:cake_wallet/entities/parsed_address.dart'; import 'package:cake_wallet/entities/preferences_key.dart'; import 'package:cake_wallet/entities/template.dart'; import 'package:cake_wallet/entities/transaction_description.dart'; @@ -103,6 +105,7 @@ abstract class SendViewModelBase extends WalletChangeListenerViewModel with Stor this._appStore, this.sendTemplateViewModel, this._fiatConversationStore, + this._adrResService, this.balanceViewModel, this.contactListViewModel, this.transactionDescriptionBox, @@ -182,6 +185,26 @@ abstract class SendViewModelBase extends WalletChangeListenerViewModel with Stor if (isBatchSending) outputs.remove(output); } + Future resolveAddressForOutput(Output output) async { + final query = output.address.trim(); + if (query.isEmpty) return null; + + // Check if the address is valid for the current currency (except for Zano, which can use handles as addresses) + if (selectedCryptoCurrency != CryptoCurrency.zano) { + final isValidAddress = AddressValidator(type: selectedCryptoCurrency).isValid(query); + if (isValidAddress) return null; + } + + + final results = await _adrResService.resolve( + query: query, + wallet: wallet, + currency: selectedCryptoCurrency, + ); + + return results.isEmpty ? null : results.first; + } + @action void clearOutputs() { outputs.clear(); @@ -383,14 +406,17 @@ abstract class SendViewModelBase extends WalletChangeListenerViewModel with Stor // If silent payments scanning, can still send payments (wallet.type == WalletType.bitcoin && wallet.syncStatus is SyncingSyncStatus); - bool isSendToSilentPayments(Output output) => - wallet.type == WalletType.bitcoin && - (RegExp(AddressValidator.silentPaymentAddressPatternMainnet).hasMatch(output.address) || - RegExp(AddressValidator.silentPaymentAddressPatternMainnet) - .hasMatch(output.extractedAddress) || - (output.parsedAddress.addresses.isNotEmpty && - RegExp(AddressValidator.silentPaymentAddressPatternMainnet) - .hasMatch(output.parsedAddress.addresses[0]))); + bool isSendToSilentPayments(Output output) { + if (wallet.type != WalletType.bitcoin) return false; + + final sp = RegExp(AddressValidator.silentPaymentAddressPatternMainnet); + + final address = output.extractedAddress.isNotEmpty + ? output.extractedAddress + : output.address; + + return sp.hasMatch(address); + } @computed List