diff --git a/lib/pages/masternodes/create_masternode_view.dart b/lib/pages/masternodes/create_masternode_view.dart index 9d2940ef7..369272496 100644 --- a/lib/pages/masternodes/create_masternode_view.dart +++ b/lib/pages/masternodes/create_masternode_view.dart @@ -14,12 +14,18 @@ class CreateMasternodeView extends ConsumerStatefulWidget { const CreateMasternodeView({ super.key, required this.firoWalletId, + required this.collateralTxid, + required this.collateralVout, + required this.collateralAddress, this.popTxidOnSuccess = true, }); static const routeName = "/createMasternodeView"; final String firoWalletId; + final String collateralTxid; + final int collateralVout; + final String collateralAddress; final bool popTxidOnSuccess; @override @@ -107,6 +113,9 @@ class _CreateMasternodeDialogState extends ConsumerState { ), child: RegisterMasternodeForm( firoWalletId: widget.firoWalletId, + collateralTxid: widget.collateralTxid, + collateralVout: widget.collateralVout, + collateralAddress: widget.collateralAddress, onRegistrationSuccess: (txid) { if (widget.popTxidOnSuccess && mounted) { Navigator.of(context, rootNavigator: Util.isDesktop).pop(txid); diff --git a/lib/pages/masternodes/masternodes_home_view.dart b/lib/pages/masternodes/masternodes_home_view.dart index 6933a5c1e..80be3bf06 100644 --- a/lib/pages/masternodes/masternodes_home_view.dart +++ b/lib/pages/masternodes/masternodes_home_view.dart @@ -1,13 +1,17 @@ +import 'dart:async'; + import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_svg/svg.dart'; - import '../../providers/global/wallets_provider.dart'; import '../../themes/stack_colors.dart'; +import '../../utilities/amount/amount.dart'; import '../../utilities/assets.dart'; import '../../utilities/logger.dart'; import '../../utilities/text_styles.dart'; import '../../utilities/util.dart'; +import '../../models/isar/models/blockchain_data/utxo.dart'; +import '../../wallets/isar/models/wallet_info.dart'; import '../../wallets/wallet/impl/firo_wallet.dart'; import '../../widgets/custom_buttons/app_bar_icon_button.dart'; import '../../widgets/desktop/desktop_app_bar.dart'; @@ -34,15 +38,206 @@ class MasternodesHomeView extends ConsumerStatefulWidget { class _MasternodesHomeViewState extends ConsumerState { late Future> _masternodesFuture; + bool _hasPromptedForCollateral = false; + bool _isCheckingForCollateral = false; + + Set _dismissedCollateral(FiroWallet wallet) { + final raw = + wallet.info.otherData[WalletInfoKeys.firoMasternodeCollateralDismissed]; + if (raw is! List) { + return {}; + } + return raw.whereType().toSet(); + } - Future _showDesktopCreateMasternodeDialog() async { - final txid = await showDialog( - context: context, - barrierDismissible: true, - builder: (context) => - SDialog(child: CreateMasternodeView(firoWalletId: widget.walletId)), + Future _persistDismissedCollateral( + FiroWallet wallet, + String txid, + int vout, + ) async { + final set = _dismissedCollateral(wallet); + set.add("$txid:$vout"); + await wallet.info.updateOtherData( + newEntries: { + WalletInfoKeys.firoMasternodeCollateralDismissed: set.toList(), + }, + isar: wallet.mainDB.isar, ); - _handleSuccessTxid(txid); + } + + Future<({String txid, int vout, String address})?> + _findCollateralUtxo() async { + final wallet = ref.read(pWallets).getWallet(widget.walletId) as FiroWallet; + final List utxos = + await (wallet.mainDB.getUTXOs(widget.walletId) as dynamic).findAll() + as List; + final currentChainHeight = await wallet.chainHeight; + final masternodeRaw = Amount.fromDecimal( + kMasterNodeValue, + fractionDigits: wallet.cryptoCurrency.fractionDigits, + ).raw.toInt(); + + for (final utxo in utxos) { + if (utxo.value == masternodeRaw && + !utxo.isBlocked && + utxo.used != true && + utxo.isConfirmed( + currentChainHeight, + wallet.cryptoCurrency.minConfirms, + wallet.cryptoCurrency.minCoinbaseConfirms, + ) && + utxo.address != null) { + return (txid: utxo.txid, vout: utxo.vout, address: utxo.address!); + } + } + return null; + } + + Future _createMasternode() async { + final collateral = await _findCollateralUtxo(); + if (!mounted) { + return; + } + + if (collateral == null) { + await showDialog( + context: context, + builder: (_) => StackOkDialog( + title: "No collateral found", + message: + "A masternode needs one confirmed, unblocked transparent " + "UTXO of exactly 1000 FIRO.\n\n" + "Total balance above 1000 FIRO is not enough if no single " + "1000 output exists. Also ensure fee is not subtracted from " + "the recipient amount when sending to yourself.", + desktopPopRootNavigator: Util.isDesktop, + maxWidth: Util.isDesktop ? 400 : null, + ), + ); + return; + } + + if (Util.isDesktop) { + final txid = await showDialog( + context: context, + barrierDismissible: true, + builder: (context) => SDialog( + child: CreateMasternodeView( + firoWalletId: widget.walletId, + collateralTxid: collateral.txid, + collateralVout: collateral.vout, + collateralAddress: collateral.address, + ), + ), + ); + _handleSuccessTxid(txid); + } else { + final txid = await Navigator.of(context).pushNamed( + CreateMasternodeView.routeName, + arguments: { + 'walletId': widget.walletId, + 'collateralTxid': collateral.txid, + 'collateralVout': collateral.vout, + 'collateralAddress': collateral.address, + }, + ); + _handleSuccessTxid(txid); + } + } + + Future _maybePromptForExistingCollateral() async { + if (_hasPromptedForCollateral || _isCheckingForCollateral || !mounted) { + return; + } + _isCheckingForCollateral = true; + + try { + final collateral = await _findCollateralUtxo(); + if (collateral == null || !mounted) { + return; + } + + final wallet = + ref.read(pWallets).getWallet(widget.walletId) as FiroWallet; + final dismissed = _dismissedCollateral(wallet); + final collateralKey = "${collateral.txid}:${collateral.vout}"; + if (dismissed.contains(collateralKey)) { + return; + } + + _hasPromptedForCollateral = true; + + final wantsMN = await showDialog( + context: context, + barrierDismissible: true, + builder: (ctx) => StackDialog( + title: "Register Masternode?", + message: + "A 1000 FIRO collateral UTXO was found in your wallet. " + "Would you like to register a masternode now?", + leftButton: TextButton( + style: Theme.of( + ctx, + ).extension()!.getSecondaryEnabledButtonStyle(ctx), + child: Text( + "Later", + style: STextStyles.button(ctx).copyWith( + color: Theme.of(ctx).extension()!.accentColorDark, + ), + ), + onPressed: () => Navigator.of(ctx).pop(false), + ), + rightButton: TextButton( + style: Theme.of( + ctx, + ).extension()!.getPrimaryEnabledButtonStyle(ctx), + child: Text("Register", style: STextStyles.button(ctx)), + onPressed: () => Navigator.of(ctx).pop(true), + ), + ), + ); + + if (wantsMN == false || wantsMN == null) { + await _persistDismissedCollateral( + wallet, + collateral.txid, + collateral.vout, + ); + } + + if (wantsMN != true || !mounted) { + return; + } + + if (Util.isDesktop) { + final txid = await showDialog( + context: context, + barrierDismissible: true, + builder: (context) => SDialog( + child: CreateMasternodeView( + firoWalletId: widget.walletId, + collateralTxid: collateral.txid, + collateralVout: collateral.vout, + collateralAddress: collateral.address, + ), + ), + ); + _handleSuccessTxid(txid); + } else { + final txid = await Navigator.of(context).pushNamed( + CreateMasternodeView.routeName, + arguments: { + 'walletId': widget.walletId, + 'collateralTxid': collateral.txid, + 'collateralVout': collateral.vout, + 'collateralAddress': collateral.address, + }, + ); + _handleSuccessTxid(txid); + } + } finally { + _isCheckingForCollateral = false; + } } void _handleSuccessTxid(Object? txid) { @@ -75,10 +270,13 @@ class _MasternodesHomeViewState extends ConsumerState { void initState() { super.initState(); - // TODO polling and update on successful registration _masternodesFuture = (ref.read(pWallets).getWallet(widget.walletId) as FiroWallet) .getMyMasternodes(); + + WidgetsBinding.instance.addPostFrameCallback((_) { + unawaited(_maybePromptForExistingCollateral()); + }); } @override @@ -143,7 +341,7 @@ class _MasternodesHomeViewState extends ConsumerState { .srcIn, ), ), - onPressed: _showDesktopCreateMasternodeDialog, + onPressed: _createMasternode, ), ), ) @@ -184,13 +382,7 @@ class _MasternodesHomeViewState extends ConsumerState { width: 20, height: 20, ), - onPressed: () async { - final txid = await Navigator.of(context).pushNamed( - CreateMasternodeView.routeName, - arguments: widget.walletId, - ); - _handleSuccessTxid(txid); - }, + onPressed: _createMasternode, ), ), ), @@ -229,17 +421,7 @@ class _MasternodesHomeViewState extends ConsumerState { label: "Create Your First Masternode", horizontalContentPadding: 16, buttonHeight: Util.isDesktop ? .l : null, - onPressed: () async { - if (Util.isDesktop) { - await _showDesktopCreateMasternodeDialog(); - } else { - final txid = await Navigator.of(context).pushNamed( - CreateMasternodeView.routeName, - arguments: widget.walletId, - ); - _handleSuccessTxid(txid); - } - }, + onPressed: _createMasternode, ), ], ), diff --git a/lib/pages/masternodes/sub_widgets/register_masternode_form.dart b/lib/pages/masternodes/sub_widgets/register_masternode_form.dart index 84977d3d4..5d8dd945f 100644 --- a/lib/pages/masternodes/sub_widgets/register_masternode_form.dart +++ b/lib/pages/masternodes/sub_widgets/register_masternode_form.dart @@ -3,13 +3,11 @@ import 'package:flutter_riverpod/flutter_riverpod.dart'; import '../../../providers/global/wallets_provider.dart'; import '../../../themes/stack_colors.dart'; -import '../../../utilities/amount/amount.dart'; import '../../../utilities/if_not_already.dart'; import '../../../utilities/logger.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/impl/firo_wallet.dart'; import '../../../widgets/conditional_parent.dart'; import '../../../widgets/desktop/primary_button.dart'; @@ -22,10 +20,16 @@ class RegisterMasternodeForm extends ConsumerStatefulWidget { const RegisterMasternodeForm({ super.key, required this.firoWalletId, + required this.collateralTxid, + required this.collateralVout, + required this.collateralAddress, required this.onRegistrationSuccess, }); final String firoWalletId; + final String collateralTxid; + final int collateralVout; + final String collateralAddress; final void Function(String) onRegistrationSuccess; @@ -36,8 +40,6 @@ class RegisterMasternodeForm extends ConsumerStatefulWidget { class _RegisterMasternodeFormState extends ConsumerState { - late final Amount _masternodeThreshold; - final _ipAndPortController = TextEditingController(); final _operatorPubKeyController = TextEditingController(); final _votingAddressController = TextEditingController(); @@ -104,6 +106,9 @@ class _RegisterMasternodeFormState votingAddress, operatorReward, payoutAddress, + collateralTxid: widget.collateralTxid, + collateralVout: widget.collateralVout, + collateralAddress: widget.collateralAddress, ); Logging.instance.i('Masternode registration submitted: $txId'); @@ -114,11 +119,6 @@ class _RegisterMasternodeFormState @override void initState() { super.initState(); - final coin = ref.read(pWalletCoin(widget.firoWalletId)); - _masternodeThreshold = Amount.fromDecimal( - kMasterNodeValue, - fractionDigits: coin.fractionDigits, - ); _register = IfNotAlreadyAsync(() async { Exception? ex; @@ -168,24 +168,6 @@ class _RegisterMasternodeFormState @override Widget build(BuildContext context) { final stack = Theme.of(context).extension()!; - final spendableFiro = ref.watch( - pWalletBalance(widget.firoWalletId).select((s) => s.spendable), - ); - final canRegister = spendableFiro >= _masternodeThreshold; - final availableCount = (spendableFiro.raw ~/ _masternodeThreshold.raw) - .toInt(); - - final infoColor = canRegister - ? stack.snackBarTextSuccess - : stack.snackBarTextError; - final infoColorBG = canRegister - ? stack.snackBarBackSuccess - : stack.snackBarBackError; - - final infoMessage = canRegister - ? "You can register $availableCount masternode(s)." - : "Insufficient funds to register a masternode. " - "You need at least 1000 public FIRO."; return Column( mainAxisSize: MainAxisSize.min, @@ -195,14 +177,36 @@ class _RegisterMasternodeFormState children: [ Expanded( child: RoundedContainer( - color: infoColorBG, + color: stack.textFieldDefaultBG, child: Padding( - padding: const EdgeInsets.all(8.0), - child: Text( - infoMessage, - style: STextStyles.w600_14( - context, - ).copyWith(color: infoColor), + padding: const EdgeInsets.symmetric( + horizontal: 12, + vertical: 10, + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + "Masternode collateral", + style: STextStyles.w500_12( + context, + ).copyWith(color: stack.textSubtitle1), + ), + const SizedBox(height: 4), + SelectableText( + widget.collateralAddress, + style: STextStyles.w500_14( + context, + ).copyWith(color: stack.textDark), + ), + const SizedBox(height: 4), + SelectableText( + "${widget.collateralTxid}:${widget.collateralVout}", + style: STextStyles.w500_12( + context, + ).copyWith(color: stack.textSubtitle1), + ), + ], ), ), ), diff --git a/lib/pages/send_view/confirm_transaction_view.dart b/lib/pages/send_view/confirm_transaction_view.dart index 424269b0d..8f7eab592 100644 --- a/lib/pages/send_view/confirm_transaction_view.dart +++ b/lib/pages/send_view/confirm_transaction_view.dart @@ -18,12 +18,14 @@ import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_svg/svg.dart'; import 'package:isar_community/isar.dart'; +import '../../models/isar/models/isar_models.dart'; import '../../models/input.dart'; import '../../models/isar/models/transaction_note.dart'; import '../../models/isar/ordinal.dart'; import '../../notifications/show_flush_bar.dart'; import '../../pages_desktop_specific/coin_control/desktop_coin_control_use_dialog.dart'; import '../../pages_desktop_specific/my_stack_view/wallet_view/sub_widgets/desktop_auth_send.dart'; +import '../../providers/global/global_nav_key_provider.dart'; import '../../providers/providers.dart'; import '../../providers/wallet/public_private_balance_state_provider.dart'; import '../../route_generator.dart'; @@ -55,6 +57,7 @@ import '../../widgets/custom_buttons/app_bar_icon_button.dart'; import '../../widgets/desktop/desktop_dialog.dart'; import '../../widgets/desktop/desktop_dialog_close_button.dart'; import '../../widgets/desktop/primary_button.dart'; +import '../../widgets/dialogs/s_dialog.dart'; import '../../widgets/icon_widgets/x_icon.dart'; import '../../widgets/rounded_container.dart'; import '../../widgets/rounded_white_container.dart'; @@ -62,6 +65,7 @@ import '../../widgets/stack_dialog.dart'; import '../../widgets/stack_text_field.dart'; import '../../widgets/textfield_icon_button.dart'; import '../../wl_gen/interfaces/libepiccash_interface.dart'; +import '../masternodes/create_masternode_view.dart'; import '../pinpad_views/lock_screen_view.dart'; import '../wallet_view/wallet_view.dart'; import 'sub_widgets/epic_slatepack_dialog.dart'; @@ -308,6 +312,97 @@ class _ConfirmTransactionViewState } } + Future _resolveFiroCollateralVout({ + required FiroWallet wallet, + required String txid, + required String recipientAddress, + required Amount amount, + }) async { + try { + final tx = await wallet.electrumXClient.getTransaction(txHash: txid); + final outputs = tx['vout']; + if (outputs is! List) { + return null; + } + + for (final output in outputs) { + if (output is! Map) { + continue; + } + final outputMap = Map.from(output); + final n = outputMap['n']; + final outputIndex = switch (n) { + int value => value, + String value => int.tryParse(value), + _ => null, + }; + if (outputIndex == null) { + continue; + } + + final valueDecimal = Decimal.tryParse(outputMap['value'].toString()); + if (valueDecimal == null) { + continue; + } + final outputAmount = Amount.fromDecimal( + valueDecimal, + fractionDigits: wallet.cryptoCurrency.fractionDigits, + ); + if (outputAmount != amount) { + continue; + } + + final scriptPubKey = outputMap['scriptPubKey']; + if (scriptPubKey is! Map) { + continue; + } + + final recipientAddresses = {}; + final addresses = scriptPubKey['addresses']; + if (addresses is List) { + recipientAddresses.addAll(addresses.whereType()); + } + + final address = scriptPubKey['address']; + if (address is String) { + recipientAddresses.add(address); + } + + if (recipientAddresses.contains(recipientAddress)) { + return outputIndex; + } + } + } catch (e, s) { + Logging.instance.w( + "Failed to resolve collateral vout for txid=$txid: $e", + error: e, + stackTrace: s, + ); + } + + return null; + } + + void _showMasternodeSubmittedDialog(BuildContext? rootContext, String txid) { + if (rootContext == null) { + return; + } + unawaited( + showDialog( + context: rootContext, + builder: (_) => StackOkDialog( + title: "Masternode Registration Submitted", + message: + "Masternode registration submitted, your masternode will " + "appear in the list after the tx is confirmed.\n\nTransaction " + "ID: $txid", + desktopPopRootNavigator: Util.isDesktop, + maxWidth: Util.isDesktop ? 400 : null, + ), + ), + ); + } + Future _attemptSend(BuildContext context) async { final wallet = ref.read(pWallets).getWallet(walletId); final coin = wallet.info.coin; @@ -425,15 +520,15 @@ class _ConfirmTransactionViewState } final results = await Future.wait([txDataFuture, time]); + final confirmedTx = results.first as TxData; sendProgressController.triggerSuccess?.call(); await Future.delayed(const Duration(seconds: 5)); - if (wallet is FiroWallet && - (results.first as TxData).sparkMints != null) { - txids.addAll((results.first as TxData).sparkMints!.map((e) => e.txid!)); + if (wallet is FiroWallet && confirmedTx.sparkMints != null) { + txids.addAll(confirmedTx.sparkMints!.map((e) => e.txid!)); } else { - txids.add((results.first as TxData).txid!); + txids.add(confirmedTx.txid!); } if (coin is! Ethereum) { ref.refresh(desktopUseUTXOs); @@ -460,8 +555,187 @@ class _ConfirmTransactionViewState widget.onSuccess.call(); - // pop back to wallet - if (context.mounted) { + // Check for 1000 FIRO transparent self-send → prompt MN registration + bool navigatedToMN = false; + if (wallet is FiroWallet && + confirmedTx.recipients != null && + confirmedTx.sparkMints == null && + txids.isNotEmpty && + context.mounted) { + try { + final masternodeAmount = Amount.fromDecimal( + kMasterNodeValue, + fractionDigits: wallet.cryptoCurrency.fractionDigits, + ); + final txFeeRaw = confirmedTx.fee?.raw ?? BigInt.zero; + + final mnRecipient = confirmedTx.recipients! + // Exact 1000 FIRO: multiple such outputs uses the first match only. + .where((r) => !r.isChange && r.amount == masternodeAmount) + .firstOrNull; + + if (mnRecipient != null && confirmedTx.txid != null) { + final ownAddress = await ref + .read(mainDBProvider) + .getAddresses(walletId) + .filter() + .valueEqualTo(mnRecipient.address) + .findFirst(); + + if (ownAddress != null && context.mounted) { + final collateralVout = await _resolveFiroCollateralVout( + wallet: wallet, + txid: confirmedTx.txid!, + recipientAddress: mnRecipient.address, + amount: masternodeAmount, + ); + if (!context.mounted) { + return; + } + + if (collateralVout == null) { + unawaited( + showFloatingFlushBar( + type: FlushBarType.warning, + message: + "Unable to determine collateral output index " + "automatically. Open Masternodes and select your " + "1000 FIRO UTXO manually.", + context: context, + ), + ); + } else { + navigatedToMN = true; + final rootContext = ref.read(pNavKey).currentContext; + + void completeMnParentNavigation() { + if (widget.onSuccessInsteadOfRouteOnSuccess == null) { + if (isDesktop) { + Navigator.of( + context, + ).popUntil(ModalRoute.withName(routeOnSuccessName)); + } else { + final navigator = Navigator.of(context); + navigator.popUntil( + ModalRoute.withName(routeOnSuccessName), + ); + } + } else { + widget.onSuccessInsteadOfRouteOnSuccess!.call(); + } + } + + completeMnParentNavigation(); + + if (isDesktop) { + if (rootContext != null && rootContext.mounted) { + unawaited( + showDialog( + context: rootContext, + barrierDismissible: true, + builder: (_) => SDialog( + child: CreateMasternodeView( + firoWalletId: walletId, + collateralTxid: confirmedTx.txid!, + collateralVout: collateralVout, + collateralAddress: mnRecipient.address, + ), + ), + ).then((result) { + if (result is String) { + _showMasternodeSubmittedDialog(rootContext, result); + } + }), + ); + } + } else { + final navContext = + (rootContext != null && rootContext.mounted) + ? rootContext + : context; + if (!navContext.mounted) { + return; + } + unawaited( + Navigator.of(navContext) + .pushNamed( + CreateMasternodeView.routeName, + arguments: { + 'walletId': walletId, + 'collateralTxid': confirmedTx.txid!, + 'collateralVout': collateralVout, + 'collateralAddress': mnRecipient.address, + }, + ) + .then((result) { + if (result is String) { + _showMasternodeSubmittedDialog(rootContext, result); + } + }), + ); + } + } + } + } else if (mnRecipient != null && + confirmedTx.txid == null && + context.mounted) { + unawaited( + showFloatingFlushBar( + type: FlushBarType.warning, + message: + "Could not determine transaction id for collateral " + "auto-detection. Register from the Masternodes screen " + "once the transaction appears.", + context: context, + ), + ); + } else { + // If fee was subtracted from the recipient, users can enter 1000 but + // end up with ~999.99... output which is not valid MN collateral. + final nearMnRecipient = + confirmedTx.recipients! + .where( + (r) => !r.isChange && r.amount.raw < masternodeAmount.raw, + ) + .where( + (r) => (masternodeAmount.raw - r.amount.raw) <= txFeeRaw, + ) + .toList() + ..sort((a, b) => b.amount.raw.compareTo(a.amount.raw)); + + if (nearMnRecipient.isNotEmpty) { + final maybeOwnAddress = await ref + .read(mainDBProvider) + .getAddresses(walletId) + .filter() + .valueEqualTo(nearMnRecipient.first.address) + .findFirst(); + + if (maybeOwnAddress != null && context.mounted) { + unawaited( + showFloatingFlushBar( + type: FlushBarType.warning, + message: + "Masternode collateral requires one exact 1000 FIRO " + "transparent output. Fee appears to have been " + "subtracted from the recipient amount. Send 1000 " + "to yourself again with fee paid on top.", + context: context, + ), + ); + } + } + } + } catch (e, s) { + Logging.instance.w( + "Skipping masternode collateral auto-detection: $e", + error: e, + stackTrace: s, + ); + } + } + + if (!navigatedToMN && context.mounted) { if (widget.onSuccessInsteadOfRouteOnSuccess == null) { Navigator.of( context, diff --git a/lib/pages_desktop_specific/desktop_menu.dart b/lib/pages_desktop_specific/desktop_menu.dart index 983570169..8c67885ff 100644 --- a/lib/pages_desktop_specific/desktop_menu.dart +++ b/lib/pages_desktop_specific/desktop_menu.dart @@ -180,112 +180,110 @@ class _DesktopMenuState extends ConsumerState { child: Column( crossAxisAlignment: CrossAxisAlignment.stretch, children: [ - DesktopMenuItem( - key: const ValueKey('myStack'), - duration: duration, - icon: const DesktopMyStackIcon(), - label: "My ${AppConfig.prefix}", - value: DesktopMenuItemId.myStack, - onChanged: updateSelectedMenuItem, - controller: controllers[0], - isExpandedInitially: !_isMinimized, - ), - if (AppConfig.hasFeature(AppFeature.swap) && - showExchange) ...[ - const SizedBox(height: 2), - DesktopMenuItem( - key: const ValueKey('swap'), - duration: duration, - icon: const DesktopExchangeIcon(), - label: "Swap", - value: DesktopMenuItemId.exchange, - onChanged: updateSelectedMenuItem, - controller: controllers[1], - isExpandedInitially: !_isMinimized, - ), - ], - if (AppConfig.hasFeature(AppFeature.buy) && - showExchange) ...[ - const SizedBox(height: 2), - DesktopMenuItem( - key: const ValueKey('buy'), - duration: duration, - icon: const DesktopBuyIcon(), - label: "Buy crypto", - value: DesktopMenuItemId.buy, - onChanged: updateSelectedMenuItem, - controller: controllers[2], - isExpandedInitially: !_isMinimized, + Expanded( + child: SingleChildScrollView( + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + DesktopMenuItem( + key: const ValueKey('myStack'), + duration: duration, + icon: const DesktopMyStackIcon(), + label: "My ${AppConfig.prefix}", + value: DesktopMenuItemId.myStack, + onChanged: updateSelectedMenuItem, + controller: controllers[0], + isExpandedInitially: !_isMinimized, + ), + if (AppConfig.hasFeature(AppFeature.swap) && + showExchange) ...[ + const SizedBox(height: 2), + DesktopMenuItem( + key: const ValueKey('swap'), + duration: duration, + icon: const DesktopExchangeIcon(), + label: "Swap", + value: DesktopMenuItemId.exchange, + onChanged: updateSelectedMenuItem, + controller: controllers[1], + isExpandedInitially: !_isMinimized, + ), + ], + if (AppConfig.hasFeature(AppFeature.buy) && + showExchange) ...[ + const SizedBox(height: 2), + DesktopMenuItem( + key: const ValueKey('buy'), + duration: duration, + icon: const DesktopBuyIcon(), + label: "Buy crypto", + value: DesktopMenuItemId.buy, + onChanged: updateSelectedMenuItem, + controller: controllers[2], + isExpandedInitially: !_isMinimized, + ), + ], + const SizedBox(height: 2), + DesktopMenuItem( + key: const ValueKey('notifications'), + duration: duration, + icon: const DesktopNotificationsIcon(), + label: "Notifications", + value: DesktopMenuItemId.notifications, + onChanged: updateSelectedMenuItem, + controller: controllers[3], + isExpandedInitially: !_isMinimized, + ), + const SizedBox(height: 2), + DesktopMenuItem( + key: const ValueKey('addressBook'), + duration: duration, + icon: const DesktopAddressBookIcon(), + label: "Address Book", + value: DesktopMenuItemId.addressBook, + onChanged: updateSelectedMenuItem, + controller: controllers[4], + isExpandedInitially: !_isMinimized, + ), + const SizedBox(height: 2), + DesktopMenuItem( + key: const ValueKey('settings'), + duration: duration, + icon: const DesktopSettingsIcon(), + label: "Settings", + value: DesktopMenuItemId.settings, + onChanged: updateSelectedMenuItem, + controller: controllers[5], + isExpandedInitially: !_isMinimized, + ), + const SizedBox(height: 2), + DesktopMenuItem( + key: const ValueKey('support'), + duration: duration, + icon: const DesktopSupportIcon(), + label: "Support", + value: DesktopMenuItemId.support, + onChanged: updateSelectedMenuItem, + controller: controllers[6], + isExpandedInitially: !_isMinimized, + ), + const SizedBox(height: 2), + DesktopMenuItem( + key: const ValueKey('about'), + duration: duration, + icon: const DesktopAboutIcon(), + label: "About", + value: DesktopMenuItemId.about, + onChanged: updateSelectedMenuItem, + controller: controllers[7], + isExpandedInitially: !_isMinimized, + ), + ], + ), ), - ], - const SizedBox(height: 2), - DesktopMenuItem( - key: const ValueKey('services'), - duration: duration, - icon: const DesktopServicesIcon(), - label: "Services", - value: DesktopMenuItemId.services, - onChanged: updateSelectedMenuItem, - controller: controllers[3], - isExpandedInitially: !_isMinimized, - ), - const SizedBox(height: 2), - DesktopMenuItem( - key: const ValueKey('notifications'), - duration: duration, - icon: const DesktopNotificationsIcon(), - label: "Notifications", - value: DesktopMenuItemId.notifications, - onChanged: updateSelectedMenuItem, - controller: controllers[4], - isExpandedInitially: !_isMinimized, - ), - const SizedBox(height: 2), - DesktopMenuItem( - key: const ValueKey('addressBook'), - duration: duration, - icon: const DesktopAddressBookIcon(), - label: "Address Book", - value: DesktopMenuItemId.addressBook, - onChanged: updateSelectedMenuItem, - controller: controllers[5], - isExpandedInitially: !_isMinimized, ), - const SizedBox(height: 2), - DesktopMenuItem( - key: const ValueKey('settings'), - duration: duration, - icon: const DesktopSettingsIcon(), - label: "Settings", - value: DesktopMenuItemId.settings, - onChanged: updateSelectedMenuItem, - controller: controllers[6], - isExpandedInitially: !_isMinimized, - ), - const SizedBox(height: 2), - DesktopMenuItem( - key: const ValueKey('support'), - duration: duration, - icon: const DesktopSupportIcon(), - label: "Support", - value: DesktopMenuItemId.support, - onChanged: updateSelectedMenuItem, - controller: controllers[7], - isExpandedInitially: !_isMinimized, - ), - const SizedBox(height: 2), - DesktopMenuItem( - key: const ValueKey('about'), - duration: duration, - icon: const DesktopAboutIcon(), - label: "About", - value: DesktopMenuItemId.about, - onChanged: updateSelectedMenuItem, - controller: controllers[8], - isExpandedInitially: !_isMinimized, - ), - const Spacer(), - if (!Platform.isIOS) + if (!Platform.isIOS) ...[ + const SizedBox(height: 16), DesktopMenuItem( key: const ValueKey('exit'), duration: duration, @@ -307,6 +305,7 @@ class _DesktopMenuState extends ConsumerState { controller: controllers[9], isExpandedInitially: !_isMinimized, ), + ], ], ), ), diff --git a/lib/pages_desktop_specific/my_stack_view/wallet_view/sub_widgets/more_features/more_features_dialog.dart b/lib/pages_desktop_specific/my_stack_view/wallet_view/sub_widgets/more_features/more_features_dialog.dart index 2a635e5c0..decfd8c09 100644 --- a/lib/pages_desktop_specific/my_stack_view/wallet_view/sub_widgets/more_features/more_features_dialog.dart +++ b/lib/pages_desktop_specific/my_stack_view/wallet_view/sub_widgets/more_features/more_features_dialog.dart @@ -331,10 +331,13 @@ class _MoreFeaturesDialogState extends ConsumerState { pWallets.select((value) => value.getWallet(widget.walletId)), ); + final maxDialogHeight = MediaQuery.sizeOf(context).height - 64; + return DesktopDialog( - maxHeight: double.infinity, + maxHeight: maxDialogHeight, child: Column( - mainAxisSize: MainAxisSize.min, + mainAxisSize: MainAxisSize.max, + crossAxisAlignment: CrossAxisAlignment.stretch, children: [ Row( mainAxisAlignment: MainAxisAlignment.spaceBetween, @@ -350,184 +353,199 @@ class _MoreFeaturesDialogState extends ConsumerState { ], ), - ...widget.options.map((option) { - switch (option.$1) { - case WalletFeature.buy: - // Buy has a special icon - return _MoreFeaturesItem( - label: option.$1.label, - detail: option.$1.description, - isSvgFile: true, - iconAsset: ref.watch( - themeProvider.select((value) => value.assets.buy), - ), - onPressed: () async { - Navigator.of(context, rootNavigator: true).pop(); - option.$3(); - }, - ); - - case WalletFeature.clearSparkCache: - return _MoreFeaturesClearSparkCacheItem( - cryptoCurrency: wallet.cryptoCurrency, - ); - - case WalletFeature.rbf: - return _MoreFeaturesItemBase( - child: Row( - children: [ - const SizedBox(width: 3), - SizedBox( - height: 20, - width: 40, - child: DraggableSwitchButton( - isOn: - ref.watch( - pWalletInfo( - widget.walletId, - ).select((value) => value.otherData), - )[WalletInfoKeys.enableOptInRbf] - as bool? ?? - false, - onValueChanged: _switchRbfToggled, - ), - ), - const SizedBox(width: 16), - Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - "Flag outgoing transactions with opt-in RBF", - style: STextStyles.w600_20(context), - ), - ], - ), - ], - ), - ); - - case WalletFeature.enableLegacyAddresses: - return _MoreFeaturesItemBase( - child: Row( - children: [ - const SizedBox(width: 3), - SizedBox( - height: 20, - width: 40, - child: DraggableSwitchButton( - isOn: - ref.watch( - pWalletInfo( - widget.walletId, - ).select((value) => value.otherData), - )[WalletInfoKeys.enableLegacyAddresses] - as bool? ?? - false, - onValueChanged: _switchLegacyToggled, - ), - ), - const SizedBox(width: 16), - Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - "Enable legacy (P2PKH) address generation", - style: STextStyles.w600_20(context), + Expanded( + child: SingleChildScrollView( + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + mainAxisSize: MainAxisSize.min, + children: [ + ...widget.options.map((option) { + switch (option.$1) { + case WalletFeature.buy: + // Buy has a special icon + return _MoreFeaturesItem( + label: option.$1.label, + detail: option.$1.description, + isSvgFile: true, + iconAsset: ref.watch( + themeProvider.select((value) => value.assets.buy), ), - ], - ), - ], - ), - ); - - case WalletFeature.reuseAddress: - return _MoreFeaturesItemBase( - onPressed: _switchReuseAddressToggled, - child: Row( - children: [ - const SizedBox(width: 3), - SizedBox( - height: 20, - width: 40, - child: IgnorePointer( - child: DraggableSwitchButton( - isOn: - ref.watch( - pWalletInfo( - widget.walletId, - ).select((value) => value.otherData), - )[WalletInfoKeys.reuseAddress] - as bool? ?? - false, - controller: _switchControllerAddressReuse, + onPressed: () async { + Navigator.of(context, rootNavigator: true).pop(); + option.$3(); + }, + ); + + case WalletFeature.clearSparkCache: + return _MoreFeaturesClearSparkCacheItem( + cryptoCurrency: wallet.cryptoCurrency, + ); + + case WalletFeature.rbf: + return _MoreFeaturesItemBase( + child: Row( + children: [ + const SizedBox(width: 3), + SizedBox( + height: 20, + width: 40, + child: DraggableSwitchButton( + isOn: + ref.watch( + pWalletInfo(widget.walletId).select( + (value) => value.otherData, + ), + )[WalletInfoKeys.enableOptInRbf] + as bool? ?? + false, + onValueChanged: _switchRbfToggled, + ), + ), + const SizedBox(width: 16), + Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + "Flag outgoing transactions with opt-in RBF", + style: STextStyles.w600_20(context), + ), + ], + ), + ], ), - ), - ), - const SizedBox(width: 16), - Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - "Reuse receiving address", - style: STextStyles.w600_20(context), + ); + + case WalletFeature.enableLegacyAddresses: + return _MoreFeaturesItemBase( + child: Row( + children: [ + const SizedBox(width: 3), + SizedBox( + height: 20, + width: 40, + child: DraggableSwitchButton( + isOn: + ref.watch( + pWalletInfo(widget.walletId).select( + (value) => value.otherData, + ), + )[WalletInfoKeys + .enableLegacyAddresses] + as bool? ?? + false, + onValueChanged: _switchLegacyToggled, + ), + ), + const SizedBox(width: 16), + Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + "Enable legacy (P2PKH) address generation", + style: STextStyles.w600_20(context), + ), + ], + ), + ], ), - ], - ), - ], - ), - ); - - case WalletFeature.enableMweb: - return _MoreFeaturesItemBase( - onPressed: _switchMwebToggleToggled, - child: Row( - children: [ - const SizedBox(width: 3), - SizedBox( - height: 20, - width: 40, - child: IgnorePointer( - child: DraggableSwitchButton( - isOn: - ref.watch( - pWalletInfo( - widget.walletId, - ).select((value) => value.otherData), - )[WalletInfoKeys.mwebEnabled] - as bool? ?? - false, - controller: _switchControllerMwebToggle, + ); + + case WalletFeature.reuseAddress: + return _MoreFeaturesItemBase( + onPressed: _switchReuseAddressToggled, + child: Row( + children: [ + const SizedBox(width: 3), + SizedBox( + height: 20, + width: 40, + child: IgnorePointer( + child: DraggableSwitchButton( + isOn: + ref.watch( + pWalletInfo( + widget.walletId, + ).select( + (value) => value.otherData, + ), + )[WalletInfoKeys.reuseAddress] + as bool? ?? + false, + controller: _switchControllerAddressReuse, + ), + ), + ), + const SizedBox(width: 16), + Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + "Reuse receiving address", + style: STextStyles.w600_20(context), + ), + ], + ), + ], ), - ), - ), - const SizedBox(width: 16), - Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - "Enable MWEB", - style: STextStyles.w600_20(context), + ); + + case WalletFeature.enableMweb: + return _MoreFeaturesItemBase( + onPressed: _switchMwebToggleToggled, + child: Row( + children: [ + const SizedBox(width: 3), + SizedBox( + height: 20, + width: 40, + child: IgnorePointer( + child: DraggableSwitchButton( + isOn: + ref.watch( + pWalletInfo( + widget.walletId, + ).select( + (value) => value.otherData, + ), + )[WalletInfoKeys.mwebEnabled] + as bool? ?? + false, + controller: _switchControllerMwebToggle, + ), + ), + ), + const SizedBox(width: 16), + Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + "Enable MWEB", + style: STextStyles.w600_20(context), + ), + ], + ), + ], ), - ], - ), - ], - ), - ); - - default: - return _MoreFeaturesItem( - label: option.$1.label, - detail: option.$1.description, - iconAsset: option.$2, - onPressed: () async { - Navigator.of(context, rootNavigator: true).pop(); - option.$3(); - }, - ); - } - }), - - const SizedBox(height: 28), + ); + + default: + return _MoreFeaturesItem( + label: option.$1.label, + detail: option.$1.description, + iconAsset: option.$2, + onPressed: () async { + Navigator.of(context, rootNavigator: true).pop(); + option.$3(); + }, + ); + } + }), + + const SizedBox(height: 28), + ], + ), + ), + ), ], ), ); diff --git a/lib/route_generator.dart b/lib/route_generator.dart index 83696c01a..501d8781e 100644 --- a/lib/route_generator.dart +++ b/lib/route_generator.dart @@ -949,10 +949,15 @@ class RouteGenerator { return _routeError("${settings.name} invalid args: ${args.toString()}"); case CreateMasternodeView.routeName: - if (args is String) { + if (args is Map) { return getRoute( shouldUseMaterialRoute: useMaterialPageRoute, - builder: (_) => CreateMasternodeView(firoWalletId: args), + builder: (_) => CreateMasternodeView( + firoWalletId: args['walletId'] as String, + collateralTxid: args['collateralTxid'] as String, + collateralVout: args['collateralVout'] as int, + collateralAddress: args['collateralAddress'] as String, + ), settings: RouteSettings(name: settings.name), ); } diff --git a/lib/utilities/firo_pro_reg_signed_message_prefix.dart b/lib/utilities/firo_pro_reg_signed_message_prefix.dart new file mode 100644 index 000000000..0844a347e --- /dev/null +++ b/lib/utilities/firo_pro_reg_signed_message_prefix.dart @@ -0,0 +1,18 @@ +/// Helpers for Firo ProReg collateral signatures that use Bitcoin-style +/// signed-message framing with [coinlib.MessageSignature.sign]. +/// +/// [coinlib.Network.messagePrefix] for Firo includes the Core magic byte +/// `0x16` before `"Zcoin Signed Message:\\n"`. Coinlib adds its own length +/// framing for signing, so that byte must be supplied explicitly rather than +/// inferred from accidental equality with `length - 1`. +library firo_pro_reg_signed_message_prefix; + +/// Prefix string passed to [MessageSignature.sign] for Firo/Zcoin networks. +String firoMessagePrefixForCoinlibSign(String networkMessagePrefix) { + const magic = 0x16; + final bytes = networkMessagePrefix.codeUnits; + if (bytes.isNotEmpty && bytes.first == magic) { + return String.fromCharCodes(bytes.sublist(1)); + } + return networkMessagePrefix; +} diff --git a/lib/wallets/isar/models/wallet_info.dart b/lib/wallets/isar/models/wallet_info.dart index db0a65c70..12329f8ce 100644 --- a/lib/wallets/isar/models/wallet_info.dart +++ b/lib/wallets/isar/models/wallet_info.dart @@ -442,7 +442,8 @@ class WalletInfo implements IsarId { }) async { await updateOtherData( newEntries: { - WalletInfoKeys.solanaCustomTokenMintAddresses: newMintAddresses.toList(), + WalletInfoKeys.solanaCustomTokenMintAddresses: newMintAddresses + .toList(), }, isar: isar, ); @@ -581,4 +582,6 @@ abstract class WalletInfoKeys { static const String solanaTokenMintAddresses = "solanaTokenMintAddressesKey"; static const String solanaCustomTokenMintAddresses = "solanaCustomTokenMintAddressesKey"; + static const String firoMasternodeCollateralDismissed = + "firoMasternodeCollateralDismissedKey"; } diff --git a/lib/wallets/wallet/impl/firo_wallet.dart b/lib/wallets/wallet/impl/firo_wallet.dart index c3b861ff7..ff2996ede 100644 --- a/lib/wallets/wallet/impl/firo_wallet.dart +++ b/lib/wallets/wallet/impl/firo_wallet.dart @@ -2,7 +2,8 @@ import 'dart:async'; import 'dart:convert'; import 'dart:typed_data'; -import 'package:coinlib_flutter/coinlib_flutter.dart' show base58Decode, P2PKH; +import 'package:coinlib_flutter/coinlib_flutter.dart' + show MessageSignature, base58Decode, P2PKH; import 'package:crypto/crypto.dart' as crypto; import 'package:decimal/decimal.dart'; import 'package:isar_community/isar.dart'; @@ -16,6 +17,7 @@ import '../../../models/isar/models/isar_models.dart'; import '../../../models/keys/view_only_wallet_data.dart'; import '../../../utilities/amount/amount.dart'; import '../../../utilities/extensions/extensions.dart'; +import '../../../utilities/firo_pro_reg_signed_message_prefix.dart'; import '../../../utilities/logger.dart'; import '../../../utilities/util.dart'; import '../../crypto_currency/crypto_currency.dart'; @@ -939,34 +941,71 @@ class FiroWallet extends Bip39HDWallet String operatorPubKey, String votingAddress, int operatorReward, - String payoutAddress, - ) async { - if (info.cachedBalance.spendable < - Amount.fromDecimal( - kMasterNodeValue, - fractionDigits: cryptoCurrency.fractionDigits, + String payoutAddress, { + required String collateralTxid, + required int collateralVout, + required String collateralAddress, + }) async { + final collateralAddr = await mainDB + .getAddresses(walletId) + .filter() + .valueEqualTo(collateralAddress) + .findFirst(); + if (collateralAddr == null || collateralAddr.derivationPath == null) { + throw Exception( + 'Collateral address $collateralAddress not found in wallet ' + 'or has no derivation path.', + ); + } + final collateralUtxo = await mainDB + .getUTXOs(walletId) + .filter() + .txidEqualTo(collateralTxid) + .and() + .voutEqualTo(collateralVout) + .findFirst(); + final currentChainHeight = await chainHeight; + if (collateralUtxo == null || + collateralUtxo.address != collateralAddress || + collateralUtxo.isBlocked || + collateralUtxo.used == true || + !collateralUtxo.isConfirmed( + currentChainHeight, + cryptoCurrency.minConfirms, + cryptoCurrency.minCoinbaseConfirms, )) { throw Exception( - 'Not enough funds to register a masternode. ' - 'You must have at least 1000 FIRO in your public balance.', + "Collateral outpoint is not yet confirmed/spendable. " + "Wait for confirmations and try again.", ); } - - Address? collateralAddress = await getCurrentReceivingAddress(); - if (collateralAddress == null) { - await generateNewReceivingAddress(); - collateralAddress = await getCurrentReceivingAddress(); + final expectedCollateralRaw = Amount.fromDecimal( + kMasterNodeValue, + fractionDigits: cryptoCurrency.fractionDigits, + ).raw.toInt(); + if (collateralUtxo.value != expectedCollateralRaw) { + throw Exception( + "Collateral outpoint must be exactly ${kMasterNodeValue.toString()} FIRO.", + ); } - await generateNewReceivingAddress(); Address? ownerAddress = await getCurrentReceivingAddress(); - if (ownerAddress == null) { + const maxOwnerAttempts = 32; + for ( + var i = 0; + i < maxOwnerAttempts && + (ownerAddress == null || ownerAddress.value == collateralAddress); + i++ + ) { await generateNewReceivingAddress(); ownerAddress = await getCurrentReceivingAddress(); } - await generateNewReceivingAddress(); + if (ownerAddress == null || ownerAddress.value == collateralAddress) { + throw Exception( + "Could not derive owner address distinct from collateral address.", + ); + } - // Create the registration transaction. final registrationTx = BytesBuilder(); // nVersion (16 bit) @@ -974,7 +1013,7 @@ class FiroWallet extends Bip39HDWallet (ByteData(2)..setInt16(0, 1, Endian.little)).buffer.asUint8List(), ); - // nType (16 bit) (this is separate from the tx nType) + // nType (16 bit) registrationTx.add( (ByteData(2)..setInt16(0, 0, Endian.little)).buffer.asUint8List(), ); @@ -984,22 +1023,23 @@ class FiroWallet extends Bip39HDWallet (ByteData(2)..setInt16(0, 0, Endian.little)).buffer.asUint8List(), ); - // collateralOutpoint.hash (256 bit) - // This is null, referring to our own transaction. - registrationTx.add(ByteData(32).buffer.asUint8List()); + // collateralOutpoint.hash (256 bit) — real txid, byte-reversed + final collateralTxidBytes = collateralTxid.toUint8ListFromHex.reversed + .toList(); + if (collateralTxidBytes.length != 32) { + throw Exception("Invalid collateral txid: $collateralTxid"); + } + registrationTx.add(collateralTxidBytes); - // collateralOutpoint.index (2 bytes) - // This is going to be 0. - // (The only other output will be change at position 1.) + // collateralOutpoint.index (uint32) registrationTx.add( - (ByteData(4)..setInt16(0, 0, Endian.little)).buffer.asUint8List(), + (ByteData( + 4, + )..setUint32(0, collateralVout, Endian.little)).buffer.asUint8List(), ); - // addr.ip (4 bytes) - final ipParts = ip - .split('.') - .map((e) => int.parse(e)) - .toList(); + // addr — IPv4-mapped IPv6 (16 bytes) + port (2 bytes big-endian) + final ipParts = ip.split('.').map((e) => int.parse(e)).toList(); if (ipParts.length != 4) { throw Exception("Invalid IP address: $ip"); } @@ -1008,60 +1048,57 @@ class FiroWallet extends Bip39HDWallet throw Exception("Invalid IP part: $part"); } } - // This is serialized as an IPv6 address (which it cannot be), - // so there will be 12 bytes of padding. registrationTx.add(ByteData(10).buffer.asUint8List()); registrationTx.add([0xff, 0xff]); registrationTx.add(ipParts); - - // addr.port (2 bytes) if (port < 1 || port > 65535) { throw Exception("Invalid port: $port"); } registrationTx.add( - // network byte order - (ByteData(2)..setInt16(0, port, Endian.big)).buffer.asUint8List(), + (ByteData(2)..setUint16(0, port, Endian.big)).buffer.asUint8List(), ); // keyIDOwner (20 bytes) - assert(ownerAddress!.value != collateralAddress!.value); - if (!cryptoCurrency.validateAddress(ownerAddress!.value)) { + if (!cryptoCurrency.validateAddress(ownerAddress.value)) { throw Exception("Invalid owner address: ${ownerAddress.value}"); } final ownerAddressBytes = base58Decode(ownerAddress.value); - assert(ownerAddressBytes.length == 21); // should be infallible - registrationTx.add(ownerAddressBytes.sublist(1)); // remove version byte + assert(ownerAddressBytes.length == 21); + registrationTx.add(ownerAddressBytes.sublist(1)); // pubKeyOperator (48 bytes) final operatorPubKeyBytes = operatorPubKey.toUint8ListFromHex; if (operatorPubKeyBytes.length != 48) { - // These actually have a required format, but we're not going to check it. - // The transaction will fail if it's not - // valid. throw Exception("Invalid operator public key: $operatorPubKey"); } registrationTx.add(operatorPubKeyBytes); - // keyIDVoting (40 bytes) + // keyIDVoting (20 bytes) + final String effectiveVotingAddress; if (votingAddress == payoutAddress) { throw Exception("Voting address and payout address cannot be the same."); - } else if (votingAddress == collateralAddress!.value) { + } else if (votingAddress == collateralAddress) { throw Exception( "Voting address cannot be the same as the collateral address.", ); } else if (votingAddress.isNotEmpty) { - if (!cryptoCurrency.validateAddress(votingAddress)) { - throw Exception("Invalid voting address: $votingAddress"); + final votingType = cryptoCurrency.getAddressType(votingAddress); + if (votingType != AddressType.p2pkh) { + throw Exception( + "Voting address must be a transparent P2PKH address, " + "not a Spark or other address type.", + ); } - final votingAddressBytes = base58Decode(votingAddress); - assert(votingAddressBytes.length == 21); // should be infallible - registrationTx.add(votingAddressBytes.sublist(1)); // remove version byte + assert(votingAddressBytes.length == 21); + registrationTx.add(votingAddressBytes.sublist(1)); + effectiveVotingAddress = votingAddress; } else { - registrationTx.add(ownerAddressBytes.sublist(1)); // remove version byte + registrationTx.add(ownerAddressBytes.sublist(1)); + effectiveVotingAddress = ownerAddress.value; } - // nOperatorReward (16 bit); the operator gets nOperatorReward/10,000 of the reward. + // nOperatorReward (16 bit) if (operatorReward < 0 || operatorReward > 10000) { throw Exception("Invalid operator reward: $operatorReward"); } @@ -1071,34 +1108,55 @@ class FiroWallet extends Bip39HDWallet )..setInt16(0, operatorReward, Endian.little)).buffer.asUint8List(), ); - // scriptPayout (variable) - if (!cryptoCurrency.validateAddress(payoutAddress)) { - throw Exception("Invalid payout address: $payoutAddress"); + // scriptPayout (variable) — must be P2PKH or P2SH per Firo consensus + final payoutType = cryptoCurrency.getAddressType(payoutAddress); + final Uint8List payoutScriptBytes; + if (payoutType == AddressType.p2pkh) { + final payoutHash = base58Decode(payoutAddress).sublist(1); + payoutScriptBytes = P2PKH.fromHash(payoutHash).script.compiled; + } else if (payoutType == AddressType.p2sh) { + final payoutHash = base58Decode(payoutAddress).sublist(1); + payoutScriptBytes = Uint8List.fromList([ + 0xa9, // OP_HASH160 + 0x14, // push 20 bytes + ...payoutHash, + 0x87, // OP_EQUAL + ]); + } else { + throw Exception( + "Payout address must be a transparent P2PKH or P2SH address, " + "not a Spark or other address type.", + ); } - final payoutAddressScript = P2PKH.fromHash( - base58Decode(payoutAddress).sublist(1), - ); - final payoutAddressScriptLength = - payoutAddressScript.script.compiled.length; - assert(payoutAddressScriptLength < 253); - registrationTx.addByte(payoutAddressScriptLength); - registrationTx.add(payoutAddressScript.script.compiled); + assert(payoutScriptBytes.length < 253); + registrationTx.addByte(payoutScriptBytes.length); + registrationTx.add(payoutScriptBytes); + + // --- coin selection for fee inputs only (exclude collateral UTXO) --- + final allUtxos = await mainDB.getUTXOs(walletId).findAll(); + final feeUtxos = allUtxos + .where( + (u) => + !(u.txid == collateralTxid && u.vout == collateralVout) && + !u.isBlocked && + u.used != true && + u.isConfirmed( + currentChainHeight, + cryptoCurrency.minConfirms, + cryptoCurrency.minCoinbaseConfirms, + ), + ) + .map((e) => StandardInput(e) as BaseInput) + .toList(); final partialTxData = TxData( - // nVersion: 3, nType: 1 (TRANSACTION_PROVIDER_REGISTER) overrideVersion: 3 + (1 << 16), - // coinSelection fee calculation uses a heuristic that doesn't know about - // vExtraData, so we'll just use a really big fee to make sure the - // transaction confirms. feeRateAmount: cryptoCurrency.defaultFeeRate * BigInt.from(10), recipients: [ TxRecipient( - address: collateralAddress.value, + address: ownerAddress.value, addressType: AddressType.p2pkh, - amount: Amount.fromDecimal( - kMasterNodeValue, - fractionDigits: cryptoCurrency.fractionDigits, - ), + amount: cryptoCurrency.dustLimit, isChange: false, ), ], @@ -1106,16 +1164,19 @@ class FiroWallet extends Bip39HDWallet final partialTx = await coinSelection( txData: partialTxData, + // Use non-coin-control mode so unavailable UTXOs are filtered out + // instead of causing a hard failure when any candidate is blocked + // or not yet spendable. coinControl: false, isSendAll: false, isSendAllCoinControlUtxos: false, + utxos: feeUtxos, ); - // Calculate inputsHash (32 bytes). + // inputsHash (SHA256d of serialized inputs) final inputsHashInput = BytesBuilder(); for (final input in partialTx.usedUTXOs!) { final standardInput = input as StandardInput; - // we reverse the txid bytes because fuck it, why not. final reversedTxidBytes = standardInput .utxo .txid @@ -1133,10 +1194,43 @@ class FiroWallet extends Bip39HDWallet final inputsHashHash = crypto.sha256.convert(inputsHash).bytes; registrationTx.add(inputsHashHash); - // vchSig is a variable length field that we need iff the collateral is - // NOT in the same transaction, but for us it is. - registrationTx.addByte(0); + // --- payload hash & signature for external collateral --- + // SerializeHash(proRegTx) with SER_GETHASH excludes vchSig. + // The bytes built so far ARE the payload without vchSig. + final payloadForHash = registrationTx.toBytes(); + final payloadHash = crypto.sha256 + .convert(crypto.sha256.convert(payloadForHash).bytes) + .bytes; + // uint256::ToString() outputs bytes in reversed order + final payloadHashHex = payloadHash.reversed + .map((b) => b.toRadixString(16).padLeft(2, '0')) + .join(); + + // MakeSignString format from Firo's providertx.cpp + final signString = + '$payoutAddress|$operatorReward|${ownerAddress.value}' + '|$effectiveVotingAddress|$payloadHashHex'; + + // Sign with the collateral private key + final root = await getRootHDNode(); + final collateralKeyPair = root.derivePath( + collateralAddr.derivationPath!.value, + ); + final signed = MessageSignature.sign( + key: collateralKeyPair.privateKey, + message: signString, + prefix: firoMessagePrefixForCoinlibSign( + cryptoCurrency.networkParams.messagePrefix, + ), + ); + // vchSig — compact-size length + 65-byte compact signature + final vchSig = signed.signature.compact; + assert(vchSig.length == 65); + registrationTx.addByte(vchSig.length); + registrationTx.add(vchSig); + + // --- build, sign, and broadcast --- final finalTxData = partialTx.copyWith( vExtraData: registrationTx.toBytes(), ); @@ -1146,7 +1240,12 @@ class FiroWallet extends Bip39HDWallet ); final finalTransactionHex = finalTx.raw!; - assert(finalTransactionHex.contains(registrationTx.toBytes().toHex)); + assert( + finalTransactionHex.toLowerCase().contains( + registrationTx.toBytes().toHex.toLowerCase(), + ), + 'ProReg payload missing from signed transaction hex', + ); final broadcastedTxHash = await electrumXClient.broadcastTransaction( rawTx: finalTransactionHex, @@ -1213,43 +1312,66 @@ class FiroWallet extends Bip39HDWallet } Future> getMyMasternodeProTxHashes() async { - // - This registers only masternodes which have collateral in the same - // transaction. - // - If this seed is shared with firod or such and a masternode is created - // there, it will probably not appear here - // because that doesn't put collateral in the protx tx. - // - An exactly 1000 FIRO vout will show up here even if it's not a - // masternode collateral. This will just log an - // info in getMyMasternodes. - // - If this wallet created a masternode not owned by this wallet it will - // erroneously be emitted here and actually - // shown to the user as our own masternode, but this is contrived and - // nothing actually produces transactions like - // that. - - // utxos are UNSPENT txos, so broken masternodes will not show up here by - // design. - final utxos = await mainDB.getUTXOs(walletId).sortByBlockHeight().findAll(); - final List r = []; + final Set collateralTxids = {}; + final Set resolvedCollateralTxids = {}; + final utxos = await mainDB.getUTXOs(walletId).sortByBlockHeight().findAll(); final rawMasterNodeAmount = Amount.fromDecimal( kMasterNodeValue, fractionDigits: cryptoCurrency.fractionDigits, ).raw.toInt(); for (final utxo in utxos) { - if (utxo.value != rawMasterNodeAmount) { - continue; + if (utxo.value == rawMasterNodeAmount) { + collateralTxids.add(utxo.txid); } + } - // A duplicate could occur if a protx transaction has a non-collateral - // 1000 FIRO vout. - if (r.contains(utxo.txid)) { - continue; + if (collateralTxids.isNotEmpty) { + try { + final walletTxids = await mainDB.isar.transactionV2s + .where() + .walletIdEqualTo(walletId) + .txidProperty() + .findAll(); + + if (walletTxids.isNotEmpty) { + final txs = await electrumXCachedClient.getBatchTransactions( + txHashes: walletTxids.toSet().toList(growable: false), + cryptoCurrency: cryptoCurrency, + ); + + for (final tx in txs) { + final txid = tx["txid"]?.toString(); + final version = tx["version"]; + final type = tx["type"]; + final proReg = tx["proReg"]; + if (txid == null || version != 3 || type != 1 || proReg is! Map) { + continue; + } + + final proRegMap = Map.from(proReg); + final collateralHash = proRegMap["collateralHash"]?.toString(); + if (collateralHash != null && + collateralTxids.contains(collateralHash) && + !r.contains(txid)) { + r.add(txid); + resolvedCollateralTxids.add(collateralHash); + } + } + } + } catch (e) { + Logging.instance.i( + "Failed to resolve proTx hashes from wallet tx history: $e", + ); } + } - r.add(utxo.txid); + for (final txid in collateralTxids) { + if (!resolvedCollateralTxids.contains(txid)) { + r.add(txid); + } } return r; diff --git a/lib/wallets/wallet/wallet_mixin_interfaces/spark_interface.dart b/lib/wallets/wallet/wallet_mixin_interfaces/spark_interface.dart index 0dec8aab2..80e19de81 100644 --- a/lib/wallets/wallet/wallet_mixin_interfaces/spark_interface.dart +++ b/lib/wallets/wallet/wallet_mixin_interfaces/spark_interface.dart @@ -394,7 +394,7 @@ mixin SparkInterface Future
generateNextSparkAddress({required bool saveToDB}) async { final currentDiversifier = - (await getCurrentReceivingAddress())?.derivationIndex; + (await getCurrentReceivingSparkAddress())?.derivationIndex; // if current is null, start at index 1 int diversifier = (currentDiversifier ?? 0) + 1; if (diversifier == libSpark.sparkChange) { diff --git a/pubspec.lock b/pubspec.lock index 970c6d5f6..b321a3354 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -285,10 +285,10 @@ packages: dependency: transitive description: name: characters - sha256: f71061c654a3380576a52b451dd5532377954cf9dbd272a78fc8479606670803 + sha256: faf38497bda5ead2a8c7615f4f7939df04333478bf32e4173fcb06d428b5716b url: "https://pub.dev" source: hosted - version: "1.4.0" + version: "1.4.1" charcode: dependency: transitive description: @@ -1578,18 +1578,18 @@ packages: dependency: transitive description: name: matcher - sha256: dc58c723c3c24bf8d3e2d3ad3f2f9d7bd9cf43ec6feaa64181775e60190153f2 + sha256: dc0b7dc7651697ea4ff3e69ef44b0407ea32c487a39fff6a4004fa585e901861 url: "https://pub.dev" source: hosted - version: "0.12.17" + version: "0.12.19" material_color_utilities: dependency: transitive description: name: material_color_utilities - sha256: f7142bb1154231d7ea5f96bc7bde4bda2a0945d2806bb11670e30b850d56bdec + sha256: "9c337007e82b1889149c82ed242ed1cb24a66044e30979c44912381e9be4c48b" url: "https://pub.dev" source: hosted - version: "0.11.1" + version: "0.13.0" memoize: dependency: transitive description: @@ -2276,26 +2276,26 @@ packages: dependency: transitive description: name: test - sha256: "75906bf273541b676716d1ca7627a17e4c4070a3a16272b7a3dc7da3b9f3f6b7" + sha256: "280d6d890011ca966ad08df7e8a4ddfab0fb3aa49f96ed6de56e3521347a9ae7" url: "https://pub.dev" source: hosted - version: "1.26.3" + version: "1.30.0" test_api: dependency: transitive description: name: test_api - sha256: ab2726c1a94d3176a45960b6234466ec367179b87dd74f1611adb1f3b5fb9d55 + sha256: "8161c84903fd860b26bfdefb7963b3f0b68fee7adea0f59ef805ecca346f0c7a" url: "https://pub.dev" source: hosted - version: "0.7.7" + version: "0.7.10" test_core: dependency: transitive description: name: test_core - sha256: "0cc24b5ff94b38d2ae73e1eb43cc302b77964fbf67abad1e296025b78deb53d0" + sha256: "0381bd1585d1a924763c308100f2138205252fb90c9d4eeaf28489ee65ccde51" url: "https://pub.dev" source: hosted - version: "0.6.12" + version: "0.6.16" tezart: dependency: "direct main" description: