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>
This commit is contained in:
Serhii 2024-09-20 23:57:43 +03:00 committed by GitHub
parent 4e2e5e708c
commit 32e119e24f
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
13 changed files with 105 additions and 45 deletions

View file

@ -35,6 +35,7 @@ class ElectrumTransactionInfo extends TransactionInfo {
List<String>? outputAddresses, List<String>? outputAddresses,
required TransactionDirection direction, required TransactionDirection direction,
required bool isPending, required bool isPending,
required bool isReplaced,
required DateTime date, required DateTime date,
required int confirmations, required int confirmations,
String? to, String? to,
@ -50,6 +51,7 @@ class ElectrumTransactionInfo extends TransactionInfo {
this.direction = direction; this.direction = direction;
this.date = date; this.date = date;
this.isPending = isPending; this.isPending = isPending;
this.isReplaced = isReplaced;
this.confirmations = confirmations; this.confirmations = confirmations;
this.to = to; this.to = to;
} }
@ -98,6 +100,7 @@ class ElectrumTransactionInfo extends TransactionInfo {
id: id, id: id,
height: height, height: height,
isPending: false, isPending: false,
isReplaced: false,
fee: fee, fee: fee,
direction: direction, direction: direction,
amount: amount, amount: amount,
@ -173,6 +176,7 @@ class ElectrumTransactionInfo extends TransactionInfo {
id: bundle.originalTransaction.txId(), id: bundle.originalTransaction.txId(),
height: height, height: height,
isPending: bundle.confirmations == 0, isPending: bundle.confirmations == 0,
isReplaced: false,
inputAddresses: inputAddresses, inputAddresses: inputAddresses,
outputAddresses: outputAddresses, outputAddresses: outputAddresses,
fee: fee, fee: fee,
@ -196,6 +200,7 @@ class ElectrumTransactionInfo extends TransactionInfo {
direction: parseTransactionDirectionFromInt(data['direction'] as int), direction: parseTransactionDirectionFromInt(data['direction'] as int),
date: DateTime.fromMillisecondsSinceEpoch(data['date'] as int), date: DateTime.fromMillisecondsSinceEpoch(data['date'] as int),
isPending: data['isPending'] as bool, isPending: data['isPending'] as bool,
isReplaced: data['isReplaced'] as bool? ?? false,
confirmations: data['confirmations'] as int, confirmations: data['confirmations'] as int,
inputAddresses: inputAddresses:
inputAddresses.isEmpty ? [] : inputAddresses.map((e) => e.toString()).toList(), inputAddresses.isEmpty ? [] : inputAddresses.map((e) => e.toString()).toList(),
@ -238,6 +243,7 @@ class ElectrumTransactionInfo extends TransactionInfo {
direction: direction, direction: direction,
date: date, date: date,
isPending: isPending, isPending: isPending,
isReplaced: isReplaced ?? false,
inputAddresses: inputAddresses, inputAddresses: inputAddresses,
outputAddresses: outputAddresses, outputAddresses: outputAddresses,
confirmations: info.confirmations); confirmations: info.confirmations);
@ -251,6 +257,7 @@ class ElectrumTransactionInfo extends TransactionInfo {
m['direction'] = direction.index; m['direction'] = direction.index;
m['date'] = date.millisecondsSinceEpoch; m['date'] = date.millisecondsSinceEpoch;
m['isPending'] = isPending; m['isPending'] = isPending;
m['isReplaced'] = isReplaced;
m['confirmations'] = confirmations; m['confirmations'] = confirmations;
m['fee'] = fee; m['fee'] = fee;
m['to'] = to; m['to'] = to;
@ -262,6 +269,6 @@ class ElectrumTransactionInfo extends TransactionInfo {
} }
String toString() { 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)';
} }
} }

View file

@ -1400,14 +1400,16 @@ abstract class ElectrumWalletBase
} }
} }
Future<bool> canReplaceByFee(ElectrumTransactionInfo tx) async { int transactionVSize(String transactionHex) => BtcTransaction.fromRaw(transactionHex).getVSize();
Future<String?> canReplaceByFee(ElectrumTransactionInfo tx) async {
try { try {
final bundle = await getTransactionExpanded(hash: tx.txHash); final bundle = await getTransactionExpanded(hash: tx.txHash);
_updateInputsAndOutputs(tx, bundle); _updateInputsAndOutputs(tx, bundle);
if (bundle.confirmations > 0) return false; if (bundle.confirmations > 0) return null;
return bundle.originalTransaction.canReplaceByFee; return bundle.originalTransaction.canReplaceByFee ? bundle.originalTransaction.toHex() : null;
} catch (e) { } catch (e) {
return false; return null;
} }
} }
@ -1589,6 +1591,13 @@ abstract class ElectrumWalletBase
hasChange: changeOutputs.isNotEmpty, hasChange: changeOutputs.isNotEmpty,
feeRate: newFee.toString(), feeRate: newFee.toString(),
)..addListener((transaction) async { )..addListener((transaction) async {
transactionHistory.transactions.values.forEach((tx) {
if (tx.id == hash) {
tx.isReplaced = true;
tx.isPending = false;
transactionHistory.addOne(tx);
}
});
transactionHistory.addOne(transaction); transactionHistory.addOne(transaction);
await updateBalance(); await updateBalance();
}); });
@ -2317,6 +2326,7 @@ Future<void> startRefresh(ScanData scanData) async {
fee: 0, fee: 0,
direction: TransactionDirection.incoming, direction: TransactionDirection.incoming,
isPending: false, isPending: false,
isReplaced: false,
date: scanData.network == BitcoinNetwork.mainnet date: scanData.network == BitcoinNetwork.mainnet
? getDateByBitcoinHeight(tweakHeight) ? getDateByBitcoinHeight(tweakHeight)
: DateTime.now(), : DateTime.now(),
@ -2424,6 +2434,7 @@ class EstimatedTxResult {
final int fee; final int fee;
final int amount; final int amount;
final bool spendsSilentPayment; final bool spendsSilentPayment;
// final bool sendsToSilentPayment; // final bool sendsToSilentPayment;
final bool hasChange; final bool hasChange;
final bool isSendAll; final bool isSendAll;

View file

@ -110,6 +110,7 @@ class PendingBitcoinTransaction with PendingTransaction {
direction: TransactionDirection.outgoing, direction: TransactionDirection.outgoing,
date: DateTime.now(), date: DateTime.now(),
isPending: true, isPending: true,
isReplaced: false,
confirmations: 0, confirmations: 0,
fee: fee); fee: fee);
} }

View file

@ -18,6 +18,7 @@ abstract class TransactionInfo extends Object with Keyable {
String? to; String? to;
String? from; String? from;
String? evmSignatureName; String? evmSignatureName;
bool? isReplaced;
List<String>? inputAddresses; List<String>? inputAddresses;
List<String>? outputAddresses; List<String>? outputAddresses;

View file

@ -411,12 +411,18 @@ class CWBitcoin extends Bitcoin {
} }
@override @override
Future<bool> canReplaceByFee(Object wallet, Object transactionInfo) async { Future<String?> canReplaceByFee(Object wallet, Object transactionInfo) async {
final bitcoinWallet = wallet as ElectrumWallet; final bitcoinWallet = wallet as ElectrumWallet;
final tx = transactionInfo as ElectrumTransactionInfo; final tx = transactionInfo as ElectrumTransactionInfo;
return bitcoinWallet.canReplaceByFee(tx); return bitcoinWallet.canReplaceByFee(tx);
} }
@override
int getTransactionVSize(Object wallet, String transactionHex) {
final bitcoinWallet = wallet as ElectrumWallet;
return bitcoinWallet.transactionVSize(transactionHex);
}
@override @override
Future<bool> isChangeSufficientForFee(Object wallet, String txId, String newFee) async { Future<bool> isChangeSufficientForFee(Object wallet, String txId, String newFee) async {
final bitcoinWallet = wallet as ElectrumWallet; final bitcoinWallet = wallet as ElectrumWallet;

View file

@ -1080,21 +1080,40 @@ Future<void> setup({
param1: derivations, param1: derivations,
))); )));
getIt.registerFactoryParam<TransactionDetailsViewModel, TransactionInfo, void>( getIt.registerFactoryParam<TransactionDetailsViewModel, List<dynamic>, void>(
(TransactionInfo transactionInfo, _) { (params, _) {
final wallet = getIt.get<AppStore>().wallet!; final transactionInfo = params[0] as TransactionInfo;
return TransactionDetailsViewModel( final canReplaceByFee = params[1] as bool? ?? false;
transactionInfo: transactionInfo, final wallet = getIt.get<AppStore>().wallet!;
transactionDescriptionBox: _transactionDescriptionBox,
wallet: wallet, return TransactionDetailsViewModel(
settingsStore: getIt.get<SettingsStore>(), transactionInfo: transactionInfo,
sendViewModel: getIt.get<SendViewModel>()); transactionDescriptionBox: _transactionDescriptionBox,
}); wallet: wallet,
settingsStore: getIt.get<SettingsStore>(),
sendViewModel: getIt.get<SendViewModel>(),
canReplaceByFee: canReplaceByFee,
);
}
);
getIt.registerFactoryParam<TransactionDetailsPage, TransactionInfo, void>( getIt.registerFactoryParam<TransactionDetailsPage, TransactionInfo, void>(
(TransactionInfo transactionInfo, _) => TransactionDetailsPage( (TransactionInfo transactionInfo, _) => TransactionDetailsPage(
transactionDetailsViewModel: transactionDetailsViewModel: getIt.get<TransactionDetailsViewModel>(
getIt.get<TransactionDetailsViewModel>(param1: transactionInfo))); param1: [transactionInfo, false])));
getIt.registerFactoryParam<RBFDetailsPage, List<dynamic>, void>(
(params, _) {
final transactionInfo = params[0] as TransactionInfo;
final txHex = params[1] as String;
return RBFDetailsPage(
transactionDetailsViewModel: getIt.get<TransactionDetailsViewModel>(
param1: [transactionInfo, true],
),
rawTransaction: txHex,
);
}
);
getIt.registerFactoryParam<NewWalletTypePage, NewWalletTypeArguments, void>( getIt.registerFactoryParam<NewWalletTypePage, NewWalletTypeArguments, void>(
(newWalletTypeArguments, _) { (newWalletTypeArguments, _) {
@ -1265,11 +1284,6 @@ Future<void> setup({
getIt.registerFactory(() => CakePayAccountPage(getIt.get<CakePayAccountViewModel>())); getIt.registerFactory(() => CakePayAccountPage(getIt.get<CakePayAccountViewModel>()));
getIt.registerFactoryParam<RBFDetailsPage, TransactionInfo, void>(
(TransactionInfo transactionInfo, _) => RBFDetailsPage(
transactionDetailsViewModel:
getIt.get<TransactionDetailsViewModel>(param1: transactionInfo)));
getIt.registerFactory(() => AnonPayApi( getIt.registerFactory(() => AnonPayApi(
useTorOnly: getIt.get<SettingsStore>().exchangeStatus == ExchangeApiMode.torOnly, useTorOnly: getIt.get<SettingsStore>().exchangeStatus == ExchangeApiMode.torOnly,
wallet: getIt.get<AppStore>().wallet!)); wallet: getIt.get<AppStore>().wallet!));

View file

@ -375,7 +375,7 @@ Route<dynamic> createRoute(RouteSettings settings) {
case Routes.bumpFeePage: case Routes.bumpFeePage:
return CupertinoPageRoute<void>( return CupertinoPageRoute<void>(
fullscreenDialog: true, fullscreenDialog: true,
builder: (_) => getIt.get<RBFDetailsPage>(param1: settings.arguments as TransactionInfo)); builder: (_) => getIt.get<RBFDetailsPage>(param1: settings.arguments as List<dynamic>));
case Routes.newSubaddress: case Routes.newSubaddress:
return CupertinoPageRoute<void>( return CupertinoPageRoute<void>(

View file

@ -84,10 +84,7 @@ class TransactionsPage extends StatelessWidget {
} }
final transaction = item.transaction; final transaction = item.transaction;
final transactionType = dashboardViewModel.type == WalletType.ethereum && final transactionType = dashboardViewModel.getTransactionType(transaction);
transaction.evmSignatureName == 'approval'
? ' (${transaction.evmSignatureName})'
: '';
return Observer( return Observer(
builder: (_) => TransactionRow( builder: (_) => TransactionRow(
@ -102,7 +99,7 @@ class TransactionsPage extends StatelessWidget {
: item.formattedFiatAmount, : item.formattedFiatAmount,
isPending: transaction.isPending, isPending: transaction.isPending,
title: title:
item.formattedTitle + item.formattedStatus + ' $transactionType', item.formattedTitle + item.formattedStatus + transactionType,
isReceivedSilentPayment: isReceivedSilentPayment:
dashboardViewModel.type == WalletType.bitcoin && dashboardViewModel.type == WalletType.bitcoin &&
bitcoin!.txIsReceivedSilentPayment(transaction), bitcoin!.txIsReceivedSilentPayment(transaction),

View file

@ -24,19 +24,22 @@ import 'package:flutter_mobx/flutter_mobx.dart';
import 'package:mobx/mobx.dart'; import 'package:mobx/mobx.dart';
class RBFDetailsPage extends BasePage { class RBFDetailsPage extends BasePage {
RBFDetailsPage({required this.transactionDetailsViewModel}); RBFDetailsPage({required this.transactionDetailsViewModel, required this.rawTransaction}) {
transactionDetailsViewModel.addBumpFeesListItems(
transactionDetailsViewModel.transactionInfo, rawTransaction);
}
@override @override
String get title => S.current.bump_fee; String get title => S.current.bump_fee;
final TransactionDetailsViewModel transactionDetailsViewModel; final TransactionDetailsViewModel transactionDetailsViewModel;
final String rawTransaction;
bool _effectsInstalled = false; bool _effectsInstalled = false;
@override @override
Widget body(BuildContext context) { Widget body(BuildContext context) {
_setEffects(context); _setEffects(context);
return Column( return Column(
children: [ children: [
Expanded( Expanded(
@ -166,7 +169,9 @@ class RBFDetailsPage extends BasePage {
actionRightButton: () async { actionRightButton: () async {
Navigator.of(popupContext).pop(); Navigator.of(popupContext).pop();
await transactionDetailsViewModel.sendViewModel.commitTransaction(); await transactionDetailsViewModel.sendViewModel.commitTransaction();
// transactionStatePopup(); try {
Navigator.of(popupContext).pop();
} catch (_) {}
}, },
actionLeftButton: () => Navigator.of(popupContext).pop(), actionLeftButton: () => Navigator.of(popupContext).pop(),
feeFiatAmount: feeFiatAmount:

View file

@ -75,7 +75,7 @@ class TransactionDetailsPage extends BasePage {
text: S.of(context).bump_fee, text: S.of(context).bump_fee,
onTap: () async { onTap: () async {
Navigator.of(context).pushNamed(Routes.bumpFeePage, Navigator.of(context).pushNamed(Routes.bumpFeePage,
arguments: transactionDetailsViewModel.transactionInfo); arguments: [transactionDetailsViewModel.transactionInfo, transactionDetailsViewModel.rawTransaction]);
}, },
), ),
); );

View file

@ -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<void> refreshDashboard() async { Future<void> refreshDashboard() async {
reconnect(); reconnect();
} }

View file

@ -36,7 +36,8 @@ abstract class TransactionDetailsViewModelBase with Store {
required this.transactionDescriptionBox, required this.transactionDescriptionBox,
required this.wallet, required this.wallet,
required this.settingsStore, required this.settingsStore,
required this.sendViewModel}) required this.sendViewModel,
this.canReplaceByFee = false})
: items = [], : items = [],
RBFListItems = [], RBFListItems = [],
newFee = 0, newFee = 0,
@ -51,8 +52,7 @@ abstract class TransactionDetailsViewModelBase with Store {
break; break;
case WalletType.bitcoin: case WalletType.bitcoin:
_addElectrumListItems(tx, dateFormat); _addElectrumListItems(tx, dateFormat);
_addBumpFeesListItems(tx); if(!canReplaceByFee)_checkForRBF(tx);
_checkForRBF(tx);
break; break;
case WalletType.litecoin: case WalletType.litecoin:
case WalletType.bitcoinCash: case WalletType.bitcoinCash:
@ -139,13 +139,11 @@ abstract class TransactionDetailsViewModelBase with Store {
bool showRecipientAddress; bool showRecipientAddress;
bool isRecipientAddressShown; bool isRecipientAddressShown;
int newFee; int newFee;
String? rawTransaction;
TransactionPriority? transactionPriority; TransactionPriority? transactionPriority;
@observable @observable
bool _canReplaceByFee = false; bool canReplaceByFee;
@computed
bool get canReplaceByFee => _canReplaceByFee /*&& transactionInfo.confirmations <= 0*/;
String _explorerUrl(WalletType type, String txId) { String _explorerUrl(WalletType type, String txId) {
switch (type) { switch (type) {
@ -347,7 +345,7 @@ abstract class TransactionDetailsViewModelBase with Store {
items.addAll(_items); items.addAll(_items);
} }
void _addBumpFeesListItems(TransactionInfo tx) { void addBumpFeesListItems(TransactionInfo tx, String rawTransaction) {
transactionPriority = bitcoin!.getBitcoinTransactionPriorityMedium(); transactionPriority = bitcoin!.getBitcoinTransactionPriorityMedium();
final inputsCount = (transactionInfo.inputAddresses?.isEmpty ?? true) final inputsCount = (transactionInfo.inputAddresses?.isEmpty ?? true)
? 1 ? 1
@ -361,6 +359,14 @@ abstract class TransactionDetailsViewModelBase with Store {
RBFListItems.add(StandartListItem(title: S.current.old_fee, value: tx.feeFormatted() ?? '0.0')); 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 priorities = priorityForWalletType(wallet.type);
final selectedItem = priorities.indexOf(sendViewModel.transactionPriority); final selectedItem = priorities.indexOf(sendViewModel.transactionPriority);
final customItem = priorities final customItem = priorities
@ -429,8 +435,9 @@ abstract class TransactionDetailsViewModelBase with Store {
Future<void> _checkForRBF(TransactionInfo tx) async { Future<void> _checkForRBF(TransactionInfo tx) async {
if (wallet.type == WalletType.bitcoin && if (wallet.type == WalletType.bitcoin &&
transactionInfo.direction == TransactionDirection.outgoing) { transactionInfo.direction == TransactionDirection.outgoing) {
if (await bitcoin!.canReplaceByFee(wallet, tx)) { rawTransaction = await bitcoin!.canReplaceByFee(wallet, tx);
_canReplaceByFee = true; if (rawTransaction != null) {
canReplaceByFee = true;
} }
} }
} }

View file

@ -205,7 +205,8 @@ abstract class Bitcoin {
bool isTestnet(Object wallet); bool isTestnet(Object wallet);
Future<PendingTransaction> replaceByFee(Object wallet, String transactionHash, String fee); Future<PendingTransaction> replaceByFee(Object wallet, String transactionHash, String fee);
Future<bool> canReplaceByFee(Object wallet, Object tx); Future<String?> canReplaceByFee(Object wallet, Object tx);
int getTransactionVSize(Object wallet, String txHex);
Future<bool> isChangeSufficientForFee(Object wallet, String txId, String newFee); Future<bool> isChangeSufficientForFee(Object wallet, String txId, String newFee);
int getFeeAmountForPriority(Object wallet, TransactionPriority priority, int inputsCount, int outputsCount, {int? size}); int getFeeAmountForPriority(Object wallet, TransactionPriority priority, int inputsCount, int outputsCount, {int? size});
int getEstimatedFeeWithFeeRate(Object wallet, int feeRate, int? amount, int getEstimatedFeeWithFeeRate(Object wallet, int feeRate, int? amount,