CW-1548: Solana wallet performance improvements (#3404)

* fix android CI

* add incremental sync, parallel fetch for native and spl token, and other improvments on perf and display regarding lagging
This commit is contained in:
David Adegoke
2026-07-16 19:57:23 +03:00
committed by GitHub
parent aa1dab4b1a
commit 0822c7d976
10 changed files with 284 additions and 108 deletions
+4
View File
@@ -191,6 +191,10 @@ class Money implements Comparable<Money> {
@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);
+4 -6
View File
@@ -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";
+6 -1
View File
@@ -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 = <int, BigInt>{};
// 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);
+105 -44
View File
@@ -32,14 +32,29 @@ class TransactionFetchResult {
});
}
class TransactionSyncResult {
final List<SolanaTransactionModel> 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<List<SolanaTransactionModel>> fetchTransactions(
Future<List<Map<String, dynamic>>> _getAllSignaturesSinceLastFetch(
SolAddress address,
String? until,
Commitment? commitment,
) async {
final signatures = <Map<String, dynamic>>[];
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<TransactionSyncResult> fetchTransactions(
SolAddress address, {
SPLToken? splToken,
Commitment? commitment,
SolAddress? walletAddress,
String? untilSignature,
required void Function(List<SolanaTransactionModel>) onUpdate,
}) async {
List<SolanaTransactionModel> transactions = [];
final transactions = <SolanaTransactionModel>[];
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 = <SolanaTransactionModel>[];
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<SolanaTransactionModel>.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<List<SolanaTransactionModel>> getSPLTokenTransfers({
final Map<String, ProgramDerivedAddress> associatedTokenAccountCache = {};
Future<TransactionSyncResult> getSPLTokenTransfers({
required String mintAddress,
required SPLToken splToken,
required SolanaPrivateKey privateKey,
String? untilSignature,
required void Function(List<SolanaTransactionModel>) 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: <SolanaTransactionModel>[]);
}
associatedTokenAccountCache[mintAddress] = associatedTokenAccount;
}
return fetchTransactions(
associatedTokenAccount.address,
splToken: splToken,
walletAddress: ownerWalletAddress,
untilSignature: untilSignature,
onUpdate: onUpdate,
);
}
@@ -1055,16 +1123,9 @@ class SolanaWalletClient {
final Map<String, SPLToken?> tokenInfoCache = {};
Future<SPLToken?> 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<SPLToken?> fetchSPLTokenInfo(String mintAddress) async {
@@ -1139,7 +1200,7 @@ class SolanaWalletClient {
}
}
void stop() {}
void stop() => _isStopped = true;
SolanaRPC? get getSolanaProvider => _provider;
+29 -11
View File
@@ -31,18 +31,36 @@ abstract class SolanaTransactionHistoryBase extends TransactionHistoryBase<Solan
await _load();
}
Future<void> _saveQueue = Future.value();
@override
Future<void> 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<void> save() => saveAndConfirm();
Future<bool> 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<void> _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
+106 -29
View File
@@ -88,6 +88,8 @@ abstract class SolanaWalletBase
Timer? _transactionsUpdateTimer;
Future<void>? _currentRefresh;
late final Box<SPLToken> 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<String?> _lastSyncedSignature(String source) async {
if (transactionHistory.transactions.isEmpty) return null;
final prefs = await _sharedPrefs.future;
return prefs.getString(_lastSyncedSignatureKey(source));
}
Future<void> _saveLastSyncedSignature(String source, String? signature) async {
if (signature == null) return;
final prefs = await _sharedPrefs.future;
await prefs.setString(_lastSyncedSignatureKey(source), signature);
}
Future<void> _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<void> pollForTransaction({
@@ -365,20 +394,34 @@ abstract class SolanaWalletBase
await updateTransactionsHistory();
}
void updateTransactions(List<SolanaTransactionModel> updatedTx) {
addTransactionsToTransactionHistory(updatedTx);
}
void updateTransactions(List<SolanaTransactionModel> updatedTx) => _addTransactions(updatedTx);
/// Fetches the native SOL transactions linked to the wallet Public Key
Future<void> _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<void> _updateStateWhenSyncForTheSourceEnds(
String source,
TransactionSyncResult result,
) async {
if (result.transactions.isNotEmpty) {
final isSaved = await transactionHistory.saveAndConfirm();
if (!isSaved) return;
}
await _saveLastSyncedSignature(source, result.newestSignature);
}
Future<void> updateSPLTokenTransactions({List<String>? specificMints}) async {
final allTokens = balance.keys.whereType<SPLToken>().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 <SolanaTransactionModel>[];
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<void> addTransactionsToTransactionHistory(
List<SolanaTransactionModel> transactions,
) async {
void _addTransactions(List<SolanaTransactionModel> transactions) {
final Map<String, SolanaTransactionInfo> result = {};
for (var transactionModel in transactions) {
@@ -436,6 +477,12 @@ abstract class SolanaWalletBase
}
transactionHistory.addMany(result);
}
Future<void> addTransactionsToTransactionHistory(
List<SolanaTransactionModel> 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<void> _refresh() {
return _currentRefresh ??= Future.wait([
updateTokenBalance(),
updateTransactionsHistory(),
_getEstimatedFees(),
]).whenComplete(() => _currentRefresh = null);
}
@action
@override
Future<void> 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<SPLToken> 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<void> deleteSPLToken(SPLToken token) async {
final sources = <String>{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');
}
});
}
+16 -3
View File
@@ -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 (_) {}
}
@@ -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;
@@ -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);
@@ -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;