diff --git a/cw_core/lib/amount/money.dart b/cw_core/lib/amount/money.dart index 3ff5af928..9c2332310 100644 --- a/cw_core/lib/amount/money.dart +++ b/cw_core/lib/amount/money.dart @@ -191,6 +191,10 @@ class Money implements Comparable { @override int get hashCode => amount.hashCode ^ currency.hashCode; + // Added this to reduce the hops we do to convert Money to double + // for fiat conversion and display + double toDouble() => amount / multiplierOf(currency.decimals); + @override String toString() => formatFixed(amount, currency.decimals); diff --git a/cw_core/lib/format_fixed.dart b/cw_core/lib/format_fixed.dart index 52c0e1470..aa0162812 100644 --- a/cw_core/lib/format_fixed.dart +++ b/cw_core/lib/format_fixed.dart @@ -4,13 +4,11 @@ String formatFixed(BigInt value, int? decimals, {int? fractionalDigits, bool tri decimals ??= 0; fractionalDigits ??= decimals; - var multiplier = getMultiplier(decimals); - // Make sure wei is a big number (convert as necessary) + final multiplier = multiplierOf(decimals); var negative = value.isNegative; - if (negative) value = value * BigInt.from(-1); + if (negative) value = -value; - var fraction = - value.modPow(BigInt.one, BigInt.parse(multiplier)).toString().padLeft(decimals, "0"); + var fraction = (value % multiplier).toString().padLeft(decimals, "0"); if (fractionalDigits < 0) fractionalDigits = 0; if (fractionalDigits > decimals) fractionalDigits = decimals; @@ -20,7 +18,7 @@ String formatFixed(BigInt value, int? decimals, {int? fractionalDigits, bool tri fraction = removeTrailing("0", fraction); } - final whole = (value ~/ BigInt.parse(multiplier)); + final whole = value ~/ multiplier; final valString = fraction.isEmpty ? "$whole" : "$whole.$fraction"; diff --git a/cw_core/lib/parse_fixed.dart b/cw_core/lib/parse_fixed.dart index 76c74dc9f..dbbc24f7c 100644 --- a/cw_core/lib/parse_fixed.dart +++ b/cw_core/lib/parse_fixed.dart @@ -58,7 +58,7 @@ BigInt parseFixed(String value, int decimals) { final wholeValue = BigInt.parse(whole); final fractionValue = BigInt.parse(fraction); - final multiplierValue = BigInt.parse(multiplier); + final multiplierValue = multiplierOf(decimals); var wei = (wholeValue * multiplierValue) + fractionValue; @@ -69,3 +69,8 @@ BigInt parseFixed(String value, int decimals) { // Returns a string "1" followed by decimal "0"s String getMultiplier(int decimals) => "1".padRight(decimals + 1, "0"); + +final _multipliers = {}; + +// this is more direct and faster than having it as string then parsing everytime to get the number +BigInt multiplierOf(int decimals) => _multipliers[decimals] ??= BigInt.from(10).pow(decimals); diff --git a/cw_solana/lib/solana_client.dart b/cw_solana/lib/solana_client.dart index b166a3505..ef1799cfa 100644 --- a/cw_solana/lib/solana_client.dart +++ b/cw_solana/lib/solana_client.dart @@ -32,14 +32,29 @@ class TransactionFetchResult { }); } +class TransactionSyncResult { + final List transactions; + final String? newestSignature; + + TransactionSyncResult({ + required this.transactions, + this.newestSignature, + }); +} + class SolanaWalletClient { // Minimum amount in SOL to consider a transaction valid (to filter spam) static Money minValidAmount = Money.parse("0.00000003", CryptoCurrency.sol); + + static const int _signaturePageSize = 1000; + late final client = ProxyWrapper().getHttpIOClient(); SolanaRPC? _provider; + bool _isStopped = false; bool connect(Node node) { try { + _isStopped = false; String formattedUrl; String protocolUsed = node.isSSL ? "https" : "http"; @@ -625,10 +640,7 @@ class SolanaWalletClient { incomingAmount = diff.toDouble(); incomingMintAddress = mint; final token = await getTokenInfo(mint); - printV(token?.symbol); - printV(token?.decimals); - incomingToken = - token ?? const CryptoCurrency(name: "TOKEN", title: "TOKEN", decimals: 6); + incomingToken = token ?? const CryptoCurrency(name: "TOKEN", title: "TOKEN", decimals: 6); incomingTo = walletAddress; // We find the intermediate account if (instructions.isNotEmpty && instructions[0].accounts.isNotEmpty) { @@ -951,27 +963,67 @@ class SolanaWalletClient { return mints.toList(); } - /// Load the Address's transactions into the account - Future> fetchTransactions( + Future>> _getAllSignaturesSinceLastFetch( + SolAddress address, + String? until, + Commitment? commitment, + ) async { + final signatures = >[]; + String? before; + + while (true) { + final currentPageSignatureResults = await _provider!.request( + SolanaRPCGetSignaturesForAddress( + account: address, + commitment: commitment, + until: until, + before: before, + limit: _signaturePageSize, + ), + ); + + if (currentPageSignatureResults.isEmpty) break; + + signatures.addAll(currentPageSignatureResults); + + if (currentPageSignatureResults.length < _signaturePageSize) break; + + if (until == null) break; + + final lastSignatureOnPage = currentPageSignatureResults.last['signature'] as String; + + if (lastSignatureOnPage == before) break; + + before = lastSignatureOnPage; + } + + return signatures; + } + + Future fetchTransactions( SolAddress address, { SPLToken? splToken, Commitment? commitment, SolAddress? walletAddress, + String? untilSignature, required void Function(List) onUpdate, }) async { - List transactions = []; + final transactions = []; + try { - final signatures = await _provider!.request( - SolanaRPCGetSignaturesForAddress( - account: address, - commitment: commitment, - ), - ); + final signatures = + await _getAllSignaturesSinceLastFetch(address, untilSignature, commitment); + + if (signatures.isEmpty) return TransactionSyncResult(transactions: transactions); // The maximum concurrent batch size. const int batchSize = 10; + bool hasFailures = false; + for (int i = 0; i < signatures.length; i += batchSize) { + if (_isStopped) return TransactionSyncResult(transactions: transactions); + final batch = signatures.skip(i).take(batchSize).toList(); final batchResponses = await Future.wait(batch.map((signature) async { @@ -985,6 +1037,7 @@ class SolanaWalletClient { ), ); } catch (e) { + hasFailures = true; return null; } })); @@ -999,16 +1052,16 @@ class SolanaWalletClient { final parsedTransactionsLists = await Future.wait(parsedTransactionsFutures); - // We flatten the list of lists into a single list + final batchTransactions = []; for (final parsedList in parsedTransactionsLists) { if (parsedList != null) { - transactions.addAll(parsedList); + batchTransactions.addAll(parsedList); } } - // Only update UI if we have new valid transactions - if (transactions.isNotEmpty) { - onUpdate(List.from(transactions)); + if (batchTransactions.isNotEmpty) { + transactions.addAll(batchTransactions); + onUpdate(batchTransactions); } if (i + batchSize < signatures.length) { @@ -1016,38 +1069,53 @@ class SolanaWalletClient { } } - return transactions; + return TransactionSyncResult( + transactions: transactions, + newestSignature: hasFailures ? null : signatures.first['signature'] as String, + ); } catch (err, s) { printV('Error fetching transactions: $err \n$s'); - return []; + return TransactionSyncResult(transactions: transactions); } } - Future> getSPLTokenTransfers({ + final Map associatedTokenAccountCache = {}; + + Future getSPLTokenTransfers({ required String mintAddress, required SPLToken splToken, required SolanaPrivateKey privateKey, + String? untilSignature, required void Function(List) onUpdate, }) async { - ProgramDerivedAddress? associatedTokenAccount; final ownerWalletAddress = privateKey.publicKey().toAddress(); - try { - associatedTokenAccount = await _getOrCreateAssociatedTokenAccount( - payerPrivateKey: privateKey, - mintAddress: SolAddress(mintAddress), - ownerAddress: ownerWalletAddress, - shouldCreateATA: false, - ); - } catch (e, s) { - printV('$e \n $s'); - } - if (associatedTokenAccount == null) return []; + var associatedTokenAccount = associatedTokenAccountCache[mintAddress]; + + if (associatedTokenAccount == null) { + try { + associatedTokenAccount = await _getOrCreateAssociatedTokenAccount( + payerPrivateKey: privateKey, + mintAddress: SolAddress(mintAddress), + ownerAddress: ownerWalletAddress, + shouldCreateATA: false, + ); + } catch (e, s) { + printV('$e \n $s'); + } + + if (associatedTokenAccount == null) { + return TransactionSyncResult(transactions: []); + } + + associatedTokenAccountCache[mintAddress] = associatedTokenAccount; + } return fetchTransactions( associatedTokenAccount.address, splToken: splToken, walletAddress: ownerWalletAddress, + untilSignature: untilSignature, onUpdate: onUpdate, ); } @@ -1055,16 +1123,9 @@ class SolanaWalletClient { final Map tokenInfoCache = {}; Future getTokenInfo(String mintAddress) async { - if (tokenInfoCache.containsKey(mintAddress)) { - printV("Cached"); - return tokenInfoCache[mintAddress]; - } else { - final token = await fetchSPLTokenInfo(mintAddress); - if (token != null) { - tokenInfoCache[mintAddress] = token; - } - return token; - } + if (tokenInfoCache.containsKey(mintAddress)) return tokenInfoCache[mintAddress]; + + return tokenInfoCache[mintAddress] = await fetchSPLTokenInfo(mintAddress); } Future fetchSPLTokenInfo(String mintAddress) async { @@ -1139,7 +1200,7 @@ class SolanaWalletClient { } } - void stop() {} + void stop() => _isStopped = true; SolanaRPC? get getSolanaProvider => _provider; diff --git a/cw_solana/lib/solana_transaction_history.dart b/cw_solana/lib/solana_transaction_history.dart index 62b9db8f7..00fcbec90 100644 --- a/cw_solana/lib/solana_transaction_history.dart +++ b/cw_solana/lib/solana_transaction_history.dart @@ -31,18 +31,36 @@ abstract class SolanaTransactionHistoryBase extends TransactionHistoryBase _saveQueue = Future.value(); + @override - Future save() async { - try { - final dirPath = await pathForWalletDir(name: walletInfo.name, type: walletInfo.type); - final path = '$dirPath/$transactionsHistoryFileName'; - final transactionMaps = transactions.map((key, value) => MapEntry(key, value.toJson())); - final data = json.encode({'transactions': transactionMaps}); - await encryptionFileUtils.write(path: path, password: _password, data: data); - } catch (e, s) { - printV('Error while saving solana transaction history: ${e.toString()}'); - printV(s); - } + Future save() => saveAndConfirm(); + + Future saveAndConfirm() { + final write = _saveQueue.then((_) async { + try { + await _write(); + + return true; + } catch (e, s) { + printV('Error while saving solana transaction history: ${e.toString()}'); + printV(s); + + return false; + } + }); + + _saveQueue = write; + + return write; + } + + Future _write() async { + final dirPath = await pathForWalletDir(name: walletInfo.name, type: walletInfo.type); + final path = '$dirPath/$transactionsHistoryFileName'; + final transactionMaps = transactions.map((key, value) => MapEntry(key, value.toJson())); + final data = json.encode({'transactions': transactionMaps}); + await encryptionFileUtils.write(path: path, password: _password, data: data); } @override diff --git a/cw_solana/lib/solana_wallet.dart b/cw_solana/lib/solana_wallet.dart index 94160686e..2305f27fb 100644 --- a/cw_solana/lib/solana_wallet.dart +++ b/cw_solana/lib/solana_wallet.dart @@ -88,6 +88,8 @@ abstract class SolanaWalletBase Timer? _transactionsUpdateTimer; + Future? _currentRefresh; + late final Box splTokensBox; @override @@ -321,6 +323,33 @@ abstract class SolanaWalletBase ]); } + static const _nativeSource = 'native'; + + String _lastSyncedSignatureKey(String source) => + 'solana_last_synced_signature_${walletInfo.name}_$source'; + + Future _lastSyncedSignature(String source) async { + if (transactionHistory.transactions.isEmpty) return null; + + final prefs = await _sharedPrefs.future; + + return prefs.getString(_lastSyncedSignatureKey(source)); + } + + Future _saveLastSyncedSignature(String source, String? signature) async { + if (signature == null) return; + + final prefs = await _sharedPrefs.future; + + await prefs.setString(_lastSyncedSignatureKey(source), signature); + } + + Future _clearLastSyncedSignature(String source) async { + final prefs = await _sharedPrefs.future; + + await prefs.remove(_lastSyncedSignatureKey(source)); + } + /// Polls for a specific transaction by signature with exponential backoff /// I'm using this in case we make the call to fetch the transaction and it has not finished its confirmations on the solana network and been indexed by the node networks we use. Future pollForTransaction({ @@ -365,20 +394,34 @@ abstract class SolanaWalletBase await updateTransactionsHistory(); } - void updateTransactions(List updatedTx) { - addTransactionsToTransactionHistory(updatedTx); - } + void updateTransactions(List updatedTx) => _addTransactions(updatedTx); /// Fetches the native SOL transactions linked to the wallet Public Key Future _updateNativeSOLTransactions() async { - final transactions = - await _client.fetchTransactions(_solanaPublicKey.toAddress(), onUpdate: updateTransactions); + final result = await _client.fetchTransactions( + _solanaPublicKey.toAddress(), + untilSignature: await _lastSyncedSignature(_nativeSource), + onUpdate: updateTransactions, + ); - await addTransactionsToTransactionHistory(transactions); + await _updateStateWhenSyncForTheSourceEnds(_nativeSource, result); + } + + Future _updateStateWhenSyncForTheSourceEnds( + String source, + TransactionSyncResult result, + ) async { + if (result.transactions.isNotEmpty) { + final isSaved = await transactionHistory.saveAndConfirm(); + + if (!isSaved) return; + } + + await _saveLastSyncedSignature(source, result.newestSignature); } Future updateSPLTokenTransactions({List? specificMints}) async { - final allTokens = balance.keys.whereType().toList(growable: false); + final allTokens = splTokensBox.values.where((t) => t.enabled).toList(growable: false); // Filter to specific mints if provided final tokens = specificMints != null @@ -394,30 +437,28 @@ abstract class SolanaWalletBase i, i + batchSize > tokens.length ? tokens.length : i + batchSize, ); - final results = await Future.wait( + + await Future.wait( batch.map((token) async { try { - return await _client.getSPLTokenTransfers( + final result = await _client.getSPLTokenTransfers( mintAddress: token.mintAddress, splToken: token, privateKey: _solanaPrivateKey, + untilSignature: await _lastSyncedSignature(token.mintAddress), onUpdate: updateTransactions, ); - } catch (_) { - return []; + + await _updateStateWhenSyncForTheSourceEnds(token.mintAddress, result); + } catch (e) { + printV('Error fetching spl token (${token.symbol}) transfers ${e.toString()}'); } }), ); - - for (final list in results) { - await addTransactionsToTransactionHistory(list); - } } } - Future addTransactionsToTransactionHistory( - List transactions, - ) async { + void _addTransactions(List transactions) { final Map result = {}; for (var transactionModel in transactions) { @@ -436,6 +477,12 @@ abstract class SolanaWalletBase } transactionHistory.addMany(result); + } + + Future addTransactionsToTransactionHistory( + List transactions, + ) async { + _addTransactions(transactions); await transactionHistory.save(); } @@ -456,6 +503,17 @@ abstract class SolanaWalletBase await transactionHistory.save(); } + // we want to handle the case where multiple refresh triggers (our users can swipe down + // multiple times), so we track the currrent refresh and join it instead of starting + // another one + Future _refresh() { + return _currentRefresh ??= Future.wait([ + updateTokenBalance(), + updateTransactionsHistory(), + _getEstimatedFees(), + ]).whenComplete(() => _currentRefresh = null); + } + @action @override Future startSync() async { @@ -469,12 +527,7 @@ abstract class SolanaWalletBase return; } - await Future.wait([ - updateTokenBalance(), - _updateNativeSOLTransactions(), - updateSPLTokenTransactions(), - _getEstimatedFees(), - ]); + await _refresh(); syncStatus = SyncedSyncStatus(); } catch (e) { @@ -626,6 +679,14 @@ abstract class SolanaWalletBase List get splTokenCurrencies => splTokensBox.values.toList(); + SPLToken? splTokenBySymbol(String symbol) { + for (final token in splTokensBox.values) { + if (token.symbol == symbol) return token; + } + + return null; + } + void addInitialTokens() { final initialSPLTokens = DefaultSPLTokens().initialSPLTokens; @@ -802,12 +863,27 @@ abstract class SolanaWalletBase } Future deleteSPLToken(SPLToken token) async { + final sources = {token.mintAddress}; + + if (token.symbol == CryptoCurrency.sol.symbol) { + sources.add(_nativeSource); + } + if (splTokensBox.isOpen) { + sources.addAll(splTokensBox.values + .where((t) => t.symbol == token.symbol) + .map((t) => t.mintAddress)); + await splTokensBox.delete(token.mintAddress); } balance.remove(token); await _removeTokenTransactionsInHistory(token); + + for (final source in sources) { + await _clearLastSyncedSignature(source); + } + updateTokenBalance(); } @@ -831,11 +907,12 @@ abstract class SolanaWalletBase _transactionsUpdateTimer!.cancel(); } - _transactionsUpdateTimer = Timer.periodic(const Duration(seconds: 30), (_) { - updateTokenBalance(); - _updateNativeSOLTransactions(); - updateSPLTokenTransactions(); - _getEstimatedFees(); + _transactionsUpdateTimer = Timer.periodic(const Duration(seconds: 30), (_) async { + try { + await _refresh(); + } catch (e) { + printV('Error on periodic solana refresh: $e'); + } }); } diff --git a/lib/solana/cw_solana.dart b/lib/solana/cw_solana.dart index f1450331b..58999d0e5 100644 --- a/lib/solana/cw_solana.dart +++ b/lib/solana/cw_solana.dart @@ -122,9 +122,13 @@ class CWSolana extends Solana { return CryptoCurrency.sol; } - return (wallet as SolanaWallet).splTokenCurrencies.firstWhere( - (element) => transaction.amount.currency.symbol == element.symbol, - ); + final token = (wallet as SolanaWallet).splTokenBySymbol(transaction.amount.currency.symbol); + + if (token == null) { + throw StateError('No SPL token for symbol ${transaction.amount.currency.symbol}'); + } + + return token; } @override @@ -401,6 +405,15 @@ class CWSolana extends Solana { } await Future.wait(tokenChecks); + + final discoveredMints = result.newTokens + .where((item) => item.token.enabled) + .map((item) => item.token.mintAddress) + .toList(); + + if (discoveredMints.isNotEmpty) { + await wallet.updateSPLTokenTransactions(specificMints: discoveredMints); + } } catch (_) {} } diff --git a/lib/view_model/dashboard/balance_view_model.dart b/lib/view_model/dashboard/balance_view_model.dart index 4f098c02f..3128f74d8 100644 --- a/lib/view_model/dashboard/balance_view_model.dart +++ b/lib/view_model/dashboard/balance_view_model.dart @@ -14,7 +14,6 @@ import 'package:cake_wallet/zano/zano.dart'; import 'package:cw_core/amount/money.dart'; import 'package:cw_core/crypto_amount_format.dart'; import 'package:cw_core/transaction_history.dart'; -import 'package:cw_core/utils/print_verbose.dart'; import 'package:cw_core/wallet_base.dart'; import 'package:cake_wallet/store/app_store.dart'; import 'package:cake_wallet/store/dashboard/fiat_conversion_store.dart'; @@ -498,7 +497,6 @@ abstract class BalanceViewModelBase with Store { final record = wallet.balance[curr]!; final available = record.available - (record.secondAvailable ?? Money.zero(curr)); final price = fiatConversionStore.prices[curr] ?? 0; - printV(record.available); ret += double.tryParse(calculateFiatAmount(price: price, cryptoAmount: available.toString()) .replaceAll(",", "")) ?? 0; diff --git a/lib/view_model/dashboard/dashboard_view_model.dart b/lib/view_model/dashboard/dashboard_view_model.dart index 4dbc82752..17ffd334a 100644 --- a/lib/view_model/dashboard/dashboard_view_model.dart +++ b/lib/view_model/dashboard/dashboard_view_model.dart @@ -227,9 +227,10 @@ abstract class DashboardViewModelBase with Store { 1; } catch (_) {} } else { - confirmations = appStore.wallet!.transactionHistory.transactions.values - .map((item) => item.isPending) - .fold(0, (val, pending) => pending ? val + 1 : val); + final pendingCount = appStore.wallet!.transactionHistory.transactions.values + .where((item) => item.isPending) + .length; + confirmations = pendingCount + 1; } return length * confirmations; }, _transactionDisposerCallback, delay: 300); @@ -1331,9 +1332,10 @@ abstract class DashboardViewModelBase with Store { 1; } catch (_) {} } else { - confirmations = appStore.wallet!.transactionHistory.transactions.values - .map((item) => item.isPending) - .fold(0, (val, pending) => pending ? val + 1 : val); + final pendingCount = appStore.wallet!.transactionHistory.transactions.values + .where((item) => item.isPending) + .length; + confirmations = pendingCount + 1; } return length * confirmations; }, _transactionDisposerCallback, delay: 300); diff --git a/lib/view_model/dashboard/transaction_list_item.dart b/lib/view_model/dashboard/transaction_list_item.dart index 8676cf86c..4d6a0d527 100644 --- a/lib/view_model/dashboard/transaction_list_item.dart +++ b/lib/view_model/dashboard/transaction_list_item.dart @@ -195,7 +195,7 @@ class TransactionListItem extends ActionListItem with Keyable { case WalletType.decred: case WalletType.zcash: amount = calculateFiatAmountRaw( - cryptoAmount: double.parse(transaction.amount.toString()), + cryptoAmount: transaction.amount.toDouble(), price: price, ).withLocalSeperator(_appStore.settingsStore.languageCode); case WalletType.ethereum: @@ -206,15 +206,15 @@ class TransactionListItem extends ActionListItem with Keyable { final asset = assetOfTransaction; final price = balanceViewModel.fiatConversionStore.prices[asset]; amount = calculateFiatAmountRaw( - cryptoAmount: double.parse(transaction.amount.toString()), + cryptoAmount: transaction.amount.toDouble(), price: price, ).withLocalSeperator(_appStore.settingsStore.languageCode); break; case WalletType.solana: - final asset = solana!.assetOfTransaction(balanceViewModel.wallet, transaction); + final asset = assetOfTransaction; final price = balanceViewModel.fiatConversionStore.prices[asset]; amount = calculateFiatAmountRaw( - cryptoAmount: double.parse(transaction.amount.toString()), + cryptoAmount: transaction.amount.toDouble(), price: price, ).withLocalSeperator(_appStore.settingsStore.languageCode); break; @@ -222,7 +222,7 @@ class TransactionListItem extends ActionListItem with Keyable { final asset = tron!.assetOfTransaction(balanceViewModel.wallet, transaction); final price = balanceViewModel.fiatConversionStore.prices[asset]; amount = calculateFiatAmountRaw( - cryptoAmount: double.parse(transaction.amount.toString()), + cryptoAmount: transaction.amount.toDouble(), price: price, ).withLocalSeperator(_appStore.settingsStore.languageCode); break; @@ -234,7 +234,7 @@ class TransactionListItem extends ActionListItem with Keyable { } final price = balanceViewModel.fiatConversionStore.prices[asset]; amount = calculateFiatAmountRaw( - cryptoAmount: double.parse(transaction.amount.toString()), + cryptoAmount: transaction.amount.toDouble(), price: price, ).withLocalSeperator(_appStore.settingsStore.languageCode); break;