From 9c8fd22bfb707d8c835867373443e01164b2206e Mon Sep 17 00:00:00 2001 From: julian Date: Fri, 24 Feb 2023 14:07:59 -0600 Subject: [PATCH] WIP load and display token transactions --- .../sub_widgets/my_token_select_item.dart | 8 +- .../token_transaction_list_widget.dart | 11 +- lib/pages/token_view/token_view.dart | 8 +- .../coins/ethereum/ethereum_wallet.dart | 17 +- lib/services/ethereum/ethereum_api.dart | 10 +- .../ethereum/ethereum_token_service.dart | 281 +++++++++--------- 6 files changed, 170 insertions(+), 165 deletions(-) diff --git a/lib/pages/token_view/sub_widgets/my_token_select_item.dart b/lib/pages/token_view/sub_widgets/my_token_select_item.dart index b85997ca8..c7bfeb25a 100644 --- a/lib/pages/token_view/sub_widgets/my_token_select_item.dart +++ b/lib/pages/token_view/sub_widgets/my_token_select_item.dart @@ -4,8 +4,10 @@ import 'package:flutter_svg/svg.dart'; import 'package:stackwallet/models/ethereum/eth_token.dart'; import 'package:stackwallet/pages/token_view/token_view.dart'; import 'package:stackwallet/providers/global/secure_store_provider.dart'; +import 'package:stackwallet/services/coins/ethereum/ethereum_wallet.dart'; import 'package:stackwallet/services/coins/manager.dart'; import 'package:stackwallet/services/ethereum/ethereum_token_service.dart'; +import 'package:stackwallet/services/transaction_notification_tracker.dart'; import 'package:stackwallet/utilities/assets.dart'; import 'package:stackwallet/utilities/constants.dart'; import 'package:stackwallet/utilities/enums/coin_enum.dart'; @@ -49,12 +51,12 @@ class MyTokenSelectItem extends ConsumerWidget { BorderRadius.circular(Constants.size.circularBorderRadius), ), onPressed: () async { - final mnemonicList = ref.read(managerProvider).mnemonic; - final tokenService = EthereumTokenService( token: token, - walletMnemonic: mnemonicList, secureStore: ref.read(secureStoreProvider), + ethWallet: ref.read(managerProvider).wallet as EthereumWallet, + tracker: TransactionNotificationTracker( + walletId: ref.read(managerProvider).walletId), ); await showLoading( diff --git a/lib/pages/token_view/sub_widgets/token_transaction_list_widget.dart b/lib/pages/token_view/sub_widgets/token_transaction_list_widget.dart index e0c6a8304..dbe3050c0 100644 --- a/lib/pages/token_view/sub_widgets/token_transaction_list_widget.dart +++ b/lib/pages/token_view/sub_widgets/token_transaction_list_widget.dart @@ -208,7 +208,7 @@ class _TransactionsListState extends ConsumerState { .select((value) => value.getManager(widget.walletId))); return FutureBuilder( - future: widget.tokenService.transaction, + future: widget.tokenService.transactions, builder: (fbContext, AsyncSnapshot> snapshot) { if (snapshot.connectionState == ConnectionState.done && snapshot.hasData) { @@ -237,13 +237,8 @@ class _TransactionsListState extends ConsumerState { _transactions2.sort((a, b) => b.timestamp - a.timestamp); return RefreshIndicator( onRefresh: () async { - //todo: check if print needed - // debugPrint("pulled down to refresh on transaction list"); - final managerProvider = ref - .read(walletsChangeNotifierProvider) - .getManagerProvider(widget.walletId); - if (!ref.read(managerProvider).isRefreshing) { - unawaited(ref.read(managerProvider).refresh()); + if (!widget.tokenService.isRefreshing) { + unawaited(widget.tokenService.refresh()); } }, child: Util.isDesktop diff --git a/lib/pages/token_view/token_view.dart b/lib/pages/token_view/token_view.dart index 71ec06edf..41357ff37 100644 --- a/lib/pages/token_view/token_view.dart +++ b/lib/pages/token_view/token_view.dart @@ -3,7 +3,7 @@ import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_svg/svg.dart'; import 'package:stackwallet/models/ethereum/eth_token.dart'; -import 'package:stackwallet/pages/wallet_view/sub_widgets/transactions_list.dart'; +import 'package:stackwallet/pages/token_view/sub_widgets/token_transaction_list_widget.dart'; import 'package:stackwallet/pages/wallet_view/transaction_views/all_transactions_view.dart'; import 'package:stackwallet/services/ethereum/ethereum_token_service.dart'; import 'package:stackwallet/utilities/assets.dart'; @@ -166,9 +166,9 @@ class _TokenViewState extends ConsumerState { crossAxisAlignment: CrossAxisAlignment.stretch, children: [ Expanded( - child: TransactionsList( - managerProvider: managerProvider, - walletId: walletId, + child: TokenTransactionsList( + tokenService: widget.tokenService, + walletId: widget.walletId, ), ), ], diff --git a/lib/services/coins/ethereum/ethereum_wallet.dart b/lib/services/coins/ethereum/ethereum_wallet.dart index d7fb3616e..37c79dc5c 100644 --- a/lib/services/coins/ethereum/ethereum_wallet.dart +++ b/lib/services/coins/ethereum/ethereum_wallet.dart @@ -130,8 +130,12 @@ class EthereumWallet extends CoinServiceAPI with WalletCache, WalletDB { Future> get utxos => db.getUTXOs(walletId).findAll(); @override - Future> get transactions => - db.getTransactions(walletId).sortByTimestampDesc().findAll(); + Future> get transactions => db + .getTransactions(walletId) + .filter() + .otherDataEqualTo(null) + .sortByTimestampDesc() + .findAll(); @override Future get currentReceivingAddress async { @@ -143,7 +147,7 @@ class EthereumWallet extends CoinServiceAPI with WalletCache, WalletDB { Future get _currentReceivingAddress => db .getAddresses(walletId) .filter() - .typeEqualTo(AddressType.p2wpkh) + .typeEqualTo(AddressType.ethereum) .subTypeEqualTo(AddressSubType.receiving) .sortByDerivationIndexDesc() .findFirst(); @@ -759,8 +763,9 @@ class EthereumWallet extends CoinServiceAPI with WalletCache, WalletDB { ), ); Logging.instance.log( - "Caught exception in refreshWalletData(): $error\n$strace", - level: LogLevel.Warning); + "Caught exception in $walletName $walletId refresh(): $error\n$strace", + level: LogLevel.Warning, + ); } } @@ -910,7 +915,7 @@ class EthereumWallet extends CoinServiceAPI with WalletCache, WalletDB { } } else { Logging.instance.log( - "Failed to refresh transactions for ${coin.prettyName} $walletName $walletId: $txs", + "Failed to refresh transactions for ${coin.prettyName} $walletName $walletId", level: LogLevel.Warning, ); } diff --git a/lib/services/ethereum/ethereum_api.dart b/lib/services/ethereum/ethereum_api.dart index 926038399..0f1a96308 100644 --- a/lib/services/ethereum/ethereum_api.dart +++ b/lib/services/ethereum/ethereum_api.dart @@ -144,14 +144,18 @@ abstract class EthereumAPI { static Future>> getTokenTransactions({ required String address, + String? contractAddress, int? startBlock, int? endBlock, // todo add more params? }) async { try { - final uri = Uri.parse( - "$blockExplorer?module=account&action=tokentx&address=$address", - ); + String uriString = + "$blockExplorer?module=account&action=tokentx&address=$address"; + if (contractAddress != null) { + uriString += "&contractAddress=$contractAddress"; + } + final uri = Uri.parse(uriString); final response = await get(uri); if (response.statusCode == 200) { diff --git a/lib/services/ethereum/ethereum_token_service.dart b/lib/services/ethereum/ethereum_token_service.dart index 3dcccc780..bae28dba9 100644 --- a/lib/services/ethereum/ethereum_token_service.dart +++ b/lib/services/ethereum/ethereum_token_service.dart @@ -1,13 +1,18 @@ -import 'dart:math'; +import 'dart:async'; import 'package:decimal/decimal.dart'; import 'package:ethereum_addresses/ethereum_addresses.dart'; import 'package:http/http.dart'; +import 'package:isar/isar.dart'; import 'package:stackwallet/models/ethereum/eth_token.dart'; +import 'package:stackwallet/models/isar/models/blockchain_data/address.dart'; +import 'package:stackwallet/models/isar/models/blockchain_data/transaction.dart'; import 'package:stackwallet/models/node_model.dart'; import 'package:stackwallet/models/paymint/fee_object_model.dart'; -import 'package:stackwallet/models/paymint/transactions_model.dart'; +import 'package:stackwallet/services/coins/ethereum/ethereum_wallet.dart'; import 'package:stackwallet/services/ethereum/ethereum_api.dart'; +import 'package:stackwallet/services/event_bus/events/global/updated_in_background_event.dart'; +import 'package:stackwallet/services/event_bus/global_event_bus.dart'; import 'package:stackwallet/services/node_service.dart'; import 'package:stackwallet/services/transaction_notification_tracker.dart'; import 'package:stackwallet/utilities/default_nodes.dart'; @@ -16,47 +21,33 @@ import 'package:stackwallet/utilities/enums/fee_rate_type_enum.dart'; import 'package:stackwallet/utilities/eth_commons.dart'; import 'package:stackwallet/utilities/flutter_secure_storage_interface.dart'; import 'package:stackwallet/utilities/format.dart'; +import 'package:stackwallet/utilities/logger.dart'; +import 'package:tuple/tuple.dart'; import 'package:web3dart/web3dart.dart' as web3dart; -const int MINIMUM_CONFIRMATIONS = 3; - class EthereumTokenService { final EthToken token; + final EthereumWallet ethWallet; + final TransactionNotificationTracker tracker; + final SecureStorageInterface _secureStore; - late bool shouldAutoSync; late web3dart.EthereumAddress _contractAddress; late web3dart.EthPrivateKey _credentials; late web3dart.DeployedContract _contract; late web3dart.ContractFunction _balanceFunction; late web3dart.ContractFunction _sendFunction; - late Future> _walletMnemonic; - late SecureStorageInterface _secureStore; late String _tokenAbi; late web3dart.Web3Client _client; - late final TransactionNotificationTracker txTracker; - TransactionData? cachedTxData; - final _gasLimit = 200000; + static const _gasLimit = 200000; EthereumTokenService({ required this.token, - required Future> walletMnemonic, + required this.ethWallet, required SecureStorageInterface secureStore, - }) { + required this.tracker, + }) : _secureStore = secureStore { _contractAddress = web3dart.EthereumAddress.fromHex(token.contractAddress); - _walletMnemonic = walletMnemonic; - _secureStore = secureStore; - } - - Future> get allOwnAddresses => - _allOwnAddresses ??= _fetchAllOwnAddresses(); - Future>? _allOwnAddresses; - - Future> _fetchAllOwnAddresses() async { - List addresses = []; - final ownAddress = _credentials.address; - addresses.add(ownAddress.toString()); - return addresses; } Future get availableBalance async { @@ -89,12 +80,19 @@ class EthereumTokenService { } Future get currentReceivingAddress async { - final _currentReceivingAddress = await _credentials.extractAddress(); - final checkSumAddress = - checksumEthereumAddress(_currentReceivingAddress.toString()); - return checkSumAddress; + final address = await _currentReceivingAddress; + return address?.value ?? + checksumEthereumAddress(_credentials.address.toString()); } + Future get _currentReceivingAddress => ethWallet.db + .getAddresses(ethWallet.walletId) + .filter() + .typeEqualTo(AddressType.ethereum) + .subTypeEqualTo(AddressSubType.receiving) + .sortByDerivationIndexDesc() + .findFirst(); + Future estimateFeeFor(int satoshiAmount, int feeRate) async { final fee = estimateFee(feeRate, _gasLimit, token.decimals); return Format.decimalAmountToSatoshis(Decimal.parse(fee.toString()), coin); @@ -111,12 +109,11 @@ class EthereumTokenService { _tokenAbi = (await _secureStore.read( key: '${_contractAddress.toString()}_tokenAbi'))!; - final mnemonic = await _walletMnemonic; - String mnemonicString = mnemonic.join(' '); + String? mnemonicString = await ethWallet.mnemonicString; //Get private key for given mnemonic - // TODO: replace empty string with actual passphrase - String privateKey = getPrivateKey(mnemonicString, ""); + String privateKey = getPrivateKey( + mnemonicString!, (await ethWallet.mnemonicPassphrase) ?? ""); _credentials = web3dart.EthPrivateKey.fromHex(privateKey); _contract = web3dart.DeployedContract( @@ -125,7 +122,7 @@ class EthereumTokenService { _sendFunction = _contract.function('transfer'); _client = await getEthClient(); - // print(_credentials.p) + unawaited(refresh()); } Future initializeNew() async { @@ -141,12 +138,11 @@ class EthereumTokenService { throw Exception('Failed to load token abi'); } - final mnemonic = await _walletMnemonic; - String mnemonicString = mnemonic.join(' '); + String? mnemonicString = await ethWallet.mnemonicString; //Get private key for given mnemonic - // TODO: replace empty string with actual passphrase - String privateKey = getPrivateKey(mnemonicString, ""); + String privateKey = getPrivateKey( + mnemonicString!, (await ethWallet.mnemonicPassphrase) ?? ""); _credentials = web3dart.EthPrivateKey.fromHex(privateKey); _contract = web3dart.DeployedContract( @@ -154,16 +150,11 @@ class EthereumTokenService { _balanceFunction = _contract.function('balanceOf'); _sendFunction = _contract.function('transfer'); _client = await getEthClient(); + + unawaited(refresh()); } - // TODO: implement isRefreshing - bool get isRefreshing => throw UnimplementedError(); - - Future get maxFee async { - final fee = (await fees).fast; - final feeEstimate = await estimateFeeFor(0, fee); - return feeEstimate; - } + bool get isRefreshing => _refreshLock; Future> prepareSend( {required String address, @@ -208,9 +199,22 @@ class EthereumTokenService { return txData; } - Future refresh() { - // TODO: implement refresh - throw UnimplementedError(); + bool _refreshLock = false; + + Future refresh() async { + if (!_refreshLock) { + _refreshLock = true; + try { + await _refreshTransactions(); + } catch (e, s) { + Logging.instance.log( + "Caught exception in ${token.name} ${ethWallet.walletName} ${ethWallet.walletId} refresh(): $e\n$s", + level: LogLevel.Warning, + ); + } finally { + _refreshLock = false; + } + } } Future get totalBalance async { @@ -227,108 +231,103 @@ class EthereumTokenService { return Decimal.parse(balanceInDecimal.toString()); } - Future get transactionData => - _transactionData ??= _fetchTransactionData(); - Future? _transactionData; + Future> get transactions => ethWallet.db + .getTransactions(ethWallet.walletId) + .filter() + .otherDataEqualTo(token.contractAddress) + .sortByTimestampDesc() + .findAll(); - Future _fetchTransactionData() async { - String thisAddress = await currentReceivingAddress; + Future _refreshTransactions() async { + String addressString = await currentReceivingAddress; - final List> midSortedArray = []; + final response = await EthereumAPI.getTokenTransactions( + address: addressString, + contractAddress: token.contractAddress, + ); - AddressTransaction txs = - await EthereumAPI.fetchAddressTransactions(thisAddress, "tokentx"); - - if (txs.message == "OK") { - final allTxs = txs.result; - for (var element in allTxs) { - Map midSortedTx = {}; - // create final tx map - midSortedTx["txid"] = element["hash"]; - int confirmations = int.parse(element['confirmations'].toString()); - - int transactionAmount = int.parse(element['value'].toString()); - int decimal = token.decimals; //Eth has up to 18 decimal places - final transactionAmountInDecimal = - transactionAmount / (pow(10, decimal)); - - //Convert to satoshi, default display for other coins - final satAmount = Format.decimalAmountToSatoshis( - Decimal.parse(transactionAmountInDecimal.toString()), coin); - - midSortedTx["confirmed_status"] = - (confirmations != 0) && (confirmations >= MINIMUM_CONFIRMATIONS); - midSortedTx["confirmations"] = confirmations; - midSortedTx["timestamp"] = element["timeStamp"]; - - if (checksumEthereumAddress(element["from"].toString()) == - thisAddress) { - midSortedTx["txType"] = "Sent"; - } else { - midSortedTx["txType"] = "Received"; - } - - midSortedTx["amount"] = satAmount; - - //Calculate fees (GasLimit * gasPrice) - int txFee = int.parse(element['gasPrice'].toString()) * - int.parse(element['gasUsed'].toString()); - final txFeeDecimal = txFee / (pow(10, decimal)); - - midSortedTx["aliens"] = []; - midSortedTx["fees"] = Format.decimalAmountToSatoshis( - Decimal.parse(txFeeDecimal.toString()), coin); - midSortedTx["address"] = element["to"]; - midSortedTx["inputSize"] = 1; - midSortedTx["outputSize"] = 1; - midSortedTx["inputs"] = []; - midSortedTx["outputs"] = []; - midSortedTx["height"] = int.parse(element['blockNumber'].toString()); - - midSortedArray.add(midSortedTx); - } + if (response.value == null) { + throw Exception("Failed to fetch token transactions"); } - midSortedArray.sort((a, b) => - (int.parse(b['timestamp'].toString())) - - (int.parse(a['timestamp'].toString()))); + final List> txnsData = []; - // buildDateTimeChunks - final Map result = {"dateTimeChunks": []}; - final dateArray = []; - - for (int i = 0; i < midSortedArray.length; i++) { - final txObject = midSortedArray[i]; - final date = - extractDateFromTimestamp(int.parse(txObject['timestamp'].toString())); - final txTimeArray = [txObject["timestamp"], date]; - - if (dateArray.contains(txTimeArray[1])) { - result["dateTimeChunks"].forEach((dynamic chunk) { - if (extractDateFromTimestamp( - int.parse(chunk['timestamp'].toString())) == - txTimeArray[1]) { - if (chunk["transactions"] == null) { - chunk["transactions"] = >[]; - } - chunk["transactions"].add(txObject); - } - }); + for (final tx in response.value!) { + bool isIncoming; + if (checksumEthereumAddress(tx.from) == addressString) { + isIncoming = false; } else { - dateArray.add(txTimeArray[1]); - final chunk = { - "timestamp": txTimeArray[0], - "transactions": [txObject], - }; - result["dateTimeChunks"].add(chunk); + isIncoming = true; } + + final txn = Transaction( + walletId: ethWallet.walletId, + txid: tx.hash, + timestamp: tx.timeStamp, + type: isIncoming ? TransactionType.incoming : TransactionType.outgoing, + subType: TransactionSubType.ethToken, + amount: tx.value.toInt(), + fee: tx.gasUsed * tx.gasPrice.toInt(), + height: tx.blockNumber, + isCancelled: false, + isLelantus: false, + slateId: null, + otherData: tx.contractAddress, + inputs: [], + outputs: [], + ); + + Address? transactionAddress = await ethWallet.db + .getAddresses(ethWallet.walletId) + .filter() + .valueEqualTo(addressString) + .findFirst(); + + if (transactionAddress == null) { + if (isIncoming) { + transactionAddress = Address( + walletId: ethWallet.walletId, + value: addressString, + publicKey: [], + derivationIndex: 0, + derivationPath: DerivationPath()..value = "$hdPathEthereum/0", + type: AddressType.ethereum, + subType: AddressSubType.receiving, + ); + } else { + final myRcvAddr = await currentReceivingAddress; + final isSentToSelf = myRcvAddr == addressString; + + transactionAddress = Address( + walletId: ethWallet.walletId, + value: addressString, + publicKey: [], + derivationIndex: isSentToSelf ? 0 : -1, + derivationPath: isSentToSelf + ? (DerivationPath()..value = "$hdPathEthereum/0") + : null, + type: AddressType.ethereum, + subType: isSentToSelf + ? AddressSubType.receiving + : AddressSubType.nonWallet, + ); + } + } + + txnsData.add(Tuple2(txn, transactionAddress)); } + await ethWallet.db.addNewTransactionData(txnsData, ethWallet.walletId); - final txModel = TransactionData.fromMap( - TransactionData.fromJson(result).getAllTransactions()); - - cachedTxData = txModel; - return txModel; + // quick hack to notify manager to call notifyListeners if + // transactions changed + if (txnsData.isNotEmpty) { + GlobalEventBus.instance.fire( + UpdatedInBackgroundEvent( + "${token.name} transactions updated/added for: ${ethWallet.walletId} ${ethWallet.walletName}", + ethWallet.walletId, + ), + ); + } } bool validateAddress(String address) {