WIP: basic full rbf

This commit is contained in:
julian 2024-06-16 13:25:07 -06:00
parent 741c0be88b
commit a566af8eb4
6 changed files with 229 additions and 45 deletions

View file

@ -8,23 +8,33 @@
*
*/
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../../../models/isar/models/blockchain_data/v2/transaction_v2.dart';
import '../../../../pages_desktop_specific/desktop_home_view.dart';
import '../../../../providers/providers.dart';
import '../../../../route_generator.dart';
import '../../../../themes/stack_colors.dart';
import '../../../../utilities/amount/amount.dart';
import '../../../../utilities/amount/amount_formatter.dart';
import '../../../../utilities/show_loading.dart';
import '../../../../utilities/text_styles.dart';
import '../../../../utilities/util.dart';
import '../../../../wallets/isar/providers/wallet_info_provider.dart';
import '../../../../wallets/wallet/wallet_mixin_interfaces/rbf_interface.dart';
import '../../../../widgets/background.dart';
import '../../../../widgets/boost_fee_slider.dart';
import '../../../../widgets/conditional_parent.dart';
import '../../../../widgets/custom_buttons/app_bar_icon_button.dart';
import '../../../../widgets/desktop/desktop_dialog.dart';
import '../../../../widgets/desktop/primary_button.dart';
import '../../../../widgets/detail_item.dart';
import '../../../../widgets/fee_slider.dart';
import '../../../../widgets/rounded_white_container.dart';
import '../../../../widgets/stack_dialog.dart';
import '../../../send_view/confirm_transaction_view.dart';
class BoostTransactionView extends ConsumerStatefulWidget {
const BoostTransactionView({
@ -47,9 +57,12 @@ class _BoostTransactionViewState extends ConsumerState<BoostTransactionView> {
late final TransactionV2 _transaction;
late final Amount fee;
late final Amount amount;
late final int rate;
BigInt? customFee;
int _newRate = 0;
bool _previewTxnLock = false;
Future<void> _previewTxn() async {
if (_previewTxnLock) {
@ -57,12 +70,79 @@ class _BoostTransactionViewState extends ConsumerState<BoostTransactionView> {
}
_previewTxnLock = true;
try {
// TODO [prio=high]: define previewSend
// build new tx and show loading/tx generation
if (_newRate <= rate) {
await showDialog<void>(
context: context,
builder: (_) => const StackOkDialog(
title: "Error",
message: "New fee rate must be greater than the current rate.",
),
);
return;
}
// on success show confirm tx screen
final wallet = (ref.read(pWallets).getWallet(walletId) as RbfInterface);
Exception? ex;
// build new tx and show loading/tx generation
final txData = await showLoading(
whileFuture: wallet.prepareRbfSend(
oldTransaction: _transaction,
newRate: _newRate,
),
context: context,
message: "Preparing RBF Transaction...",
onException: (e) => ex = e,
);
// on failure show error message
if (txData == null && mounted) {
await showDialog<void>(
context: context,
builder: (_) => StackOkDialog(
title: "RBF send error",
message: ex?.toString() ?? "Unknown error found",
),
);
return;
} else {
// on success show confirm tx screen
if (isDesktop && mounted) {
unawaited(
showDialog(
context: context,
builder: (context) => DesktopDialog(
maxHeight: MediaQuery.of(context).size.height - 64,
maxWidth: 580,
child: ConfirmTransactionView(
txData: txData!,
walletId: walletId,
onSuccess: () {},
// isPaynymTransaction: isPaynymSend, TODO ?
routeOnSuccessName: DesktopHomeView.routeName,
),
),
),
);
} else if (mounted) {
unawaited(
Navigator.of(context).push(
RouteGenerator.getRoute(
shouldUseMaterialRoute: RouteGenerator.useMaterialPageRoute,
builder: (_) => ConfirmTransactionView(
txData: txData!,
walletId: walletId,
// isPaynymTransaction: isPaynymSend, TODO ?
onSuccess: () {},
),
settings: const RouteSettings(
name: ConfirmTransactionView.routeName,
),
),
),
);
}
}
} finally {
_previewTxnLock = false;
}
@ -79,6 +159,8 @@ class _BoostTransactionViewState extends ConsumerState<BoostTransactionView> {
amount = _transaction.getAmountSentFromThisWallet(
fractionDigits: ref.read(pWalletCoin(walletId)).fractionDigits,
);
rate = (fee.raw ~/ BigInt.from(_transaction.vSize!)).toInt();
_newRate = rate + 1;
super.initState();
}
@ -92,9 +174,7 @@ class _BoostTransactionViewState extends ConsumerState<BoostTransactionView> {
final String amountString = ref.watch(pAmountFormatter(coin)).format(
amount,
);
final String feeRateString =
"${(fee.raw / BigInt.from(_transaction.vSize!)).toStringAsFixed(1)}"
" sats/vByte";
final String feeRateString = "$rate sats/vByte";
return ConditionalParent(
condition: !isDesktop,
@ -155,10 +235,12 @@ class _BoostTransactionViewState extends ConsumerState<BoostTransactionView> {
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
RoundedWhiteContainer(
padding: isDesktop
? const EdgeInsets.all(0)
: const EdgeInsets.all(12),
ConditionalParent(
condition: isDesktop,
builder: (child) => RoundedWhiteContainer(
padding: EdgeInsets.zero,
child: child,
),
child: Column(
children: [
DetailItem(
@ -174,38 +256,32 @@ class _BoostTransactionViewState extends ConsumerState<BoostTransactionView> {
),
const _Divider(),
DetailItem(
title: "Current fee rate",
title: "Current rate",
detail: feeRateString,
horizontal: true,
),
const _Divider(),
Padding(
padding: const EdgeInsets.all(10),
child: BoostFeeSlider(
padding: const EdgeInsets.all(16),
child: FeeSlider(
overrideLabel: "Select a higher rate",
onSatVByteChanged: (value) => _newRate = value,
coin: coin,
onFeeChanged: (fee) {
customFee = fee;
},
min: _transaction
.getFee(fractionDigits: coin.fractionDigits)
.raw,
max: _transaction
.getFee(fractionDigits: coin.fractionDigits)
.raw *
BigInt.from(4),
// TODO [prio=med]: The max fee should be set to an absurd fee.
min: rate.toDouble() + 1,
max: rate * 5.0,
pow: 1,
),
),
],
),
),
if (!isDesktop) const Spacer(),
if (!isDesktop)
const SizedBox(
height: 20,
height: 16,
),
if (!isDesktop)
PrimaryButton(
buttonHeight: ButtonHeight.l,
label: "Preview send",
onPressed: _previewTxn,
),

View file

@ -164,7 +164,7 @@ class _TransactionV2DetailsViewState
unawaited(
Navigator.of(context).pushNamed(
BoostTransactionView.routeName,
arguments: (tx: _transaction,),
arguments: _transaction,
),
);
}
@ -181,7 +181,8 @@ class _TransactionV2DetailsViewState
if (_transaction.type
case TransactionType.sentToSelf || TransactionType.outgoing) {
supportsRbf = ref.read(pWallets).getWallet(walletId) is RbfInterface;
supportsRbf = _transaction.subType == TransactionSubType.none &&
ref.read(pWallets).getWallet(walletId) is RbfInterface;
} else {
supportsRbf = false;
}
@ -563,12 +564,10 @@ class _TransactionV2DetailsViewState
outputLabel = "Sent to";
}
// TODO: [prio=high]: revert the following when done testing
final confirmedTxn = false;
// final confirmedTxn = _transaction.isConfirmed(
// currentHeight,
// coin.minConfirms,
// );
final confirmedTxn = _transaction.isConfirmed(
currentHeight,
coin.minConfirms,
);
return ConditionalParent(
condition: !isDesktop,

View file

@ -1,13 +1,14 @@
import 'package:cw_monero/pending_monero_transaction.dart';
import 'package:cw_wownero/pending_wownero_transaction.dart';
import 'package:tezart/tezart.dart' as tezart;
import 'package:web3dart/web3dart.dart' as web3dart;
import '../../models/isar/models/blockchain_data/v2/transaction_v2.dart';
import '../../models/isar/models/isar_models.dart';
import '../../models/paynym/paynym_account_lite.dart';
import '../../utilities/amount/amount.dart';
import '../../utilities/enums/fee_rate_type_enum.dart';
import '../isar/models/spark_coin.dart';
import 'package:tezart/tezart.dart' as tezart;
import 'package:web3dart/web3dart.dart' as web3dart;
class TxData {
final FeeRateType? feeRateType;
@ -76,6 +77,8 @@ class TxData {
final TransactionV2? tempTx;
final bool ignoreCachedBalanceChecks;
TxData({
this.feeRateType,
this.feeRateAmount,
@ -112,6 +115,7 @@ class TxData {
this.sparkMints,
this.usedSparkCoins,
this.tempTx,
this.ignoreCachedBalanceChecks = false,
});
Amount? get amount => recipients != null && recipients!.isNotEmpty
@ -196,6 +200,7 @@ class TxData {
List<TxData>? sparkMints,
List<SparkCoin>? usedSparkCoins,
TransactionV2? tempTx,
bool? ignoreCachedBalanceChecks,
}) {
return TxData(
feeRateType: feeRateType ?? this.feeRateType,
@ -235,6 +240,8 @@ class TxData {
sparkMints: sparkMints ?? this.sparkMints,
usedSparkCoins: usedSparkCoins ?? this.usedSparkCoins,
tempTx: tempTx ?? this.tempTx,
ignoreCachedBalanceChecks:
ignoreCachedBalanceChecks ?? this.ignoreCachedBalanceChecks,
);
}
@ -274,5 +281,6 @@ class TxData {
'sparkMints: $sparkMints, '
'usedSparkCoins: $usedSparkCoins, '
'tempTx: $tempTx, '
'ignoreCachedBalanceChecks: $ignoreCachedBalanceChecks, '
'}';
}

View file

@ -1648,7 +1648,8 @@ mixin ElectrumXInterface<T extends ElectrumXCurrencyInterface>
if (customSatsPerVByte != null) {
// check for send all
bool isSendAll = false;
if (txData.amount == info.cachedBalance.spendable) {
if (txData.ignoreCachedBalanceChecks ||
txData.amount == info.cachedBalance.spendable) {
isSendAll = true;
}

View file

@ -1,7 +1,13 @@
import 'dart:convert';
import 'package:isar/isar.dart';
import '../../../models/isar/models/blockchain_data/v2/transaction_v2.dart';
import '../../../models/isar/models/isar_models.dart';
import '../../../utilities/amount/amount.dart';
import '../../../utilities/enums/fee_rate_type_enum.dart';
import '../../crypto_currency/interfaces/electrumx_currency_interface.dart';
import '../../models/tx_data.dart';
import 'electrumx_interface.dart';
typedef TxSize = ({int real, int virtual});
@ -31,5 +37,83 @@ mixin RbfInterface<T extends ElectrumXCurrencyInterface>
return updatedTx;
}
// TODO more RBF specific logic
Future<TxData> prepareRbfSend({
required TransactionV2 oldTransaction,
required int newRate,
}) async {
final Set<UTXO> utxos = {};
for (final input in oldTransaction.inputs) {
final utxo = UTXO(
walletId: walletId,
txid: input.outpoint!.txid,
vout: input.outpoint!.vout,
value: input.value.toInt(),
name: "rbf",
isBlocked: false,
blockedReason: null,
isCoinbase: false,
blockHash: "rbf",
blockHeight: 1,
blockTime: 1,
used: false,
address: input.addresses.first,
);
utxos.add(utxo);
}
Amount sendAmount = oldTransaction.getAmountSentFromThisWallet(
fractionDigits: cryptoCurrency.fractionDigits,
);
// TODO: fix fragile firstWhere (or at least add some error checking)
final address = oldTransaction.outputs
.firstWhere(
(e) => e.value == sendAmount.raw,
)
.addresses
.first;
final inSum = utxos
.map((e) => BigInt.from(e.value))
.fold(BigInt.zero, (p, e) => p + e);
if (oldTransaction
.getFee(fractionDigits: cryptoCurrency.fractionDigits)
.raw +
sendAmount.raw ==
inSum) {
sendAmount = Amount(
rawValue: oldTransaction
.getFee(fractionDigits: cryptoCurrency.fractionDigits)
.raw +
sendAmount.raw,
fractionDigits: cryptoCurrency.fractionDigits,
);
}
final note = await mainDB.isar.transactionNotes
.where()
.walletIdEqualTo(walletId)
.filter()
.txidEqualTo(oldTransaction.txid)
.findFirst();
final txData = TxData(
recipients: [
(
address: address,
amount: sendAmount,
isChange: false,
),
],
feeRateType: FeeRateType.custom,
satsPerVByte: newRate,
utxos: utxos,
ignoreCachedBalanceChecks: true,
note: note?.value ?? "",
);
return await prepareSend(txData: txData);
}
}

View file

@ -1,32 +1,45 @@
import 'dart:math';
import 'package:flutter/material.dart';
import '../utilities/text_styles.dart';
import '../wallets/crypto_currency/crypto_currency.dart';
/// This has limitations. At least one of [pow] or [min] must be set to 1
class FeeSlider extends StatefulWidget {
const FeeSlider({
super.key,
required this.onSatVByteChanged,
required this.coin,
this.min = 1,
this.max = 5,
this.pow = 4,
this.showWU = false,
this.overrideLabel,
});
final CryptoCurrency coin;
final double min;
final double max;
final double pow;
final bool showWU;
final void Function(int) onSatVByteChanged;
final String? overrideLabel;
@override
State<FeeSlider> createState() => _FeeSliderState();
}
class _FeeSliderState extends State<FeeSlider> {
static const double min = 1;
static const double max = 4;
double sliderValue = 0;
int rate = min.toInt();
late int rate;
@override
void initState() {
rate = widget.min.toInt();
super.initState();
}
@override
Widget build(BuildContext context) {
@ -36,7 +49,7 @@ class _FeeSliderState extends State<FeeSlider> {
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(
widget.showWU ? "sat/WU" : "sat/vByte",
widget.overrideLabel ?? (widget.showWU ? "sat/WU" : "sat/vByte"),
style: STextStyles.smallMed12(context),
),
Text(
@ -50,7 +63,10 @@ class _FeeSliderState extends State<FeeSlider> {
onChanged: (value) {
setState(() {
sliderValue = value;
final number = pow(sliderValue * (max - min) + min, 4).toDouble();
final number = pow(
sliderValue * (widget.max - widget.min) + widget.min,
widget.pow,
).toDouble();
if (widget.coin is Dogecoin) {
rate = (number * 1000).toInt();
} else {