diff --git a/.gitignore b/.gitignore index 1b5b2b46cc..be1910e115 100644 --- a/.gitignore +++ b/.gitignore @@ -74,6 +74,8 @@ secp256k1.dll ## other generated project files pubspec.yaml +!crypto_plugins/firo_bls_dart/**/pubspec.yaml +crypto_plugins/firo_bls_dart/**/target/ /android/app/build.gradle /android/app/src/debug/AndroidManifest.xml diff --git a/lib/pages/masternodes/masternodes_home_view.dart b/lib/pages/masternodes/masternodes_home_view.dart index f6dd1caeeb..ac506d228f 100644 --- a/lib/pages/masternodes/masternodes_home_view.dart +++ b/lib/pages/masternodes/masternodes_home_view.dart @@ -34,6 +34,7 @@ import '../../widgets/stack_dialog.dart'; import '../send_view/send_view.dart'; import 'create_masternode_view.dart'; import 'sub_widgets/masternodes_list.dart'; +import 'sub_widgets/unban_masternode_form.dart'; class MasternodesHomeView extends ConsumerStatefulWidget { const MasternodesHomeView({super.key, required this.walletId}); @@ -530,9 +531,9 @@ class _MasternodesHomeViewState extends ConsumerState { width: Util.isDesktop ? 580 : null, padding: .all(Util.isDesktop ? 32 : 24), leftButton: TextButton( - style: Theme.of( - ctx, - ).extension()!.getSecondaryEnabledButtonStyle(ctx), + style: Theme.of(ctx) + .extension()! + .getSecondaryEnabledButtonStyle(ctx), child: Text( "Later", style: STextStyles.button(ctx).copyWith( @@ -542,9 +543,9 @@ class _MasternodesHomeViewState extends ConsumerState { onPressed: () => Navigator.of(ctx).pop(false), ), rightButton: TextButton( - style: Theme.of( - ctx, - ).extension()!.getPrimaryEnabledButtonStyle(ctx), + style: Theme.of(ctx) + .extension()! + .getPrimaryEnabledButtonStyle(ctx), child: Text("Register", style: STextStyles.button(ctx)), onPressed: () => Navigator.of(ctx).pop(true), ), @@ -600,6 +601,53 @@ class _MasternodesHomeViewState extends ConsumerState { } } + Future _showUnbanForm([MasternodeInfo? node]) async { + await showDialog( + context: context, + barrierDismissible: false, + builder: (dialogContext) => AnimatedPadding( + padding: EdgeInsets.only( + bottom: Util.isDesktop + ? 0 + : MediaQuery.viewInsetsOf(dialogContext).bottom, + ), + duration: const Duration(milliseconds: 180), + curve: Curves.easeOut, + child: SDialog( + contentCanScroll: true, + padding: EdgeInsets.all(Util.isDesktop ? 24 : 20), + child: SizedBox( + width: Util.isDesktop ? 560 : null, + child: UnbanMasternodeForm( + walletId: widget.walletId, + node: node, + onSuccess: (txid) { + Navigator.of(dialogContext).pop(); + if (!mounted) return; + setState(() => _masternodesFuture = _fetchMasternodes()); + unawaited( + showDialog( + context: context, + builder: (_) => StackOkDialog( + title: "Masternode Unban Submitted", + message: + "Masternode service update submitted. The masternode " + "will become active after the transaction is " + "confirmed.\n\n" + "Transaction ID: $txid", + desktopPopRootNavigator: Util.isDesktop, + maxWidth: Util.isDesktop ? 440 : null, + ), + ), + ); + }, + ), + ), + ), + ), + ); + } + @override void initState() { super.initState(); @@ -627,18 +675,18 @@ class _MasternodesHomeViewState extends ConsumerState { padding: const EdgeInsets.only(left: 24, right: 20), child: AppBarIconButton( size: 32, - color: Theme.of( - context, - ).extension()!.textFieldDefaultBG, + color: Theme.of(context) + .extension()! + .textFieldDefaultBG, shadows: const [], icon: SvgPicture.asset( Assets.svg.arrowLeft, width: 18, height: 18, colorFilter: ColorFilter.mode( - Theme.of( - context, - ).extension()!.topNavIconPrimary, + Theme.of(context) + .extension()! + .topNavIconPrimary, BlendMode.srcIn, ), ), @@ -660,20 +708,31 @@ class _MasternodesHomeViewState extends ConsumerState { ), trailing: Padding( padding: const EdgeInsets.only(right: 24), - child: PrimaryButton( - label: "Create Masternode", - buttonHeight: .l, - horizontalContentPadding: 10, - icon: SvgPicture.asset( - Assets.svg.circlePlus, - colorFilter: ColorFilter.mode( - Theme.of( - context, - ).extension()!.buttonTextPrimary, - .srcIn, + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + SecondaryButton( + label: "Unban by ProTx hash", + buttonHeight: .l, + onPressed: () => _showUnbanForm(), ), - ), - onPressed: _createMasternode, + const SizedBox(width: 12), + PrimaryButton( + label: "Create Masternode", + buttonHeight: .l, + horizontalContentPadding: 10, + icon: SvgPicture.asset( + Assets.svg.circlePlus, + colorFilter: ColorFilter.mode( + Theme.of(context) + .extension()! + .buttonTextPrimary, + .srcIn, + ), + ), + onPressed: _createMasternode, + ), + ], ), ), ) @@ -688,6 +747,11 @@ class _MasternodesHomeViewState extends ConsumerState { overflow: TextOverflow.ellipsis, ), actions: [ + IconButton( + tooltip: "Unban masternode by ProTx hash", + onPressed: () => _showUnbanForm(), + icon: const Icon(Icons.restore), + ), Padding( padding: const EdgeInsets.only( top: 10, @@ -700,15 +764,15 @@ class _MasternodesHomeViewState extends ConsumerState { key: const Key("createNewMasterNodeButton"), size: 36, shadows: const [], - color: Theme.of( - context, - ).extension()!.background, + color: Theme.of(context) + .extension()! + .background, icon: SvgPicture.asset( Assets.svg.plus, colorFilter: ColorFilter.mode( - Theme.of( - context, - ).extension()!.accentColorDark, + Theme.of(context) + .extension()! + .accentColorDark, .srcIn, ), width: 20, @@ -743,7 +807,7 @@ class _MasternodesHomeViewState extends ConsumerState { ); } - return MasternodesList(nodes: nodes); + return MasternodesList(nodes: nodes, onUnban: _showUnbanForm); }, ), ); diff --git a/lib/pages/masternodes/sub_widgets/masternodes_list.dart b/lib/pages/masternodes/sub_widgets/masternodes_list.dart index 085ac2412a..00577ad62a 100644 --- a/lib/pages/masternodes/sub_widgets/masternodes_list.dart +++ b/lib/pages/masternodes/sub_widgets/masternodes_list.dart @@ -5,15 +5,17 @@ import '../../../utilities/text_styles.dart'; import '../../../utilities/util.dart'; import '../../../wallets/wallet/impl/firo_wallet.dart'; import '../../../widgets/desktop/desktop_dialog_close_button.dart'; +import '../../../widgets/desktop/secondary_button.dart'; import '../../../widgets/dialogs/s_dialog.dart'; import '../../../widgets/rounded_white_container.dart'; import '../masternode_details_view.dart'; import 'masternode_info_widget.dart'; class MasternodesList extends StatelessWidget { - const MasternodesList({super.key, required this.nodes}); + const MasternodesList({super.key, required this.nodes, this.onUnban}); final List nodes; + final Future Function(MasternodeInfo node)? onUnban; @override Widget build(BuildContext context) { @@ -21,15 +23,17 @@ class MasternodesList extends StatelessWidget { padding: EdgeInsets.all(Util.isDesktop ? 24 : 16), itemCount: nodes.length, separatorBuilder: (_, __) => const SizedBox(height: 8), - itemBuilder: (_, index) => _MasternodeCard(node: nodes[index]), + itemBuilder: (_, index) => + _MasternodeCard(node: nodes[index], onUnban: onUnban), ); } } class _MasternodeCard extends StatelessWidget { - const _MasternodeCard({required this.node}); + const _MasternodeCard({required this.node, required this.onUnban}); final MasternodeInfo node; + final Future Function(MasternodeInfo node)? onUnban; Future _showDetails(BuildContext context) async { if (Util.isDesktop) { @@ -81,42 +85,69 @@ class _MasternodeCard extends StatelessWidget { return RoundedWhiteContainer( padding: const EdgeInsets.all(16), onPressed: () => _showDetails(context), - child: Row( + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, children: [ - Expanded( - child: Column( - crossAxisAlignment: .start, - mainAxisSize: .min, - children: [ - Text( - "${node.serviceAddr}:${node.servicePort}", - style: STextStyles.titleBold12(context), - overflow: TextOverflow.ellipsis, + Row( + children: [ + Expanded( + child: Column( + crossAxisAlignment: .start, + mainAxisSize: .min, + children: [ + Text( + "${node.serviceAddr}:${node.servicePort}", + style: STextStyles.titleBold12(context), + overflow: TextOverflow.ellipsis, + ), + const SizedBox(height: 2), + Text( + "Last paid height: ${node.lastPaidHeight}", + style: STextStyles.baseXS(context) + .copyWith(color: stack.textSubtitle1), + ), + ], ), - const SizedBox(height: 2), - Text( - "Last paid height: ${node.lastPaidHeight}", - style: STextStyles.baseXS(context) - .copyWith(color: stack.textSubtitle1), + ), + const SizedBox(width: 12), + IntrinsicWidth( + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Container( + padding: const EdgeInsets.symmetric( + horizontal: 8, + vertical: 4, + ), + decoration: BoxDecoration( + color: switch (status) { + MasternodeStatus.active => stack.accentColorGreen, + MasternodeStatus.banned => stack.accentColorRed, + }, + borderRadius: BorderRadius.circular(8), + ), + child: Text( + status.label, + textAlign: TextAlign.center, + style: STextStyles.w600_12(context) + .copyWith(color: stack.textWhite), + ), + ), + if (status == MasternodeStatus.banned && + node.poseBanHeight != -1 && + node.revocationReason == 0 && + onUnban != null) ...[ + const SizedBox(height: 8), + SecondaryButton( + label: "Unban", + onPressed: () => onUnban!(node), + buttonHeight: Util.isDesktop ? .xs : .xl, + ), + ], + ], ), - ], - ), - ), - const SizedBox(width: 12), - Container( - padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4), - decoration: BoxDecoration( - color: switch (status) { - MasternodeStatus.active => stack.accentColorGreen, - MasternodeStatus.banned => stack.accentColorRed, - }, - borderRadius: BorderRadius.circular(8), - ), - child: Text( - status.label, - style: STextStyles.w600_12(context) - .copyWith(color: stack.textWhite), - ), + ), + ], ), ], ), diff --git a/lib/pages/masternodes/sub_widgets/unban_masternode_form.dart b/lib/pages/masternodes/sub_widgets/unban_masternode_form.dart new file mode 100644 index 0000000000..627726b44e --- /dev/null +++ b/lib/pages/masternodes/sub_widgets/unban_masternode_form.dart @@ -0,0 +1,254 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +import '../../../providers/global/wallets_provider.dart'; +import '../../../themes/stack_colors.dart'; +import '../../../utilities/if_not_already.dart'; +import '../../../utilities/show_loading.dart'; +import '../../../utilities/text_styles.dart'; +import '../../../utilities/util.dart'; +import '../../../wallets/wallet/impl/firo_wallet.dart'; +import '../../../widgets/conditional_parent.dart'; +import '../../../widgets/desktop/primary_button.dart'; +import '../../../widgets/desktop/secondary_button.dart'; +import '../../../widgets/desktop/desktop_dialog_close_button.dart'; +import '../../../widgets/stack_dialog.dart'; +import '../../../widgets/textfields/adaptive_text_field.dart'; + +class UnbanMasternodeForm extends ConsumerStatefulWidget { + const UnbanMasternodeForm({ + super.key, + required this.walletId, + this.node, + required this.onSuccess, + }); + + final String walletId; + final MasternodeInfo? node; + final void Function(String txid) onSuccess; + + @override + ConsumerState createState() => + _UnbanMasternodeFormState(); +} + +class _UnbanMasternodeFormState extends ConsumerState { + late final TextEditingController _serviceController; + late final TextEditingController _proTxHashController; + final _operatorKeyController = TextEditingController(); + final _feeAddressController = TextEditingController(); + late final IfNotAlreadyAsync _submit; + bool _canSubmit = false; + + @override + void initState() { + super.initState(); + _proTxHashController = TextEditingController( + text: widget.node?.proTxHash ?? "", + ); + _serviceController = TextEditingController( + text: widget.node == null + ? "" + : widget.node!.serviceAddr.contains(":") + ? "[${widget.node!.serviceAddr}]:${widget.node!.servicePort}" + : "${widget.node!.serviceAddr}:${widget.node!.servicePort}", + ); + _submit = IfNotAlreadyAsync(_submitUnban); + _validate(); + } + + @override + void dispose() { + _serviceController.dispose(); + _proTxHashController.dispose(); + _operatorKeyController.dispose(); + _feeAddressController.dispose(); + super.dispose(); + } + + void _validate() { + if (!mounted) return; + setState(() { + _canSubmit = + RegExp(r'^[0-9a-fA-F]{64}$') + .hasMatch(_proTxHashController.text.trim()) && + _serviceController.text.trim().isNotEmpty && + RegExp(r'^[0-9a-fA-F]{64}$') + .hasMatch(_operatorKeyController.text.trim()); + }); + } + + Future _submitUnban() async { + final privateKey = _operatorKeyController.text.trim(); + final proTxHash = _proTxHashController.text.trim(); + final service = _serviceController.text.trim(); + final feeAddress = _feeAddressController.text.trim(); + FocusScope.of(context).unfocus(); + _operatorKeyController.clear(); + + Exception? error; + final txid = await showLoading( + whileFutureAlt: () async { + final wallet = + ref.read(pWallets).getWallet(widget.walletId) as FiroWallet; + return wallet.unbanMasternode( + proTxHash: proTxHash, + ipAndPort: service, + operatorPrivateKey: privateKey, + feeSourceAddress: feeAddress.isEmpty ? null : feeAddress, + ); + }, + context: context, + rootNavigator: Util.isDesktop, + message: "Signing and submitting the masternode unban transaction...", + delay: const Duration(seconds: 1), + onException: (e) => error = e, + ); + + if (!mounted) return; + if (error != null || txid == null) { + var message = + error?.toString().trim() ?? "Could not submit the transaction."; + const prefix = "Exception:"; + while (message.startsWith(prefix)) { + message = message.substring(prefix.length).trim(); + } + await showDialog( + context: context, + builder: (context) => StackDialog( + title: "Masternode unban failed", + message: message, + width: Util.isDesktop ? 520 : null, + rightButton: PrimaryButton( + label: "OK", + onPressed: Navigator.of(context).pop, + ), + ), + ); + return; + } + widget.onSuccess(txid); + } + + TextStyle _labelStyle(BuildContext context) => Util.isDesktop + ? STextStyles.desktopTextExtraExtraSmall(context).copyWith( + color: Theme.of(context) + .extension()! + .textFieldActiveSearchIconRight, + ) + : STextStyles.smallMed12(context); + + @override + Widget build(BuildContext context) { + final stack = Theme.of(context).extension()!; + final spacing = Util.isDesktop ? 16.0 : 12.0; + return Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Row( + children: [ + Expanded( + child: Text( + "Unban masternode", + style: Util.isDesktop + ? STextStyles.desktopH3(context) + : STextStyles.pageTitleH2(context), + ), + ), + if (Util.isDesktop) const DesktopDialogCloseButton(), + ], + ), + SizedBox(height: spacing), + SelectableText("ProTx hash", style: _labelStyle(context)), + const SizedBox(height: 6), + AdaptiveTextField( + controller: _proTxHashController, + showPasteClearButton: true, + maxLines: 1, + keyboardType: TextInputType.visiblePassword, + readOnly: widget.node != null, + onChangedComprehensive: (_) => _validate(), + ), + SizedBox(height: spacing), + SelectableText("Masternode IP:Port", style: _labelStyle(context)), + const SizedBox(height: 6), + AdaptiveTextField( + controller: _serviceController, + showPasteClearButton: true, + maxLines: 1, + onChangedComprehensive: (_) => _validate(), + ), + SizedBox(height: spacing), + SelectableText("Operator BLS private key", style: _labelStyle(context)), + const SizedBox(height: 6), + AdaptiveTextField( + controller: _operatorKeyController, + showPasteClearButton: true, + maxLines: 1, + obscureText: true, + autocorrect: false, + enableSuggestions: false, + keyboardType: TextInputType.visiblePassword, + onChangedComprehensive: (_) => _validate(), + ), + const SizedBox(height: 4), + Text( + "The key is used on this device to sign the transaction.", + style: STextStyles.baseXS(context) + .copyWith(color: stack.textSubtitle1), + ), + SizedBox(height: spacing), + SelectableText( + "Fee source address (optional)", + style: _labelStyle(context), + ), + const SizedBox(height: 6), + AdaptiveTextField( + controller: _feeAddressController, + showPasteClearButton: true, + maxLines: 1, + labelText: "Use any confirmed transparent FIRO in this wallet", + ), + SizedBox(height: Util.isDesktop ? 18 : 16), + Text( + "This submits a network transaction and uses FIRO from your wallet " + "for the fee. Make sure the masternode service is running at this " + "address. You can enter a ProTx hash registered outside Stack.", + style: STextStyles.baseXS(context) + .copyWith(color: stack.textSubtitle1), + ), + SizedBox(height: Util.isDesktop ? 20 : 16), + ConditionalParent( + condition: Util.isDesktop, + builder: (child) => Row( + children: [ + Expanded( + child: SecondaryButton( + label: "Cancel", + onPressed: Navigator.of(context).pop, + buttonHeight: .l, + ), + ), + const SizedBox(width: 16), + Expanded(child: child), + ], + ), + child: PrimaryButton( + label: "Unban masternode", + enabled: _canSubmit, + onPressed: _canSubmit ? () => _submit.execute() : null, + buttonHeight: Util.isDesktop ? .l : null, + ), + ), + if (!Util.isDesktop) ...[ + const SizedBox(height: 8), + SecondaryButton( + label: "Cancel", + onPressed: Navigator.of(context).pop, + ), + ], + ], + ); + } +} diff --git a/lib/utilities/firo_legacy_bls.dart b/lib/utilities/firo_legacy_bls.dart new file mode 100644 index 0000000000..e69136a928 --- /dev/null +++ b/lib/utilities/firo_legacy_bls.dart @@ -0,0 +1,58 @@ +import 'dart:typed_data'; + +import 'package:bls_dart/bls_dart.dart'; + +abstract final class FiroLegacyBls { + static Future? _initialization; + + static Future _ensureInitialized() => + _initialization ??= RustLib.init(); + + static Uint8List _decodeHex(String keyHex) { + final normalized = keyHex.trim().replaceFirst(RegExp(r'^0x'), ''); + if (normalized.length != 64 || + !RegExp(r'^[0-9a-fA-F]{64}$').hasMatch(normalized)) { + throw const FormatException( + 'Operator BLS private key must be 64 hexadecimal characters.', + ); + } + return Uint8List.fromList([ + for (var i = 0; i < normalized.length; i += 2) + int.parse(normalized.substring(i, i + 2), radix: 16), + ]); + } + + static Future publicKeyFromPrivateKey(String keyHex) async { + await _ensureInitialized(); + final key = _decodeHex(keyHex); + try { + final publicKey = firoLegacyBlsPublicKey(secretKey: key); + if (publicKey.length != 48) { + throw StateError('BLS library returned an invalid public key.'); + } + return publicKey; + } finally { + key.fillRange(0, key.length, 0); + } + } + + static Future signDigest({ + required String privateKeyHex, + required Uint8List digest, + }) async { + if (digest.length != 32) { + throw ArgumentError.value(digest.length, 'digest.length', 'must be 32'); + } + await _ensureInitialized(); + final key = _decodeHex(privateKeyHex); + try { + final signature = firoLegacyBlsSignDigest(secretKey: key, digest: digest); + if (signature.length != 96) { + throw StateError('BLS library returned an invalid signature.'); + } + return signature; + } finally { + key.fillRange(0, key.length, 0); + } + } +} diff --git a/lib/wallets/wallet/impl/firo_wallet.dart b/lib/wallets/wallet/impl/firo_wallet.dart index 338551547d..4e68347dc7 100644 --- a/lib/wallets/wallet/impl/firo_wallet.dart +++ b/lib/wallets/wallet/impl/firo_wallet.dart @@ -17,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_legacy_bls.dart'; import '../../../utilities/firo_pro_reg_signed_message_prefix.dart'; import '../../../utilities/logger.dart'; import '../../../utilities/util.dart'; @@ -59,6 +60,7 @@ class MasternodeInfo { final String votingAddress; final String payoutAddress; final String pubKeyOperator; + final String operatorPayoutAddress; MasternodeInfo({ required this.proTxHash, @@ -78,6 +80,7 @@ class MasternodeInfo { required this.votingAddress, required this.payoutAddress, required this.pubKeyOperator, + this.operatorPayoutAddress = "", }); MasternodeStatus get status { @@ -98,6 +101,8 @@ class MasternodeInfo { "Owner Address": ownerAddress, "Voting Address": votingAddress, "Operator Public Key": pubKeyOperator, + if (operatorPayoutAddress.isNotEmpty) + "Operator Payout Address": operatorPayoutAddress, "Operator Reward": "$operatorReward %", "Collateral Hash": collateralHash, "Collateral Index": collateralIndex.toString(), @@ -1306,6 +1311,296 @@ class FiroWallet extends Bip39HDWallet return broadcastedTxHash; } + Future unbanMasternode({ + required String proTxHash, + required String ipAndPort, + required String operatorPrivateKey, + String? feeSourceAddress, + }) async { + final normalizedProTxHash = proTxHash.trim().toLowerCase(); + if (!RegExp(r'^[0-9a-f]{64}$').hasMatch(normalizedProTxHash)) { + throw Exception("Invalid ProTx hash."); + } + + final endpoint = _parseFiroServiceEndpoint(ipAndPort.trim()); + final info = await electrumXClient.request( + command: 'protx.info', + args: [normalizedProTxHash], + ); + if (info is! Map) { + throw Exception("Could not load masternode information."); + } + final state = Map.from(info["state"] as Map); + final pubKeyOperator = state["pubKeyOperator"]?.toString(); + if (pubKeyOperator == null || pubKeyOperator.length != 96) { + throw Exception("Masternode has an invalid operator public key."); + } + + final poseBanHeight = int.tryParse( + state["PoSeBanHeight"]?.toString() ?? "-1", + ); + final revocationReason = int.tryParse( + state["revocationReason"]?.toString() ?? "0", + ); + if (poseBanHeight == null || revocationReason == null) { + throw Exception("Masternode status data is incomplete."); + } + if (poseBanHeight == -1) { + throw Exception( + revocationReason == 0 + ? "This masternode is not PoSe-banned." + : "This masternode is revoked and cannot be restored with " + "update_service.", + ); + } + + final derivedOperatorPublicKey = + (await FiroLegacyBls.publicKeyFromPrivateKey(operatorPrivateKey)).toHex; + if (derivedOperatorPublicKey.toLowerCase() != + pubKeyOperator.toLowerCase()) { + throw Exception( + "The operator private key does not match this masternode.", + ); + } + + final operatorReward = double.tryParse( + info["operatorReward"]?.toString() ?? "0", + ); + if (operatorReward == null) { + throw Exception("Masternode operator reward data is invalid."); + } + final operatorPayoutAddress = + state["operatorPayoutAddress"]?.toString() ?? ""; + if (operatorReward > 0 && operatorPayoutAddress.isEmpty) { + throw Exception( + "Could not read the current operator payout address. " + "Refusing to update this masternode so its reward settings are kept.", + ); + } + + final operatorPayoutScript = operatorReward == 0 + ? Uint8List(0) + : _transparentAddressScript(operatorPayoutAddress); + + final currentChainHeight = await chainHeight; + final allUtxos = await mainDB.getUTXOs(walletId).findAll(); + final feeUtxos = allUtxos + .where( + (utxo) => + !utxo.isBlocked && + utxo.used != true && + utxo.address != null && + (feeSourceAddress == null || + feeSourceAddress.trim().isEmpty || + utxo.address == feeSourceAddress.trim()) && + utxo.isConfirmed( + currentChainHeight, + cryptoCurrency.minConfirms, + cryptoCurrency.minCoinbaseConfirms, + ), + ) + .map((utxo) => StandardInput(utxo) as BaseInput) + .toList(); + if (feeUtxos.isEmpty) { + throw Exception( + feeSourceAddress == null || feeSourceAddress.trim().isEmpty + ? "No confirmed transparent FIRO is available to pay the " + "network fee." + : "No confirmed spendable FIRO was found at the fee source address.", + ); + } + final feeReturnAddress = feeSourceAddress?.trim().isNotEmpty == true + ? feeSourceAddress!.trim() + : (feeUtxos.first as StandardInput).utxo.address!; + final feeAddress = await mainDB + .getAddresses(walletId) + .filter() + .valueEqualTo(feeReturnAddress) + .findFirst(); + if (feeAddress == null || + !cryptoCurrency.validateAddress(feeReturnAddress) || + cryptoCurrency.getAddressType(feeReturnAddress) != AddressType.p2pkh) { + throw Exception( + "The fee source must be a transparent P2PKH address in this wallet.", + ); + } + + final partialTx = await coinSelection( + txData: TxData( + overrideVersion: 3 + (2 << 16), + feeRateAmount: cryptoCurrency.defaultFeeRate * BigInt.from(10), + recipients: [ + TxRecipient( + address: feeReturnAddress, + addressType: AddressType.p2pkh, + amount: cryptoCurrency.dustLimit, + isChange: false, + ), + ], + ), + coinControl: false, + isSendAll: false, + isSendAllCoinControlUtxos: false, + utxos: feeUtxos, + ); + + final inputsHashInput = BytesBuilder(); + for (final input in partialTx.usedUTXOs!) { + final utxo = (input as StandardInput).utxo; + final txidBytes = utxo.txid.toUint8ListFromHex.reversed.toList(); + if (txidBytes.length != 32) { + throw Exception("Invalid fee transaction ID: ${utxo.txid}"); + } + inputsHashInput.add(txidBytes); + inputsHashInput.add( + (ByteData( + 4, + )..setUint32(0, utxo.vout, Endian.little)).buffer.asUint8List(), + ); + } + final inputsHash = crypto.sha256 + .convert(crypto.sha256.convert(inputsHashInput.toBytes()).bytes) + .bytes; + + final payload = BytesBuilder(); + payload.add( + (ByteData(2)..setUint16(0, 1, Endian.little)).buffer.asUint8List(), + ); + payload.add(normalizedProTxHash.toUint8ListFromHex.reversed.toList()); + payload.add(endpoint.ipBytes); + payload.add( + (ByteData( + 2, + )..setUint16(0, endpoint.port, Endian.big)).buffer.asUint8List(), + ); + payload.addByte(operatorPayoutScript.length); + payload.add(operatorPayoutScript); + payload.add(inputsHash); + + final payloadWithoutSignature = payload.toBytes(); + final payloadDigest = crypto.sha256 + .convert(crypto.sha256.convert(payloadWithoutSignature).bytes) + .bytes; + final signature = await FiroLegacyBls.signDigest( + privateKeyHex: operatorPrivateKey, + digest: Uint8List.fromList(payloadDigest), + ); + payload.add(signature); + + final finalTx = await buildTransaction( + txData: partialTx.copyWith(vExtraData: payload.toBytes()), + inputsWithKeys: partialTx.usedUTXOs!, + ); + final rawTx = finalTx.raw!; + if (!rawTx.toLowerCase().contains(payload.toBytes().toHex.toLowerCase())) { + throw Exception( + "ProUpServTx payload is missing from the signed transaction.", + ); + } + final txid = await electrumXClient.broadcastTransaction(rawTx: rawTx); + if (txid.toUint8ListFromHex.length != 32) { + throw Exception( + "Failed to broadcast masternode unban transaction: $txid", + ); + } + await updateSentCachedTxData(txData: finalTx); + return txid; + } + + Uint8List _transparentAddressScript(String address) { + if (!cryptoCurrency.validateAddress(address)) { + throw Exception("Invalid operator payout address."); + } + final type = cryptoCurrency.getAddressType(address); + final hash = base58Decode(address).sublist(1); + if (hash.length != 20) { + throw Exception("Invalid operator payout address."); + } + if (type == AddressType.p2pkh) { + return P2PKH.fromHash(hash).script.compiled; + } + if (type == AddressType.p2sh) { + return Uint8List.fromList([0xa9, 0x14, ...hash, 0x87]); + } + throw Exception("Operator payout address must be transparent P2PKH/P2SH."); + } + + ({Uint8List ipBytes, int port}) _parseFiroServiceEndpoint(String value) { + String host; + String portText; + if (value.startsWith("[")) { + final closeBracket = value.indexOf("]"); + if (closeBracket < 0 || + closeBracket + 1 >= value.length || + value[closeBracket + 1] != ":") { + throw Exception("Enter IPv6 services as [address]:port."); + } + host = value.substring(1, closeBracket); + portText = value.substring(closeBracket + 2); + } else { + final separator = value.lastIndexOf(":"); + if (separator <= 0) { + throw Exception("Enter the masternode service as IP:port."); + } + host = value.substring(0, separator); + portText = value.substring(separator + 1); + if (host.contains(":")) { + throw Exception("Wrap an IPv6 address in brackets: [address]:port."); + } + } + final port = int.tryParse(portText); + if (port == null || port < 1 || port > 65535) { + throw Exception("Service port must be between 1 and 65535."); + } + final ipv4Parts = host.split("."); + if (ipv4Parts.length == 4) { + final octets = ipv4Parts.map(int.tryParse).toList(); + if (octets.any((part) => part == null || part < 0 || part > 255)) { + throw Exception("Invalid IPv4 service address."); + } + return ( + ipBytes: Uint8List.fromList([ + ...List.filled(10, 0), + 0xff, + 0xff, + ...octets.cast(), + ]), + port: port, + ); + } + + final halves = host.split("::"); + if (halves.length > 2 || host.isEmpty) { + throw Exception("Invalid IPv6 service address."); + } + List parseGroups(String half) { + if (half.isEmpty) return const []; + return half.split(":").map((group) { + if (group.isEmpty || + group.length > 4 || + !RegExp(r'^[0-9a-fA-F]+$').hasMatch(group)) { + throw Exception("Invalid IPv6 service address."); + } + return int.parse(group, radix: 16); + }).toList(); + } + + final left = parseGroups(halves.first); + final right = halves.length == 2 ? parseGroups(halves.last) : []; + final zeroCount = 8 - left.length - right.length; + if ((halves.length == 1 && zeroCount != 0) || + (halves.length == 2 && zeroCount < 1)) { + throw Exception("Invalid IPv6 service address."); + } + final groups = [...left, ...List.filled(zeroCount, 0), ...right]; + final bytes = []; + for (final group in groups) { + bytes.add((group >> 8) & 0xff); + bytes.add(group & 0xff); + } + return (ipBytes: Uint8List.fromList(bytes), port: port); + } + Future> getMyMasternodes() async { final proTxHashes = await getMyMasternodeProTxHashes(); @@ -1342,6 +1637,8 @@ class FiroWallet extends Bip39HDWallet votingAddress: info["state"]["votingAddress"] as String, payoutAddress: info["state"]["payoutAddress"] as String, pubKeyOperator: info["state"]["pubKeyOperator"] as String, + operatorPayoutAddress: + info["state"]["operatorPayoutAddress"]?.toString() ?? "", ); } catch (err) { // getMyMasternodeProTxHashes() may give non-masternode txids, so diff --git a/lib/widgets/textfields/adaptive_text_field.dart b/lib/widgets/textfields/adaptive_text_field.dart index 62241fc2c9..903ea9a4d9 100644 --- a/lib/widgets/textfields/adaptive_text_field.dart +++ b/lib/widgets/textfields/adaptive_text_field.dart @@ -23,6 +23,7 @@ class AdaptiveTextField extends StatefulWidget { this.smartQuotesType, this.desktopMed = false, this.readOnly = false, + this.obscureText = false, this.enabled = true, this.enableSuggestions = true, this.onChanged, @@ -57,6 +58,7 @@ class AdaptiveTextField extends StatefulWidget { final int? maxLines; final bool readOnly; + final bool obscureText; final bool enabled; final bool enableSuggestions; @@ -153,6 +155,7 @@ class _AdaptiveTextFieldState extends State { onChanged: widget.onChanged, onTap: widget.onTap, readOnly: widget.readOnly, + obscureText: widget.obscureText, enabled: widget.enabled, autocorrect: widget.autocorrect, smartDashesType: widget.smartDashesType, diff --git a/scripts/app_config/templates/pubspec.template.yaml b/scripts/app_config/templates/pubspec.template.yaml index 6c576862ec..c651f893f1 100644 --- a/scripts/app_config/templates/pubspec.template.yaml +++ b/scripts/app_config/templates/pubspec.template.yaml @@ -44,6 +44,8 @@ dependencies: # %%END_ENABLE_XEL%% # %%ENABLE_FIRO%% +# bls_dart: +# path: crypto_plugins/firo_bls_dart # flutter_libsparkmobile: # git: # url: https://github.com/cypherstack/flutter_libsparkmobile.git @@ -309,6 +311,10 @@ flutter_native_splash: color_dark: "2A2D34" dependency_overrides: +# %%ENABLE_FIRO%% +# rust_lib_bls_dart: +# path: crypto_plugins/firo_bls_dart/rust_builder +# %%END_ENABLE_FIRO%% # required for: native_toolchain_rust >=1.0.3 depends on toml ^0.18.0 # stellar sdk package needs to get 0.18.0 to remove this override toml: ^0.18.0