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/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..3dbecad96 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'; @@ -1736,37 +1734,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 +2085,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 +2116,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); @@ -2434,20 +2407,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 +3181,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 +3762,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_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_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_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_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/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_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/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/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 821f51130..bf2b64443 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'; @@ -198,7 +199,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'; @@ -899,6 +899,7 @@ Future setup({ getIt.get(), getIt.get(), getIt.get(), + getIt.get(), getIt.get(), getIt.get(), _transactionDescriptionBox, @@ -1100,7 +1101,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, _) => @@ -1249,7 +1253,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( @@ -1257,6 +1264,7 @@ Future setup({ return NewSwapPage( getIt.get(), getIt.get(), + getIt.get(), paymentRequest, walletSwitcherViewModel: getIt.get(), initialCurrency: initialCurrency, @@ -1494,7 +1502,7 @@ Future setup({ getIt.registerFactory(() => BuySellViewModel(getIt.get())); - getIt.registerFactory(() => BuySellPage(getIt.get())); + getIt.registerFactory(() => BuySellPage(getIt.get(), getIt.get())); getIt.registerFactoryParam, void>((List args, _) { final items = args.first as List; @@ -1592,10 +1600,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/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/send_page.dart b/lib/new-ui/pages/send_page.dart index aa7cff5af..a993afbb2 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 @@ -1429,3 +1449,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..2120a9a1f 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; @@ -423,8 +423,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 { 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/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_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/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 73c238b57..2db9a9568 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/currency_picker/currency_picker_args.dart'; import 'package:cake_wallet/new-ui/widgets/currency_picker/currency_picker_sheet.dart'; @@ -16,7 +16,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'; @@ -28,9 +27,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(); @@ -300,7 +300,7 @@ class BuySellPage extends BasePage { }); reaction((_) => buySellViewModel.cryptoCurrencyAddress, (String address) { - if (cryptoAddressController != address) { + if (cryptoAddressController.text != address) { cryptoCurrencyKey.currentState!.addressController.text = address; } }); @@ -320,8 +320,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; + } } }); @@ -410,8 +412,23 @@ class BuySellPage extends BasePage { currencyButtonColor: Colors.transparent, addressButtonsColor: Theme.of(context).colorScheme.surfaceContainerHighest, borderColor: Theme.of(context).colorScheme.outlineVariant, - onPushPasteButton: (context) async {}, - onPushAddressBookButton: (context) async {}, + onPushPasteButton: (context) async { + final domain = cryptoCurrencyKey.currentState!.addressController.text; + final parsed = + await fetchParsedAddress(context, domain, buySellViewModel.cryptoCurrency); + if (parsed.isNotEmpty) { + buySellViewModel.cryptoCurrencyAddress = parsed; + } + }, + + onPushAddressBookButton: (context) async { + final domain = cryptoCurrencyKey.currentState!.addressController.text; + final parsed = + await fetchParsedAddress(context, domain, buySellViewModel.cryptoCurrency); + if (parsed.isNotEmpty) { + buySellViewModel.cryptoCurrencyAddress = parsed; + } + }, fillColor: buySellViewModel.isBuyAction ? Theme.of(context).colorScheme.surfaceContainer : Theme.of(context).colorScheme.surfaceContainerLow, @@ -537,9 +554,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/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/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/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/exception_handler.dart b/lib/utils/exception_handler.dart index e46650517..eee768c61 100644 --- a/lib/utils/exception_handler.dart +++ b/lib/utils/exception_handler.dart @@ -299,14 +299,15 @@ class ExceptionHandler { "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 +442,20 @@ 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; + } + } + + 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 62d916fbc..8bdb6d4e9 100644 --- a/lib/view_model/dashboard/dashboard_view_model.dart +++ b/lib/view_model/dashboard/dashboard_view_model.dart @@ -4,6 +4,7 @@ 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"; @@ -36,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'; diff --git a/lib/view_model/dashboard/transaction_list_item.dart b/lib/view_model/dashboard/transaction_list_item.dart index d29fd5296..5a3b003b7 100644 --- a/lib/view_model/dashboard/transaction_list_item.dart +++ b/lib/view_model/dashboard/transaction_list_item.dart @@ -55,6 +55,12 @@ class TransactionListItem extends ActionListItem with Keyable { } 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