From 953acb493c518e308e41f1d2d865885de96275b9 Mon Sep 17 00:00:00 2001 From: julian Date: Wed, 27 Dec 2023 10:01:13 -0600 Subject: [PATCH] spark spend from transparent and various clean up --- lib/pages/receive_view/receive_view.dart | 390 ++++++++++++++---- .../send_view/confirm_transaction_view.dart | 170 ++++---- lib/pages/send_view/send_view.dart | 56 +-- lib/pages/wallet_view/wallet_view.dart | 9 +- .../wallet_view/sub_widgets/desktop_send.dart | 70 ++-- lib/wallets/models/tx_data.dart | 5 + .../spark_interface.dart | 374 ++++++++--------- 7 files changed, 673 insertions(+), 401 deletions(-) diff --git a/lib/pages/receive_view/receive_view.dart b/lib/pages/receive_view/receive_view.dart index 83a55092e..56a341f5d 100644 --- a/lib/pages/receive_view/receive_view.dart +++ b/lib/pages/receive_view/receive_view.dart @@ -10,15 +10,18 @@ import 'dart:async'; +import 'package:dropdown_button2/dropdown_button2.dart'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_svg/flutter_svg.dart'; +import 'package:isar/isar.dart'; import 'package:qr_flutter/qr_flutter.dart'; import 'package:stackwallet/models/isar/models/isar_models.dart'; import 'package:stackwallet/notifications/show_flush_bar.dart'; import 'package:stackwallet/pages/receive_view/addresses/wallet_addresses_view.dart'; import 'package:stackwallet/pages/receive_view/generate_receiving_uri_qr_code_view.dart'; +import 'package:stackwallet/providers/db/main_db_provider.dart'; import 'package:stackwallet/providers/providers.dart'; import 'package:stackwallet/route_generator.dart'; import 'package:stackwallet/themes/stack_colors.dart'; @@ -30,7 +33,9 @@ import 'package:stackwallet/utilities/enums/coin_enum.dart'; import 'package:stackwallet/utilities/text_styles.dart'; import 'package:stackwallet/wallets/isar/providers/wallet_info_provider.dart'; import 'package:stackwallet/wallets/wallet/wallet_mixin_interfaces/multi_address_interface.dart'; +import 'package:stackwallet/wallets/wallet/wallet_mixin_interfaces/spark_interface.dart'; import 'package:stackwallet/widgets/background.dart'; +import 'package:stackwallet/widgets/conditional_parent.dart'; import 'package:stackwallet/widgets/custom_buttons/app_bar_icon_button.dart'; import 'package:stackwallet/widgets/custom_buttons/blue_text_button.dart'; import 'package:stackwallet/widgets/custom_loading_overlay.dart'; @@ -58,6 +63,11 @@ class _ReceiveViewState extends ConsumerState { late final Coin coin; late final String walletId; late final ClipboardInterface clipboard; + late final bool supportsSpark; + + String? _sparkAddress; + String? _qrcodeContent; + bool _showSparkAddress = true; Future generateNewAddress() async { final wallet = ref.read(pWallets).getWallet(walletId); @@ -96,23 +106,106 @@ class _ReceiveViewState extends ConsumerState { } } + Future generateNewSparkAddress() async { + final wallet = ref.read(pWallets).getWallet(walletId); + if (wallet is SparkInterface) { + bool shouldPop = false; + unawaited( + showDialog( + context: context, + builder: (_) { + return WillPopScope( + onWillPop: () async => shouldPop, + child: Container( + color: Theme.of(context) + .extension()! + .overlay + .withOpacity(0.5), + child: const CustomLoadingOverlay( + message: "Generating address", + eventBus: null, + ), + ), + ); + }, + ), + ); + + final address = await wallet.generateNextSparkAddress(); + await ref.read(mainDBProvider).isar.writeTxn(() async { + await ref.read(mainDBProvider).isar.addresses.put(address); + }); + + shouldPop = true; + + if (mounted) { + Navigator.of(context, rootNavigator: true).pop(); + if (_sparkAddress != address.value) { + setState(() { + _sparkAddress = address.value; + }); + } + } + } + } + + StreamSubscription? _streamSub; + @override void initState() { walletId = widget.walletId; coin = ref.read(pWalletCoin(walletId)); clipboard = widget.clipboard; + supportsSpark = ref.read(pWallets).getWallet(walletId) is SparkInterface; + + if (supportsSpark) { + _streamSub = ref + .read(mainDBProvider) + .isar + .addresses + .where() + .walletIdEqualTo(walletId) + .filter() + .typeEqualTo(AddressType.spark) + .sortByDerivationIndexDesc() + .findFirst() + .asStream() + .listen((event) { + WidgetsBinding.instance.addPostFrameCallback((_) { + if (mounted) { + setState(() { + _sparkAddress = event?.value; + }); + } + }); + }); + } super.initState(); } + @override + void dispose() { + _streamSub?.cancel(); + super.dispose(); + } + @override Widget build(BuildContext context) { debugPrint("BUILD: $runtimeType"); - final receivingAddress = ref.watch(pWalletReceivingAddress(walletId)); - final ticker = widget.tokenContract?.symbol ?? coin.ticker; + if (supportsSpark) { + if (_showSparkAddress) { + _qrcodeContent = _sparkAddress; + } else { + _qrcodeContent = ref.watch(pWalletReceivingAddress(walletId)); + } + } else { + _qrcodeContent = ref.watch(pWalletReceivingAddress(walletId)); + } + return Background( child: Scaffold( backgroundColor: Theme.of(context).extension()!.background, @@ -225,86 +318,239 @@ class _ReceiveViewState extends ConsumerState { child: Column( crossAxisAlignment: CrossAxisAlignment.stretch, children: [ - GestureDetector( - onTap: () { - HapticFeedback.lightImpact(); - clipboard.setData( - ClipboardData(text: receivingAddress), - ); - showFloatingFlushBar( - type: FlushBarType.info, - message: "Copied to clipboard", - iconAsset: Assets.svg.copy, - context: context, - ); - }, - child: RoundedWhiteContainer( - child: Column( - children: [ - Row( - children: [ - Text( - "Your $ticker address", - style: STextStyles.itemSubtitle(context), - ), - const Spacer(), - Row( - children: [ - SvgPicture.asset( - Assets.svg.copy, - width: 10, - height: 10, - color: Theme.of(context) - .extension()! - .infoItemIcons, - ), - const SizedBox( - width: 4, - ), - Text( - "Copy", - style: STextStyles.link2(context), - ), - ], - ), - ], - ), - const SizedBox( - height: 4, - ), - Row( - children: [ - Expanded( + ConditionalParent( + condition: supportsSpark, + builder: (child) => Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + DropdownButtonHideUnderline( + child: DropdownButton2( + value: _showSparkAddress, + items: [ + DropdownMenuItem( + value: true, child: Text( - receivingAddress, - style: STextStyles.itemSubtitle12(context), + "Spark address", + style: STextStyles.desktopTextMedium(context), + ), + ), + DropdownMenuItem( + value: false, + child: Text( + "Transparent address", + style: STextStyles.desktopTextMedium(context), ), ), ], + onChanged: (value) { + if (value is bool && value != _showSparkAddress) { + setState(() { + _showSparkAddress = value; + }); + } + }, + isExpanded: true, + iconStyleData: IconStyleData( + icon: Padding( + padding: const EdgeInsets.only(right: 10), + child: SvgPicture.asset( + Assets.svg.chevronDown, + width: 12, + height: 6, + color: Theme.of(context) + .extension()! + .textFieldActiveSearchIconRight, + ), + ), + ), + dropdownStyleData: DropdownStyleData( + offset: const Offset(0, -10), + elevation: 0, + decoration: BoxDecoration( + color: Theme.of(context) + .extension()! + .textFieldDefaultBG, + borderRadius: BorderRadius.circular( + Constants.size.circularBorderRadius, + ), + ), + ), + menuItemStyleData: const MenuItemStyleData( + padding: EdgeInsets.symmetric( + horizontal: 16, + vertical: 8, + ), + ), ), - ], + ), + const SizedBox( + height: 12, + ), + if (_showSparkAddress) + GestureDetector( + onTap: () { + clipboard.setData( + ClipboardData(text: _sparkAddress ?? "Error"), + ); + showFloatingFlushBar( + type: FlushBarType.info, + message: "Copied to clipboard", + iconAsset: Assets.svg.copy, + context: context, + ); + }, + child: Container( + decoration: BoxDecoration( + border: Border.all( + color: Theme.of(context) + .extension()! + .backgroundAppBar, + width: 1, + ), + borderRadius: BorderRadius.circular( + Constants.size.circularBorderRadius, + ), + ), + child: RoundedWhiteContainer( + child: Column( + children: [ + Row( + children: [ + Text( + "Your ${coin.ticker} SPARK address", + style: + STextStyles.itemSubtitle(context), + ), + const Spacer(), + Row( + children: [ + SvgPicture.asset( + Assets.svg.copy, + width: 15, + height: 15, + color: Theme.of(context) + .extension()! + .infoItemIcons, + ), + const SizedBox( + width: 4, + ), + Text( + "Copy", + style: STextStyles.link2(context), + ), + ], + ), + ], + ), + const SizedBox( + height: 8, + ), + Row( + children: [ + Expanded( + child: Text( + _sparkAddress ?? "Error", + style: STextStyles + .desktopTextExtraExtraSmall( + context) + .copyWith( + color: Theme.of(context) + .extension()! + .textDark, + ), + ), + ), + ], + ), + ], + ), + ), + ), + ), + if (!_showSparkAddress) child, + ], + ), + child: GestureDetector( + onTap: () { + HapticFeedback.lightImpact(); + clipboard.setData( + ClipboardData( + text: + ref.watch(pWalletReceivingAddress(walletId))), + ); + showFloatingFlushBar( + type: FlushBarType.info, + message: "Copied to clipboard", + iconAsset: Assets.svg.copy, + context: context, + ); + }, + child: RoundedWhiteContainer( + child: Column( + children: [ + Row( + children: [ + Text( + "Your $ticker address", + style: STextStyles.itemSubtitle(context), + ), + const Spacer(), + Row( + children: [ + SvgPicture.asset( + Assets.svg.copy, + width: 10, + height: 10, + color: Theme.of(context) + .extension()! + .infoItemIcons, + ), + const SizedBox( + width: 4, + ), + Text( + "Copy", + style: STextStyles.link2(context), + ), + ], + ), + ], + ), + const SizedBox( + height: 4, + ), + Row( + children: [ + Expanded( + child: Text( + ref.watch( + pWalletReceivingAddress(walletId)), + style: STextStyles.itemSubtitle12(context), + ), + ), + ], + ), + ], + ), ), ), ), - if (coin != Coin.epicCash && - coin != Coin.ethereum && - coin != Coin.banano && - coin != Coin.nano && - coin != Coin.stellar && - coin != Coin.stellarTestnet && - coin != Coin.tezos) + if (ref.watch(pWallets + .select((value) => value.getWallet(walletId))) + is MultiAddressInterface || + supportsSpark) const SizedBox( height: 12, ), - if (coin != Coin.epicCash && - coin != Coin.ethereum && - coin != Coin.banano && - coin != Coin.nano && - coin != Coin.stellar && - coin != Coin.stellarTestnet && - coin != Coin.tezos) + if (ref.watch(pWallets + .select((value) => value.getWallet(walletId))) + is MultiAddressInterface || + supportsSpark) TextButton( - onPressed: generateNewAddress, + onPressed: supportsSpark && _showSparkAddress + ? generateNewSparkAddress + : generateNewAddress, style: Theme.of(context) .extension()! .getSecondaryEnabledButtonStyle(context), @@ -328,7 +574,7 @@ class _ReceiveViewState extends ConsumerState { QrImageView( data: AddressUtils.buildUriString( coin, - receivingAddress, + _qrcodeContent ?? "", {}, ), size: MediaQuery.of(context).size.width / 2, @@ -347,7 +593,7 @@ class _ReceiveViewState extends ConsumerState { RouteGenerator.useMaterialPageRoute, builder: (_) => GenerateUriQrCodeView( coin: coin, - receivingAddress: receivingAddress, + receivingAddress: _qrcodeContent ?? "", ), settings: const RouteSettings( name: GenerateUriQrCodeView.routeName, diff --git a/lib/pages/send_view/confirm_transaction_view.dart b/lib/pages/send_view/confirm_transaction_view.dart index 2fa7332bc..1fe6078d9 100644 --- a/lib/pages/send_view/confirm_transaction_view.dart +++ b/lib/pages/send_view/confirm_transaction_view.dart @@ -120,7 +120,7 @@ class _ConfirmTransactionViewState ), ); - late String txid; + final List txids = []; Future txDataFuture; final note = noteController.text; @@ -143,7 +143,12 @@ class _ConfirmTransactionViewState if (wallet is FiroWallet) { switch (ref.read(publicPrivateBalanceStateProvider.state).state) { case FiroType.public: - txDataFuture = wallet.confirmSend(txData: widget.txData); + if (widget.txData.sparkMints == null) { + txDataFuture = wallet.confirmSend(txData: widget.txData); + } else { + txDataFuture = + wallet.confirmSparkMintTransactions(txData: widget.txData); + } break; case FiroType.lelantus: @@ -175,17 +180,24 @@ class _ConfirmTransactionViewState sendProgressController.triggerSuccess?.call(); await Future.delayed(const Duration(seconds: 5)); - txid = (results.first as TxData).txid!; + if (wallet is FiroWallet && + (results.first as TxData).sparkMints != null) { + txids.addAll((results.first as TxData).sparkMints!.map((e) => e.txid!)); + } else { + txids.add((results.first as TxData).txid!); + } ref.refresh(desktopUseUTXOs); // save note - await ref.read(mainDBProvider).putTransactionNote( - TransactionNote( - walletId: walletId, - txid: txid, - value: note, - ), - ); + for (final txid in txids) { + await ref.read(mainDBProvider).putTransactionNote( + TransactionNote( + walletId: walletId, + txid: txid, + value: note, + ), + ); + } if (widget.isTokenTx) { unawaited(ref.read(tokenServiceProvider)!.refresh()); @@ -333,6 +345,48 @@ class _ConfirmTransactionViewState } else { unit = coin.ticker; } + + final Amount? fee; + final Amount amount; + + final wallet = ref.watch(pWallets).getWallet(walletId); + + if (wallet is FiroWallet) { + switch (ref.read(publicPrivateBalanceStateProvider.state).state) { + case FiroType.public: + if (widget.txData.sparkMints != null) { + fee = widget.txData.sparkMints! + .map((e) => e.fee!) + .reduce((value, element) => value += element); + amount = widget.txData.sparkMints! + .map((e) => e.amountSpark!) + .reduce((value, element) => value += element); + } else { + fee = widget.txData.fee; + amount = widget.txData.amount!; + } + break; + + case FiroType.lelantus: + fee = widget.txData.fee; + amount = widget.txData.amount!; + break; + + case FiroType.spark: + fee = widget.txData.fee; + amount = (widget.txData.amount ?? + Amount.zeroWith( + fractionDigits: wallet.cryptoCurrency.fractionDigits)) + + (widget.txData.amountSpark ?? + Amount.zeroWith( + fractionDigits: wallet.cryptoCurrency.fractionDigits)); + break; + } + } else { + fee = widget.txData.fee; + amount = widget.txData.amount!; + } + return ConditionalParent( condition: !isDesktop, builder: (child) => Background( @@ -438,7 +492,8 @@ class _ConfirmTransactionViewState Text( widget.isPaynymTransaction ? widget.txData.paynymAccountLite!.nymName - : widget.txData.recipients!.first.address, + : widget.txData.recipients?.first.address ?? + widget.txData.sparkRecipients!.first.address, style: STextStyles.itemSubtitle12(context), ), ], @@ -457,7 +512,7 @@ class _ConfirmTransactionViewState ), SelectableText( ref.watch(pAmountFormatter(coin)).format( - widget.txData.amount!, + amount, ethContract: ref .watch(tokenServiceProvider) ?.tokenContract, @@ -482,9 +537,7 @@ class _ConfirmTransactionViewState style: STextStyles.smallMed12(context), ), SelectableText( - ref - .watch(pAmountFormatter(coin)) - .format(widget.txData.fee!), + ref.watch(pAmountFormatter(coin)).format(fee!), style: STextStyles.itemSubtitle12(context), textAlign: TextAlign.right, ), @@ -508,7 +561,7 @@ class _ConfirmTransactionViewState height: 4, ), SelectableText( - "~${widget.txData.fee!.raw.toInt() ~/ widget.txData.vSize!}", + "~${fee!.raw.toInt() ~/ widget.txData.vSize!}", style: STextStyles.itemSubtitle12(context), ), ], @@ -639,9 +692,6 @@ class _ConfirmTransactionViewState ), Builder( builder: (context) { - // TODO: [prio=high] spark transaction specifics - better handling - final amount = widget.txData.amount ?? - widget.txData.amountSpark!; final externalCalls = ref.watch( prefsChangeNotifierProvider.select( (value) => value.externalCalls)); @@ -778,24 +828,15 @@ class _ConfirmTransactionViewState const SizedBox( height: 2, ), - Builder( - builder: (context) { - final fee = widget.txData.fee!; - - return SelectableText( - ref - .watch(pAmountFormatter(coin)) - .format(fee), - style: - STextStyles.desktopTextExtraExtraSmall( - context) - .copyWith( - color: Theme.of(context) - .extension()! - .textDark, - ), - ); - }, + SelectableText( + ref.watch(pAmountFormatter(coin)).format(fee!), + style: STextStyles.desktopTextExtraExtraSmall( + context) + .copyWith( + color: Theme.of(context) + .extension()! + .textDark, + ), ), ], ), @@ -1000,15 +1041,9 @@ class _ConfirmTransactionViewState color: Theme.of(context) .extension()! .textFieldDefaultBG, - child: Builder( - builder: (context) { - final fee = widget.txData.fee!; - - return SelectableText( - ref.watch(pAmountFormatter(coin)).format(fee), - style: STextStyles.itemSubtitle(context), - ); - }, + child: SelectableText( + ref.watch(pAmountFormatter(coin)).format(fee!), + style: STextStyles.itemSubtitle(context), ), ), ), @@ -1044,7 +1079,7 @@ class _ConfirmTransactionViewState .extension()! .textFieldDefaultBG, child: SelectableText( - "~${widget.txData.fee!.raw.toInt() ~/ widget.txData.vSize!}", + "~${fee!.raw.toInt() ~/ widget.txData.vSize!}", style: STextStyles.itemSubtitle(context), ), ), @@ -1088,31 +1123,22 @@ class _ConfirmTransactionViewState .textConfirmTotalAmount, ), ), - Builder(builder: (context) { - final fee = widget.txData.fee!; - - // TODO: [prio=high] spark transaction specifics - better handling - final amount = - widget.txData.amount ?? widget.txData.amountSpark!; - return SelectableText( - ref - .watch(pAmountFormatter(coin)) - .format(amount + fee), - style: isDesktop - ? STextStyles.desktopTextExtraExtraSmall(context) - .copyWith( - color: Theme.of(context) - .extension()! - .textConfirmTotalAmount, - ) - : STextStyles.itemSubtitle12(context).copyWith( - color: Theme.of(context) - .extension()! - .textConfirmTotalAmount, - ), - textAlign: TextAlign.right, - ); - }), + SelectableText( + ref.watch(pAmountFormatter(coin)).format(amount + fee!), + style: isDesktop + ? STextStyles.desktopTextExtraExtraSmall(context) + .copyWith( + color: Theme.of(context) + .extension()! + .textConfirmTotalAmount, + ) + : STextStyles.itemSubtitle12(context).copyWith( + color: Theme.of(context) + .extension()! + .textConfirmTotalAmount, + ), + textAlign: TextAlign.right, + ), ], ), ), diff --git a/lib/pages/send_view/send_view.dart b/lib/pages/send_view/send_view.dart index 5dd8e0ae3..b9878e5cd 100644 --- a/lib/pages/send_view/send_view.dart +++ b/lib/pages/send_view/send_view.dart @@ -524,29 +524,39 @@ class _SendViewState extends ConsumerState { } else if (wallet is FiroWallet) { switch (ref.read(publicPrivateBalanceStateProvider.state).state) { case FiroType.public: - txDataFuture = wallet.prepareSend( - txData: TxData( - recipients: _isSparkAddress - ? null - : [(address: _address!, amount: amount)], - sparkRecipients: _isSparkAddress - ? [ - ( - address: _address!, - amount: amount, - memo: memoController.text, - ) - ] - : null, - feeRateType: ref.read(feeRateTypeStateProvider), - satsPerVByte: isCustomFee ? customFeeRate : null, - utxos: (wallet is CoinControlInterface && - coinControlEnabled && - selectedUTXOs.isNotEmpty) - ? selectedUTXOs - : null, - ), - ); + if (_isSparkAddress) { + txDataFuture = wallet.prepareSparkMintTransaction( + txData: TxData( + sparkRecipients: [ + ( + address: _address!, + amount: amount, + memo: memoController.text, + ) + ], + feeRateType: ref.read(feeRateTypeStateProvider), + satsPerVByte: isCustomFee ? customFeeRate : null, + utxos: (wallet is CoinControlInterface && + coinControlEnabled && + selectedUTXOs.isNotEmpty) + ? selectedUTXOs + : null, + ), + ); + } else { + txDataFuture = wallet.prepareSend( + txData: TxData( + recipients: [(address: _address!, amount: amount)], + feeRateType: ref.read(feeRateTypeStateProvider), + satsPerVByte: isCustomFee ? customFeeRate : null, + utxos: (wallet is CoinControlInterface && + coinControlEnabled && + selectedUTXOs.isNotEmpty) + ? selectedUTXOs + : null, + ), + ); + } break; case FiroType.lelantus: diff --git a/lib/pages/wallet_view/wallet_view.dart b/lib/pages/wallet_view/wallet_view.dart index 9a61dc61f..7a8e02844 100644 --- a/lib/pages/wallet_view/wallet_view.dart +++ b/lib/pages/wallet_view/wallet_view.dart @@ -68,6 +68,7 @@ import 'package:stackwallet/wallets/isar/providers/wallet_info_provider.dart'; import 'package:stackwallet/wallets/wallet/impl/firo_wallet.dart'; import 'package:stackwallet/wallets/wallet/wallet_mixin_interfaces/cash_fusion_interface.dart'; import 'package:stackwallet/wallets/wallet/wallet_mixin_interfaces/paynym_interface.dart'; +import 'package:stackwallet/wallets/wallet/wallet_mixin_interfaces/spark_interface.dart'; import 'package:stackwallet/widgets/background.dart'; import 'package:stackwallet/widgets/conditional_parent.dart'; import 'package:stackwallet/widgets/custom_buttons/app_bar_icon_button.dart'; @@ -115,6 +116,8 @@ class _WalletViewState extends ConsumerState { late final String walletId; late final Coin coin; + late final bool isSparkWallet; + late final bool _shouldDisableAutoSyncOnLogOut; late WalletSyncStatus _currentSyncStatus; @@ -171,6 +174,8 @@ class _WalletViewState extends ConsumerState { _shouldDisableAutoSyncOnLogOut = false; } + isSparkWallet = wallet is SparkInterface; + if (coin == Coin.firo && (wallet as FiroWallet).lelantusCoinIsarRescanRequired) { _rescanningOnOpen = true; @@ -758,11 +763,11 @@ class _WalletViewState extends ConsumerState { ), ), ), - if (coin == Coin.firo) + if (isSparkWallet) const SizedBox( height: 10, ), - if (coin == Coin.firo) + if (isSparkWallet) Padding( padding: const EdgeInsets.symmetric(horizontal: 16), child: Row( diff --git a/lib/pages_desktop_specific/my_stack_view/wallet_view/sub_widgets/desktop_send.dart b/lib/pages_desktop_specific/my_stack_view/wallet_view/sub_widgets/desktop_send.dart index cd3f1ee6d..dd596037e 100644 --- a/lib/pages_desktop_specific/my_stack_view/wallet_view/sub_widgets/desktop_send.dart +++ b/lib/pages_desktop_specific/my_stack_view/wallet_view/sub_widgets/desktop_send.dart @@ -321,29 +321,39 @@ class _DesktopSendState extends ConsumerState { } else if (wallet is FiroWallet) { switch (ref.read(publicPrivateBalanceStateProvider.state).state) { case FiroType.public: - txDataFuture = wallet.prepareSend( - txData: TxData( - recipients: _isSparkAddress - ? null - : [(address: _address!, amount: amount)], - sparkRecipients: _isSparkAddress - ? [ - ( - address: _address!, - amount: amount, - memo: memoController.text, - ) - ] - : null, - feeRateType: ref.read(feeRateTypeStateProvider), - satsPerVByte: isCustomFee ? customFeeRate : null, - utxos: (wallet is CoinControlInterface && - coinControlEnabled && - ref.read(desktopUseUTXOs).isNotEmpty) - ? ref.read(desktopUseUTXOs) - : null, - ), - ); + if (_isSparkAddress) { + txDataFuture = wallet.prepareSparkMintTransaction( + txData: TxData( + sparkRecipients: [ + ( + address: _address!, + amount: amount, + memo: memoController.text, + ) + ], + feeRateType: ref.read(feeRateTypeStateProvider), + satsPerVByte: isCustomFee ? customFeeRate : null, + utxos: (wallet is CoinControlInterface && + coinControlEnabled && + ref.read(desktopUseUTXOs).isNotEmpty) + ? ref.read(desktopUseUTXOs) + : null, + ), + ); + } else { + txDataFuture = wallet.prepareSend( + txData: TxData( + recipients: [(address: _address!, amount: amount)], + feeRateType: ref.read(feeRateTypeStateProvider), + satsPerVByte: isCustomFee ? customFeeRate : null, + utxos: (wallet is CoinControlInterface && + coinControlEnabled && + ref.read(desktopUseUTXOs).isNotEmpty) + ? ref.read(desktopUseUTXOs) + : null, + ), + ); + } break; case FiroType.lelantus: @@ -579,7 +589,9 @@ class _DesktopSendState extends ConsumerState { ref.read(pWallets).getWallet(walletId).cryptoCurrency; final isValidAddress = walletCurrency.validateAddress(address ?? ""); - _isSparkAddress = isValidAddress + _isSparkAddress = isValidAddress && + ref.read(publicPrivateBalanceStateProvider.state).state != + FiroType.lelantus ? SparkInterface.validateSparkAddress( address: address!, isTestNet: walletCurrency.network == CryptoCurrencyNetwork.test, @@ -1409,11 +1421,17 @@ class _DesktopSendState extends ConsumerState { } }, ), - if (isStellar || _isSparkAddress) + if (isStellar || + (_isSparkAddress && + ref.watch(publicPrivateBalanceStateProvider) != + FiroType.public)) const SizedBox( height: 10, ), - if (isStellar || _isSparkAddress) + if (isStellar || + (_isSparkAddress && + ref.watch(publicPrivateBalanceStateProvider) != + FiroType.public)) ClipRRect( borderRadius: BorderRadius.circular( Constants.size.circularBorderRadius, diff --git a/lib/wallets/models/tx_data.dart b/lib/wallets/models/tx_data.dart index 9602a4e11..9148d182e 100644 --- a/lib/wallets/models/tx_data.dart +++ b/lib/wallets/models/tx_data.dart @@ -62,6 +62,7 @@ class TxData { Amount amount, String memo, })>? sparkRecipients; + final List? sparkMints; TxData({ this.feeRateType, @@ -94,6 +95,7 @@ class TxData { this.mintsMapLelantus, this.tezosOperationsList, this.sparkRecipients, + this.sparkMints, }); Amount? get amount => recipients != null && recipients!.isNotEmpty @@ -150,6 +152,7 @@ class TxData { String memo, })>? sparkRecipients, + List? sparkMints, }) { return TxData( feeRateType: feeRateType ?? this.feeRateType, @@ -183,6 +186,7 @@ class TxData { mintsMapLelantus: mintsMapLelantus ?? this.mintsMapLelantus, tezosOperationsList: tezosOperationsList ?? this.tezosOperationsList, sparkRecipients: sparkRecipients ?? this.sparkRecipients, + sparkMints: sparkMints ?? this.sparkMints, ); } @@ -218,5 +222,6 @@ class TxData { 'mintsMapLelantus: $mintsMapLelantus, ' 'tezosOperationsList: $tezosOperationsList, ' 'sparkRecipients: $sparkRecipients, ' + 'sparkMints: $sparkMints, ' '}'; } diff --git a/lib/wallets/wallet/wallet_mixin_interfaces/spark_interface.dart b/lib/wallets/wallet/wallet_mixin_interfaces/spark_interface.dart index 33fb946bf..d5900cbd9 100644 --- a/lib/wallets/wallet/wallet_mixin_interfaces/spark_interface.dart +++ b/lib/wallets/wallet/wallet_mixin_interfaces/spark_interface.dart @@ -2,6 +2,7 @@ import 'dart:convert'; import 'dart:math'; import 'package:bitcoindart/bitcoindart.dart' as btc; +import 'package:decimal/decimal.dart'; import 'package:flutter/foundation.dart'; import 'package:flutter_libsparkmobile/flutter_libsparkmobile.dart'; import 'package:isar/isar.dart'; @@ -19,8 +20,12 @@ import 'package:stackwallet/wallets/wallet/wallet_mixin_interfaces/electrumx_int const kDefaultSparkIndex = 1; +// TODO dart style constants. Maybe move to spark lib? const MAX_STANDARD_TX_WEIGHT = 400000; +//https://github.com/firoorg/sparkmobile/blob/ef2e39aae18ecc49e0ddc63a3183e9764b96012e/include/spark.h#L16 +const SPARK_OUT_LIMIT_PER_TX = 16; + const OP_SPARKMINT = 0xd1; const OP_SPARKSMINT = 0xd2; const OP_SPARKSPEND = 0xd3; @@ -125,6 +130,47 @@ mixin SparkInterface on Bip39HDWallet, ElectrumXInterface { Future prepareSendSpark({ required TxData txData, }) async { + // There should be at least one output. + if (!(txData.recipients?.isNotEmpty == true || + txData.sparkRecipients?.isNotEmpty == true)) { + throw Exception("No recipients provided."); + } + + if (txData.sparkRecipients?.isNotEmpty == true && + txData.sparkRecipients!.length >= SPARK_OUT_LIMIT_PER_TX - 1) { + throw Exception("Spark shielded output limit exceeded."); + } + + final transparentSumOut = + (txData.recipients ?? []).map((e) => e.amount).fold( + Amount( + rawValue: BigInt.zero, + fractionDigits: cryptoCurrency.fractionDigits, + ), + (p, e) => p + e); + + // See SPARK_VALUE_SPEND_LIMIT_PER_TRANSACTION at https://github.com/firoorg/sparkmobile/blob/ef2e39aae18ecc49e0ddc63a3183e9764b96012e/include/spark.h#L17 + // and COIN https://github.com/firoorg/sparkmobile/blob/ef2e39aae18ecc49e0ddc63a3183e9764b96012e/bitcoin/amount.h#L17 + // Note that as MAX_MONEY is greater than this limit, we can ignore it. See https://github.com/firoorg/sparkmobile/blob/ef2e39aae18ecc49e0ddc63a3183e9764b96012e/bitcoin/amount.h#L31 + if (transparentSumOut > + Amount.fromDecimal( + Decimal.parse("10000"), + fractionDigits: cryptoCurrency.fractionDigits, + )) { + throw Exception( + "Spend to transparent address limit exceeded (10,000 Firo per transaction)."); + } + + final sparkSumOut = + (txData.sparkRecipients ?? []).map((e) => e.amount).fold( + Amount( + rawValue: BigInt.zero, + fractionDigits: cryptoCurrency.fractionDigits, + ), + (p, e) => p + e); + + final txAmount = transparentSumOut + sparkSumOut; + // fetch spendable spark coins final coins = await mainDB.isar.sparkCoins .where() @@ -140,19 +186,6 @@ mixin SparkInterface on Bip39HDWallet, ElectrumXInterface { final available = info.cachedBalanceTertiary.spendable; - final txAmount = (txData.recipients ?? []).map((e) => e.amount).fold( - Amount( - rawValue: BigInt.zero, - fractionDigits: cryptoCurrency.fractionDigits, - ), - (p, e) => p + e) + - (txData.sparkRecipients ?? []).map((e) => e.amount).fold( - Amount( - rawValue: BigInt.zero, - fractionDigits: cryptoCurrency.fractionDigits, - ), - (p, e) => p + e); - if (txAmount > available) { throw Exception("Insufficient Spark balance"); } @@ -583,7 +616,8 @@ mixin SparkInterface on Bip39HDWallet, ElectrumXInterface { } } - Future> createSparkMintTransactions({ + // modelled on CSparkWallet::CreateSparkMintTransactions https://github.com/firoorg/firo/blob/39c41e5e7ec634ced3700fe3f4f5509dc2e480d0/src/spark/sparkwallet.cpp#L752 + Future> _createSparkMintTransactions({ required List availableUtxos, required List outputs, required bool subtractFeeFromAmount, @@ -593,6 +627,11 @@ mixin SparkInterface on Bip39HDWallet, ElectrumXInterface { if (outputs.isEmpty) { throw Exception("Cannot mint without some recipients"); } + + // TODO remove when multiple recipients gui is added. Will need to handle + // addresses when confirming the transactions later as well + assert(outputs.length == 1); + BigInt valueToMint = outputs.map((e) => e.value).reduce((value, element) => value + element); @@ -615,7 +654,9 @@ mixin SparkInterface on Bip39HDWallet, ElectrumXInterface { // setup some vars int nChangePosInOut = -1; int nChangePosRequest = nChangePosInOut; - List outputs_ = outputs.toList(); + List outputs_ = outputs + .map((e) => MutableSparkRecipient(e.address, e.value, e.memo)) + .toList(); // deep copy final feesObject = await fees; final currentHeight = await chainHeight; final random = Random.secure(); @@ -671,8 +712,13 @@ mixin SparkInterface on Bip39HDWallet, ElectrumXInterface { vin.clear(); vout.clear(); setCoins.clear(); - final remainingOutputs = outputs_.toList(); + + // deep copy + final remainingOutputs = outputs_ + .map((e) => MutableSparkRecipient(e.address, e.value, e.memo)) + .toList(); final List singleTxOutputs = []; + if (autoMintAll) { singleTxOutputs.add( MutableSparkRecipient( @@ -682,7 +728,8 @@ mixin SparkInterface on Bip39HDWallet, ElectrumXInterface { ), ); } else { - BigInt remainingMintValue = mintedValue; + BigInt remainingMintValue = BigInt.parse(mintedValue.toString()); + while (remainingMintValue > BigInt.zero) { final singleMintValue = _min(remainingMintValue, remainingOutputs.first.value); @@ -877,7 +924,10 @@ mixin SparkInterface on Bip39HDWallet, ElectrumXInterface { ++i; } - outputs_ = remainingOutputs; + // deep copy + outputs_ = remainingOutputs + .map((e) => MutableSparkRecipient(e.address, e.value, e.memo)) + .toList(); break; // Done, enough fee included. } @@ -926,12 +976,17 @@ mixin SparkInterface on Bip39HDWallet, ElectrumXInterface { rethrow; } final builtTx = txb.build(); + + // TODO: see todo at top of this function + assert(outputs.length == 1); + final data = TxData( - // TODO: add fee output to recipients? sparkRecipients: vout + .where((e) => e.$1 is Uint8List) // ignore change .map( (e) => ( - address: "lol", + address: outputs.first + .address, // for display purposes on confirm tx screen. See todos above memo: "", amount: Amount( rawValue: BigInt.from(e.$2), @@ -947,6 +1002,7 @@ mixin SparkInterface on Bip39HDWallet, ElectrumXInterface { rawValue: nFeeRet, fractionDigits: cryptoCurrency.fractionDigits, ), + usedUTXOs: vin.map((e) => e.utxo).toList(), ); if (nFeeRet.toInt() < data.vSize!) { @@ -1030,7 +1086,7 @@ mixin SparkInterface on Bip39HDWallet, ElectrumXInterface { throw Exception("No available UTXOs found to anonymize"); } - final results = await createSparkMintTransactions( + final mints = await _createSparkMintTransactions( subtractFeeFromAmount: subtractFeeFromAmount, autoMintAll: true, availableUtxos: spendableUtxos, @@ -1045,9 +1101,7 @@ mixin SparkInterface on Bip39HDWallet, ElectrumXInterface { ], ); - for (final data in results) { - await confirmSparkMintTransaction(txData: data); - } + await confirmSparkMintTransactions(txData: TxData(sparkMints: mints)); } catch (e, s) { Logging.instance.log( "Exception caught in anonymizeAllSpark(): $e\n$s", @@ -1061,196 +1115,98 @@ mixin SparkInterface on Bip39HDWallet, ElectrumXInterface { /// /// See https://docs.google.com/document/d/1RG52GoYTZDvKlZz_3G4sQu-PpT6JWSZGHLNswWcrE3o Future prepareSparkMintTransaction({required TxData txData}) async { - // "this kind of transaction is generated like a regular transaction, but in - // place of [regular] outputs we put spark outputs... we construct the input - // part of the transaction first then we generate spark related data [and] - // we sign like regular transactions at the end." - - // Validate inputs. - - // There should be at least one input. - if (txData.utxos == null || txData.utxos!.isEmpty) { - throw Exception("No inputs provided."); - } - - // Validate individual inputs. - for (final utxo in txData.utxos!) { - // Input amount must be greater than zero. - if (utxo.value == 0) { - throw Exception("Input value cannot be zero."); - } - - // Input value must be greater than dust limit. - if (BigInt.from(utxo.value) < cryptoCurrency.dustLimit.raw) { - throw Exception("Input value below dust limit."); - } - } - - // Validate outputs. - - // There should be at least one output. - if (txData.recipients == null || txData.recipients!.isEmpty) { - throw Exception("No recipients provided."); - } - - // For now let's limit to one output. - if (txData.recipients!.length > 1) { - throw Exception("Only one recipient supported."); - // TODO remove and test with multiple recipients. - } - - // Limit outputs per tx to 16. - // - // See SPARK_OUT_LIMIT_PER_TX at https://github.com/firoorg/sparkmobile/blob/ef2e39aae18ecc49e0ddc63a3183e9764b96012e/include/spark.h#L16 - if (txData.recipients!.length > 16) { - throw Exception("Too many recipients."); - } - - // Limit spend value per tx to 1000000000000 satoshis. - // - // See SPARK_VALUE_SPEND_LIMIT_PER_TRANSACTION at https://github.com/firoorg/sparkmobile/blob/ef2e39aae18ecc49e0ddc63a3183e9764b96012e/include/spark.h#L17 - // and COIN https://github.com/firoorg/sparkmobile/blob/ef2e39aae18ecc49e0ddc63a3183e9764b96012e/bitcoin/amount.h#L17 - // Note that as MAX_MONEY is greater than this limit, we can ignore it. See https://github.com/firoorg/sparkmobile/blob/ef2e39aae18ecc49e0ddc63a3183e9764b96012e/bitcoin/amount.h#L31 - // - // This will be added to and checked as we validate outputs. - Amount totalAmount = Amount( - rawValue: BigInt.zero, - fractionDigits: cryptoCurrency.fractionDigits, - ); - - // Validate individual outputs. - for (final recipient in txData.recipients!) { - // Output amount must be greater than zero. - if (recipient.amount.raw == BigInt.zero) { - throw Exception("Output amount cannot be zero."); - // Could refactor this for loop to use an index and remove this output. - } - - // Output amount must be greater than dust limit. - if (recipient.amount < cryptoCurrency.dustLimit) { - throw Exception("Output below dust limit."); - } - - // Do not add outputs that would exceed the spend limit. - totalAmount += recipient.amount; - if (totalAmount.raw > BigInt.from(1000000000000)) { - throw Exception( - "Spend limit exceeded (10,000 FIRO per tx).", - ); - } - } - - // Create a transaction builder and set locktime and version. - final txb = btc.TransactionBuilder( - network: _bitcoinDartNetwork, - ); - txb.setLockTime(await chainHeight); - txb.setVersion(1); - - final signingData = await fetchBuildTxData(txData.utxos!.toList()); - - // Create the serial context. - // - // "...serial_context is a byte array, which should be unique for each - // transaction, and for that we serialize and put all inputs into - // serial_context vector." - final serialContext = LibSpark.serializeMintContext( - inputs: signingData - .map((e) => ( - e.utxo.txid, - e.utxo.vout, - )) - .toList(), - ); - - // Add inputs. - for (final sd in signingData) { - txb.addInput( - sd.utxo.txid, - sd.utxo.vout, - 0xffffffff - - 1, // minus 1 is important. 0xffffffff on its own will burn funds - sd.output, - ); - } - - // Create mint recipients. - final mintRecipients = LibSpark.createSparkMintRecipients( - outputs: txData.recipients! - .map((e) => ( - sparkAddress: e.address, - value: e.amount.raw.toInt(), - memo: "", - )) - .toList(), - serialContext: Uint8List.fromList(serialContext), - generate: true, - ); - - // Add mint output(s). - for (final mint in mintRecipients) { - txb.addOutput( - mint.scriptPubKey, - mint.amount, - ); - } - try { - // Sign the transaction accordingly - for (var i = 0; i < signingData.length; i++) { - txb.sign( - vin: i, - keyPair: signingData[i].keyPair!, - witnessValue: signingData[i].utxo.value, - redeemScript: signingData[i].redeemScript, - ); + if (txData.sparkRecipients?.isNotEmpty != true) { + throw Exception("Missing spark recipients."); } + final recipients = txData.sparkRecipients! + .map( + (e) => MutableSparkRecipient( + e.address, + e.amount.raw, + e.memo, + ), + ) + .toList(); + + final total = recipients + .map((e) => e.value) + .reduce((value, element) => value += element); + + if (total < BigInt.zero) { + throw Exception("Attempted send of negative amount"); + } else if (total == BigInt.zero) { + throw Exception("Attempted send of zero amount"); + } + + final currentHeight = await chainHeight; + + // coin control not enabled for firo currently so we can ignore this + // final utxosToUse = txData.utxos?.toList() ?? await mainDB.isar.utxos + // .where() + // .walletIdEqualTo(walletId) + // .filter() + // .isBlockedEqualTo(false) + // .and() + // .group((q) => q.usedEqualTo(false).or().usedIsNull()) + // .and() + // .valueGreaterThan(0) + // .findAll(); + final spendableUtxos = await mainDB.isar.utxos + .where() + .walletIdEqualTo(walletId) + .filter() + .isBlockedEqualTo(false) + .and() + .group((q) => q.usedEqualTo(false).or().usedIsNull()) + .and() + .valueGreaterThan(0) + .findAll(); + + spendableUtxos.removeWhere( + (e) => !e.isConfirmed( + currentHeight, + cryptoCurrency.minConfirms, + ), + ); + + if (spendableUtxos.isEmpty) { + throw Exception("No available UTXOs found to anonymize"); + } + + final available = spendableUtxos + .map((e) => BigInt.from(e.value)) + .reduce((value, element) => value += element); + + final bool subtractFeeFromAmount; + if (available < total) { + throw Exception("Insufficient balance"); + } else if (available == total) { + subtractFeeFromAmount = true; + } else { + subtractFeeFromAmount = false; + } + + final mints = await _createSparkMintTransactions( + subtractFeeFromAmount: subtractFeeFromAmount, + autoMintAll: false, + availableUtxos: spendableUtxos, + outputs: recipients, + ); + + return txData.copyWith(sparkMints: mints); } catch (e, s) { Logging.instance.log( - "Caught exception while signing spark mint transaction: $e\n$s", - level: LogLevel.Error, + "Exception caught in prepareSparkMintTransaction(): $e\n$s", + level: LogLevel.Warning, ); rethrow; } - - final builtTx = txb.build(); - - // TODO any changes to this txData object required? - return txData.copyWith( - // recipients: [ - // ( - // amount: Amount( - // rawValue: BigInt.from(incomplete.outs[0].value!), - // fractionDigits: cryptoCurrency.fractionDigits, - // ), - // address: "no address for lelantus mints", - // ) - // ], - vSize: builtTx.virtualSize(), - txid: builtTx.getId(), - raw: builtTx.toHex(), - ); } - /// Broadcast a tx and TODO update Spark balance. - Future confirmSparkMintTransaction({required TxData txData}) async { - // Broadcast tx. - final txid = await electrumXClient.broadcastTransaction( - rawTx: txData.raw!, - ); - - // Check txid. - if (txid == txData.txid!) { - print("SPARK TXIDS MATCH!!"); - } else { - print("SUBMITTED SPARK TXID DOES NOT MATCH WHAT WE GENERATED"); - } - - // TODO update spark balance. - - return txData.copyWith( - txid: txid, - ); + Future confirmSparkMintTransactions({required TxData txData}) async { + final futures = txData.sparkMints!.map((e) => confirmSend(txData: e)); + return txData.copyWith(sparkMints: await Future.wait(futures)); } @override @@ -1259,7 +1215,8 @@ mixin SparkInterface on Bip39HDWallet, ElectrumXInterface { // what ever class this mixin is used on uses LelantusInterface as well) final normalBalanceFuture = super.updateBalance(); - // todo: spark balance aka update info.tertiaryBalance + // todo: spark balance aka update info.tertiaryBalance here? + // currently happens on spark coins update/refresh // wait for normalBalanceFuture to complete before returning await normalBalanceFuture; @@ -1477,4 +1434,9 @@ class MutableSparkRecipient { String memo; MutableSparkRecipient(this.address, this.value, this.memo); + + @override + String toString() { + return 'MutableSparkRecipient{ address: $address, value: $value, memo: $memo }'; + } }