diff --git a/cw_bitcoin/lib/electrum_transaction_info.dart b/cw_bitcoin/lib/electrum_transaction_info.dart index 8b52b7aca..f2ef251c7 100644 --- a/cw_bitcoin/lib/electrum_transaction_info.dart +++ b/cw_bitcoin/lib/electrum_transaction_info.dart @@ -35,6 +35,7 @@ class ElectrumTransactionInfo extends TransactionInfo { List? outputAddresses, required TransactionDirection direction, required bool isPending, + required bool isReplaced, required DateTime date, required int confirmations, String? to, @@ -50,6 +51,7 @@ class ElectrumTransactionInfo extends TransactionInfo { this.direction = direction; this.date = date; this.isPending = isPending; + this.isReplaced = isReplaced; this.confirmations = confirmations; this.to = to; } @@ -98,6 +100,7 @@ class ElectrumTransactionInfo extends TransactionInfo { id: id, height: height, isPending: false, + isReplaced: false, fee: fee, direction: direction, amount: amount, @@ -173,6 +176,7 @@ class ElectrumTransactionInfo extends TransactionInfo { id: bundle.originalTransaction.txId(), height: height, isPending: bundle.confirmations == 0, + isReplaced: false, inputAddresses: inputAddresses, outputAddresses: outputAddresses, fee: fee, @@ -196,6 +200,7 @@ class ElectrumTransactionInfo extends TransactionInfo { direction: parseTransactionDirectionFromInt(data['direction'] as int), date: DateTime.fromMillisecondsSinceEpoch(data['date'] as int), isPending: data['isPending'] as bool, + isReplaced: data['isReplaced'] as bool? ?? false, confirmations: data['confirmations'] as int, inputAddresses: inputAddresses.isEmpty ? [] : inputAddresses.map((e) => e.toString()).toList(), @@ -238,6 +243,7 @@ class ElectrumTransactionInfo extends TransactionInfo { direction: direction, date: date, isPending: isPending, + isReplaced: isReplaced ?? false, inputAddresses: inputAddresses, outputAddresses: outputAddresses, confirmations: info.confirmations); @@ -251,6 +257,7 @@ class ElectrumTransactionInfo extends TransactionInfo { m['direction'] = direction.index; m['date'] = date.millisecondsSinceEpoch; m['isPending'] = isPending; + m['isReplaced'] = isReplaced; m['confirmations'] = confirmations; m['fee'] = fee; m['to'] = to; @@ -262,6 +269,6 @@ class ElectrumTransactionInfo extends TransactionInfo { } String toString() { - return 'ElectrumTransactionInfo(id: $id, height: $height, amount: $amount, fee: $fee, direction: $direction, date: $date, isPending: $isPending, confirmations: $confirmations, to: $to, unspent: $unspents, inputAddresses: $inputAddresses, outputAddresses: $outputAddresses)'; + return 'ElectrumTransactionInfo(id: $id, height: $height, amount: $amount, fee: $fee, direction: $direction, date: $date, isPending: $isPending, isReplaced: $isReplaced, confirmations: $confirmations, to: $to, unspent: $unspents, inputAddresses: $inputAddresses, outputAddresses: $outputAddresses)'; } } diff --git a/cw_bitcoin/lib/electrum_wallet.dart b/cw_bitcoin/lib/electrum_wallet.dart index 5eb4bd85c..fee115650 100644 --- a/cw_bitcoin/lib/electrum_wallet.dart +++ b/cw_bitcoin/lib/electrum_wallet.dart @@ -1400,14 +1400,16 @@ abstract class ElectrumWalletBase } } - Future canReplaceByFee(ElectrumTransactionInfo tx) async { + int transactionVSize(String transactionHex) => BtcTransaction.fromRaw(transactionHex).getVSize(); + + Future canReplaceByFee(ElectrumTransactionInfo tx) async { try { final bundle = await getTransactionExpanded(hash: tx.txHash); _updateInputsAndOutputs(tx, bundle); - if (bundle.confirmations > 0) return false; - return bundle.originalTransaction.canReplaceByFee; + if (bundle.confirmations > 0) return null; + return bundle.originalTransaction.canReplaceByFee ? bundle.originalTransaction.toHex() : null; } catch (e) { - return false; + return null; } } @@ -1589,6 +1591,13 @@ abstract class ElectrumWalletBase hasChange: changeOutputs.isNotEmpty, feeRate: newFee.toString(), )..addListener((transaction) async { + transactionHistory.transactions.values.forEach((tx) { + if (tx.id == hash) { + tx.isReplaced = true; + tx.isPending = false; + transactionHistory.addOne(tx); + } + }); transactionHistory.addOne(transaction); await updateBalance(); }); @@ -2317,6 +2326,7 @@ Future startRefresh(ScanData scanData) async { fee: 0, direction: TransactionDirection.incoming, isPending: false, + isReplaced: false, date: scanData.network == BitcoinNetwork.mainnet ? getDateByBitcoinHeight(tweakHeight) : DateTime.now(), @@ -2424,6 +2434,7 @@ class EstimatedTxResult { final int fee; final int amount; final bool spendsSilentPayment; + // final bool sendsToSilentPayment; final bool hasChange; final bool isSendAll; diff --git a/cw_bitcoin/lib/pending_bitcoin_transaction.dart b/cw_bitcoin/lib/pending_bitcoin_transaction.dart index 26ed3a4be..e90e2193a 100644 --- a/cw_bitcoin/lib/pending_bitcoin_transaction.dart +++ b/cw_bitcoin/lib/pending_bitcoin_transaction.dart @@ -110,6 +110,7 @@ class PendingBitcoinTransaction with PendingTransaction { direction: TransactionDirection.outgoing, date: DateTime.now(), isPending: true, + isReplaced: false, confirmations: 0, fee: fee); } diff --git a/cw_core/lib/transaction_info.dart b/cw_core/lib/transaction_info.dart index 971e4ecdb..9d0c968d8 100644 --- a/cw_core/lib/transaction_info.dart +++ b/cw_core/lib/transaction_info.dart @@ -18,6 +18,7 @@ abstract class TransactionInfo extends Object with Keyable { String? to; String? from; String? evmSignatureName; + bool? isReplaced; List? inputAddresses; List? outputAddresses; diff --git a/lib/bitcoin/cw_bitcoin.dart b/lib/bitcoin/cw_bitcoin.dart index f09547fae..d1869a05d 100644 --- a/lib/bitcoin/cw_bitcoin.dart +++ b/lib/bitcoin/cw_bitcoin.dart @@ -411,12 +411,18 @@ class CWBitcoin extends Bitcoin { } @override - Future canReplaceByFee(Object wallet, Object transactionInfo) async { + Future canReplaceByFee(Object wallet, Object transactionInfo) async { final bitcoinWallet = wallet as ElectrumWallet; final tx = transactionInfo as ElectrumTransactionInfo; return bitcoinWallet.canReplaceByFee(tx); } + @override + int getTransactionVSize(Object wallet, String transactionHex) { + final bitcoinWallet = wallet as ElectrumWallet; + return bitcoinWallet.transactionVSize(transactionHex); + } + @override Future isChangeSufficientForFee(Object wallet, String txId, String newFee) async { final bitcoinWallet = wallet as ElectrumWallet; diff --git a/lib/di.dart b/lib/di.dart index 63b47f5e1..0b98244e6 100644 --- a/lib/di.dart +++ b/lib/di.dart @@ -1080,21 +1080,40 @@ Future setup({ param1: derivations, ))); - getIt.registerFactoryParam( - (TransactionInfo transactionInfo, _) { - final wallet = getIt.get().wallet!; - return TransactionDetailsViewModel( - transactionInfo: transactionInfo, - transactionDescriptionBox: _transactionDescriptionBox, - wallet: wallet, - settingsStore: getIt.get(), - sendViewModel: getIt.get()); - }); + getIt.registerFactoryParam, void>( + (params, _) { + final transactionInfo = params[0] as TransactionInfo; + final canReplaceByFee = params[1] as bool? ?? false; + final wallet = getIt.get().wallet!; + + return TransactionDetailsViewModel( + transactionInfo: transactionInfo, + transactionDescriptionBox: _transactionDescriptionBox, + wallet: wallet, + settingsStore: getIt.get(), + sendViewModel: getIt.get(), + canReplaceByFee: canReplaceByFee, + ); + } + ); getIt.registerFactoryParam( - (TransactionInfo transactionInfo, _) => TransactionDetailsPage( - transactionDetailsViewModel: - getIt.get(param1: transactionInfo))); + (TransactionInfo transactionInfo, _) => TransactionDetailsPage( + transactionDetailsViewModel: getIt.get( + param1: [transactionInfo, false]))); + + getIt.registerFactoryParam, void>( + (params, _) { + final transactionInfo = params[0] as TransactionInfo; + final txHex = params[1] as String; + return RBFDetailsPage( + transactionDetailsViewModel: getIt.get( + param1: [transactionInfo, true], + ), + rawTransaction: txHex, + ); + } + ); getIt.registerFactoryParam( (newWalletTypeArguments, _) { @@ -1265,11 +1284,6 @@ Future setup({ getIt.registerFactory(() => CakePayAccountPage(getIt.get())); - getIt.registerFactoryParam( - (TransactionInfo transactionInfo, _) => RBFDetailsPage( - transactionDetailsViewModel: - getIt.get(param1: transactionInfo))); - getIt.registerFactory(() => AnonPayApi( useTorOnly: getIt.get().exchangeStatus == ExchangeApiMode.torOnly, wallet: getIt.get().wallet!)); diff --git a/lib/router.dart b/lib/router.dart index 0f65b4a05..16eeefeb1 100644 --- a/lib/router.dart +++ b/lib/router.dart @@ -375,7 +375,7 @@ Route createRoute(RouteSettings settings) { case Routes.bumpFeePage: return CupertinoPageRoute( fullscreenDialog: true, - builder: (_) => getIt.get(param1: settings.arguments as TransactionInfo)); + builder: (_) => getIt.get(param1: settings.arguments as List)); case Routes.newSubaddress: return CupertinoPageRoute( diff --git a/lib/src/screens/dashboard/pages/transactions_page.dart b/lib/src/screens/dashboard/pages/transactions_page.dart index a9a1213ce..2538824ed 100644 --- a/lib/src/screens/dashboard/pages/transactions_page.dart +++ b/lib/src/screens/dashboard/pages/transactions_page.dart @@ -84,10 +84,7 @@ class TransactionsPage extends StatelessWidget { } final transaction = item.transaction; - final transactionType = dashboardViewModel.type == WalletType.ethereum && - transaction.evmSignatureName == 'approval' - ? ' (${transaction.evmSignatureName})' - : ''; + final transactionType = dashboardViewModel.getTransactionType(transaction); return Observer( builder: (_) => TransactionRow( @@ -102,7 +99,7 @@ class TransactionsPage extends StatelessWidget { : item.formattedFiatAmount, isPending: transaction.isPending, title: - item.formattedTitle + item.formattedStatus + ' $transactionType', + item.formattedTitle + item.formattedStatus + transactionType, isReceivedSilentPayment: dashboardViewModel.type == WalletType.bitcoin && bitcoin!.txIsReceivedSilentPayment(transaction), diff --git a/lib/src/screens/transaction_details/rbf_details_page.dart b/lib/src/screens/transaction_details/rbf_details_page.dart index 3faec48a8..b117a0b68 100644 --- a/lib/src/screens/transaction_details/rbf_details_page.dart +++ b/lib/src/screens/transaction_details/rbf_details_page.dart @@ -24,19 +24,22 @@ import 'package:flutter_mobx/flutter_mobx.dart'; import 'package:mobx/mobx.dart'; class RBFDetailsPage extends BasePage { - RBFDetailsPage({required this.transactionDetailsViewModel}); + RBFDetailsPage({required this.transactionDetailsViewModel, required this.rawTransaction}) { + transactionDetailsViewModel.addBumpFeesListItems( + transactionDetailsViewModel.transactionInfo, rawTransaction); + } @override String get title => S.current.bump_fee; final TransactionDetailsViewModel transactionDetailsViewModel; + final String rawTransaction; bool _effectsInstalled = false; @override Widget body(BuildContext context) { _setEffects(context); - return Column( children: [ Expanded( @@ -166,7 +169,9 @@ class RBFDetailsPage extends BasePage { actionRightButton: () async { Navigator.of(popupContext).pop(); await transactionDetailsViewModel.sendViewModel.commitTransaction(); - // transactionStatePopup(); + try { + Navigator.of(popupContext).pop(); + } catch (_) {} }, actionLeftButton: () => Navigator.of(popupContext).pop(), feeFiatAmount: diff --git a/lib/src/screens/transaction_details/transaction_details_page.dart b/lib/src/screens/transaction_details/transaction_details_page.dart index d06b935dd..1b088fc31 100644 --- a/lib/src/screens/transaction_details/transaction_details_page.dart +++ b/lib/src/screens/transaction_details/transaction_details_page.dart @@ -75,7 +75,7 @@ class TransactionDetailsPage extends BasePage { text: S.of(context).bump_fee, onTap: () async { Navigator.of(context).pushNamed(Routes.bumpFeePage, - arguments: transactionDetailsViewModel.transactionInfo); + arguments: [transactionDetailsViewModel.transactionInfo, transactionDetailsViewModel.rawTransaction]); }, ), ); diff --git a/lib/view_model/dashboard/dashboard_view_model.dart b/lib/view_model/dashboard/dashboard_view_model.dart index 9aeb3a46b..4c3a9e1ea 100644 --- a/lib/view_model/dashboard/dashboard_view_model.dart +++ b/lib/view_model/dashboard/dashboard_view_model.dart @@ -764,6 +764,16 @@ abstract class DashboardViewModelBase with Store { } } + String getTransactionType(TransactionInfo tx) { + if (wallet.type == WalletType.bitcoin) { + if (tx.isReplaced == true) return ' (replaced)'; + } + + if (wallet.type == WalletType.ethereum && tx.evmSignatureName == 'approval') + return ' (${tx.evmSignatureName})'; + return ''; + } + Future refreshDashboard() async { reconnect(); } diff --git a/lib/view_model/transaction_details_view_model.dart b/lib/view_model/transaction_details_view_model.dart index e4f9c3786..d3aa2a841 100644 --- a/lib/view_model/transaction_details_view_model.dart +++ b/lib/view_model/transaction_details_view_model.dart @@ -36,7 +36,8 @@ abstract class TransactionDetailsViewModelBase with Store { required this.transactionDescriptionBox, required this.wallet, required this.settingsStore, - required this.sendViewModel}) + required this.sendViewModel, + this.canReplaceByFee = false}) : items = [], RBFListItems = [], newFee = 0, @@ -51,8 +52,7 @@ abstract class TransactionDetailsViewModelBase with Store { break; case WalletType.bitcoin: _addElectrumListItems(tx, dateFormat); - _addBumpFeesListItems(tx); - _checkForRBF(tx); + if(!canReplaceByFee)_checkForRBF(tx); break; case WalletType.litecoin: case WalletType.bitcoinCash: @@ -139,13 +139,11 @@ abstract class TransactionDetailsViewModelBase with Store { bool showRecipientAddress; bool isRecipientAddressShown; int newFee; + String? rawTransaction; TransactionPriority? transactionPriority; @observable - bool _canReplaceByFee = false; - - @computed - bool get canReplaceByFee => _canReplaceByFee /*&& transactionInfo.confirmations <= 0*/; + bool canReplaceByFee; String _explorerUrl(WalletType type, String txId) { switch (type) { @@ -347,7 +345,7 @@ abstract class TransactionDetailsViewModelBase with Store { items.addAll(_items); } - void _addBumpFeesListItems(TransactionInfo tx) { + void addBumpFeesListItems(TransactionInfo tx, String rawTransaction) { transactionPriority = bitcoin!.getBitcoinTransactionPriorityMedium(); final inputsCount = (transactionInfo.inputAddresses?.isEmpty ?? true) ? 1 @@ -361,6 +359,14 @@ abstract class TransactionDetailsViewModelBase with Store { RBFListItems.add(StandartListItem(title: S.current.old_fee, value: tx.feeFormatted() ?? '0.0')); + if (transactionInfo.fee != null && rawTransaction.isNotEmpty) { + final size = bitcoin!.getTransactionVSize(wallet, rawTransaction); + final recommendedRate = (transactionInfo.fee! / size).round() + 1; + + RBFListItems.add( + StandartListItem(title: 'New recommended fee rate', value: '$recommendedRate sat/byte')); + } + final priorities = priorityForWalletType(wallet.type); final selectedItem = priorities.indexOf(sendViewModel.transactionPriority); final customItem = priorities @@ -429,8 +435,9 @@ abstract class TransactionDetailsViewModelBase with Store { Future _checkForRBF(TransactionInfo tx) async { if (wallet.type == WalletType.bitcoin && transactionInfo.direction == TransactionDirection.outgoing) { - if (await bitcoin!.canReplaceByFee(wallet, tx)) { - _canReplaceByFee = true; + rawTransaction = await bitcoin!.canReplaceByFee(wallet, tx); + if (rawTransaction != null) { + canReplaceByFee = true; } } } diff --git a/tool/configure.dart b/tool/configure.dart index af1f591a1..815c9e399 100644 --- a/tool/configure.dart +++ b/tool/configure.dart @@ -205,7 +205,8 @@ abstract class Bitcoin { bool isTestnet(Object wallet); Future replaceByFee(Object wallet, String transactionHash, String fee); - Future canReplaceByFee(Object wallet, Object tx); + Future canReplaceByFee(Object wallet, Object tx); + int getTransactionVSize(Object wallet, String txHex); Future isChangeSufficientForFee(Object wallet, String txId, String newFee); int getFeeAmountForPriority(Object wallet, TransactionPriority priority, int inputsCount, int outputsCount, {int? size}); int getEstimatedFeeWithFeeRate(Object wallet, int feeRate, int? amount,