rbf-fixes-recomended-fee-rate (#1684)

* rbf fixes * Revert "rbf fixes" * fix replaced transactions * re-format electrum_wallet.dart [skip ci] * minor fixes [skip ci] --------- Co-authored-by: OmarHatem <omarh.ismail1@gmail.com>

Serhii committed Sep 20, 2024 at 23:57 UTC 32e119e24fcd1a78fbe397e065f54701dbc767f1
13 files changed +105 -45
cw_bitcoin/lib/electrum_transaction_info.dart
+8 -1
@@ -35,6 +35,7 @@ class ElectrumTransactionInfo extends TransactionInfo {
35 List<String>? outputAddresses,
36 required TransactionDirection direction,
37 required bool isPending,
38 + required bool isReplaced,
39 required DateTime date,
40 required int confirmations,
41 String? to,
@@ -50,6 +51,7 @@ class ElectrumTransactionInfo extends TransactionInfo {
51 this.direction = direction;
52 this.date = date;
53 this.isPending = isPending;
54 + this.isReplaced = isReplaced;
55 this.confirmations = confirmations;
56 this.to = to;
57 }
@@ -98,6 +100,7 @@ class ElectrumTransactionInfo extends TransactionInfo {
100 id: id,
101 height: height,
102 isPending: false,
103 + isReplaced: false,
104 fee: fee,
105 direction: direction,
106 amount: amount,
@@ -173,6 +176,7 @@ class ElectrumTransactionInfo extends TransactionInfo {
176 id: bundle.originalTransaction.txId(),
177 height: height,
178 isPending: bundle.confirmations == 0,
179 + isReplaced: false,
180 inputAddresses: inputAddresses,
181 outputAddresses: outputAddresses,
182 fee: fee,
@@ -196,6 +200,7 @@ class ElectrumTransactionInfo extends TransactionInfo {
200 direction: parseTransactionDirectionFromInt(data['direction'] as int),
201 date: DateTime.fromMillisecondsSinceEpoch(data['date'] as int),
202 isPending: data['isPending'] as bool,
203 + isReplaced: data['isReplaced'] as bool? ?? false,
204 confirmations: data['confirmations'] as int,
205 inputAddresses:
206 inputAddresses.isEmpty ? [] : inputAddresses.map((e) => e.toString()).toList(),
@@ -238,6 +243,7 @@ class ElectrumTransactionInfo extends TransactionInfo {
243 direction: direction,
244 date: date,
245 isPending: isPending,
246 + isReplaced: isReplaced ?? false,
247 inputAddresses: inputAddresses,
248 outputAddresses: outputAddresses,
249 confirmations: info.confirmations);
@@ -251,6 +257,7 @@ class ElectrumTransactionInfo extends TransactionInfo {
257 m['direction'] = direction.index;
258 m['date'] = date.millisecondsSinceEpoch;
259 m['isPending'] = isPending;
260 + m['isReplaced'] = isReplaced;
261 m['confirmations'] = confirmations;
262 m['fee'] = fee;
263 m['to'] = to;
@@ -262,6 +269,6 @@ class ElectrumTransactionInfo extends TransactionInfo {
269 }
270
271 String toString() {
265 - 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)';
272 + 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)';
273 }
274 }
cw_bitcoin/lib/electrum_wallet.dart
+15 -4
@@ -1400,14 +1400,16 @@ abstract class ElectrumWalletBase
1400 }
1401 }
1402
1403 - Future<bool> canReplaceByFee(ElectrumTransactionInfo tx) async {
1403 + int transactionVSize(String transactionHex) => BtcTransaction.fromRaw(transactionHex).getVSize();
1404 +
1405 + Future<String?> canReplaceByFee(ElectrumTransactionInfo tx) async {
1406 try {
1407 final bundle = await getTransactionExpanded(hash: tx.txHash);
1408 _updateInputsAndOutputs(tx, bundle);
1407 - if (bundle.confirmations > 0) return false;
1408 - return bundle.originalTransaction.canReplaceByFee;
1409 + if (bundle.confirmations > 0) return null;
1410 + return bundle.originalTransaction.canReplaceByFee ? bundle.originalTransaction.toHex() : null;
1411 } catch (e) {
1410 - return false;
1412 + return null;
1413 }
1414 }
1415
@@ -1589,6 +1591,13 @@ abstract class ElectrumWalletBase
1591 hasChange: changeOutputs.isNotEmpty,
1592 feeRate: newFee.toString(),
1593 )..addListener((transaction) async {
1594 + transactionHistory.transactions.values.forEach((tx) {
1595 + if (tx.id == hash) {
1596 + tx.isReplaced = true;
1597 + tx.isPending = false;
1598 + transactionHistory.addOne(tx);
1599 + }
1600 + });
1601 transactionHistory.addOne(transaction);
1602 await updateBalance();
1603 });
@@ -2317,6 +2326,7 @@ Future<void> startRefresh(ScanData scanData) async {
2326 fee: 0,
2327 direction: TransactionDirection.incoming,
2328 isPending: false,
2329 + isReplaced: false,
2330 date: scanData.network == BitcoinNetwork.mainnet
2331 ? getDateByBitcoinHeight(tweakHeight)
2332 : DateTime.now(),
@@ -2424,6 +2434,7 @@ class EstimatedTxResult {
2434 final int fee;
2435 final int amount;
2436 final bool spendsSilentPayment;
2437 +
2438 // final bool sendsToSilentPayment;
2439 final bool hasChange;
2440 final bool isSendAll;
cw_bitcoin/lib/pending_bitcoin_transaction.dart
+1
@@ -110,6 +110,7 @@ class PendingBitcoinTransaction with PendingTransaction {
110 direction: TransactionDirection.outgoing,
111 date: DateTime.now(),
112 isPending: true,
113 + isReplaced: false,
114 confirmations: 0,
115 fee: fee);
116 }
cw_core/lib/transaction_info.dart
+1
@@ -18,6 +18,7 @@ abstract class TransactionInfo extends Object with Keyable {
18 String? to;
19 String? from;
20 String? evmSignatureName;
21 + bool? isReplaced;
22 List<String>? inputAddresses;
23 List<String>? outputAddresses;
24
lib/bitcoin/cw_bitcoin.dart
+7 -1
@@ -411,12 +411,18 @@ class CWBitcoin extends Bitcoin {
411 }
412
413 @override
414 - Future<bool> canReplaceByFee(Object wallet, Object transactionInfo) async {
414 + Future<String?> canReplaceByFee(Object wallet, Object transactionInfo) async {
415 final bitcoinWallet = wallet as ElectrumWallet;
416 final tx = transactionInfo as ElectrumTransactionInfo;
417 return bitcoinWallet.canReplaceByFee(tx);
418 }
419
420 + @override
421 + int getTransactionVSize(Object wallet, String transactionHex) {
422 + final bitcoinWallet = wallet as ElectrumWallet;
423 + return bitcoinWallet.transactionVSize(transactionHex);
424 + }
425 +
426 @override
427 Future<bool> isChangeSufficientForFee(Object wallet, String txId, String newFee) async {
428 final bitcoinWallet = wallet as ElectrumWallet;
lib/di.dart
+32 -18
@@ -1080,21 +1080,40 @@ Future<void> setup({
1080 param1: derivations,
1081 )));
1082
1083 - getIt.registerFactoryParam<TransactionDetailsViewModel, TransactionInfo, void>(
1084 - (TransactionInfo transactionInfo, _) {
1085 - final wallet = getIt.get<AppStore>().wallet!;
1086 - return TransactionDetailsViewModel(
1087 - transactionInfo: transactionInfo,
1088 - transactionDescriptionBox: _transactionDescriptionBox,
1089 - wallet: wallet,
1090 - settingsStore: getIt.get<SettingsStore>(),
1091 - sendViewModel: getIt.get<SendViewModel>());
1092 - });
1083 + getIt.registerFactoryParam<TransactionDetailsViewModel, List<dynamic>, void>(
1084 + (params, _) {
1085 + final transactionInfo = params[0] as TransactionInfo;
1086 + final canReplaceByFee = params[1] as bool? ?? false;
1087 + final wallet = getIt.get<AppStore>().wallet!;
1088 +
1089 + return TransactionDetailsViewModel(
1090 + transactionInfo: transactionInfo,
1091 + transactionDescriptionBox: _transactionDescriptionBox,
1092 + wallet: wallet,
1093 + settingsStore: getIt.get<SettingsStore>(),
1094 + sendViewModel: getIt.get<SendViewModel>(),
1095 + canReplaceByFee: canReplaceByFee,
1096 + );
1097 + }
1098 + );
1099
1100 getIt.registerFactoryParam<TransactionDetailsPage, TransactionInfo, void>(
1095 - (TransactionInfo transactionInfo, _) => TransactionDetailsPage(
1096 - transactionDetailsViewModel:
1097 - getIt.get<TransactionDetailsViewModel>(param1: transactionInfo)));
1101 + (TransactionInfo transactionInfo, _) => TransactionDetailsPage(
1102 + transactionDetailsViewModel: getIt.get<TransactionDetailsViewModel>(
1103 + param1: [transactionInfo, false])));
1104 +
1105 + getIt.registerFactoryParam<RBFDetailsPage, List<dynamic>, void>(
1106 + (params, _) {
1107 + final transactionInfo = params[0] as TransactionInfo;
1108 + final txHex = params[1] as String;
1109 + return RBFDetailsPage(
1110 + transactionDetailsViewModel: getIt.get<TransactionDetailsViewModel>(
1111 + param1: [transactionInfo, true],
1112 + ),
1113 + rawTransaction: txHex,
1114 + );
1115 + }
1116 + );
1117
1118 getIt.registerFactoryParam<NewWalletTypePage, NewWalletTypeArguments, void>(
1119 (newWalletTypeArguments, _) {
@@ -1265,11 +1284,6 @@ Future<void> setup({
1284
1285 getIt.registerFactory(() => CakePayAccountPage(getIt.get<CakePayAccountViewModel>()));
1286
1268 - getIt.registerFactoryParam<RBFDetailsPage, TransactionInfo, void>(
1269 - (TransactionInfo transactionInfo, _) => RBFDetailsPage(
1270 - transactionDetailsViewModel:
1271 - getIt.get<TransactionDetailsViewModel>(param1: transactionInfo)));
1272 -
1287 getIt.registerFactory(() => AnonPayApi(
1288 useTorOnly: getIt.get<SettingsStore>().exchangeStatus == ExchangeApiMode.torOnly,
1289 wallet: getIt.get<AppStore>().wallet!));
lib/router.dart
+1 -1
@@ -375,7 +375,7 @@ Route<dynamic> createRoute(RouteSettings settings) {
375 case Routes.bumpFeePage:
376 return CupertinoPageRoute<void>(
377 fullscreenDialog: true,
378 - builder: (_) => getIt.get<RBFDetailsPage>(param1: settings.arguments as TransactionInfo));
378 + builder: (_) => getIt.get<RBFDetailsPage>(param1: settings.arguments as List<dynamic>));
379
380 case Routes.newSubaddress:
381 return CupertinoPageRoute<void>(
lib/src/screens/dashboard/pages/transactions_page.dart
+2 -5
@@ -84,10 +84,7 @@ class TransactionsPage extends StatelessWidget {
84 }
85
86 final transaction = item.transaction;
87 - final transactionType = dashboardViewModel.type == WalletType.ethereum &&
88 - transaction.evmSignatureName == 'approval'
89 - ? ' (${transaction.evmSignatureName})'
90 - : '';
87 + final transactionType = dashboardViewModel.getTransactionType(transaction);
88
89 return Observer(
90 builder: (_) => TransactionRow(
@@ -102,7 +99,7 @@ class TransactionsPage extends StatelessWidget {
99 : item.formattedFiatAmount,
100 isPending: transaction.isPending,
101 title:
105 - item.formattedTitle + item.formattedStatus + ' $transactionType',
102 + item.formattedTitle + item.formattedStatus + transactionType,
103 isReceivedSilentPayment:
104 dashboardViewModel.type == WalletType.bitcoin &&
105 bitcoin!.txIsReceivedSilentPayment(transaction),
lib/src/screens/transaction_details/rbf_details_page.dart
+8 -3
@@ -24,19 +24,22 @@ import 'package:flutter_mobx/flutter_mobx.dart';
24 import 'package:mobx/mobx.dart';
25
26 class RBFDetailsPage extends BasePage {
27 - RBFDetailsPage({required this.transactionDetailsViewModel});
27 + RBFDetailsPage({required this.transactionDetailsViewModel, required this.rawTransaction}) {
28 + transactionDetailsViewModel.addBumpFeesListItems(
29 + transactionDetailsViewModel.transactionInfo, rawTransaction);
30 + }
31
32 @override
33 String get title => S.current.bump_fee;
34
35 final TransactionDetailsViewModel transactionDetailsViewModel;
36 + final String rawTransaction;
37
38 bool _effectsInstalled = false;
39
40 @override
41 Widget body(BuildContext context) {
42 _setEffects(context);
39 -
43 return Column(
44 children: [
45 Expanded(
@@ -166,7 +169,9 @@ class RBFDetailsPage extends BasePage {
169 actionRightButton: () async {
170 Navigator.of(popupContext).pop();
171 await transactionDetailsViewModel.sendViewModel.commitTransaction();
169 - // transactionStatePopup();
172 + try {
173 + Navigator.of(popupContext).pop();
174 + } catch (_) {}
175 },
176 actionLeftButton: () => Navigator.of(popupContext).pop(),
177 feeFiatAmount:
lib/src/screens/transaction_details/transaction_details_page.dart
+1 -1
@@ -75,7 +75,7 @@ class TransactionDetailsPage extends BasePage {
75 text: S.of(context).bump_fee,
76 onTap: () async {
77 Navigator.of(context).pushNamed(Routes.bumpFeePage,
78 - arguments: transactionDetailsViewModel.transactionInfo);
78 + arguments: [transactionDetailsViewModel.transactionInfo, transactionDetailsViewModel.rawTransaction]);
79 },
80 ),
81 );
lib/view_model/dashboard/dashboard_view_model.dart
+10
@@ -764,6 +764,16 @@ abstract class DashboardViewModelBase with Store {
764 }
765 }
766
767 + String getTransactionType(TransactionInfo tx) {
768 + if (wallet.type == WalletType.bitcoin) {
769 + if (tx.isReplaced == true) return ' (replaced)';
770 + }
771 +
772 + if (wallet.type == WalletType.ethereum && tx.evmSignatureName == 'approval')
773 + return ' (${tx.evmSignatureName})';
774 + return '';
775 + }
776 +
777 Future<void> refreshDashboard() async {
778 reconnect();
779 }
lib/view_model/transaction_details_view_model.dart
+17 -10
@@ -36,7 +36,8 @@ abstract class TransactionDetailsViewModelBase with Store {
36 required this.transactionDescriptionBox,
37 required this.wallet,
38 required this.settingsStore,
39 - required this.sendViewModel})
39 + required this.sendViewModel,
40 + this.canReplaceByFee = false})
41 : items = [],
42 RBFListItems = [],
43 newFee = 0,
@@ -51,8 +52,7 @@ abstract class TransactionDetailsViewModelBase with Store {
52 break;
53 case WalletType.bitcoin:
54 _addElectrumListItems(tx, dateFormat);
54 - _addBumpFeesListItems(tx);
55 - _checkForRBF(tx);
55 + if(!canReplaceByFee)_checkForRBF(tx);
56 break;
57 case WalletType.litecoin:
58 case WalletType.bitcoinCash:
@@ -139,13 +139,11 @@ abstract class TransactionDetailsViewModelBase with Store {
139 bool showRecipientAddress;
140 bool isRecipientAddressShown;
141 int newFee;
142 + String? rawTransaction;
143 TransactionPriority? transactionPriority;
144
145 @observable
145 - bool _canReplaceByFee = false;
146 -
147 - @computed
148 - bool get canReplaceByFee => _canReplaceByFee /*&& transactionInfo.confirmations <= 0*/;
146 + bool canReplaceByFee;
147
148 String _explorerUrl(WalletType type, String txId) {
149 switch (type) {
@@ -347,7 +345,7 @@ abstract class TransactionDetailsViewModelBase with Store {
345 items.addAll(_items);
346 }
347
350 - void _addBumpFeesListItems(TransactionInfo tx) {
348 + void addBumpFeesListItems(TransactionInfo tx, String rawTransaction) {
349 transactionPriority = bitcoin!.getBitcoinTransactionPriorityMedium();
350 final inputsCount = (transactionInfo.inputAddresses?.isEmpty ?? true)
351 ? 1
@@ -361,6 +359,14 @@ abstract class TransactionDetailsViewModelBase with Store {
359
360 RBFListItems.add(StandartListItem(title: S.current.old_fee, value: tx.feeFormatted() ?? '0.0'));
361
362 + if (transactionInfo.fee != null && rawTransaction.isNotEmpty) {
363 + final size = bitcoin!.getTransactionVSize(wallet, rawTransaction);
364 + final recommendedRate = (transactionInfo.fee! / size).round() + 1;
365 +
366 + RBFListItems.add(
367 + StandartListItem(title: 'New recommended fee rate', value: '$recommendedRate sat/byte'));
368 + }
369 +
370 final priorities = priorityForWalletType(wallet.type);
371 final selectedItem = priorities.indexOf(sendViewModel.transactionPriority);
372 final customItem = priorities
@@ -429,8 +435,9 @@ abstract class TransactionDetailsViewModelBase with Store {
435 Future<void> _checkForRBF(TransactionInfo tx) async {
436 if (wallet.type == WalletType.bitcoin &&
437 transactionInfo.direction == TransactionDirection.outgoing) {
432 - if (await bitcoin!.canReplaceByFee(wallet, tx)) {
433 - _canReplaceByFee = true;
438 + rawTransaction = await bitcoin!.canReplaceByFee(wallet, tx);
439 + if (rawTransaction != null) {
440 + canReplaceByFee = true;
441 }
442 }
443 }
tool/configure.dart
+2 -1
@@ -205,7 +205,8 @@ abstract class Bitcoin {
205 bool isTestnet(Object wallet);
206
207 Future<PendingTransaction> replaceByFee(Object wallet, String transactionHash, String fee);
208 - Future<bool> canReplaceByFee(Object wallet, Object tx);
208 + Future<String?> canReplaceByFee(Object wallet, Object tx);
209 + int getTransactionVSize(Object wallet, String txHex);
210 Future<bool> isChangeSufficientForFee(Object wallet, String txId, String newFee);
211 int getFeeAmountForPriority(Object wallet, TransactionPriority priority, int inputsCount, int outputsCount, {int? size});
212 int getEstimatedFeeWithFeeRate(Object wallet, int feeRate, int? amount,