solana nft improvements and other fixes (#3581)

* feat: improvements covering solana nfts(send, ownership badge, persistent imports etc), closing emptied token accounts and other fixes from patch

* feat: more improvements for nfts including security hardening

* fix: display error in full
This commit is contained in:
David Adegoke
2026-09-03 01:54:11 +08:00
committed by GitHub
parent 87ba3838ae
commit ea4d752914
68 changed files with 2830 additions and 396 deletions
@@ -0,0 +1,132 @@
import "dart:async";
import "package:cake_wallet/core/execution_state.dart";
import "package:cake_wallet/entities/solana_nft_asset_model.dart";
import "package:cake_wallet/generated/i18n.dart";
import "package:cake_wallet/solana/solana.dart";
import "package:cake_wallet/store/app_store.dart";
import "package:cake_wallet/view_model/contact_list/contact_list_view_model.dart";
import "package:cw_core/exceptions.dart";
import "package:cw_core/pending_transaction.dart";
import "package:cw_core/utils/print_verbose.dart";
import "package:cw_core/wallet_base.dart";
import "package:mobx/mobx.dart";
part "nft_send_view_model.g.dart";
class NFTSendViewModel = NFTSendViewModelBase with _$NFTSendViewModel;
abstract class NFTSendViewModelBase with Store {
NFTSendViewModelBase(this._appStore, this._contactListViewModel);
final AppStore _appStore;
final ContactListViewModel _contactListViewModel;
@observable
ExecutionState state = InitialExecutionState();
@observable
PendingTransaction? pendingTransaction;
bool shouldRequireTOTP2FAFor(String destinationAddress) {
final settingsStore = _appStore.settingsStore;
final isContact = _contactListViewModel.contactsToShow
.any((contact) => contact.address == destinationAddress);
if (isContact) {
return settingsStore.shouldRequireTOTP2FAForSendsToContact;
}
final isInternalWallet = _contactListViewModel.walletContactsToShow
.any((contact) => contact.address == destinationAddress);
if (isInternalWallet) {
return settingsStore.shouldRequireTOTP2FAForSendsToInternalWallets;
}
return settingsStore.shouldRequireTOTP2FAForSendsToNonContact;
}
@action
Future<void> createTransaction(SolanaNFTAssetModel asset, String destinationAddress) async {
state = IsExecutingState();
try {
pendingTransaction = await solana!.sendNFT(
_appStore.wallet!,
mintAddress: asset.mint!,
destinationAddress: destinationAddress,
name: asset.name,
);
state = ExecutedSuccessfullyState();
} catch (e) {
pendingTransaction = null;
state = FailureState(_errorMessage(e));
}
}
@action
Future<void> commitTransaction() async {
state = IsExecutingState();
try {
final wallet = _appStore.wallet!;
final transaction = pendingTransaction!;
await transaction.commit();
final signature = transaction.id;
pendingTransaction = null;
state = ExecutedSuccessfullyState();
if (signature.isNotEmpty) {
unawaited(_pollForTransaction(wallet, signature));
}
} catch (e) {
state = FailureState(_errorMessage(e));
}
}
@action
void markSendStarted() => state = IsExecutingState();
@action
void reset() {
pendingTransaction = null;
state = InitialExecutionState();
}
Future<void> _pollForTransaction(WalletBase wallet, String signature) async {
try {
await solana!.pollForTransaction(wallet, signature);
} catch (e) {
printV("Failed to poll for NFT transfer: ${e.toString()}");
}
}
String _errorMessage(Object error) {
if (error is NotAnNFTException) {
return S.current.nft_send_not_an_nft;
}
if (error is NoAssociatedTokenAccountException) {
return S.current.solana_no_associated_token_account_exception;
}
if (error is SignSPLTokenTransactionRentException) {
return S.current.solana_sign_spl_token_transaction_rent_exception;
}
if (error is CreateAssociatedTokenAccountException) {
return "${S.current.solana_create_associated_token_account_exception} "
"${S.current.added_message_for_ata_error}";
}
printV("NFT send failed: ${error.toString()}");
return error.toString();
}
}
+382 -112
View File
@@ -1,13 +1,18 @@
import "dart:async";
import 'dart:convert';
import 'dart:developer';
import "package:cake_wallet/entities/saved_nfts.dart";
import 'package:cake_wallet/entities/solana_nft_asset_model.dart';
import 'package:cake_wallet/generated/i18n.dart';
import 'package:cake_wallet/reactions/wallet_connect.dart';
import "package:cake_wallet/solana/solana.dart";
import 'package:cake_wallet/src/screens/wallet_connect/services/bottom_sheet_service.dart';
import 'package:cake_wallet/src/screens/wallet_connect/widgets/bottom_sheet/bottom_sheet_message_display_widget.dart';
import 'package:cake_wallet/evm/evm.dart';
import 'package:cake_wallet/utils/debounce.dart';
import "package:cw_core/utils/ipfs_url.dart";
import "package:cw_core/utils/print_verbose.dart";
import "package:cw_core/wallet_base.dart";
import 'package:cw_core/wallet_type.dart';
import 'package:cw_core/utils/proxy_wrapper.dart';
import 'package:mobx/mobx.dart';
@@ -41,94 +46,96 @@ abstract class NFTViewModelBase with Store {
final BottomSheetService bottomSheetService;
final Debounce _fetchDebounce = Debounce(const Duration(milliseconds: 500));
Future<void> _lastFetch = Future.value();
String? _loadedWalletAddress;
Completer<void>? _queuedFetch;
final SavedNFTs _savedNFTs = SavedNFTs();
int _currentRefreshId = 0;
static const _requestTimeout = Duration(seconds: 20);
static const _runDeadline = Duration(minutes: 2);
Map<String, String> get _moralisHeaders => {
"Accept": "application/json",
"X-API-Key": secrets.moralisApiKey,
};
@observable
bool isLoading;
@observable
bool isImportNFTLoading;
@observable
int unresolvedNFTCount = 0;
ObservableList<NFTAssetModel> nftAssetByWalletModels;
ObservableList<SolanaNFTAssetModel> solanaNftAssetModels;
bool get isSolanaWallet => appStore.wallet?.type == WalletType.solana;
String? get currentWalletName => appStore.wallet?.name;
@action
Future<void> getNFTAssetByWallet() async {
final wallet = appStore.wallet!;
void onWalletChanged() {
nftAssetByWalletModels.clear();
solanaNftAssetModels.clear();
unresolvedNFTCount = 0;
_nftErrorPopupShown = false;
_fetchDebounce.run(getNFTAssetByWallet);
}
if (!isNFTACtivatedChain(wallet.type, chainId: wallet.chainId)) return;
@action
Future<void> getNFTAssetByWallet() {
final queued = _queuedFetch;
final walletAddress = wallet.walletInfo.address;
log('Fetching wallet NFTs for $walletAddress');
if (queued != null) {
return queued.future;
}
final chainName = getChainNameBasedOnWalletType(wallet.type, chainId: wallet.chainId);
// the [chain] refers to the chain network that the nft is on
// the [format] refers to the number format type of the responses
// the [normalizedMetadata] field is a boolean that determines if
// the response would include a json string of the NFT Metadata that can be decoded
// and used within the wallet
// the [excludeSpam] field is a boolean that determines if spam nfts be excluded from the response.
final done = Completer<void>();
_queuedFetch = done;
Uri uri;
if (wallet.type == WalletType.solana) {
uri = Uri.https(
'solana-gateway.moralis.io',
'/account/$chainName/$walletAddress/nft',
);
} else {
uri = Uri.https(
'deep-index.moralis.io',
'/api/v2.2/$walletAddress/nft',
{
"chain": chainName,
"format": "decimal",
"media_items": "false",
"exclude_spam": "true",
"normalizeMetadata": "true",
},
);
final waitFor = _lastFetch;
_lastFetch = done.future;
unawaited(_fetchAfterPreviousRun(waitFor, done));
return done.future;
}
Future<void> _fetchAfterPreviousRun(Future<void> waitFor, Completer<void> done) async {
await waitFor;
if (identical(_queuedFetch, done)) {
_queuedFetch = null;
}
try {
if (isLoading) return;
final wallet = appStore.wallet!;
if (!isNFTACtivatedChain(wallet.type, chainId: wallet.chainId)) {
return;
}
final walletAddress = wallet.walletInfo.address;
final chainName = getChainNameBasedOnWalletType(wallet.type, chainId: wallet.chainId);
isLoading = true;
final response = await ProxyWrapper().get(
clearnetUri: uri,
headers: {
"Accept": "application/json",
"X-API-Key": secrets.moralisApiKey,
},
);
final refreshId = ++_currentRefreshId;
final decodedResponse = jsonDecode(response.body);
if (isSolanaWallet) {
await _getSolanaNFTAssets(wallet, walletAddress, chainName, refreshId)
.timeout(_runDeadline);
} else {
await _getEvmNFTAssets(wallet.name, walletAddress, chainName, refreshId);
}
_nftErrorPopupShown = false;
if (wallet.type == WalletType.solana) {
final results = await Future.wait(
(decodedResponse as List<dynamic>).map(
(x) {
final data = x as Map<String, dynamic>;
final mint = data['mint'] as String? ?? '';
return getSolanaNFTDetails(mint, chainName);
},
).toList(),
);
solanaNftAssetModels.clear();
solanaNftAssetModels.addAll(results);
} else {
final result =
WalletNFTsResponseModel.fromJson(decodedResponse as Map<String, dynamic>).result ?? [];
nftAssetByWalletModels.clear();
nftAssetByWalletModels.addAll(result);
}
} catch (e) {
log(e.toString());
printV("Fetching wallet NFTs failed: ${e.toString()}");
if (!_nftErrorPopupShown) {
_nftErrorPopupShown = true;
bottomSheetService.queueBottomSheet(
@@ -140,79 +147,342 @@ abstract class NFTViewModelBase with Store {
}
} finally {
isLoading = false;
done.complete();
}
}
Future<SolanaNFTAssetModel> getSolanaNFTDetails(String address, String chainName) async {
final uri = Uri.https(
'solana-gateway.moralis.io',
'/nft/$chainName/$address/metadata',
Future<void> _getEvmNFTAssets(
String walletName,
String walletAddress,
String chainName,
int refreshId,
) async {
final saved = await _savedNFTs.evmNFTs(walletName, chainName);
var result = const <NFTAssetModel>[];
Object? indexerError;
try {
final response = await ProxyWrapper()
.get(
clearnetUri: Uri.https(
"deep-index.moralis.io",
"/api/v2.2/$walletAddress/nft",
{
"chain": chainName,
"format": "decimal",
"media_items": "false",
"exclude_spam": "true",
"normalizeMetadata": "true",
},
),
headers: _moralisHeaders,
)
.timeout(_requestTimeout);
result = WalletNFTsResponseModel.fromJson(
jsonDecode(response.body) as Map<String, dynamic>,
).result ??
[];
} catch (e) {
indexerError = e;
}
if (appStore.wallet?.walletInfo.address != walletAddress || refreshId != _currentRefreshId) {
return;
}
final listed = result.map(SavedNFTs.evmIdentifier).toSet();
nftAssetByWalletModels.clear();
nftAssetByWalletModels.addAll(result);
nftAssetByWalletModels
.addAll(saved.where((asset) => !listed.contains(SavedNFTs.evmIdentifier(asset))));
if (indexerError != null) {
throw indexerError;
}
}
Future<void> _getSolanaNFTAssets(
WalletBase wallet,
String walletAddress,
String chainName,
int refreshId,
) async {
final importedAssets = await _savedNFTs.solanaNFTs(wallet.name);
var indexerMints = const <String>[];
Object? indexerError;
try {
indexerMints = await _fetchMintsFromIndexer(walletAddress, chainName);
} catch (e) {
indexerError = e;
}
final heldMints = await _fetchMintsHeldOnChain(wallet);
final failedMints = <String>{};
final assets = await _loadAssetsForMints(
wallet: wallet,
chainName: chainName,
mints: <String>{...indexerMints, ...importedAssets.keys},
heldMints: heldMints,
importedAssets: importedAssets,
failedMints: failedMints,
);
final response = await ProxyWrapper().get(
clearnetUri: uri,
headers: {
"Accept": "application/json",
"X-API-Key": secrets.moralisApiKey,
},
if (appStore.wallet?.walletInfo.address != walletAddress || refreshId != _currentRefreshId) {
return;
}
assets.addAll(
_assetsToKeepFromPreviousRefresh(walletAddress, assets, failedMints, indexerError != null));
solanaNftAssetModels.clear();
solanaNftAssetModels.addAll(assets);
_loadedWalletAddress = walletAddress;
final shownMints = assets.map((asset) => asset.mint).toSet();
unresolvedNFTCount = failedMints.where((mint) => !shownMints.contains(mint)).length;
if (heldMints != null) {
await _savedNFTs.refreshSolana(wallet.name, assets);
}
if (indexerError != null) {
throw indexerError;
}
}
Future<List<String>> _fetchMintsFromIndexer(String walletAddress, String chainName) async {
final response = await ProxyWrapper()
.get(
clearnetUri: Uri.https(
"solana-gateway.moralis.io",
"/account/$chainName/$walletAddress/nft",
),
headers: _moralisHeaders,
)
.timeout(_requestTimeout);
if (response.statusCode < 200 || response.statusCode >= 300) {
throw Exception("Failed to fetch wallet NFTs (${response.statusCode})");
}
return (jsonDecode(response.body) as List<dynamic>)
.map((entry) => (entry as Map<String, dynamic>)["mint"] as String? ?? "")
.where((mint) => mint.isNotEmpty)
.toList();
}
Future<Set<String>?> _fetchMintsHeldOnChain(WalletBase wallet) async {
try {
return await solana!.getHeldTokenMints(wallet);
} catch (e) {
printV("Could not fetch held token mints: ${e.toString()}");
return null;
}
}
Future<List<SolanaNFTAssetModel>> _loadAssetsForMints({
required WalletBase wallet,
required String chainName,
required Set<String> mints,
required Set<String>? heldMints,
required Map<String, SolanaNFTAssetModel> importedAssets,
required Set<String> failedMints,
}) async {
const batchSize = 5;
final ordered = mints.toList();
final assets = <SolanaNFTAssetModel>[];
for (var i = 0; i < ordered.length; i += batchSize) {
final described = await Future.wait(
ordered.skip(i).take(batchSize).map((mint) async {
try {
final asset = await getSolanaNFTDetails(wallet, mint, chainName);
asset.isOwned = heldMints?.contains(mint) ?? importedAssets[mint]?.isOwned;
return asset;
} catch (e) {
printV("Failed to load NFT $mint: ${e.toString()}");
failedMints.add(mint);
final cached = importedAssets[mint];
if (cached != null && heldMints != null) {
cached.isOwned = heldMints.contains(mint);
}
return cached;
}
}),
);
assets.addAll(described.whereType<SolanaNFTAssetModel>());
}
return assets;
}
List<SolanaNFTAssetModel> _assetsToKeepFromPreviousRefresh(
String walletAddress,
List<SolanaNFTAssetModel> described,
Set<String> failedMints,
bool indexerFailed,
) {
if (_loadedWalletAddress != walletAddress) {
return const [];
}
final describedMints = described.map((asset) => asset.mint).toSet();
return solanaNftAssetModels.where((asset) {
final mint = asset.mint;
if (mint == null || describedMints.contains(mint)) {
return false;
}
return indexerFailed || failedMints.contains(mint);
}).toList();
}
Future<SolanaNFTAssetModel> getSolanaNFTDetails(
WalletBase wallet,
String address,
String chainName,
) async {
SolanaNFTAssetModel? asset;
try {
asset = await _fetchMoralisNFT(address, chainName);
asset.address = address;
asset.mint = address;
} catch (e) {
printV("Moralis NFT metadata failed for $address: ${e.toString()}");
}
if (asset != null && (asset.imageOriginalUrl?.isNotEmpty ?? false)) {
return asset;
}
// fallback for when moralis is unavailable, we still get the core details for that nft onchain
final onChainData = await solana!.getNFTOnChainMetadata(wallet, address);
if (asset != null) {
if (onChainData != null) {
asset.imageOriginalUrl = tryNormalizeIpfsUrl(onChainData.imageUrl);
}
return asset;
}
if (onChainData == null) {
throw Exception("Could not load NFT metadata for $address");
}
return SolanaNFTAssetModel(
address: address,
mint: address,
standard: "metaplex",
name: onChainData.name,
symbol: onChainData.symbol,
imageOriginalUrl: onChainData.imageUrl,
metadataOriginalUrl: onChainData.metadataUri,
);
}
final decodedResponse = jsonDecode(response.body) as Map<String, dynamic>;
Future<SolanaNFTAssetModel> _fetchMoralisNFT(String address, String chainName) async {
final response = await ProxyWrapper()
.get(
clearnetUri: Uri.https(
"solana-gateway.moralis.io",
"/nft/$chainName/$address/metadata",
),
headers: _moralisHeaders,
)
.timeout(_requestTimeout);
return SolanaNFTAssetModel.fromJson(decodedResponse);
if (response.statusCode < 200 || response.statusCode >= 300) {
throw Exception("Failed to fetch NFT metadata (${response.statusCode})");
}
return SolanaNFTAssetModel.fromJson(jsonDecode(response.body) as Map<String, dynamic>);
}
@action
Future<void> onNFTSent(String walletName, String mint) async {
await _savedNFTs.removeSolana(walletName, mint);
if (appStore.wallet?.name == walletName) {
solanaNftAssetModels.removeWhere((asset) => asset.mint == mint);
}
}
Future<void> _importEvmNFT(
String walletName,
String tokenAddress,
String? tokenId,
String chainName,
) async {
final response = await ProxyWrapper()
.get(
clearnetUri: Uri.https(
"deep-index.moralis.io",
"/api/v2.2/nft/$tokenAddress/$tokenId",
{
"chain": chainName,
"format": "decimal",
"media_items": "false",
"normalizeMetadata": "true",
},
),
headers: _moralisHeaders,
)
.timeout(_requestTimeout);
final asset = NFTAssetModel.fromJson(jsonDecode(response.body) as Map<String, dynamic>);
nftAssetByWalletModels.add(asset);
await _savedNFTs.addEvm(walletName, chainName, asset);
}
@action
Future<void> importNFT(String tokenAddress, String? tokenId) async {
final walletType = appStore.wallet!.type;
final wallet = appStore.wallet!;
int? chainId;
if (isEVMCompatibleChain(walletType)) {
chainId = evm!.getSelectedChainId(appStore.wallet!);
if (isEVMCompatibleChain(wallet.type)) {
chainId = evm!.getSelectedChainId(wallet);
}
final chainName = getChainNameBasedOnWalletType(walletType, chainId: chainId);
// the [chain] refers to the chain network that the nft is on
// the [format] refers to the number format type of the responses
// the [normalizedMetadata] field is a boolean that determines if
// the response would include a json string of the NFT Metadata that can be decoded
// and used within the wallet
final chainName = getChainNameBasedOnWalletType(wallet.type, chainId: chainId);
try {
isImportNFTLoading = true;
if (appStore.wallet!.type == WalletType.solana) {
final result = await getSolanaNFTDetails(tokenAddress, chainName);
if (isSolanaWallet) {
final result = await getSolanaNFTDetails(wallet, tokenAddress, chainName);
try {
result.isOwned = (await solana!.getHeldTokenMints(wallet)).contains(result.mint);
} catch (e) {
printV("Could not check NFT ownership: ${e.toString()}");
}
solanaNftAssetModels.removeWhere((asset) => asset.mint == result.mint);
solanaNftAssetModels.add(result);
await _savedNFTs.addSolana(wallet.name, result);
} else {
final uri = Uri.https(
'deep-index.moralis.io',
'/api/v2.2/nft/$tokenAddress/$tokenId',
{
"chain": chainName,
"format": "decimal",
"media_items": "false",
"normalizeMetadata": "true",
},
);
final response = await ProxyWrapper().get(
clearnetUri: uri,
headers: {
"Accept": "application/json",
"X-API-Key": secrets.moralisApiKey,
},
);
final decodedResponse = jsonDecode(response.body) as Map<String, dynamic>;
final nftAsset = NFTAssetModel.fromJson(decodedResponse);
nftAssetByWalletModels.add(nftAsset);
await _importEvmNFT(wallet.name, tokenAddress, tokenId, chainName);
}
} catch (e) {
printV("Importing NFT $tokenAddress failed: ${e.toString()}");
bottomSheetService.queueBottomSheet(
isModalDismissible: true,
widget: BottomSheetMessageDisplayWidget(
message: e.toString(),
message: S.current.nft_import_failed,
),
);
} finally {
+15 -19
View File
@@ -3,7 +3,6 @@ import 'dart:async';
import 'package:cake_wallet/core/key_service.dart';
import 'package:cake_wallet/di.dart';
import 'package:cake_wallet/monero/monero.dart';
import 'package:cw_monero/monero_wallet.dart';
import 'package:mobx/mobx.dart';
import 'package:cw_core/wallet_base.dart';
@@ -56,26 +55,24 @@ abstract class DevMoneroBackgroundSyncBase with Store {
bool isBackgroundSyncing = false;
Future<void> _setValues() async {
final w = (wallet as MoneroWallet);
localBlockHeight = (await monero!.getCurrentHeight()).toString();
nodeBlockHeight = (await w.getNodeHeight()).toString();
final keys = w.keys;
primaryAddress = keys.primaryAddress;
publicViewKey = keys.publicViewKey;
privateViewKey = keys.privateViewKey;
publicSpendKey = keys.publicSpendKey;
privateSpendKey = keys.privateSpendKey;
passphrase = keys.passphrase;
seed = w.seed;
seedLegacy = w.seedLegacy("English");
nodeBlockHeight = (await monero!.getNodeHeight(wallet)).toString();
final keys = monero!.getKeys(wallet);
primaryAddress = keys["primaryAddress"];
publicViewKey = keys["publicViewKey"];
privateViewKey = keys["privateViewKey"];
publicSpendKey = keys["publicSpendKey"];
privateSpendKey = keys["privateSpendKey"];
passphrase = keys["passphrase"];
seed = wallet.seed;
seedLegacy = monero!.getLegacySeed(wallet, "English");
tick = refreshTimer?.tick ?? -1;
isBackgroundSyncing = w.isBackgroundSyncRunning;
isBackgroundSyncing = monero!.isBackgroundSyncRunning(wallet);
}
@action
Future<void> manualRescan() async {
final w = (wallet as MoneroWallet);
await wallet.rescan(height: await w.getNodeHeight() - 10000);
await wallet.rescan(height: await monero!.getNodeHeight(wallet) - 10000);
}
@action
@@ -93,14 +90,13 @@ abstract class DevMoneroBackgroundSyncBase with Store {
@action
void startBackgroundSync() {
final w = (wallet as MoneroWallet);
w.startBackgroundSync();
unawaited(monero!.startBackgroundSync(wallet));
}
@action
Future<void> stopBackgroundSync() async {
final w = (wallet as MoneroWallet);
final keyService = getIt.get<KeyService>();
await w.stopBackgroundSync(await keyService.getWalletPassword(walletName: wallet.name));
final password = await keyService.getWalletPassword(walletName: wallet.name);
await monero!.stopBackgroundSync(wallet, password);
}
}
+1 -4
View File
@@ -11,13 +11,10 @@ import 'package:cw_core/transaction_info.dart';
import 'package:cw_core/wallet_base.dart';
import 'package:cw_core/wallet_info.dart';
import 'package:cw_core/wallet_type.dart';
import 'package:cw_monero/monero_wallet.dart';
import 'package:flutter/foundation.dart';
import 'package:mobx/mobx.dart';
import 'package:cake_wallet/decred/decred.dart';
import 'package:polyseed/polyseed.dart';
import 'package:cake_wallet/evm/evm.dart';
import 'package:cake_wallet/reactions/wallet_connect.dart';
part 'wallet_keys_view_model.g.dart';
@@ -118,7 +115,7 @@ abstract class WalletKeysViewModelBase with Store {
final langName = PolyseedLang.getByPhrase(_wallet.seed!).nameEnglish;
if (_wallet.type == WalletType.monero) {
return (_wallet as MoneroWalletBase).seedLegacy(langName);
return monero!.getLegacySeed(_wallet, langName);
} else if (_wallet.type == WalletType.wownero) {
return wownero!.getLegacySeed(_wallet, langName);
}