mirror of
https://github.com/Such-Software/hash-wallet.git
synced 2026-10-26 06:08:40 +01:00
Merge branch 'dev' into cw-1440-new-buy-sell-flow
# Conflicts: # res/values/strings_it.arb # res/values/strings_ja.arb # res/values/strings_ko.arb # res/values/strings_my.arb # res/values/strings_nl.arb # res/values/strings_pl.arb # res/values/strings_pt.arb # res/values/strings_pt_BR.arb # res/values/strings_ru.arb # res/values/strings_th.arb # res/values/strings_tl.arb # res/values/strings_tr.arb # res/values/strings_uk.arb # res/values/strings_ur.arb # res/values/strings_vi.arb # res/values/strings_yo.arb # res/values/strings_zh.arb
This commit is contained in:
@@ -1,5 +1,6 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:cake_wallet/cake_pay/src/cake_pay_exceptions.dart';
|
||||
import 'package:cake_wallet/order/order_provider_description.dart';
|
||||
import 'package:cake_wallet/order/order.dart';
|
||||
import 'package:cake_wallet/order/order_source_description.dart';
|
||||
@@ -221,6 +222,8 @@ abstract class CakePayBuyCardViewModelBase with Store {
|
||||
orders.add(orderRecord);
|
||||
updateRemainingTime();
|
||||
_startExpirationTimer();
|
||||
} on CakePayUnauthorizedException {
|
||||
rethrow;
|
||||
} catch (e) {
|
||||
sendViewModel.state = FailureState(
|
||||
sendViewModel.translateErrorMessage(e, walletType, sendViewModel.wallet.currency));
|
||||
@@ -275,8 +278,6 @@ abstract class CakePayBuyCardViewModelBase with Store {
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> logout() async => await _cakePayService.logout();
|
||||
|
||||
void _startExpirationTimer() {
|
||||
_timer?.cancel();
|
||||
_timer = Timer.periodic(Duration(seconds: 1), (_) {
|
||||
|
||||
@@ -78,7 +78,6 @@ abstract class CakePayCardsListViewModelBase with Store {
|
||||
};
|
||||
|
||||
String searchString;
|
||||
String? username;
|
||||
int page;
|
||||
|
||||
late Country _initialSelectedCountry;
|
||||
@@ -87,6 +86,9 @@ abstract class CakePayCardsListViewModelBase with Store {
|
||||
late bool _initialDisplayDenominationsCards;
|
||||
late bool _initialDisplayCustomValueCards;
|
||||
|
||||
@observable
|
||||
String? username;
|
||||
|
||||
@observable
|
||||
List<CakePayCard> userCards;
|
||||
|
||||
@@ -189,12 +191,9 @@ abstract class CakePayCardsListViewModelBase with Store {
|
||||
|
||||
@action
|
||||
Future<void> checkAuth() async {
|
||||
authFuture = ObservableFuture(cakePayService.isLogged());
|
||||
|
||||
final logged = await authFuture!;
|
||||
if (logged) {
|
||||
username = await cakePayService.getUserEmail();
|
||||
}
|
||||
final logged = await cakePayService.isLogged();
|
||||
username = logged ? await cakePayService.getUserEmail() : null;
|
||||
authFuture = ObservableFuture.value(logged);
|
||||
}
|
||||
|
||||
@action
|
||||
|
||||
@@ -14,6 +14,7 @@ import 'package:cake_wallet/utils/mobx.dart';
|
||||
import 'package:cake_wallet/utils/token_utilities.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_info.dart';
|
||||
import 'package:cw_core/wallet_type.dart';
|
||||
import 'package:hive/hive.dart';
|
||||
@@ -33,79 +34,103 @@ abstract class ContactListViewModelBase with Store {
|
||||
}
|
||||
|
||||
Future<void> _init() async {
|
||||
final walletInfos = await WalletInfo.getAll();
|
||||
for (final info in walletInfos) {
|
||||
final addressInfos = await info.getAddressInfos();
|
||||
final addresses = await info.getAddresses();
|
||||
if ([WalletType.monero, WalletType.wownero, WalletType.haven].contains(info.type) &&
|
||||
addressInfos.isNotEmpty) {
|
||||
for (var key in addressInfos.keys) {
|
||||
final value = addressInfos[key];
|
||||
final address = value?.first;
|
||||
if (address != null) {
|
||||
final name = _createName(info.name, address.label, key: key);
|
||||
walletContacts.add(WalletContact(
|
||||
address.address,
|
||||
await loadWalletContacts();
|
||||
|
||||
_subscription = contactSource.bindToListWithTransform(
|
||||
contacts,
|
||||
(contact) => ContactRecord(contactSource, contact),
|
||||
initialFire: true,
|
||||
);
|
||||
|
||||
setOrderType(settingsStore.contactListOrder);
|
||||
}
|
||||
|
||||
Future<void> _lastWalletContactsLoad = Future.value();
|
||||
|
||||
@action
|
||||
Future<void> loadWalletContacts() async {
|
||||
final waitFor = _lastWalletContactsLoad;
|
||||
final done = Completer<void>();
|
||||
_lastWalletContactsLoad = done.future;
|
||||
|
||||
await waitFor;
|
||||
|
||||
try {
|
||||
final loadedContacts = <WalletContact>[];
|
||||
final walletInfos = await WalletInfo.getAll();
|
||||
for (final info in walletInfos) {
|
||||
final addressInfos = await info.getAddressInfos();
|
||||
final addresses = await info.getAddresses();
|
||||
if ([WalletType.monero, WalletType.wownero, WalletType.haven].contains(info.type) &&
|
||||
addressInfos.isNotEmpty) {
|
||||
for (var key in addressInfos.keys) {
|
||||
final value = addressInfos[key];
|
||||
final address = value?.first;
|
||||
if (address != null) {
|
||||
final name = _createName(info.name, address.label, key: key);
|
||||
loadedContacts.add(WalletContact(
|
||||
address.address,
|
||||
name,
|
||||
getCryptoCurrencyForWalletListItem(
|
||||
info.type,
|
||||
),
|
||||
walletType: info.type,
|
||||
));
|
||||
}
|
||||
}
|
||||
} else if (addresses.isNotEmpty == true && addresses.length > 1) {
|
||||
if ([WalletType.monero, WalletType.wownero, WalletType.haven, WalletType.decred]
|
||||
.contains(info.type)) {
|
||||
final address = info.address;
|
||||
final name = _createName(info.name, "", key: 0);
|
||||
loadedContacts.add(WalletContact(
|
||||
address,
|
||||
name,
|
||||
getCryptoCurrencyForWalletListItem(
|
||||
info.type,
|
||||
),
|
||||
walletType: info.type,
|
||||
));
|
||||
} else {
|
||||
addresses.forEach((address, label) {
|
||||
if (label.isEmpty) {
|
||||
return;
|
||||
}
|
||||
final name = _createName(info.name, label, key: null);
|
||||
loadedContacts.add(WalletContact(
|
||||
address,
|
||||
name,
|
||||
getCryptoCurrencyForWalletListItem(
|
||||
info.type,
|
||||
isTestnet: info.network == null
|
||||
? false
|
||||
: info.network!.toLowerCase().contains("testnet"),
|
||||
),
|
||||
walletType: info.type,
|
||||
));
|
||||
});
|
||||
}
|
||||
}
|
||||
} else if (addresses.isNotEmpty == true && addresses.length > 1) {
|
||||
if ([WalletType.monero, WalletType.wownero, WalletType.haven, WalletType.decred]
|
||||
.contains(info.type)) {
|
||||
final address = info.address;
|
||||
final name = _createName(info.name, "", key: 0);
|
||||
walletContacts.add(WalletContact(
|
||||
address,
|
||||
name,
|
||||
} else {
|
||||
loadedContacts.add(WalletContact(
|
||||
info.address,
|
||||
_createName(info.name, "",
|
||||
key: [WalletType.monero, WalletType.wownero, WalletType.haven].contains(info.type)
|
||||
? 0
|
||||
: null),
|
||||
getCryptoCurrencyForWalletListItem(
|
||||
info.type,
|
||||
),
|
||||
walletType: info.type,
|
||||
));
|
||||
} else {
|
||||
addresses.forEach((address, label) {
|
||||
if (label.isEmpty) {
|
||||
return;
|
||||
}
|
||||
final name = _createName(info.name, label, key: null);
|
||||
walletContacts.add(WalletContact(
|
||||
address,
|
||||
name,
|
||||
getCryptoCurrencyForWalletListItem(
|
||||
info.type,
|
||||
isTestnet:
|
||||
info.network == null ? false : info.network!.toLowerCase().contains("testnet"),
|
||||
),
|
||||
walletType: info.type,
|
||||
));
|
||||
});
|
||||
}
|
||||
} else {
|
||||
walletContacts.add(WalletContact(
|
||||
info.address,
|
||||
_createName(info.name, "",
|
||||
key: [WalletType.monero, WalletType.wownero, WalletType.haven].contains(info.type)
|
||||
? 0
|
||||
: null),
|
||||
getCryptoCurrencyForWalletListItem(
|
||||
info.type,
|
||||
),
|
||||
walletType: info.type,
|
||||
));
|
||||
}
|
||||
|
||||
walletContacts = loadedContacts;
|
||||
} catch (e) {
|
||||
printV("loadWalletContacts failed: $e");
|
||||
} finally {
|
||||
done.complete();
|
||||
}
|
||||
|
||||
_subscription = contactSource.bindToListWithTransform(
|
||||
contacts, (Contact contact) => ContactRecord(contactSource, contact),
|
||||
initialFire: true);
|
||||
|
||||
setOrderType(settingsStore.contactListOrder);
|
||||
walletContacts = walletContacts.toList(); // rebuild
|
||||
}
|
||||
|
||||
String _createName(String walletName, String label, {int? key = null}) {
|
||||
|
||||
@@ -614,6 +614,7 @@ abstract class DashboardViewModelBase with Store {
|
||||
|
||||
@computed
|
||||
bool get shouldShowMwebAd {
|
||||
return false;
|
||||
if (wallet.type != WalletType.litecoin) return false;
|
||||
|
||||
if (mwebEnabled) return false;
|
||||
|
||||
@@ -0,0 +1,146 @@
|
||||
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));
|
||||
}
|
||||
}
|
||||
|
||||
/// Re-entrancy latch for the send button, held only while the tap handler is
|
||||
/// inside [AuthService.authenticateAction].
|
||||
///
|
||||
/// Deliberately not [state]: the auth sheet is pushed closable, and closing it
|
||||
/// pops the route without ever invoking onAuthenticationFinished, so a flag
|
||||
/// that waits on the auth result would never be cleared. The caller clears
|
||||
/// this in a finally instead. Once auth succeeds, createTransaction takes over
|
||||
/// the guard by setting IsExecutingState synchronously.
|
||||
bool _isStartingSend = false;
|
||||
|
||||
bool get isStartingSend => _isStartingSend;
|
||||
|
||||
void markSendStarted() => _isStartingSend = true;
|
||||
|
||||
void markSendSettled() => _isStartingSend = false;
|
||||
|
||||
@action
|
||||
void reset() {
|
||||
_isStartingSend = false;
|
||||
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();
|
||||
}
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -29,6 +29,8 @@ import "package:trezor_flutter/trezor_flutter.dart" as sdk;
|
||||
|
||||
part "trezor_connect_view_model.g.dart";
|
||||
|
||||
const trezorUseNative = [WalletType.bitcoin, WalletType.monero];
|
||||
|
||||
class TrezorConnectViewModel = TrezorConnectViewModelBase with _$TrezorConnectViewModel;
|
||||
|
||||
abstract class TrezorConnectViewModelBase extends HardwareWalletViewModel with Store {
|
||||
@@ -242,7 +244,7 @@ abstract class TrezorConnectViewModelBase extends HardwareWalletViewModel with S
|
||||
}
|
||||
|
||||
@override
|
||||
bool isConnected(WalletType type) => type == WalletType.monero
|
||||
bool isConnected(WalletType type) => trezorUseNative.contains(type)
|
||||
? _client != null && _client?.connection.isDisconnected == false
|
||||
: true;
|
||||
|
||||
@@ -252,9 +254,9 @@ abstract class TrezorConnectViewModelBase extends HardwareWalletViewModel with S
|
||||
case WalletType.monero:
|
||||
return monero!.getTrezorHardwareWalletService(_client!);
|
||||
case WalletType.bitcoin:
|
||||
return bitcoin!.getTrezorHardwareWalletService(trezorConnect, true);
|
||||
return bitcoin!.getTrezorHardwareWalletService(null, _client!, true);
|
||||
case WalletType.litecoin:
|
||||
return bitcoin!.getTrezorHardwareWalletService(trezorConnect, false);
|
||||
return bitcoin!.getTrezorHardwareWalletService(trezorConnect, null, false);
|
||||
case WalletType.ethereum:
|
||||
case WalletType.polygon:
|
||||
return evm!.getTrezorHardwareWalletService(trezorConnect);
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:cake_wallet/buy/buy_provider_description.dart';
|
||||
import 'package:cake_wallet/cake_pay/src/cake_pay_exceptions.dart';
|
||||
import 'package:cake_wallet/cake_pay/src/models/cake_pay_order.dart';
|
||||
import 'package:cake_wallet/cake_pay/src/services/cake_pay_service.dart';
|
||||
import 'package:cake_wallet/core/utilities.dart';
|
||||
@@ -64,6 +65,9 @@ abstract class OrderDetailsViewModelBase with Store {
|
||||
@observable
|
||||
ObservableList<StandartListItem> items;
|
||||
|
||||
@observable
|
||||
bool isCakePaySessionExpired = false;
|
||||
|
||||
final CakePayService cakePayService;
|
||||
final Box<Order> orders;
|
||||
OrderProvider? _provider;
|
||||
@@ -74,7 +78,9 @@ abstract class OrderDetailsViewModelBase with Store {
|
||||
@action
|
||||
Future<void> _updateOrder() async {
|
||||
try {
|
||||
if (_provider == null) return;
|
||||
if (_provider == null || isCakePaySessionExpired) {
|
||||
return;
|
||||
}
|
||||
|
||||
final updatedOrderObj = await _provider!.findOrderById(order.id);
|
||||
final updatedOrder = updatedOrderObj.$1;
|
||||
@@ -95,6 +101,9 @@ abstract class OrderDetailsViewModelBase with Store {
|
||||
}
|
||||
|
||||
_updateItems();
|
||||
} on CakePayUnauthorizedException {
|
||||
timer?.cancel();
|
||||
isCakePaySessionExpired = true;
|
||||
} catch (e) {
|
||||
printV(e.toString());
|
||||
}
|
||||
|
||||
@@ -907,9 +907,11 @@ abstract class SendViewModelBase extends WalletChangeListenerViewModel with Stor
|
||||
final isSendAll = outputs.any((output) => output.sendAll);
|
||||
|
||||
if (!isSendAll) {
|
||||
final estimateTxAmountDouble = outputs.fold<double>(
|
||||
0, (acc, output) => acc + (double.tryParse(output.cryptoAmount) ?? 0));
|
||||
if (estimateTxAmountDouble <= 0) throw Exception('Amount must be greater than 0');
|
||||
final estimateTxAmount = outputs.fold<BigInt>(
|
||||
BigInt.zero, (acc, output) => acc + output.cryptoAmountMoney.amount);
|
||||
if (estimateTxAmount <= BigInt.zero) {
|
||||
throw Exception('Amount must be greater than 0');
|
||||
}
|
||||
}
|
||||
|
||||
pendingTransaction = await wallet.createTransaction(_credentials(provider));
|
||||
|
||||
@@ -56,7 +56,7 @@ abstract class WalletHardwareRestoreViewModelBase extends WalletCreationVM with
|
||||
|
||||
availableAccounts.addAll(accounts);
|
||||
_nextIndex += limit;
|
||||
} catch (e) {
|
||||
} on Exception catch (e) {
|
||||
printV(e);
|
||||
error =
|
||||
hardwareWalletVM.interpretErrorCode(e.toString()) ?? S.current.ledger_connection_error;
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user