diff --git a/lib/data/models/multi_wallet_transaction_cubit.dart b/lib/data/models/multi_wallet_transaction_cubit.dart index 02b5d06fdc66e3a7a91fb4720d7097771382c871..6618e3f959b46de39414772f30f43bb14f718809 100644 --- a/lib/data/models/multi_wallet_transaction_cubit.dart +++ b/lib/data/models/multi_wallet_transaction_cubit.dart @@ -12,6 +12,8 @@ import '../../shared_prefs_helper.dart'; import '../../ui/logger.dart'; import '../../ui/notification_controller.dart'; import '../../ui/pay_helper.dart'; +import '../../ui/ui_helpers.dart'; +import '../../ui/widgets/connectivity_widget_wrapper_wrapper.dart'; import 'app_cubit.dart'; import 'contact.dart'; import 'multi_wallet_transaction_state.dart'; @@ -22,6 +24,7 @@ import 'transaction.dart'; import 'transaction_state.dart'; import 'transaction_type.dart'; import 'transactions_bloc.dart'; +import 'utxo_cubit.dart'; class MultiWalletTransactionCubit extends HydratedCubit<MultiWalletTransactionState> { @@ -51,7 +54,8 @@ class MultiWalletTransactionCubit _emitState(key, newState); } - void _emitState(String key, TransactionState newState) { + void _emitState(String keyRaw, TransactionState newState) { + final String key = extractPublicKey(keyRaw); final Map<String, TransactionState> newStates = Map<String, TransactionState>.of(state.map)..[key] = newState; emit(MultiWalletTransactionState(newStates)); @@ -62,7 +66,8 @@ class MultiWalletTransactionCubit return key; } - TransactionState _getStateOfWallet(String key) { + TransactionState _getStateOfWallet(String keyRaw) { + final String key = extractPublicKey(keyRaw); final TransactionState currentState = state.map[key] ?? TransactionState.emptyState; return currentState; @@ -85,7 +90,7 @@ class MultiWalletTransactionCubit final List<Transaction> newPendingTransactions = <Transaction>[]; for (final Transaction t in currentState.pendingTransactions) { if (tx.from == t.from && - tx.to == t.to && + tx.recipients == t.recipients && tx.amount == t.amount && tx.comment == t.comment) { newPendingTransactions @@ -118,10 +123,12 @@ class MultiWalletTransactionCubit DateTime get lastChecked => currentWalletState().lastChecked; - String _getTxKey(Transaction t) => '${t.to.pubKey}-${t.comment}-${t.amount}'; + String _getTxKey(Transaction t) => t.isToMultiple + ? '${t.recipients.map((Contact c) => c.pubKey).join('-')}-${t.comment}-${t.amount}' + : '${t.to.pubKey}-${t.comment}-${t.amount}'; Future<List<Transaction>> fetchTransactions( - NodeListCubit cubit, AppCubit appCubit, + NodeListCubit cubit, UtxoCubit utxoCubit, AppCubit appCubit, {int retries = 5, int? pageSize, String? cursor, String? pubKey}) async { pubKey = _defKey(pubKey); final TransactionState currentState = _getStateOfWallet(pubKey); @@ -145,7 +152,7 @@ class MultiWalletTransactionCubit final Map<String, dynamic> txData = txDataResult.item1!; final TransactionState newParsedState = - await transactionsGvaParser(txData, currentState); + await transactionsGvaParser(txData, currentState, pubKey); if (newParsedState.balance < 0) { logger('Warning: Negative balance in node ${txDataResult.item2}'); @@ -175,6 +182,8 @@ class MultiWalletTransactionCubit logger( 'Last sent notification: ${currentModifiedState.latestSentNotification.toIso8601String()})}'); + logger( + '>>>>>>>>>>>>>>>>>>> Transactions: ${currentModifiedState.transactions.length}, wallets ${state.map.length}'); for (final Transaction tx in currentModifiedState.transactions.reversed) { bool stateModified = false; @@ -197,13 +206,13 @@ class MultiWalletTransactionCubit if (tx.type == TransactionType.sent && currentModifiedState.latestSentNotification.isBefore(tx.time)) { // Future - final Contact to = tx.to; NotificationController.notifyTransaction( tx.time.millisecondsSinceEpoch.toString(), amount: -tx.amount, currentUd: appCubit.currentUd, comment: tx.comment, - to: to.title, + to: humanizeContacts( + fromAddress: tx.from.pubKey, contacts: tx.recipients), isG1: isG1); currentModifiedState = currentModifiedState.copyWith(latestSentNotification: tx.time); @@ -352,4 +361,35 @@ class MultiWalletTransactionCubit } return newState; } + + Future<void> clearState() async { + final Set<String> keys = <String>{}; + for (final String keyRaw in state.map.keys) { + final String key = extractPublicKey(keyRaw); + keys.add(key); + } + + // remove old key:hash keys in state that wre duplicates + final MultiWalletTransactionState newState = state.copyWith(); + final Set<String> mapKeys = Set<String>.from(state.map.keys); + + for (final String key in mapKeys) { + if (!keys.contains(key)) { + newState.map.remove(key); + } + } + + emit(newState); + + // Clear tx if is connected and refresh + final bool isConnected = await ConnectivityWidgetWrapperWrapper.isConnected; + if (isConnected) { + for (final String key in state.map.keys) { + final TransactionState currentState = _getStateOfWallet(key); + final TransactionState newState = + currentState.copyWith(transactions: <Transaction>[]); + _emitState(key, newState); + } + } + } } diff --git a/lib/data/models/transaction.dart b/lib/data/models/transaction.dart index bf03832af9068bc6053f26401caf4ef78d5a0537..7431add88033c2aa0d46d317897becb0679c8178 100644 --- a/lib/data/models/transaction.dart +++ b/lib/data/models/transaction.dart @@ -17,19 +17,27 @@ class Transaction extends Equatable { required this.comment, required this.time, required this.from, - required this.to, - this.debugInfo}); + @Deprecated('Use recipients instead') required this.to, + // Old tx does not store outputs so let it be null + List<Contact>? recipients, + List<double>? recipientsAmounts, + this.debugInfo}) + : recipients = recipients ?? const <Contact>[], + recipientsAmounts = recipientsAmounts ?? const <double>[]; factory Transaction.fromJson(Map<String, dynamic> json) => _$TransactionFromJson(json); final TransactionType type; final Contact from; + @Deprecated('Use recipients instead') final Contact to; final double amount; final String comment; final DateTime time; final String? debugInfo; + final List<Contact> recipients; + final List<double> recipientsAmounts; bool get isFailed => type == TransactionType.failed; @@ -41,12 +49,23 @@ class Transaction extends Equatable { bool get isIncoming => type == TransactionType.receiving || type == TransactionType.received; + bool get isToMultiple => recipients.length > 2; + Map<String, dynamic> toJson() => _$TransactionToJson(this); String toStringSmall(String pubKey) => "Transaction { type: ${type.name}, from: ${from.toStringSmall(pubKey)}, to: ${to.toStringSmall(pubKey)}, amount: $amount, comment: $comment, time: ${humanizeTime(time, 'en')}, debugInfo: '$debugInfo' }"; @override - List<Object?> get props => - <dynamic>[type, from, to, amount, comment, time, debugInfo]; + List<Object?> get props => <dynamic>[ + type, + from, + to, + amount, + comment, + time, + debugInfo, + recipients, + recipientsAmounts + ]; } diff --git a/lib/data/models/transaction.g.dart b/lib/data/models/transaction.g.dart index b721984bab7b327a763e5402896308bd3ac27967..303d116937cfb41b4f4a4ceaff7a2d02162e3a5e 100644 --- a/lib/data/models/transaction.g.dart +++ b/lib/data/models/transaction.g.dart @@ -19,6 +19,10 @@ abstract class _$TransactionCWProxy { Transaction to(Contact to); + Transaction recipients(List<Contact>? recipients); + + Transaction recipientsAmounts(List<double>? recipientsAmounts); + Transaction debugInfo(String? debugInfo); /// This function **does support** nullification of nullable fields. All `null` values passed to `non-nullable` fields will be ignored. You can also use `Transaction(...).copyWith.fieldName(...)` to override fields one at a time with nullification support. @@ -34,6 +38,8 @@ abstract class _$TransactionCWProxy { DateTime? time, Contact? from, Contact? to, + List<Contact>? recipients, + List<double>? recipientsAmounts, String? debugInfo, }); } @@ -62,6 +68,14 @@ class _$TransactionCWProxyImpl implements _$TransactionCWProxy { @override Transaction to(Contact to) => this(to: to); + @override + Transaction recipients(List<Contact>? recipients) => + this(recipients: recipients); + + @override + Transaction recipientsAmounts(List<double>? recipientsAmounts) => + this(recipientsAmounts: recipientsAmounts); + @override Transaction debugInfo(String? debugInfo) => this(debugInfo: debugInfo); @@ -80,6 +94,8 @@ class _$TransactionCWProxyImpl implements _$TransactionCWProxy { Object? time = const $CopyWithPlaceholder(), Object? from = const $CopyWithPlaceholder(), Object? to = const $CopyWithPlaceholder(), + Object? recipients = const $CopyWithPlaceholder(), + Object? recipientsAmounts = const $CopyWithPlaceholder(), Object? debugInfo = const $CopyWithPlaceholder(), }) { return Transaction( @@ -107,6 +123,14 @@ class _$TransactionCWProxyImpl implements _$TransactionCWProxy { ? _value.to // ignore: cast_nullable_to_non_nullable : to as Contact, + recipients: recipients == const $CopyWithPlaceholder() + ? _value.recipients + // ignore: cast_nullable_to_non_nullable + : recipients as List<Contact>?, + recipientsAmounts: recipientsAmounts == const $CopyWithPlaceholder() + ? _value.recipientsAmounts + // ignore: cast_nullable_to_non_nullable + : recipientsAmounts as List<double>?, debugInfo: debugInfo == const $CopyWithPlaceholder() ? _value.debugInfo // ignore: cast_nullable_to_non_nullable @@ -132,6 +156,12 @@ Transaction _$TransactionFromJson(Map<String, dynamic> json) => Transaction( time: DateTime.parse(json['time'] as String), from: Contact.fromJson(json['from'] as Map<String, dynamic>), to: Contact.fromJson(json['to'] as Map<String, dynamic>), + recipients: (json['recipients'] as List<dynamic>?) + ?.map((e) => Contact.fromJson(e as Map<String, dynamic>)) + .toList(), + recipientsAmounts: (json['recipientsAmounts'] as List<dynamic>?) + ?.map((e) => (e as num).toDouble()) + .toList(), debugInfo: json['debugInfo'] as String?, ); @@ -144,6 +174,8 @@ Map<String, dynamic> _$TransactionToJson(Transaction instance) => 'comment': instance.comment, 'time': instance.time.toIso8601String(), 'debugInfo': instance.debugInfo, + 'recipients': instance.recipients, + 'recipientsAmounts': instance.recipientsAmounts, }; const _$TransactionTypeEnumMap = { diff --git a/lib/data/models/transaction_cubit_remove.dart b/lib/data/models/transaction_cubit_remove.dart index c8b142feaffe781abfa9595278878158bec69ddd..bb0cc995d9fed6db162578cda7588365f6a29ecf 100644 --- a/lib/data/models/transaction_cubit_remove.dart +++ b/lib/data/models/transaction_cubit_remove.dart @@ -1,3 +1,5 @@ +// ignore_for_file: deprecated_member_use_from_same_package + import 'dart:collection'; import 'package:flutter/foundation.dart'; @@ -74,7 +76,8 @@ class TransactionCubitRemove extends HydratedCubit<TransactionState> { } final Map<String, dynamic> txData = txDataResult.item1!; - TransactionState newState = await transactionsGvaParser(txData, state); + TransactionState newState = + await transactionsGvaParser(txData, state, myPubKey); if (newState.balance < 0) { logger('Warning: Negative balance in node ${txDataResult.item2}'); diff --git a/lib/data/models/transactions_bloc.dart b/lib/data/models/transactions_bloc.dart index db268e2652a04c36a4454b578b3687878de5e399..30912f434ed60a0ac6d046328c5e89cbfb607c0f 100644 --- a/lib/data/models/transactions_bloc.dart +++ b/lib/data/models/transactions_bloc.dart @@ -10,6 +10,7 @@ import 'app_cubit.dart'; import 'multi_wallet_transaction_cubit.dart'; import 'node_list_cubit.dart'; import 'transaction.dart'; +import 'utxo_cubit.dart'; part 'transactions_state.dart'; @@ -29,6 +30,7 @@ class TransactionsBloc { late AppCubit appCubit; late NodeListCubit nodeListCubit; late MultiWalletTransactionCubit transCubit; + late UtxoCubit utxoCubit; static const int _pageSize = 20; @@ -71,10 +73,11 @@ class TransactionsBloc { } void init(MultiWalletTransactionCubit transCubit, NodeListCubit nodeListCubit, - AppCubit appCubit) { + AppCubit appCubit, UtxoCubit utxoCubit) { this.appCubit = appCubit; this.transCubit = transCubit; this.nodeListCubit = nodeListCubit; + this.utxoCubit = utxoCubit; } Stream<TransactionsState> _fetchTransactionsList(String? pageKey) async* { @@ -98,8 +101,8 @@ class TransactionsBloc { itemList: transCubit.transactions, ); } else { - final List<Transaction> fetchedItems = - await transCubit.fetchTransactions(nodeListCubit, appCubit, + final List<Transaction> fetchedItems = await transCubit + .fetchTransactions(nodeListCubit, utxoCubit, appCubit, cursor: pageKey, pageSize: _pageSize); final bool isLastPage = fetchedItems.length < _pageSize; diff --git a/lib/data/models/utxo_cubit.dart b/lib/data/models/utxo_cubit.dart index 8db41be917097b49ad8900b78adb78c08433a111..e6a32725ece363bed80df88dd72a5213fca1669b 100644 --- a/lib/data/models/utxo_cubit.dart +++ b/lib/data/models/utxo_cubit.dart @@ -3,6 +3,7 @@ import 'package:hydrated_bloc/hydrated_bloc.dart'; import 'package:tuple/tuple.dart'; import '../../g1/api.dart'; +import '../../ui/logger.dart'; import 'node.dart'; import 'utxo.dart'; import 'utxo_state.dart'; @@ -15,7 +16,11 @@ class UtxoCubit extends HydratedCubit<UtxoState> { @override UtxoState? fromJson(Map<String, dynamic> json) { - return UtxoLoaded.fromJson(json); + try { + return UtxoLoaded.fromJson(json); + } catch (e) { + return UtxoInitial(); + } } @override @@ -34,6 +39,8 @@ class UtxoCubit extends HydratedCubit<UtxoState> { pubKeyRaw: myPubKey, cursor: state is UtxoLoaded ? (state as UtxoLoaded).cursor : null); + loggerDev('utxoDataResult: $utxoDataResult'); + if (utxoDataResult.item1 != null) { final List<Utxo> utxos = <Utxo>[]; double total = state is UtxoLoaded ? (state as UtxoLoaded).total : 0; @@ -57,7 +64,7 @@ class UtxoCubit extends HydratedCubit<UtxoState> { } } - List<Utxo> consume(double amount) { + List<Utxo>? consume(double amount) { if (state is UtxoLoaded) { final UtxoLoaded currentState = state as UtxoLoaded; @@ -76,8 +83,9 @@ class UtxoCubit extends HydratedCubit<UtxoState> { } if (total < amount) { - emit(UtxosError('Insufficient UTXOs to cover the requested amount')); - return <Utxo>[]; + const String error = 'Insufficient UTXOs to cover the requested amount'; + emit(UtxosError(error)); + throw Exception(error); } final List<Utxo> updatedUtxos = currentState.utxos @@ -87,8 +95,9 @@ class UtxoCubit extends HydratedCubit<UtxoState> { emit(currentState.copyWith(utxos: updatedUtxos)); return selectedUtxos; } else { - emit(UtxosError('Wrong utxo state')); - return <Utxo>[]; + const String error = 'Wrong utxo state'; + emit(UtxosError(error)); + throw Exception(error); } } diff --git a/lib/g1/api.dart b/lib/g1/api.dart index 86ab470fe7294f88a8c11ebd59644121d80ca778..38b4e0f176df5b93ce3b3f44b223a95304afe894 100644 --- a/lib/g1/api.dart +++ b/lib/g1/api.dart @@ -1,5 +1,6 @@ import 'dart:convert'; import 'dart:io'; +import 'dart:math'; import 'package:crypto/crypto.dart'; import 'package:durt/durt.dart'; @@ -15,6 +16,7 @@ import '../data/models/contact.dart'; import '../data/models/node.dart'; import '../data/models/node_manager.dart'; import '../data/models/node_type.dart'; +import '../data/models/utxo.dart'; import '../shared_prefs_helper.dart'; import '../ui/logger.dart'; import '../ui/ui_helpers.dart'; @@ -31,7 +33,8 @@ final String currency = currencyDotEnv.isEmpty ? 'g1' : currencyDotEnv; Future<String> getTxHistory(String publicKey) async { final Response response = - await requestWithRetry(NodeType.duniter, '/tx/history/$publicKey'); + (await requestWithRetry(NodeType.duniter, '/tx/history/$publicKey')) + .item2; if (response.statusCode == 200) { return response.body; } else { @@ -40,9 +43,10 @@ Future<String> getTxHistory(String publicKey) async { } Future<Response> getPeers() async { - final Response response = await requestWithRetry( - NodeType.duniter, '/network/peers', - dontRecord: true); + final Response response = (await requestWithRetry( + NodeType.duniter, '/network/peers', + dontRecord: true)) + .item2; if (response.statusCode == 200) { return response; } else { @@ -60,7 +64,7 @@ Future<Response> searchCPlusUser(String initialSearchTerm) async { '/user/profile/_search?q=title:$searchTermLower OR issuer:$searchTerm OR title:$searchTermCapitalized OR title:$searchTerm'; final Response response = - await requestCPlusWithRetry(query, retryWith404: false); + (await requestCPlusWithRetry(query, retryWith404: false)).item2; return response; } @@ -68,9 +72,10 @@ Future<Contact> getProfile(String pubKeyRaw, [bool onlyCPlusProfile = false]) async { final String pubKey = extractPublicKey(pubKeyRaw); try { - final Response cPlusResponse = await requestCPlusWithRetry( - '/user/profile/$pubKey', - retryWith404: false); + final Response cPlusResponse = (await requestCPlusWithRetry( + '/user/profile/$pubKey', + retryWith404: false)) + .item2; final Map<String, dynamic> result = const JsonDecoder().convert(cPlusResponse.body) as Map<String, dynamic>; if (result['found'] == false) { @@ -108,9 +113,10 @@ Not found sample: */ Future<List<Contact>> searchWot(String initialSearchTerm) async { final String searchTerm = normalizeQuery(initialSearchTerm); - final Response response = await requestDuniterWithRetry( - '/wot/lookup/$searchTerm', - retryWith404: false); + final Response response = (await requestDuniterWithRetry( + '/wot/lookup/$searchTerm', + retryWith404: false)) + .item2; // Will be better to analyze the 404 response (to detect faulty node) final List<Contact> contacts = <Contact>[]; if (response.statusCode == HttpStatus.ok) { @@ -136,9 +142,10 @@ Future<List<Contact>> searchWot(String initialSearchTerm) async { } Future<Contact> getWot(Contact contact) async { - final Response response = await requestDuniterWithRetry( - '/wot/lookup/${contact.pubKey}', - retryWith404: false); + final Response response = (await requestDuniterWithRetry( + '/wot/lookup/${contact.pubKey}', + retryWith404: false)) + .item2; // Will be better to analyze the 404 response (to detect faulty node) if (response.statusCode == HttpStatus.ok) { final Map<String, dynamic> data = @@ -159,7 +166,7 @@ Future<Contact> getWot(Contact contact) async { @Deprecated('use getProfile') Future<String> _getDataImageFromKey(String publicKey) async { final Response response = - await requestCPlusWithRetry('/user/profile/$publicKey'); + (await requestCPlusWithRetry('/user/profile/$publicKey')).item2; if (response.statusCode == HttpStatus.ok) { final Map<String, dynamic> data = json.decode(response.body) as Map<String, dynamic>; @@ -214,12 +221,13 @@ Future<void> _fetchDuniterNodes({bool force = false}) async { const NodeType type = NodeType.duniter; NodeManager().loading = true; final bool forceOrFewNodes = - force || NodeManager().nodesWorking(type) < NodeManager.maxNodes; + force || (NodeManager().nodesWorking(type) < NodeManager.maxNodes); if (forceOrFewNodes) { + defaultDuniterNodes.shuffle(); NodeManager().updateNodes(type, defaultDuniterNodes); } logger( - 'Fetching ${type.name} nodes, we have ${NodeManager().nodesWorking(type)}'); + 'Fetching (forced: $force) ${type.name} nodes, we have ${NodeManager().nodesWorking(type)}'); final List<Node> nodes = await _fetchDuniterNodesFromPeers(type); NodeManager().updateNodes(type, nodes); NodeManager().loading = false; @@ -462,22 +470,22 @@ Future<NodeCheck> _pingNode(String node, NodeType type) async { } } -Future<http.Response> requestWithRetry(NodeType type, String path, +Future<Tuple2<Node, http.Response>> requestWithRetry(NodeType type, String path, {bool dontRecord = false, bool retryWith404 = true}) async { return _requestWithRetry(type, path, dontRecord, retryWith404); } -Future<http.Response> requestDuniterWithRetry(String path, +Future<Tuple2<Node, http.Response>> requestDuniterWithRetry(String path, {bool retryWith404 = true}) async { return _requestWithRetry(NodeType.duniter, path, true, retryWith404); } -Future<http.Response> requestCPlusWithRetry(String path, +Future<Tuple2<Node, http.Response>> requestCPlusWithRetry(String path, {bool retryWith404 = true}) async { return _requestWithRetry(NodeType.cesiumPlus, path, true, retryWith404); } -Future<http.Response> requestGvaWithRetry(String path, +Future<Tuple2<Node, http.Response>> requestGvaWithRetry(String path, {bool retryWith404 = true, HttpType httpType = HttpType.get, Map<String, String>? headers, @@ -489,7 +497,7 @@ Future<http.Response> requestGvaWithRetry(String path, enum HttpType { get, post, delete } -Future<http.Response> _requestWithRetry( +Future<Tuple2<Node, http.Response>> _requestWithRetry( NodeType type, String path, bool dontRecord, bool retryWith404, {HttpType httpType = HttpType.get, Map<String, String>? headers, @@ -528,7 +536,7 @@ Future<http.Response> _requestWithRetry( if (!dontRecord) { NodeManager().updateNode(type, node.copyWith(latency: newLatency)); } - return response; + return Tuple2<Node, Response>(node, response); } else if (response.statusCode == 404) { logger('404 on fetch $url'); if (retryWith404) { @@ -540,7 +548,7 @@ Future<http.Response> _requestWithRetry( if (!kReleaseMode) { logger('Returning not 200 or 400 response'); } - return response; + return Tuple2<Node, Response>(node, response); } } else { /* await Sentry.captureMessage( @@ -564,31 +572,44 @@ Future<http.Response> _requestWithRetry( 'Cannot make the request to any of the ${nodes.length} nodes'); } -Future<PayResult> pay( - {required String to, required double amount, String? comment}) async { +Future<PayResult> payWithGVA( + {required List<String> to, required double amount, String? comment}) async { try { - final SelectedGvaNode selected = getGvaNode(); + final Tuple2<String, Node> selected = getGvaNode(); - final String nodeUrl = selected.url; + final String nodeUrl = selected.item1; try { final Gva gva = Gva(node: nodeUrl); final CesiumWallet wallet = await SharedPreferencesHelper().getWallet(); logger( 'Trying $nodeUrl to send $amount to $to with comment ${comment ?? ''}'); - - final String response = await gva.pay( - recipient: extractPublicKey(to), - amount: amount, - comment: comment ?? '', - cesiumSeed: wallet.seed, - useMempool: true, - raiseException: true); + String response; + if (to.length == 1) { + response = await gva.pay( + recipient: extractPublicKey(to[0]), + amount: amount, + comment: comment ?? '', + cesiumSeed: wallet.seed, + useMempool: true, + raiseException: true); + } else { + response = await gva.complexPay( + recipients: to + .map((String recipient) => extractPublicKey(recipient)) + .toList(), + amounts: List<double>.filled(to.length, amount), + totalAmount: amount * to.length, + comment: comment ?? '', + cesiumSeed: wallet.seed, + useMempool: true, + raiseException: true); + } logger('GVA replied with "$response"'); - return PayResult(message: response, node: selected); + return PayResult(message: response, node: selected.item2); } on GraphQLException catch (e) { final List<String> eCause = e.cause.split('message: '); return PayResult( - node: selected, + node: selected.item2, message: eCause.isNotEmpty ? eCause[eCause.length > 1 ? 1 : 0].split(',')[0] : 'Transaction failed for unknown reason'); @@ -597,14 +618,15 @@ Future<PayResult> pay( logger(e); logger(stacktrace); return PayResult( - node: selected, message: "Something didn't work as expected ($e)"); + node: selected.item2, + message: "Something didn't work as expected ($e)"); } } catch (e) { return PayResult(message: "Something didn't work as expected ($e)"); } } -SelectedGvaNode getGvaNode() { +Tuple2<String, Node> getGvaNode() { final List<Node> nodes = _getBestGvaNodes(); if (nodes.isNotEmpty) { final Node? currentGvaNode = NodeManager().getCurrentGvaNode(); @@ -612,24 +634,17 @@ SelectedGvaNode getGvaNode() { if (currentGvaNode == null) { NodeManager().setCurrentGvaNode(node); } - return SelectedGvaNode(url: proxyfyNode(node.url), node: node); + return Tuple2<String, Node>(proxyfyNode(node.url), node); } else { throw Exception( 'Sorry: I cannot find a working node to send the transaction'); } } -class SelectedGvaNode { - SelectedGvaNode({required this.url, required this.node}); - - final String url; - final Node node; -} - class PayResult { PayResult({required this.message, this.node}); - final SelectedGvaNode? node; + final Node? node; final String message; } @@ -647,33 +662,25 @@ Future<Tuple2<Map<String, dynamic>?, Node>> gvaHistoryAndBalance( logger('Get tx history (page size: $pageSize: cursor $cursor)'); final String pubKey = extractPublicKey(pubKeyRaw); return gvaFunctionWrapper<Map<String, dynamic>>( - pubKey, (Gva gva) => gva.history(pubKey, pageSize, cursor)); + (Gva gva) => gva.history(pubKey, pageSize, cursor)); } Future<Tuple2<double?, Node>> gvaBalance(String pubKey) async { - return gvaFunctionWrapper<double>( - extractPublicKey(pubKey), (Gva gva) => gva.balance(pubKey)); + return gvaFunctionWrapper<double>((Gva gva) => gva.balance(pubKey)); } Future<Tuple2<String?, Node>> gvaNick(String pubKey) async { return gvaFunctionWrapper<String>( - pubKey, (Gva gva) => gva.getUsername(extractPublicKey(pubKey))); + (Gva gva) => gva.getUsername(extractPublicKey(pubKey))); } -Future<Tuple2<Map<String, dynamic>?, Node>> gvaFetchUtxosOfScript( - {required String pubKeyRaw, - int pageSize = 100, - String? cursor, - int? amount}) { - final String pubKey = extractPublicKey(pubKeyRaw); +Future<Tuple2<Map<String, dynamic>?, Node>> getCurrentBlockGVA() async { return gvaFunctionWrapper<Map<String, dynamic>>( - pubKey, - (Gva gva) => gva.fetchUtxosOfScript( - script: pubKey, pageSize: pageSize, amount: amount, cursor: cursor)); + (Gva gva) => gva.getCurrentBlockExtended()); } Future<Tuple2<T?, Node>> gvaFunctionWrapper<T>( - String pubKey, Future<T?> Function(Gva) specificFunction) async { + Future<T?> Function(Gva) specificFunction) async { final List<Node> nodes = _getBestGvaNodes(); // Try first the current GVA node @@ -763,21 +770,22 @@ Future<void> createOrUpdateCesiumPlusUser(String name) async { 'tags': <String>[], }; - signAndHash(userProfile, wallet); + hashAndSign(userProfile, wallet); // Convert the user profile data into a JSON string again, now including hash and signature final String userProfileJsonWithHashAndSignature = jsonEncode(userProfile); if (userName != null) { logger('User exists, update the user profile'); - final http.Response updateResponse = await _requestWithRetry( - NodeType.cesiumPlus, - '/user/profile/$pubKey/_update?pubkey=$pubKey', - false, - true, - httpType: HttpType.post, - headers: _defCPlusHeaders(), - body: userProfileJsonWithHashAndSignature); + final http.Response updateResponse = (await _requestWithRetry( + NodeType.cesiumPlus, + '/user/profile/$pubKey/_update?pubkey=$pubKey', + false, + true, + httpType: HttpType.post, + headers: _defCPlusHeaders(), + body: userProfileJsonWithHashAndSignature)) + .item2; if (updateResponse.statusCode == 200) { logger('User profile updated successfully.'); } else { @@ -787,11 +795,12 @@ Future<void> createOrUpdateCesiumPlusUser(String name) async { } } else if (userName == null) { logger('User does not exist, create a new user profile'); - final http.Response createResponse = await _requestWithRetry( - NodeType.cesiumPlus, '/user/profile', false, false, - httpType: HttpType.post, - headers: _defCPlusHeaders(), - body: userProfileJsonWithHashAndSignature); + final http.Response createResponse = (await _requestWithRetry( + NodeType.cesiumPlus, '/user/profile', false, false, + httpType: HttpType.post, + headers: _defCPlusHeaders(), + body: userProfileJsonWithHashAndSignature)) + .item2; if (createResponse.statusCode == 200) { logger('User profile created successfully.'); @@ -809,12 +818,12 @@ Map<String, String> _defCPlusHeaders() { }; } -void signAndHash(Map<String, dynamic> userProfile, CesiumWallet wallet) { - final String userProfileJson = jsonEncode(userProfile); - final String hash = calculateHash(userProfileJson); +void hashAndSign(Map<String, dynamic> data, CesiumWallet wallet) { + final String dataJson = jsonEncode(data); + final String hash = calculateHash(dataJson); final String signature = wallet.sign(hash); - userProfile['hash'] = hash; - userProfile['signature'] = signature; + data['hash'] = hash; + data['signature'] = signature; } Future<String?> getCesiumPlusUser(String pubKey) async { @@ -835,13 +844,14 @@ Future<bool> deleteCesiumPlusUser() async { 1000, // current time in seconds }; - signAndHash(userProfile, wallet); + hashAndSign(userProfile, wallet); - final http.Response delResponse = await _requestWithRetry( - NodeType.cesiumPlus, '/history/delete', false, false, - httpType: HttpType.post, - headers: _defCPlusHeaders(), - body: jsonEncode(userProfile)); + final http.Response delResponse = (await _requestWithRetry( + NodeType.cesiumPlus, '/history/delete', false, false, + httpType: HttpType.post, + headers: _defCPlusHeaders(), + body: jsonEncode(userProfile))) + .item2; return delResponse.statusCode == 200; } @@ -850,3 +860,136 @@ String calculateHash(String input) { final Digest digest = sha256.convert(bytes); return digest.toString().toUpperCase(); } + +Future<Tuple2<Map<String, dynamic>?, Node>> gvaFetchUtxosOfScript( + {required String pubKeyRaw, + int pageSize = 100, + String? cursor, + int? amount}) { + final String pubKey = extractPublicKey(pubKeyRaw); + return gvaFunctionWrapper<Map<String, dynamic>>((Gva gva) => + gva.fetchUtxosOfScript( + script: pubKey, pageSize: pageSize, amount: amount, cursor: cursor)); +} + +Future<PayResult> payWithBMA({ + required CesiumWallet wallet, + required List<Utxo> utxos, + required String destPub, + required double amount, + required String blockNumber, + required String blockHash, + String? comment, +}) async { + try { + final String issuer = wallet.pubkey; + // Change back address == issuer + final String restPub = issuer; + + final List<List<Utxo>> utxoSlices = sliceUtxos(utxos); + Response? finalResponse; + Node? node; + for (final List<Utxo> utxoSlice in utxoSlices) { + final Map<String, Object> transaction = <String, Object>{ + 'Version': 10, + 'Currency': currency, + 'Blockstamp': '$blockNumber-$blockHash', + 'Locktime': 0, + 'Issuers': <String>[issuer], + 'Comment': comment ?? '' + }; + + // Inputs + final List<String> inputs = <String>[]; + for (final Utxo utxo in utxoSlice) { + // if D (DU) : AMOUNT:BASE:D:PUBLIC_KEY:BLOCK_ID + // if T (TX) : AMOUNT:BASE:T:T_HASH:T_INDEX + inputs.add( + '${utxo.amount}:${utxo.base}:T:${utxo.txHash}:${utxo.outputIndex}'); + } + transaction['Inputs'] = inputs; + // Unlocks + final List<String> unlocks = <String>[]; + for (int i = 0; i < utxos.length; i++) { + // INPUT_INDEX:UNLOCK_CONDITION + unlocks.add('$i:SIG(0)'); + } + transaction['Unlocks'] = unlocks; + + final List<String> outputs = <String>[]; + + // AMOUNT:BASE:CONDITIONS + double rest = amount; + final int maxBase = + utxos.fold(0, (int prev, Utxo utxo) => max(prev, utxo.base)); + final double inputsAmount = + utxos.fold(0, (double prev, Utxo utxo) => prev + utxo.amount); + int outputBase = maxBase; + int outputOffset = 0; + final List<Map<String, dynamic>> newSources = <Map<String, dynamic>>[]; + + if (destPub != issuer) { + while (rest > 0) { + double outputAmount = truncBase(rest, outputBase); + rest -= outputAmount; + if (outputAmount > 0) { + outputAmount = outputBase == 0 + ? outputAmount + : outputAmount / pow(10, outputBase); + outputs.add('$outputAmount:$outputBase:SIG($destPub)'); + outputOffset++; + } + outputBase--; + } + rest = inputsAmount - amount; + outputBase = maxBase; + } + + while (rest > 0) { + double outputAmount = truncBase(rest, outputBase); + rest -= outputAmount; + if (outputAmount > 0) { + outputAmount = outputBase == 0 + ? outputAmount + : outputAmount / pow(10, outputBase); + outputs.add('$outputAmount:$outputBase:SIG($restPub)'); + if (issuer == restPub) { + newSources.add(<String, dynamic>{ + 'type': 'T', + 'noffset': outputOffset, + 'amount': outputAmount, + 'base': outputBase, + 'conditions': 'SIG($restPub)', + 'consumed': false, + }); + } + outputOffset++; + } + outputBase--; + } + transaction['Outputs'] = outputs; + + hashAndSign(transaction, wallet); + final String transactionJson = jsonEncode(transaction); + + // final List<int> bytes = utf8.encode(transactionJson); + logger(transactionJson); + + final Tuple2<Node, http.Response> response = await _requestWithRetry( + NodeType.duniter, '/tx/processTesting', false, false, + httpType: HttpType.post, + // headers: ?? + body: transactionJson); + finalResponse = response.item2; + node = response.item1; + if (response.item2.statusCode != 200) { + return PayResult( + node: response.item1, + message: "Something didn't work as expected ($e)"); + } + } + return PayResult(message: finalResponse!.body, node: node); + } catch (e) { + return PayResult(message: "Something didn't work as expected ($e)"); + } +} diff --git a/lib/g1/transaction_parser.dart b/lib/g1/transaction_parser.dart index 48dbbaaf80ac3a62e2fe3182978d7da21a959c59..baddc7a992cd7bb10431f13585fb7bcb3b98ba06 100644 --- a/lib/g1/transaction_parser.dart +++ b/lib/g1/transaction_parser.dart @@ -4,7 +4,7 @@ import '../data/models/contact.dart'; import '../data/models/transaction.dart'; import '../data/models/transaction_state.dart'; import '../data/models/transaction_type.dart'; -import '../ui/contacts_cache.dart'; +import 'g1_helper.dart'; final RegExp exp = RegExp(r'\((.*?)\)'); @@ -42,8 +42,8 @@ Future<TransactionState> transactionParser( logger('Timestamp: $timestamp'); logger('Fecha: $txDate'); } */ - final Contact fromC = await ContactsCache().getContact(address2!); - final Contact toC = await ContactsCache().getContact(address1!); + final Contact fromC = Contact(pubKey: address2!); + final Contact toC = Contact(pubKey: address1!); tx.insert( 0, @@ -62,8 +62,9 @@ Future<TransactionState> transactionParser( lastChecked: DateTime.now()); } -Future<TransactionState> transactionsGvaParser( - Map<String, dynamic> txData, TransactionState state) async { +Future<TransactionState> transactionsGvaParser(Map<String, dynamic> txData, + TransactionState state, String myPubKeyRaw) async { + final String myPubKey = extractPublicKey(myPubKeyRaw); // Balance final dynamic rawBalance = txData['balance']; final double amount = rawBalance != null @@ -88,21 +89,29 @@ Future<TransactionState> transactionsGvaParser( final List<Transaction> txs = <Transaction>[]; for (final dynamic edgeRaw in edges) { final Transaction tx = - await _transactionGvaParser(edgeRaw as Map<String, dynamic>); - txs.add(tx); + await _transactionGvaParser(edgeRaw as Map<String, dynamic>, myPubKey); + if (tx.from.pubKey == myPubKey && + tx.to.pubKey == myPubKey && + tx.recipients.length == 1) { + // This is a return cash back to me + continue; + } else { + txs.add(tx); + } } final List<dynamic> receiving = txsHistoryMp['receiving'] as List<dynamic>; final List<dynamic> sending = txsHistoryMp['sending'] as List<dynamic>; for (final dynamic receiveRaw in receiving) { - final Transaction tx = await _txGvaParse( - receiveRaw as Map<String, dynamic>, TransactionType.receiving); + final Transaction tx = await _txGvaParse(receiveRaw as Map<String, dynamic>, + myPubKey, TransactionType.receiving); txs.insert(0, tx); } for (final dynamic sendingRaw in sending) { final Transaction tx = await _txGvaParse( - sendingRaw as Map<String, dynamic>, TransactionType.sending); + sendingRaw as Map<String, dynamic>, myPubKey, TransactionType.sending); txs.insert(0, tx); } + return state.copyWith( transactions: txs, balance: amount, @@ -111,7 +120,8 @@ Future<TransactionState> transactionsGvaParser( endCursor: pageInfo['endCursor'] as String?); } -Future<Transaction> _transactionGvaParser(Map<String, dynamic> edge) { +Future<Transaction> _transactionGvaParser( + Map<String, dynamic> edge, String myPubKey) { final Map<String, dynamic> parsedTxData = edge; // Direction final String direction = parsedTxData['direction'] as String; @@ -119,34 +129,67 @@ Future<Transaction> _transactionGvaParser(Map<String, dynamic> edge) { direction == 'SENT' ? TransactionType.sent : TransactionType.received; final Map<String, dynamic> tx = parsedTxData['node'] as Map<String, dynamic>; - return _txGvaParse(tx, type); + return _txGvaParse(tx, myPubKey, type); } Future<Transaction> _txGvaParse( - Map<String, dynamic> tx, TransactionType type) async { + Map<String, dynamic> tx, String myPubKey, TransactionType type) async { final List<dynamic> issuers = tx['issuers'] as List<dynamic>; final List<dynamic> outputs = tx['outputs'] as List<dynamic>; final String from = issuers[0] as String; - final String? to = exp.firstMatch(outputs[0] as String)!.group(1); + + final List<Contact> recipients = <Contact>[]; + final List<double> recipientsAmounts = <double>[]; + double amount = 0.0; + Contact? toC; + final bool isSent = + type == TransactionType.sent || type == TransactionType.sending; + for (final dynamic output in outputs) { + // Extract the recipient from each output + final String outputS = output as String; + final String? recipient = exp.firstMatch(outputS)!.group(1); + final Contact recipientContact = Contact(pubKey: recipient!); + recipients.add(recipientContact); + + final double outputAmount = double.parse(outputS.split(':')[0]); + recipientsAmounts.add(amount); + if (isSent) { + if (recipient != myPubKey) { + // Is not the return cash back to me + amount += outputAmount; + } + } else { + if (recipient == myPubKey) { + amount = outputAmount; + toC = recipientContact; + } + } + } + + if (isSent) { + // this only works in the case of a single recipient + toC = recipients.first; + } // Time final dynamic writtenTime = tx['writtenTime']; final DateTime time = writtenTime == null ? DateTime.now() : DateTime.fromMillisecondsSinceEpoch((writtenTime as int) * 1000); - // Amount - double amount = double.parse((outputs.first as String).split(':')[0]); - if (type == TransactionType.sent || type == TransactionType.sending) { + + if (isSent) { amount = -amount; } // Comment final String comment = tx['comment'] as String; - final Contact fromC = await ContactsCache().getContact(from); - final Contact toC = await ContactsCache().getContact(to!); + final Contact fromC = Contact(pubKey: from); + return Transaction( type: type, from: fromC, - to: toC, + to: toC!, + recipients: recipients, + recipientsAmounts: recipientsAmounts, amount: amount, comment: comment, time: time, diff --git a/lib/ui/pay_helper.dart b/lib/ui/pay_helper.dart index d59a0b1188bb102bb472159a6404163e61119ea1..8a779d70b971503058b73c99db8042ea6ae9731a 100644 --- a/lib/ui/pay_helper.dart +++ b/lib/ui/pay_helper.dart @@ -1,8 +1,10 @@ import 'dart:async'; +import 'package:durt/durt.dart'; import 'package:easy_localization/easy_localization.dart'; import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:tuple/tuple.dart'; import '../../../data/models/contact.dart'; import '../../../data/models/node_type.dart'; @@ -17,6 +19,8 @@ import '../data/models/multi_wallet_transaction_cubit.dart'; import '../data/models/node.dart'; import '../data/models/node_manager.dart'; import '../data/models/payment_state.dart'; +import '../data/models/utxo.dart'; +import '../data/models/utxo_cubit.dart'; import '../g1/currency.dart'; import '../g1/g1_helper.dart'; import 'contacts_cache.dart'; @@ -27,15 +31,16 @@ import 'widgets/fifth_screen/import_dialog.dart'; Future<bool> payWithRetry( {required BuildContext context, - required Contact to, + required List<Contact> recipients, required double amount, required String comment, bool isRetry = false, - bool isMultiPayment = false, required bool isG1, - required double currentUd}) async { + required double currentUd, + bool useBMA = false}) async { assert(amount > 0); bool hasPass = false; + final bool isToMultiple = recipients.length > 1; if (!SharedPreferencesHelper().isG1nkgoCard() && !SharedPreferencesHelper().hasVolatile()) { hasPass = await showImportCesiumWalletDialog( @@ -54,25 +59,26 @@ Future<bool> payWithRetry( final AppCubit appCubit = context.read<AppCubit>(); paymentCubit.sending(); final String fromPubKey = SharedPreferencesHelper().getPubKey(); - final String contactPubKey = to.pubKey; - bool? confirmed; - if (!isMultiPayment) { - confirmed = await _confirmSend(context, amount.toString(), - humanizeContact(fromPubKey, to, true), isRetry, appCubit.currency); - } else { - confirmed = true; - } + + final bool? confirmed = await _confirmSend(context, amount.toString(), + fromPubKey, recipients, isRetry, appCubit.currency, isToMultiple); final Contact fromContact = await ContactsCache().getContact(fromPubKey); + final CesiumWallet wallet = await SharedPreferencesHelper().getWallet(); + if (!context.mounted) { + return false; + } + final UtxoCubit utxoCubit = context.read<UtxoCubit>(); final double convertedAmount = toG1(amount, isG1, currentUd); - if (confirmed == null || !confirmed) { paymentCubit.sentFailed(); } else { final Transaction tx = Transaction( type: TransactionType.pending, from: fromContact, - to: to, - amount: -toCG1(convertedAmount).toDouble(), + to: recipients[0], + recipients: recipients, + recipientsAmounts: List<double>.filled(recipients.length, amount), + amount: -toCG1(convertedAmount).toDouble() * recipients.length, comment: comment, time: DateTime.now()); final bool isConnected = @@ -83,35 +89,54 @@ Future<bool> payWithRetry( if (!context.mounted) { return true; } - if (!isMultiPayment) { - showAlertDialog(context, tr('payment_waiting_internet_title'), - tr('payment_waiting_internet_desc_beta')); - } + showAlertDialog(context, tr('payment_waiting_internet_title'), + tr('payment_waiting_internet_desc_beta')); final Transaction pending = tx.copyWith(type: TransactionType.waitingNetwork); txCubit.addPendingTransaction(pending); - if (!isMultiPayment) { - context.read<BottomNavCubit>().updateIndex(3); - } + context.read<BottomNavCubit>().updateIndex(3); return true; } else { - final PayResult result = await pay( - to: contactPubKey, comment: comment, amount: convertedAmount); + // PAY! + PayResult result; + if (!useBMA) { + result = await payWithGVA( + to: recipients.map((Contact c) => c.pubKey).toList(), + comment: comment, + amount: convertedAmount); + } else { + await utxoCubit.fetchUtxos(fromPubKey); + final List<Utxo>? utxos = utxoCubit.consume(convertedAmount); + final Tuple2<Map<String, dynamic>?, Node> currentBlock = + await getCurrentBlockGVA(); + + if (currentBlock != null && utxos != null) { + result = await payWithBMA( + destPub: recipients[0].pubKey, + blockHash: '${currentBlock.item1!['hash']}', + blockNumber: '${currentBlock.item1!['number']}', + comment: comment, + wallet: wallet, + utxos: utxos, + amount: convertedAmount); + } else { + final Node triedNode = currentBlock.item2; + result = PayResult( + message: 'Error retrieving payment data', node: triedNode); + } + } final Transaction pending = tx.copyWith( debugInfo: - 'Node used: ${result.node != null ? result.node!.url : 'unknown'}'); + 'Node used: ${result != null && result.node != null ? result.node!.url : 'unknown'}'); if (result.message == 'success') { paymentCubit.sent(); // ignore: use_build_context_synchronously if (!context.mounted) { return true; } - if (!isMultiPayment) { - showAlertDialog(context, tr('payment_successful'), - tr('payment_successful_desc')); - } - + showAlertDialog(context, tr('payment_successful'), + tr('payment_successful_desc')); if (!isRetry) { // Add here the transaction to the pending list (so we can check it the tx is confirmed) txCubit.addPendingTransaction(pending); @@ -136,9 +161,8 @@ Future<bool> payWithRetry( // We try to translate the error, like "insufficient balance" 'error': tr(result.message) }), - isMultiPayment: isMultiPayment, increaseErrors: failedWithBalance, - node: result.node!.node); + node: result.node); if (!isRetry) { txCubit.insertPendingTransaction( pending.copyWith(type: TransactionType.failed)); @@ -158,7 +182,6 @@ Future<bool> payWithRetry( showPayError( context: context, desc: tr('payment_error_no_pass'), - isMultiPayment: isMultiPayment, increaseErrors: false); } return false; @@ -175,22 +198,35 @@ bool weHaveBalance(BuildContext context, double amount) { double getBalance(BuildContext context) => context.read<MultiWalletTransactionCubit>().balance; -Future<bool?> _confirmSend(BuildContext context, String amount, String to, - bool isRetry, Currency currency) async { +Future<bool?> _confirmSend( + BuildContext context, + String amount, + String fromPubKey, + List<Contact> recipients, + bool isRetry, + Currency currency, + bool isPayToMultiple) async { return showDialog<bool>( context: context, builder: (BuildContext context) { return AlertDialog( title: Text(tr('please_confirm_sent')), - content: Text(tr( - isRetry - ? 'please_confirm_retry_sent_desc' - : 'please_confirm_sent_desc', - namedArgs: <String, String>{ - 'amount': amount, - 'to': to, - 'currency': currency.name() - })), + content: isPayToMultiple + ? Text(tr('please_confirm_sent_multi_desc', + namedArgs: <String, String>{ + 'amount': amount, + 'currency': currency.name(), + 'people': recipients.length.toString() + })) + : Text(tr( + isRetry + ? 'please_confirm_retry_sent_desc' + : 'please_confirm_sent_desc', + namedArgs: <String, String>{ + 'amount': amount, + 'to': humanizeContact(fromPubKey, recipients[0], true), + 'currency': currency.name() + })), actions: <Widget>[ TextButton( onPressed: () => Navigator.of(context).pop(false), @@ -209,12 +245,9 @@ Future<bool?> _confirmSend(BuildContext context, String amount, String to, void showPayError( {required BuildContext context, required String desc, - required bool isMultiPayment, required bool increaseErrors, Node? node}) { - if (!isMultiPayment) { - showAlertDialog(context, tr('payment_error'), desc); - } + showAlertDialog(context, tr('payment_error'), desc); context.read<PaymentCubit>().sentFailed(); if (node != null && increaseErrors) { NodeManager().increaseNodeErrors(NodeType.gva, node); diff --git a/lib/ui/widgets/first_screen/pay_form.dart b/lib/ui/widgets/first_screen/pay_form.dart index fcd753e8d30eca27175d90a936c792ef029b82af..ea6a289f36e222d476797c0a761c15d71fa184d8 100644 --- a/lib/ui/widgets/first_screen/pay_form.dart +++ b/lib/ui/widgets/first_screen/pay_form.dart @@ -5,8 +5,6 @@ import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import '../../../data/models/app_cubit.dart'; -import '../../../data/models/bottom_nav_cubit.dart'; -import '../../../data/models/contact.dart'; import '../../../data/models/multi_wallet_transaction_cubit.dart'; import '../../../data/models/payment_cubit.dart'; import '../../../data/models/payment_state.dart'; @@ -152,93 +150,24 @@ class _PayFormState extends State<PayForm> { if (notCanBeSent || nullAmount || notValidComment || - notBalance(context, state, currency, currentUd)) { + notBalance( + context, state, currency, currentUd, state.contacts.length)) { return null; - } else if (state.isMultiple()) { - // Multiple payments - return () async { - final bool? confirmed = await showDialog<bool>( - context: context, - builder: (BuildContext context) { - return AlertDialog( - title: Text(tr('please_confirm_sent')), - content: Text(tr('please_confirm_sent_multi_desc', - namedArgs: <String, String>{ - 'amount': state.amount.toString(), - 'currency': currency.name() - })), - actions: <Widget>[ - TextButton( - child: Text(tr('cancel')), - onPressed: () => Navigator.of(context).pop(false), - ), - TextButton( - child: Text(tr('accept')), - onPressed: () => Navigator.of(context).pop(true), - ), - ], - ); - }, - ); - - if (confirmed != null && confirmed) { - int validPayments = 0; - int invalidPayments = 0; - - paymentResultsStreamController.stream.listen((String paymentResult) { - if (Navigator.canPop(context)) { - Navigator.pop(context); - } - showAlertDialog(context, tr('multi_pay_results'), paymentResult); - }); - for (final Contact contact in state.contacts) { - if (!mounted) { - return; - } - final bool result = await payWithRetry( - context: context, - to: contact, - amount: state.amount!, - isG1: isG1, - currentUd: currentUd, - isMultiPayment: true, - comment: state.comment, - ); - if (result) { - validPayments++; - } else { - invalidPayments++; - } - paymentResultsStreamController.add(tr('multi_pay_results_desc', - namedArgs: <String, String>{ - 'success': validPayments.toString(), - 'fail': invalidPayments.toString() - })); - // await Future<void>.delayed(const Duration(milliseconds: 200)); - } - if (!mounted) { - return; - } - context.read<BottomNavCubit>().updateIndex(3); - } - }; - } else { - // Single payment + } else return () async { await payWithRetry( context: context, - to: state.contacts[0], + recipients: state.contacts, amount: state.amount!, isG1: isG1, currentUd: currentUd, comment: state.comment); }; - } } bool notBalance(BuildContext context, PaymentState state, Currency currency, - double currentUd) => - !_weHaveBalance(context, state.amount!, currency, currentUd); + double currentUd, int recipients) => + !_weHaveBalance(context, state.amount!, currency, currentUd, recipients); bool _commentValidate() { final String currentComment = _commentController.value.text; @@ -253,11 +182,11 @@ class _PayFormState extends State<PayForm> { } bool _weHaveBalance(BuildContext context, double amount, Currency currency, - double currentUd) { + double currentUd, int recipients) { final double balance = convertAmount(currency == Currency.G1, getBalance(context), currentUd); - logger('We have $balance G1, need $amount'); - final bool weHave = balance >= amount; + logger('We have $balance G1, need ${amount * recipients}'); + final bool weHave = balance >= amount * recipients; if (!weHave) { _feedbackNotifier.value = tr('insufficient balance'); diff --git a/lib/ui/widgets/fourth_screen/transaction_item.dart b/lib/ui/widgets/fourth_screen/transaction_item.dart index 64eb5a7d54a0fac633c2ef20c20efa4b25c82587..277b0ebb9c4a278ce50251493cc0bd712d4e6b25 100644 --- a/lib/ui/widgets/fourth_screen/transaction_item.dart +++ b/lib/ui/widgets/fourth_screen/transaction_item.dart @@ -222,8 +222,11 @@ class TransactionListItem extends StatelessWidget { 'from': '${humanizeContact(myPubKey, transaction.from)} 🫴 ', 'to': transaction.isToMultiple - ? humanizeContacts(myPubKey, - transaction.recipients) + ? humanizeContacts( + fromAddress: + transaction.from.pubKey, + contacts: + transaction.recipients) : humanizeContact( myPubKey, transaction.to) }), diff --git a/lib/ui/widgets/fourth_screen/transaction_page.dart b/lib/ui/widgets/fourth_screen/transaction_page.dart index 8f0c9499d2d6f970077f3b0488fcbd2461c3adb3..09ddc6e5993d70052dcadf6d8fe944a7a24730e3 100644 --- a/lib/ui/widgets/fourth_screen/transaction_page.dart +++ b/lib/ui/widgets/fourth_screen/transaction_page.dart @@ -16,6 +16,7 @@ import '../../../data/models/node_list_cubit.dart'; import '../../../data/models/theme_cubit.dart'; import '../../../data/models/transaction.dart'; import '../../../data/models/transactions_bloc.dart'; +import '../../../data/models/utxo_cubit.dart'; import '../../../g1/currency.dart'; import '../../../shared_prefs_helper.dart'; import '../../logger.dart'; @@ -43,6 +44,7 @@ class _TransactionsAndBalanceWidgetState late AppCubit appCubit; late NodeListCubit nodeListCubit; late MultiWalletTransactionCubit transCubit; + late UtxoCubit utxoCubit; final PagingController<String?, Transaction> _pagingController = PagingController<String?, Transaction>(firstPageKey: null); @@ -62,7 +64,8 @@ class _TransactionsAndBalanceWidgetState appCubit = context.read<AppCubit>(); transCubit = context.read<MultiWalletTransactionCubit>(); nodeListCubit = context.read<NodeListCubit>(); - _bloc.init(transCubit, nodeListCubit, appCubit); + utxoCubit = context.read<UtxoCubit>(); + _bloc.init(transCubit, nodeListCubit, appCubit, utxoCubit); _pagingController.addPageRequestListener((String? cursor) { _bloc.onPageRequestSink.add(cursor); }); @@ -114,7 +117,7 @@ class _TransactionsAndBalanceWidgetState _refresh(); } catch (e) { logger('Failed via _refresh, lets try a basic fetchTransactions'); - transCubit.fetchTransactions(nodeListCubit, appCubit); + transCubit.fetchTransactions(nodeListCubit, utxoCubit, appCubit); } }); tutorial = FourthTutorial(context); diff --git a/pubspec.lock b/pubspec.lock index 68f1f8e65b1d09eb8e62b8cf718aff30a23e50dd..ab3cc751b6c6b331d4a39aab99bfdb9ab343ff74 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -149,10 +149,10 @@ packages: dependency: "direct dev" description: name: build_runner - sha256: "10c6bcdbf9d049a0b666702cf1cee4ddfdc38f02a19d35ae392863b47519848b" + sha256: "67d591d602906ef9201caf93452495ad1812bea2074f04e25dbd7c133785821b" url: "https://pub.dev" source: hosted - version: "2.4.6" + version: "2.4.7" build_runner_core: dependency: transitive description: @@ -173,10 +173,10 @@ packages: dependency: transitive description: name: built_value - sha256: "723b4021e903217dfc445ec4cf5b42e27975aece1fc4ebbc1ca6329c2d9fb54e" + sha256: "69acb7007eb2a31dc901512bfe0f7b767168be34cb734835d54c070bfa74c1b2" url: "https://pub.dev" source: hosted - version: "8.7.0" + version: "8.8.0" characters: dependency: transitive description: @@ -229,10 +229,10 @@ packages: dependency: transitive description: name: code_builder - sha256: "1be9be30396d7e4c0db42c35ea6ccd7cc6a1e19916b5dc64d6ac216b5544d677" + sha256: b2151ce26a06171005b379ecff6e08d34c470180ffe16b8e14b6d52be292b55f url: "https://pub.dev" source: hosted - version: "4.7.0" + version: "4.8.0" collection: dependency: "direct main" description: @@ -245,10 +245,10 @@ packages: dependency: transitive description: name: connectivity_plus - sha256: b502a681ba415272ecc41400bd04fe543ed1a62632137dc84d25a91e7746f55f + sha256: "224a77051d52a11fbad53dd57827594d3bd24f945af28bd70bab376d68d437f0" url: "https://pub.dev" source: hosted - version: "5.0.1" + version: "5.0.2" connectivity_plus_platform_interface: dependency: transitive description: @@ -301,10 +301,10 @@ packages: dependency: transitive description: name: cross_file - sha256: "445db18de832dba8d851e287aff8ccf169bed30d2e94243cb54c7d2f1ed2142c" + sha256: "2f9d2cbccb76127ba28528cb3ae2c2326a122446a83de5a056aaa3880d3882c5" url: "https://pub.dev" source: hosted - version: "0.3.3+6" + version: "0.3.3+7" crypto: dependency: "direct main" description: @@ -325,26 +325,26 @@ packages: dependency: transitive description: name: dart_style - sha256: abd7625e16f51f554ea244d090292945ec4d4be7bfbaf2ec8cccea568919d334 + sha256: "40ae61a5d43feea6d24bd22c0537a6629db858963b99b4bc1c3db80676f32368" url: "https://pub.dev" source: hosted - version: "2.3.3" + version: "2.3.4" dbus: dependency: transitive description: name: dbus - sha256: "6f07cba3f7b3448d42d015bfd3d53fe12e5b36da2423f23838efc1d5fb31a263" + sha256: "365c771ac3b0e58845f39ec6deebc76e3276aa9922b0cc60840712094d9047ac" url: "https://pub.dev" source: hosted - version: "0.7.8" + version: "0.7.10" device_info_plus: dependency: transitive description: name: device_info_plus - sha256: "7035152271ff67b072a211152846e9f1259cf1be41e34cd3e0b5463d2d6b8419" + sha256: "0042cb3b2a76413ea5f8a2b40cec2a33e01d0c937e91f0f7c211fde4f7739ba6" url: "https://pub.dev" source: hosted - version: "9.1.0" + version: "9.1.1" device_info_plus_platform_interface: dependency: transitive description: @@ -357,10 +357,10 @@ packages: dependency: transitive description: name: dio - sha256: "417e2a6f9d83ab396ec38ff4ea5da6c254da71e4db765ad737a42af6930140b7" + sha256: "797e1e341c3dd2f69f2dad42564a6feff3bfb87187d05abb93b9609e6f1645c3" url: "https://pub.dev" source: hosted - version: "5.3.3" + version: "5.4.0" dom_tools: dependency: transitive description: @@ -380,10 +380,9 @@ packages: durt: dependency: "direct main" description: - name: durt - sha256: "97007458aa5ba95c78465af8489e1330d0f7d48c74e10699f6283ce4cf8a5410" - url: "https://pub.dev" - source: hosted + path: "../durt" + relative: true + source: path version: "0.1.7" easy_debounce: dependency: "direct main" @@ -914,10 +913,10 @@ packages: dependency: "direct main" description: name: l10n_esperanto - sha256: "05a7aa7e8026c63aa25e476f556e3098e43bf1ab1e6987730d3bd301680df52b" + sha256: d3b969643aa91ed8111bf04c4114d23891fff3ee5fbc90179e66576481b24267 url: "https://pub.dev" source: hosted - version: "2.0.6" + version: "2.0.7" lehttp_overrides: dependency: "direct main" description: @@ -1146,10 +1145,10 @@ packages: dependency: transitive description: name: plugin_platform_interface - sha256: da3fdfeccc4d4ff2da8f8c556704c08f912542c5fb3cf2233ed75372384a034d + sha256: f4f88d4a900933e7267e2b353594774fc0d07fb072b47eedcd5b54e1ea3269f8 url: "https://pub.dev" source: hosted - version: "2.1.6" + version: "2.1.7" pointycastle: dependency: transitive description: @@ -1258,10 +1257,10 @@ packages: dependency: transitive description: name: sentry - sha256: "9cfd325611ab54b57d5e26957466823f05bea9d6cfcc8d48f11817b8bcedf0d1" + sha256: e7ded42974bac5f69e4ca4ddc57d30499dd79381838f24b7e8fd9aa4139e7b79 url: "https://pub.dev" source: hosted - version: "7.12.0" + version: "7.13.2" sentry_dart_plugin: dependency: "direct main" description: @@ -1274,18 +1273,18 @@ packages: dependency: "direct main" description: name: sentry_flutter - sha256: "0cd7d622cb63c94fd1b2f87ab508e158b950bd281e2a80f327ebf73bb217eaf3" + sha256: d6f55ec7a1f681784165021f749007712a72ff57eadf91e963331b6ae326f089 url: "https://pub.dev" source: hosted - version: "7.12.0" + version: "7.13.2" sentry_logging: dependency: "direct main" description: name: sentry_logging - sha256: e71c78e3dd01d67455f4ee489778a358a44016ec06358943fbf30e7901eec7e8 + sha256: "157b30f8ad6b360333e651366022003399c337c2bf04d822711ae78f80576216" url: "https://pub.dev" source: hosted - version: "7.12.0" + version: "7.13.2" share_plus: dependency: "direct main" description: @@ -1591,10 +1590,10 @@ packages: dependency: transitive description: name: url_launcher_ios - sha256: "4ac97281cf60e2e8c5cc703b2b28528f9b50c8f7cebc71df6bdf0845f647268a" + sha256: bba3373219b7abb6b5e0d071b0fe66dfbe005d07517a68e38d4fc3638f35c6d3 url: "https://pub.dev" source: hosted - version: "6.2.0" + version: "6.2.1" url_launcher_linux: dependency: transitive description: @@ -1679,10 +1678,10 @@ packages: dependency: "direct main" description: name: vibration - sha256: "63d4f6b03e38d106599da18e786d5edcd02354433a4ed478fccbbcfc347193ab" + sha256: "778ace40e84852e6cf6017cdbaf6790a837d73ff3dd50b27da9ac232a19de8fc" url: "https://pub.dev" source: hosted - version: "1.8.3" + version: "1.8.4" watcher: dependency: transitive description: @@ -1727,10 +1726,10 @@ packages: dependency: transitive description: name: win32 - sha256: "350a11abd2d1d97e0cc7a28a81b781c08002aa2864d9e3f192ca0ffa18b06ed3" + sha256: b0f37db61ba2f2e9b7a78a1caece0052564d1bc70668156cf3a29d676fe4e574 url: "https://pub.dev" source: hosted - version: "5.0.9" + version: "5.1.1" win32_registry: dependency: transitive description: diff --git a/pubspec.yaml b/pubspec.yaml index b6f54d620624022d4dede0d5ae0c48ad1f09818a..64a976c1b012805301c68216d730853d7a092257 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -39,9 +39,9 @@ dependencies: qr_flutter: ^4.0.0 introduction_screen: ^3.1.6 responsive_framework: ^0.2.0 - durt: ^0.1.7 - #durt: - # path: ../durt + #durt: ^0.1.7 + durt: + path: ../durt # git: # url: https://git.duniter.org/clients/durt flutter_neumorphic: