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..51f48571d 100644 --- a/cw_bitcoin/lib/bitcoin_wallet.dart +++ b/cw_bitcoin/lib/bitcoin_wallet.dart @@ -551,8 +551,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_wallet.dart b/cw_bitcoin/lib/electrum_wallet.dart index 21b671466..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; diff --git a/cw_bitcoin/lib/lightning/lightning_wallet.dart b/cw_bitcoin/lib/lightning/lightning_wallet.dart index d93a84b58..42dda7cb2 100644 --- a/cw_bitcoin/lib/lightning/lightning_wallet.dart +++ b/cw_bitcoin/lib/lightning/lightning_wallet.dart @@ -441,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, @@ -450,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/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/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/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/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/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/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/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/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/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/exchange/exchange_trade_view_model.dart b/lib/view_model/exchange/exchange_trade_view_model.dart index 49514bfa6..2579b4644 100644 --- a/lib/view_model/exchange/exchange_trade_view_model.dart +++ b/lib/view_model/exchange/exchange_trade_view_model.dart @@ -415,9 +415,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/transaction_details_view_model.dart b/lib/view_model/transaction_details_view_model.dart index 7199ed1a9..0cc1bdb82 100644 --- a/lib/view_model/transaction_details_view_model.dart +++ b/lib/view_model/transaction_details_view_model.dart @@ -9,7 +9,6 @@ import 'package:cake_wallet/zano/zano.dart'; import 'package:cw_core/crypto_amount_format.dart'; import 'package:cw_core/crypto_currency.dart'; import 'package:cw_core/currency_for_wallet_type.dart'; -import 'package:cw_core/utils/print_verbose.dart'; import 'package:cw_core/wallet_base.dart'; import 'package:cw_core/transaction_info.dart'; import 'package:cw_core/wallet_type.dart'; @@ -50,10 +49,9 @@ String _moneroRecipientAddressForDisplay(String raw, WalletType walletType) { return match?.group(0) ?? raw.trim(); } -bool isLightning(TransactionInfo tx) { - printV(tx.additionalInfo); - return (tx.additionalInfo["isLightning"] as bool?) ?? false; -} +bool isLightning(TransactionInfo tx) => (tx.additionalInfo["isLightning"] as bool?) ?? false; + +bool hasLightningPreimage(TransactionInfo tx) => (tx.additionalInfo["preimage"] as String?) != null; class TxDetailRowDefinition { final String keyString; @@ -188,6 +186,13 @@ class TxDetailRowDefinition { }, applicable: (vm) => vm.wallet.type == WalletType.monero), + TxDetailRowDefinition( + keyString: "standard_list_item_lightning_preimage", + title: S.current.transaction_preimage, + valueGetter: (vm) => vm.transactionInfo.additionalInfo['preimage'] as String? ?? "", + applicable: (vm) => + hasLightningPreimage(vm.transactionInfo) && isLightning(vm.transactionInfo), + ), TxDetailRowDefinition( keyString: "standard_list_item_transaction_confirmed_key", @@ -316,10 +321,9 @@ abstract class TransactionDetailsViewModelBase with Store { TransactionPriority? transactionPriority; CryptoCurrency get transactionAsset { + if (isEVMCompatibleChain(wallet.type)) return evm!.assetOfTransaction(wallet, transactionInfo); - if (isEVMCompatibleChain(wallet.type)) { - return evm!.assetOfTransaction(wallet, transactionInfo); - } + if (isLightning(transactionInfo)) return CryptoCurrency.btcln; return switch (wallet.type) { WalletType.solana => solana!.assetOfTransaction(wallet, transactionInfo), @@ -329,7 +333,6 @@ abstract class TransactionDetailsViewModelBase with Store { }; } - // TODO integrate these getters with the TransactionInfo object String get formattedPendingStatus { switch (wallet.type) { diff --git a/pubspec_base.yaml b/pubspec_base.yaml index 1fb2d3f4a..90c72af2b 100644 --- a/pubspec_base.yaml +++ b/pubspec_base.yaml @@ -205,8 +205,10 @@ dependency_overrides: ref: 7bcb00380783f8611f68f5cd63fec96261fea2c2 ledger_usb_plus: git: - url: https://github.com/konstantinullrich/ledger-usb-plus - ref: dad482c847c0d0eedceabbb40d3478e64b65e630 + # url: https://github.com/konstantinullrich/ledger-usb-plus + # ref: dad482c847c0d0eedceabbb40d3478e64b65e630 + url: https://github.com/cake-tech/ledger-usb-plus + ref: 734c3d6f8267ba52ad73b6e1e78e570c07298890 socks5_proxy: git: url: https://github.com/LacticWhale/socks_dart.git diff --git a/res/values/strings_en.arb b/res/values/strings_en.arb index 1debd0a8f..1523c53e2 100644 --- a/res/values/strings_en.arb +++ b/res/values/strings_en.arb @@ -1253,6 +1253,7 @@ "transaction_error": "Transaction Error", "transaction_key": "Transaction Key", "transaction_memo_optional": "Transaction Memo (optional)", + "transaction_preimage": "Preimage", "transaction_priority_fast": "Fast", "transaction_priority_fastest": "Fastest", "transaction_priority_medium": "Medium", @@ -1452,4 +1453,4 @@ "zcash_card_missing_funds": "Missing funds?", "zcash_card_scan": "Scan", "zcash_card_warning": "Do not close the app until the procedure completes, if you do so this process will need to restart from scratch." -} \ No newline at end of file +}