From 4249f1f78901977e93f82155253bd3b309513cf1 Mon Sep 17 00:00:00 2001 From: cassandras-lies <203535133+cassandras-lies@users.noreply.github.com> Date: Fri, 9 Jan 2026 05:53:34 +0000 Subject: [PATCH 1/3] DO NOT MERGE. Use an overridden coinlib from cassandras-lies. --- scripts/app_config/templates/pubspec.template.yaml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/scripts/app_config/templates/pubspec.template.yaml b/scripts/app_config/templates/pubspec.template.yaml index 16e9259dd..0feb9bf22 100644 --- a/scripts/app_config/templates/pubspec.template.yaml +++ b/scripts/app_config/templates/pubspec.template.yaml @@ -316,9 +316,9 @@ dependency_overrides: # coinlib_flutter requires this coinlib: git: - url: https://www.github.com/julian-CStack/coinlib + url: https://github.com/cassandras-lies/coinlib.git path: coinlib - ref: f90600053a4f149a6153f30057ac7f75c21ab962 + ref: 22778c0899d761f86e1080a0f9e2b4004d2d033e bip47: git: From 22a1015e1d5c10117c57e5d52d32a8df418e1b45 Mon Sep 17 00:00:00 2001 From: cassandras-lies <203535133+cassandras-lies@users.noreply.github.com> Date: Sun, 11 Jan 2026 11:04:58 +0000 Subject: [PATCH 2/3] Make sure (hypothetical, nothing is configured this way) single app testnet wallets work correctly. --- .../address_book_views/address_book_view.dart | 5 +- lib/wallets/crypto_currency/coins/firo.dart | 75 ++++++++++++------- 2 files changed, 50 insertions(+), 30 deletions(-) diff --git a/lib/pages/address_book_views/address_book_view.dart b/lib/pages/address_book_views/address_book_view.dart index 4a873cba6..d2fb19877 100644 --- a/lib/pages/address_book_views/address_book_view.dart +++ b/lib/pages/address_book_views/address_book_view.dart @@ -73,10 +73,7 @@ class _AddressBookViewState extends ConsumerState { } else { ref .read(addressBookFilterProvider) - .addAll( - coins.where((e) => e.network != CryptoCurrencyNetwork.test), - false, - ); + .addAll(coins.where((e) => !e.network.isTestNet), false); } } else { ref.read(addressBookFilterProvider).add(widget.coin!, false); diff --git a/lib/wallets/crypto_currency/coins/firo.dart b/lib/wallets/crypto_currency/coins/firo.dart index ac3ca1cac..a2eea8ff0 100644 --- a/lib/wallets/crypto_currency/coins/firo.dart +++ b/lib/wallets/crypto_currency/coins/firo.dart @@ -14,42 +14,62 @@ import '../interfaces/electrumx_currency_interface.dart'; import '../intermediate/bip39_hd_currency.dart'; class Firo extends Bip39HDCurrency with ElectrumXCurrencyInterface { - Firo(super.network) { - _idMain = "firo"; - _uriScheme = "firo"; + Firo(super.network); + + String get identifier { switch (network) { case CryptoCurrencyNetwork.main: - _id = _idMain; - _name = "Firo"; - _ticker = "FIRO"; + return "firo"; case CryptoCurrencyNetwork.test: - _id = "firoTestNet"; - _name = "tFiro"; - _ticker = "tFIRO"; + return "firoTestNet"; default: throw Exception("Unsupported network: $network"); } } - late final String _id; - @override - String get identifier => _id; - - late final String _idMain; - @override - String get mainNetId => _idMain; + String get ticker { + switch (network) { + case CryptoCurrencyNetwork.main: + return "FIRO"; + case CryptoCurrencyNetwork.test: + return "tFIRO"; + default: + throw Exception("Unsupported network: $network"); + } + } - late final String _name; - @override - String get prettyName => _name; + String get mainNetId { + switch (network) { + case CryptoCurrencyNetwork.main: + return "Firo"; + case CryptoCurrencyNetwork.test: + return "tFiro"; + default: + throw Exception("Unsupported network: $network"); + } + } - late final String _uriScheme; - @override - String get uriScheme => _uriScheme; + String get prettyName { + switch (network) { + case CryptoCurrencyNetwork.main: + return "Firo"; + case CryptoCurrencyNetwork.test: + return "tFiro"; + default: + throw Exception("Unsupported network: $network"); + } + } - late final String _ticker; - @override - String get ticker => _ticker; + String get uriScheme { + switch (network) { + case CryptoCurrencyNetwork.main: + return "firo"; + case CryptoCurrencyNetwork.test: + return "tfiro"; + default: + throw Exception("Unsupported network: $network"); + } + } @override int get minConfirms => 1; @@ -197,7 +217,10 @@ class Firo extends Bip39HDCurrency with ElectrumXCurrencyInterface { } bool validateSparkAddress(String address) { - return SparkInterface.validateSparkAddress(address: address, isTestNet: network.isTestNet); + return SparkInterface.validateSparkAddress( + address: address, + isTestNet: network.isTestNet, + ); } bool isExchangeAddress(String address) { From f16250ca4e815fc91d47a608e1ae5d19781a1001 Mon Sep 17 00:00:00 2001 From: cassandras-lies <203535133+cassandras-lies@users.noreply.github.com> Date: Sun, 11 Jan 2026 11:08:13 +0000 Subject: [PATCH 3/3] Add support for creating Firo masternodes. --- .../masternodes/masternodes_home_view.dart | 842 ++++++++++++++++++ .../buy_spark_name_option_widget.dart | 2 +- lib/pages/wallet_view/wallet_view.dart | 22 + .../sub_widgets/desktop_wallet_features.dart | 11 + .../firo_desktop_wallet_summary.dart | 33 +- lib/route_generator.dart | 32 +- lib/wallets/models/tx_data.dart | 13 + lib/wallets/wallet/impl/firo_wallet.dart | 353 +++++++- lib/wallets/wallet/wallet.dart | 27 +- .../electrumx_interface.dart | 5 +- .../spark_interface.dart | 25 +- 11 files changed, 1313 insertions(+), 52 deletions(-) create mode 100644 lib/pages/masternodes/masternodes_home_view.dart diff --git a/lib/pages/masternodes/masternodes_home_view.dart b/lib/pages/masternodes/masternodes_home_view.dart new file mode 100644 index 000000000..e0b5dbe42 --- /dev/null +++ b/lib/pages/masternodes/masternodes_home_view.dart @@ -0,0 +1,842 @@ +import 'package:decimal/decimal.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:flutter_svg/svg.dart'; +import '../../themes/stack_colors.dart'; +import '../../utilities/assets.dart'; +import '../../utilities/text_styles.dart'; +import '../../utilities/util.dart'; +import '../../utilities/logger.dart'; +import '../../widgets/custom_buttons/app_bar_icon_button.dart'; +import '../../widgets/desktop/desktop_app_bar.dart'; +import '../../widgets/desktop/desktop_scaffold.dart'; +import '../../widgets/stack_dialog.dart'; +import '../../providers/global/wallets_provider.dart'; +import '../../wallets/wallet/impl/firo_wallet.dart'; + +class MasternodesHomeView extends ConsumerStatefulWidget { + const MasternodesHomeView({super.key, required this.walletId}); + + final String walletId; + + static const String routeName = "/masternodesHomeView"; + + @override + ConsumerState createState() => + _MasternodesHomeViewState(); +} + +class _MasternodesHomeViewState extends ConsumerState { + late Future> _masternodesFuture; + + FiroWallet get _wallet => + ref.read(pWallets).getWallet(widget.walletId) as FiroWallet; + + @override + void initState() { + super.initState(); + _masternodesFuture = _wallet.getMyMasternodes(); + } + + @override + Widget build(BuildContext context) { + final isDesktop = Util.isDesktop; + + return MasterScaffold( + isDesktop: isDesktop, + appBar: isDesktop + ? DesktopAppBar( + isCompactHeight: true, + background: Theme.of(context).extension()!.popupBG, + leading: Row( + children: [ + Padding( + padding: const EdgeInsets.only(left: 24, right: 20), + child: AppBarIconButton( + size: 32, + 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, + BlendMode.srcIn, + ), + ), + onPressed: Navigator.of(context).pop, + ), + ), + SvgPicture.asset( + Assets.svg.robotHead, + width: 32, + height: 32, + colorFilter: ColorFilter.mode( + Theme.of(context).extension()!.textDark, + BlendMode.srcIn, + ), + ), + const SizedBox(width: 10), + Text("Masternodes", style: STextStyles.desktopH3(context)), + ], + ), + trailing: Padding( + padding: const EdgeInsets.only(right: 24), + child: ElevatedButton.icon( + onPressed: _showCreateMasternodeDialog, + style: ElevatedButton.styleFrom( + backgroundColor: Theme.of( + context, + ).extension()!.buttonBackPrimary, + foregroundColor: Theme.of( + context, + ).extension()!.buttonTextPrimary, + ), + icon: const Icon(Icons.add), + label: const Text('Create Masternode'), + ), + ), + ) + : AppBar( + leading: AppBarBackButton( + onPressed: () => Navigator.of(context).pop(), + ), + titleSpacing: 0, + title: Text( + "Masternodes", + style: STextStyles.navBarTitle(context), + overflow: TextOverflow.ellipsis, + ), + actions: [ + Padding( + padding: const EdgeInsets.only(right: 16), + child: IconButton( + onPressed: _showCreateMasternodeDialog, + icon: const Icon(Icons.add), + tooltip: 'Create Masternode', + ), + ), + ], + ), + body: _buildMasternodesTable(context), + ); + } + + Widget _buildMasternodesTable(BuildContext context) { + return FutureBuilder>( + future: _masternodesFuture, + builder: (context, snapshot) { + if (snapshot.connectionState == ConnectionState.waiting) { + return const Center(child: CircularProgressIndicator()); + } + if (snapshot.hasError) { + return Center( + child: Text( + "Failed to load masternodes", + style: STextStyles.w600_14(context), + ), + ); + } + final nodes = snapshot.data ?? const []; + if (nodes.isEmpty) { + return Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Text( + "No masternodes found", + style: STextStyles.w600_14(context), + ), + const SizedBox(height: 16), + ElevatedButton.icon( + onPressed: _showCreateMasternodeDialog, + style: ElevatedButton.styleFrom( + backgroundColor: Theme.of( + context, + ).extension()!.buttonBackPrimary, + foregroundColor: Theme.of( + context, + ).extension()!.buttonTextPrimary, + ), + icon: const Icon(Icons.add), + label: const Text('Create Your First Masternode'), + ), + ], + ), + ); + } + + final isDesktop = Util.isDesktop; + final stack = Theme.of(context).extension()!; + + if (isDesktop) { + return _buildDesktopTable(nodes, stack); + } else { + return _buildMobileTable(nodes, stack); + } + }, + ); + } + + Widget _buildDesktopTable(List nodes, StackColors stack) { + return Container( + color: stack.textFieldDefaultBG, + child: Column( + children: [ + // Fixed header + Container( + height: 56, + color: stack.textFieldDefaultBG, + child: Row( + children: [ + const Expanded( + flex: 2, + child: Align( + alignment: Alignment.centerRight, + child: Padding( + padding: EdgeInsets.symmetric(horizontal: 16), + child: Text('IP'), + ), + ), + ), + const Expanded( + flex: 2, + child: Align( + alignment: Alignment.centerRight, + child: Padding( + padding: EdgeInsets.symmetric(horizontal: 16), + child: Text('Last Paid Height'), + ), + ), + ), + const Expanded( + flex: 2, + child: Align( + alignment: Alignment.centerRight, + child: Padding( + padding: EdgeInsets.symmetric(horizontal: 16), + child: Text('Status'), + ), + ), + ), + Expanded(flex: 3, child: Container()), + ], + ), + ), + // Scrollable content + Expanded( + child: Container( + width: double.infinity, + color: stack.textFieldDefaultBG, + child: SingleChildScrollView( + scrollDirection: Axis.vertical, + child: Column( + children: nodes.map((node) { + final status = node.revocationReason == 0 + ? 'Active' + : 'Revoked'; + return SizedBox( + height: 48, + child: Row( + children: [ + Expanded( + flex: 2, + child: Align( + alignment: Alignment.centerRight, + child: Padding( + padding: const EdgeInsets.symmetric( + horizontal: 16, + ), + child: Text( + node.serviceAddr, + overflow: TextOverflow.ellipsis, + ), + ), + ), + ), + Expanded( + flex: 2, + child: Align( + alignment: Alignment.centerRight, + child: Padding( + padding: const EdgeInsets.symmetric( + horizontal: 16, + ), + child: Text( + node.lastPaidHeight.toString(), + overflow: TextOverflow.ellipsis, + ), + ), + ), + ), + Expanded( + flex: 2, + child: Align( + alignment: Alignment.centerRight, + child: Padding( + padding: const EdgeInsets.symmetric( + horizontal: 16, + ), + child: Container( + padding: const EdgeInsets.symmetric( + horizontal: 8, + vertical: 4, + ), + decoration: BoxDecoration( + color: status.toLowerCase() == 'active' + ? stack.accentColorGreen + : stack.accentColorRed, + borderRadius: BorderRadius.circular(8), + ), + child: Text( + status.toUpperCase(), + style: STextStyles.w600_12( + context, + ).copyWith(color: stack.textWhite), + ), + ), + ), + ), + ), + Expanded( + flex: 3, + child: Align( + alignment: Alignment.centerRight, + child: Padding( + padding: const EdgeInsets.symmetric( + horizontal: 16, + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + IconButton( + onPressed: () => + _showMasternodeInfoDialog(node), + icon: const Icon(Icons.info_outline), + tooltip: 'View Details', + ), + ], + ), + ), + ), + ), + ], + ), + ); + }).toList(), + ), + ), + ), + ), + ], + ), + ); + } + + Widget _buildMobileTable(List nodes, StackColors stack) { + return Container( + color: stack.textFieldDefaultBG, + child: ListView.separated( + padding: EdgeInsets.zero, + itemCount: nodes.length, + separatorBuilder: (_, __) => const SizedBox(height: 1), + itemBuilder: (context, index) { + final node = nodes[index]; + final status = node.revocationReason == 0 ? 'Active' : 'Revoked'; + + return Container( + width: double.infinity, + color: stack.textFieldDefaultBG, + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Expanded( + child: Align( + alignment: Alignment.centerLeft, + child: Text( + 'IP: ${node.serviceAddr}', + style: STextStyles.w600_14(context), + overflow: TextOverflow.ellipsis, + ), + ), + ), + Container( + padding: const EdgeInsets.symmetric( + horizontal: 8, + vertical: 4, + ), + decoration: BoxDecoration( + color: status.toLowerCase() == 'active' + ? stack.accentColorGreen + : stack.accentColorRed, + borderRadius: BorderRadius.circular(8), + ), + child: Text( + status.toUpperCase(), + style: STextStyles.w600_12( + context, + ).copyWith(color: stack.textWhite), + ), + ), + ], + ), + const SizedBox(height: 8), + _buildMobileRow( + 'Last Paid Height', + node.lastPaidHeight.toString(), + ), + const SizedBox(height: 12), + Row( + mainAxisAlignment: MainAxisAlignment.end, + children: [ + OutlinedButton.icon( + onPressed: () => _showMasternodeInfoDialog(node), + icon: const Icon(Icons.info_outline), + label: const Text('Details'), + style: OutlinedButton.styleFrom( + backgroundColor: stack.textFieldDefaultBG, + foregroundColor: stack.buttonTextSecondary, + side: BorderSide( + color: stack.buttonBackBorderSecondary, + ), + ), + ), + ], + ), + ], + ), + ); + }, + ), + ); + } + + Widget _buildMobileRow(String label, String value) { + return Padding( + padding: const EdgeInsets.only(bottom: 4), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + SizedBox( + width: 120, + child: Align( + alignment: Alignment.centerLeft, + child: Text( + '$label:', + style: STextStyles.w500_12(context).copyWith( + color: Theme.of( + context, + ).extension()!.textSubtitle1, + ), + ), + ), + ), + Expanded( + child: Align( + alignment: Alignment.centerLeft, + child: Text(value, style: STextStyles.w500_12(context)), + ), + ), + ], + ), + ); + } + + void _showCreateMasternodeDialog() { + showDialog( + context: context, + barrierDismissible: true, + builder: (context) => _CreateMasternodeDialog(wallet: _wallet), + ); + } + + void _showMasternodeInfoDialog(MasternodeInfo node) { + showDialog( + context: context, + barrierDismissible: true, + builder: (context) => _MasternodeInfoDialog(node: node), + ); + } +} + +class _CreateMasternodeDialog extends StatefulWidget { + const _CreateMasternodeDialog({required this.wallet}); + + final FiroWallet wallet; + + @override + State<_CreateMasternodeDialog> createState() => + _CreateMasternodeDialogState(); +} + +class _CreateMasternodeDialogState extends State<_CreateMasternodeDialog> { + final GlobalKey _formKey = GlobalKey(); + final TextEditingController _ipAndPortController = TextEditingController(); + final TextEditingController _operatorPubKeyController = + TextEditingController(); + final TextEditingController _votingAddressController = + TextEditingController(); + final TextEditingController _operatorRewardController = TextEditingController( + text: "0", + ); + final TextEditingController _payoutAddressController = + TextEditingController(); + bool _isRegistering = false; + String? _errorMessage; + + @override + void dispose() { + _ipAndPortController.dispose(); + _operatorPubKeyController.dispose(); + _votingAddressController.dispose(); + _operatorRewardController.dispose(); + _payoutAddressController.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + final stack = Theme.of(context).extension()!; + final spendable = widget.wallet.info.cachedBalance.spendable; + final spendableFiro = spendable.decimal; + final threshold = Decimal.fromInt(1000); + final canRegister = spendableFiro >= threshold; + final availableCount = (spendableFiro ~/ threshold).toInt(); + + return AlertDialog( + backgroundColor: stack.popupBG, + title: const Text('Create Masternode'), + content: SizedBox( + width: 500, + child: Form( + key: _formKey, + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + if (!canRegister) + Container( + width: double.infinity, + margin: const EdgeInsets.only(bottom: 12), + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: stack.textFieldErrorBG, + borderRadius: BorderRadius.circular(8), + ), + child: Text( + 'Insufficient funds to register a masternode. You need at least 1000 public FIRO.', + style: STextStyles.w600_14( + context, + ).copyWith(color: stack.textDark), + ), + ) + else + Container( + width: double.infinity, + margin: const EdgeInsets.only(bottom: 12), + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: stack.textFieldSuccessBG, + borderRadius: BorderRadius.circular(8), + ), + child: Text( + 'You can register $availableCount masternode(s).', + style: STextStyles.w600_14( + context, + ).copyWith(color: stack.textDark), + ), + ), + if (_errorMessage != null) + Container( + width: double.infinity, + margin: const EdgeInsets.only(bottom: 12), + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: stack.textFieldErrorBG, + borderRadius: BorderRadius.circular(8), + ), + child: Text( + 'Registration failed: $_errorMessage', + style: STextStyles.w600_14( + context, + ).copyWith(color: stack.textDark), + ), + ), + TextFormField( + controller: _ipAndPortController, + decoration: const InputDecoration( + labelText: 'IP:Port', + hintText: '123.45.67.89:8168', + ), + validator: (v) { + if (v == null || v.trim().isEmpty) return 'Required'; + final parts = v.split(':'); + if (parts.length != 2) return 'Format must be ip:port'; + if (int.tryParse(parts[1]) == null) return 'Invalid port'; + return null; + }, + ), + const SizedBox(height: 8), + TextFormField( + controller: _operatorPubKeyController, + decoration: const InputDecoration( + labelText: 'Operator public key (BLS)', + ), + validator: (v) => + (v == null || v.trim().isEmpty) ? 'Required' : null, + ), + const SizedBox(height: 8), + TextFormField( + controller: _votingAddressController, + decoration: const InputDecoration( + labelText: 'Voting address (optional)', + hintText: 'Defaults to owner address', + ), + ), + const SizedBox(height: 8), + TextFormField( + controller: _operatorRewardController, + decoration: const InputDecoration( + labelText: 'Operator reward (%)', + hintText: '0', + ), + keyboardType: TextInputType.number, + ), + const SizedBox(height: 8), + TextFormField( + controller: _payoutAddressController, + decoration: const InputDecoration(labelText: 'Payout address'), + validator: (v) => + (v == null || v.trim().isEmpty) ? 'Required' : null, + ), + ], + ), + ), + ), + actions: [ + TextButton( + onPressed: _isRegistering ? null : () => Navigator.of(context).pop(), + child: const Text('Cancel'), + ), + FilledButton( + onPressed: _isRegistering || !canRegister + ? null + : _registerMasternode, + style: FilledButton.styleFrom( + backgroundColor: stack.buttonBackPrimary, + foregroundColor: stack.buttonTextPrimary, + ), + child: _isRegistering + ? const SizedBox( + width: 16, + height: 16, + child: CircularProgressIndicator(strokeWidth: 2), + ) + : const Text('Create'), + ), + ], + ); + } + + Future _registerMasternode() async { + setState(() { + _isRegistering = true; + _errorMessage = null; // Clear any previous error + }); + + try { + final parts = _ipAndPortController.text.trim().split(':'); + final ip = parts[0]; + final port = int.parse(parts[1]); + final operatorPubKey = _operatorPubKeyController.text.trim(); + final votingAddress = _votingAddressController.text.trim(); + final operatorReward = _operatorRewardController.text.trim().isNotEmpty + ? (double.parse(_operatorRewardController.text.trim()) * 100).floor() + : 0; + final payoutAddress = _payoutAddressController.text.trim(); + + final txId = await widget.wallet.registerMasternode( + ip, + port, + operatorPubKey, + votingAddress, + operatorReward, + payoutAddress, + ); + + if (!mounted) return; + + // Get the parent navigator context before popping + final navigator = Navigator.of(context, rootNavigator: Util.isDesktop); + navigator.pop(); + + Logging.instance.i('Masternode registration submitted: $txId'); + + // Show success dialog after frame is complete to ensure navigation stack is correct + if (!mounted) return; + WidgetsBinding.instance.addPostFrameCallback((_) { + if (!mounted) return; + showDialog( + context: context, + barrierDismissible: true, + useRootNavigator: Util.isDesktop, + 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, + ), + ); + }); + } catch (e, s) { + Logging.instance.e( + "Masternode registration failed", + error: e, + stackTrace: s, + ); + + if (!mounted) return; + + setState(() { + _errorMessage = e.toString(); + _isRegistering = false; + }); + } + } +} + +class _MasternodeInfoDialog extends StatelessWidget { + const _MasternodeInfoDialog({required this.node}); + + final MasternodeInfo node; + + @override + Widget build(BuildContext context) { + final stack = Theme.of(context).extension()!; + final status = node.revocationReason == 0 ? 'Active' : 'Revoked'; + + return AlertDialog( + backgroundColor: stack.popupBG, + title: const Text('Masternode Information'), + content: SizedBox( + width: 500, + child: SingleChildScrollView( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + _buildInfoRow(context, 'ProTx Hash', node.proTxHash), + _buildInfoRow( + context, + 'IP:Port', + '${node.serviceAddr}:${node.servicePort}', + ), + _buildInfoRow(context, 'Status', status), + _buildInfoRow( + context, + 'Registered Height', + node.registeredHeight.toString(), + ), + _buildInfoRow( + context, + 'Last Paid Height', + node.lastPaidHeight.toString(), + ), + _buildInfoRow(context, 'Payout Address', node.payoutAddress), + _buildInfoRow(context, 'Owner Address', node.ownerAddress), + _buildInfoRow(context, 'Voting Address', node.votingAddress), + _buildInfoRow( + context, + 'Operator Public Key', + node.pubKeyOperator, + ), + _buildInfoRow( + context, + 'Operator Reward', + '${node.operatorReward / 100} %', + ), + _buildInfoRow(context, 'Collateral Hash', node.collateralHash), + _buildInfoRow( + context, + 'Collateral Index', + node.collateralIndex.toString(), + ), + _buildInfoRow( + context, + 'Collateral Address', + node.collateralAddress, + ), + _buildInfoRow( + context, + 'Pose Penalty', + node.posePenalty.toString(), + ), + _buildInfoRow( + context, + 'Pose Revived Height', + node.poseRevivedHeight.toString(), + ), + _buildInfoRow( + context, + 'Pose Ban Height', + node.poseBanHeight.toString(), + ), + _buildInfoRow( + context, + 'Revocation Reason', + node.revocationReason.toString(), + ), + ], + ), + ), + ), + actions: [ + FilledButton( + onPressed: () => Navigator.of(context).pop(), + style: FilledButton.styleFrom( + backgroundColor: stack.buttonBackPrimary, + foregroundColor: stack.buttonTextPrimary, + ), + child: const Text('Close'), + ), + ], + ); + } + + Widget _buildInfoRow(BuildContext context, String label, String value) { + return Padding( + padding: const EdgeInsets.only(bottom: 12), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + label, + style: STextStyles.w600_14(context).copyWith( + color: Theme.of(context).extension()!.textSubtitle1, + ), + ), + const SizedBox(height: 4), + Container( + width: double.infinity, + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: Theme.of( + context, + ).extension()!.textFieldDefaultBG, + borderRadius: BorderRadius.circular(8), + ), + child: Text(value, style: STextStyles.w500_12(context)), + ), + ], + ), + ); + } +} diff --git a/lib/pages/spark_names/sub_widgets/buy_spark_name_option_widget.dart b/lib/pages/spark_names/sub_widgets/buy_spark_name_option_widget.dart index 7a2d17974..6ccc76f4b 100644 --- a/lib/pages/spark_names/sub_widgets/buy_spark_name_option_widget.dart +++ b/lib/pages/spark_names/sub_widgets/buy_spark_name_option_widget.dart @@ -47,7 +47,7 @@ class _BuySparkNameWidgetState extends ConsumerState { ref.read(pWallets).getWallet(widget.walletId) as SparkInterface; try { - await wallet.electrumXClient.getSparkNameData(sparkName: name); + await wallet.getSparkNameData(sparkName: name); // name exists return false; } catch (e) { diff --git a/lib/pages/wallet_view/wallet_view.dart b/lib/pages/wallet_view/wallet_view.dart index b3a333879..ef6119291 100644 --- a/lib/pages/wallet_view/wallet_view.dart +++ b/lib/pages/wallet_view/wallet_view.dart @@ -106,6 +106,7 @@ import '../settings_views/wallet_settings_view/wallet_network_settings_view/wall import '../settings_views/wallet_settings_view/wallet_settings_view.dart'; import '../signing/signing_view.dart'; import '../spark_names/spark_names_home_view.dart'; +import '../masternodes/masternodes_home_view.dart'; import '../token_view/my_tokens_view.dart'; import 'sub_widgets/transactions_list.dart'; import 'sub_widgets/wallet_summary.dart'; @@ -1185,6 +1186,27 @@ class _WalletViewState extends ConsumerState { ); }, ), + if (!viewOnly && wallet is FiroWallet) + WalletNavigationBarItemData( + label: "Masternodes", + icon: SvgPicture.asset( + Assets.svg.recycle, + height: 20, + width: 20, + colorFilter: ColorFilter.mode( + Theme.of( + context, + ).extension()!.bottomNavIconIcon, + BlendMode.srcIn, + ), + ), + onTap: () { + Navigator.of(context).pushNamed( + MasternodesHomeView.routeName, + arguments: widget.walletId, + ); + }, + ), if (wallet is NamecoinWallet) WalletNavigationBarItemData( label: "Domains", diff --git a/lib/pages_desktop_specific/my_stack_view/wallet_view/sub_widgets/desktop_wallet_features.dart b/lib/pages_desktop_specific/my_stack_view/wallet_view/sub_widgets/desktop_wallet_features.dart index e052df551..81a9b839b 100644 --- a/lib/pages_desktop_specific/my_stack_view/wallet_view/sub_widgets/desktop_wallet_features.dart +++ b/lib/pages_desktop_specific/my_stack_view/wallet_view/sub_widgets/desktop_wallet_features.dart @@ -27,6 +27,7 @@ import '../../../../pages/paynym/paynym_home_view.dart'; import '../../../../pages/salvium_stake/salvium_create_stake_view.dart'; import '../../../../pages/signing/signing_view.dart'; import '../../../../pages/spark_names/spark_names_home_view.dart'; +import '../../../../pages/masternodes/masternodes_home_view.dart'; import '../../../../providers/desktop/current_desktop_menu_item.dart'; import '../../../../providers/global/paynym_api_provider.dart'; import '../../../../providers/providers.dart'; @@ -92,6 +93,7 @@ enum WalletFeature { sparkNames("Names", "Spark names"), salviumStaking("Staking", "Staking"), sign("Sign/Verify", "Sign / Verify messages"), + masternodes("Masternodes", "Manage masternodes"), // special cases clearSparkCache("", ""), @@ -454,6 +456,12 @@ class _DesktopWalletFeaturesState extends ConsumerState { ); } + void _onMasternodesPressed() { + Navigator.of( + context, + ).pushNamed(MasternodesHomeView.routeName, arguments: widget.walletId); + } + List<(WalletFeature, String, FutureOr Function())> _getOptions( Wallet wallet, bool showExchange, @@ -496,6 +504,9 @@ class _DesktopWalletFeaturesState extends ConsumerState { if (wallet is SignVerifyInterface && !isViewOnly) (WalletFeature.sign, Assets.svg.pencil, _onSignPressed), + if (!isViewOnly && wallet is FiroWallet) + (WalletFeature.masternodes, Assets.svg.recycle, _onMasternodesPressed), + if (showCoinControl) ( WalletFeature.coinControl, diff --git a/lib/pages_desktop_specific/my_stack_view/wallet_view/sub_widgets/firo_desktop_wallet_summary.dart b/lib/pages_desktop_specific/my_stack_view/wallet_view/sub_widgets/firo_desktop_wallet_summary.dart index 650f67f68..d1a51ba63 100644 --- a/lib/pages_desktop_specific/my_stack_view/wallet_view/sub_widgets/firo_desktop_wallet_summary.dart +++ b/lib/pages_desktop_specific/my_stack_view/wallet_view/sub_widgets/firo_desktop_wallet_summary.dart @@ -55,6 +55,7 @@ class _WFiroDesktopWalletSummaryState void initState() { super.initState(); walletId = widget.walletId; + coin = ref.read(pWalletCoin(widget.walletId)) as Firo; } @@ -66,14 +67,13 @@ class _WFiroDesktopWalletSummaryState if (ref.watch( prefsChangeNotifierProvider.select((value) => value.externalCalls), )) { - price = - ref - .watch( - priceAnd24hChangeNotifierProvider.select( - (value) => value.getPrice(coin), - ), - ) - ?.value; + price = ref + .watch( + priceAnd24hChangeNotifierProvider.select( + (value) => value.getPrice(coin), + ), + ) + ?.value; } final _showAvailable = @@ -81,14 +81,16 @@ class _WFiroDesktopWalletSummaryState WalletBalanceToggleState.available; final balance0 = ref.watch(pWalletBalanceTertiary(walletId)); - final balanceToShowSpark = - _showAvailable ? balance0.spendable : balance0.total; + final balanceToShowSpark = _showAvailable + ? balance0.spendable + : balance0.total; final balance1 = ref.watch(pWalletBalanceSecondary(walletId)); final balance2 = ref.watch(pWalletBalance(walletId)); - final balanceToShowPublic = - _showAvailable ? balance2.spendable : balance2.total; + final balanceToShowPublic = _showAvailable + ? balance2.spendable + : balance2.total; return Consumer( builder: (context, ref, __) { @@ -168,10 +170,9 @@ class _Prefix extends StatelessWidget { SizedBox( width: 20, height: 20, - child: - asset.endsWith(".png") - ? Image(image: AssetImage(asset)) - : SvgPicture.asset(asset), + child: asset.endsWith(".png") + ? Image(image: AssetImage(asset)) + : SvgPicture.asset(asset), ), const SizedBox(width: 6), diff --git a/lib/route_generator.dart b/lib/route_generator.dart index 35bf76bbb..4961b23ae 100644 --- a/lib/route_generator.dart +++ b/lib/route_generator.dart @@ -163,6 +163,7 @@ import 'pages/spark_names/buy_spark_name_view.dart'; import 'pages/spark_names/confirm_spark_name_transaction_view.dart'; import 'pages/spark_names/spark_names_home_view.dart'; import 'pages/spark_names/sub_widgets/spark_name_details.dart'; +import 'pages/masternodes/masternodes_home_view.dart'; import 'pages/special/firo_rescan_recovery_error_dialog.dart'; import 'pages/stack_privacy_calls.dart'; import 'pages/token_view/my_tokens_view.dart'; @@ -897,6 +898,16 @@ class RouteGenerator { } return _routeError("${settings.name} invalid args: ${args.toString()}"); + case MasternodesHomeView.routeName: + if (args is String) { + return getRoute( + shouldUseMaterialRoute: useMaterialPageRoute, + builder: (_) => MasternodesHomeView(walletId: args), + settings: RouteSettings(name: settings.name), + ); + } + return _routeError("${settings.name} invalid args: ${args.toString()}"); + case BuySparkNameView.routeName: if (args is ({String walletId, String name})) { return getRoute( @@ -1846,10 +1857,8 @@ class RouteGenerator { if (args is (String, String)) { return getRoute( shouldUseMaterialRoute: useMaterialPageRoute, - builder: (_) => SolTokenSendView( - walletId: args.$1, - tokenMint: args.$2, - ), + builder: (_) => + SolTokenSendView(walletId: args.$1, tokenMint: args.$2), settings: RouteSettings(name: settings.name), ); } @@ -1859,10 +1868,8 @@ class RouteGenerator { if (args is (String, String)) { return getRoute( shouldUseMaterialRoute: useMaterialPageRoute, - builder: (_) => SolTokenReceiveView( - walletId: args.$1, - tokenMint: args.$2, - ), + builder: (_) => + SolTokenReceiveView(walletId: args.$1, tokenMint: args.$2), settings: RouteSettings(name: settings.name), ); } @@ -2617,7 +2624,8 @@ class RouteGenerator { ), settings: RouteSettings(name: settings.name), ); - } else if (args is ({String walletId, String tokenMint, bool popPrevious})) { + } else if (args + is ({String walletId, String tokenMint, bool popPrevious})) { return getRoute( shouldUseMaterialRoute: useMaterialPageRoute, builder: (_) => SolTokenView( @@ -2636,10 +2644,8 @@ class RouteGenerator { if (args is (String, String)) { return getRoute( shouldUseMaterialRoute: useMaterialPageRoute, - builder: (_) => SparkViewKeyView( - walletId: args.$1, - sparkViewKeyHex: args.$2, - ), + builder: (_) => + SparkViewKeyView(walletId: args.$1, sparkViewKeyHex: args.$2), settings: RouteSettings(name: settings.name), ); } diff --git a/lib/wallets/models/tx_data.dart b/lib/wallets/models/tx_data.dart index 6db43109a..0ac1d2917 100644 --- a/lib/wallets/models/tx_data.dart +++ b/lib/wallets/models/tx_data.dart @@ -1,3 +1,5 @@ +import 'dart:typed_data'; + import 'package:tezart/tezart.dart' as tezart; import 'package:web3dart/web3dart.dart' as web3dart; @@ -7,6 +9,7 @@ import '../../models/isar/models/isar_models.dart'; import '../../models/paynym/paynym_account_lite.dart'; import '../../utilities/amount/amount.dart'; import '../../utilities/enums/fee_rate_type_enum.dart'; +import '../../utilities/extensions/impl/uint8_list.dart'; import '../../widgets/eth_fee_form.dart'; import '../../wl_gen/interfaces/cs_monero_interface.dart' show CsPendingTransaction; @@ -94,6 +97,8 @@ class TxData { int validBlocks, })? sparkNameInfo; + final Uint8List? vExtraData; + final int? overrideVersion; // xelis specific final String? otherData; @@ -147,6 +152,8 @@ class TxData { this.ignoreCachedBalanceChecks = false, this.opNameState, this.sparkNameInfo, + this.vExtraData, + this.overrideVersion, this.type = TxType.regular, this.salviumStakeTx = false, }); @@ -299,6 +306,8 @@ class TxData { int validBlocks, })? sparkNameInfo, + Uint8List? vExtraData, + int? overrideVersion, TxType? type, }) { return TxData( @@ -342,6 +351,8 @@ class TxData { ignoreCachedBalanceChecks ?? this.ignoreCachedBalanceChecks, opNameState: opNameState ?? this.opNameState, sparkNameInfo: sparkNameInfo ?? this.sparkNameInfo, + vExtraData: vExtraData ?? this.vExtraData, + overrideVersion: overrideVersion ?? this.overrideVersion, type: type ?? this.type, ); } @@ -381,6 +392,8 @@ class TxData { 'ignoreCachedBalanceChecks: $ignoreCachedBalanceChecks, ' 'opNameState: $opNameState, ' 'sparkNameInfo: $sparkNameInfo, ' + 'vExtraData: ${vExtraData?.toHex}, ' + 'overrideVersion: $overrideVersion, ' 'type: $type, ' '}'; } diff --git a/lib/wallets/wallet/impl/firo_wallet.dart b/lib/wallets/wallet/impl/firo_wallet.dart index bd2b3f70f..a8868d47f 100644 --- a/lib/wallets/wallet/impl/firo_wallet.dart +++ b/lib/wallets/wallet/impl/firo_wallet.dart @@ -1,10 +1,17 @@ import 'dart:async'; import 'dart:convert'; +import 'dart:math'; +import 'dart:typed_data'; +import 'package:coinlib_flutter/coinlib_flutter.dart' + show base58Decode, P2SH, Base58Address, P2PKH; +import 'package:crypto/crypto.dart' as Cryptography; import 'package:decimal/decimal.dart'; import 'package:isar_community/isar.dart'; import '../../../db/sqlite/firo_cache.dart'; +import '../../../models/buy/response_objects/crypto.dart'; +import '../../../models/input.dart'; import '../../../models/isar/models/blockchain_data/v2/input_v2.dart'; import '../../../models/isar/models/blockchain_data/v2/output_v2.dart'; import '../../../models/isar/models/blockchain_data/v2/transaction_v2.dart'; @@ -16,6 +23,7 @@ import '../../../utilities/logger.dart'; import '../../../utilities/util.dart'; import '../../crypto_currency/crypto_currency.dart'; import '../../crypto_currency/interfaces/electrumx_currency_interface.dart'; +import '../../crypto_currency/intermediate/bip39_hd_currency.dart'; import '../../isar/models/spark_coin.dart'; import '../../isar/models/wallet_info.dart'; import '../../models/tx_data.dart'; @@ -25,7 +33,45 @@ import '../wallet_mixin_interfaces/electrumx_interface.dart'; import '../wallet_mixin_interfaces/extended_keys_interface.dart'; import '../wallet_mixin_interfaces/spark_interface.dart'; -const sparkStartBlock = 819300; // (approx 18 Jan 2024) +class MasternodeInfo { + final String proTxHash; + final String collateralHash; + final int collateralIndex; + final String collateralAddress; + final int operatorReward; + final String serviceAddr; + final int servicePort; + final int registeredHeight; + final int lastPaidHeight; + final int posePenalty; + final int poseRevivedHeight; + final int poseBanHeight; + final int revocationReason; + final String ownerAddress; + final String votingAddress; + final String payoutAddress; + final String pubKeyOperator; + + MasternodeInfo({ + required this.proTxHash, + required this.collateralHash, + required this.collateralIndex, + required this.collateralAddress, + required this.operatorReward, + required this.serviceAddr, + required this.servicePort, + required this.registeredHeight, + required this.lastPaidHeight, + required this.posePenalty, + required this.poseRevivedHeight, + required this.poseBanHeight, + required this.revocationReason, + required this.ownerAddress, + required this.votingAddress, + required this.payoutAddress, + required this.pubKeyOperator, + }); +} class FiroWallet extends Bip39HDWallet with @@ -815,4 +861,309 @@ class FiroWallet extends Bip39HDWallet int estimateTxFee({required int vSize, required BigInt feeRatePerKB}) { return (feeRatePerKB * BigInt.from(vSize) ~/ BigInt.from(1000)).toInt(); } + + Future registerMasternode( + String ip, + int port, + String operatorPubKey, + String votingAddress, + int operatorReward, + String payoutAddress, + ) async { + if (info.cachedBalance.spendable < + Amount.fromDecimal( + Decimal.fromInt(1000), + fractionDigits: cryptoCurrency.fractionDigits, + )) { + throw Exception( + 'Not enough funds to register a masternode. You must have at least 1000 FIRO in your public balance.', + ); + } + + Address? collateralAddress = await getCurrentReceivingAddress(); + if (collateralAddress == null) { + await generateNewReceivingAddress(); + collateralAddress = await getCurrentReceivingAddress(); + } + await generateNewReceivingAddress(); + + Address? ownerAddress = await getCurrentReceivingAddress(); + if (ownerAddress == null) { + await generateNewReceivingAddress(); + ownerAddress = await getCurrentReceivingAddress(); + } + await generateNewReceivingAddress(); + + // Create the registration transaction. + final registrationTx = BytesBuilder(); + + // nVersion (16 bit) + registrationTx.add( + (ByteData(2)..setInt16(0, 1, Endian.little)).buffer.asUint8List(), + ); + + // nType (16 bit) (this is separate from the tx nType) + registrationTx.add( + (ByteData(2)..setInt16(0, 0, Endian.little)).buffer.asUint8List(), + ); + + // nMode (16 bit) + registrationTx.add( + (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.index (2 bytes) + // This is going to be 0. (The only other output will be change at position 1.) + registrationTx.add( + (ByteData(4)..setInt16(0, 0, Endian.little)).buffer.asUint8List(), + ); + + // addr.ip (4 bytes) + final ipParts = ip + .split('.') + .map((e) => int.parse(e)) + .toList() + .reversed + .toList(); // network byte order + if (ipParts.length != 4) { + throw Exception("Invalid IP address: $ip"); + } + for (final part in ipParts) { + if (part < 0 || part > 255) { + 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 < 0 || port > 65535) { + throw Exception("Invalid port: $port"); + } + registrationTx.add( + (ByteData(2)..setInt16(0, port, Endian.little)).buffer.asUint8List(), + ); + + // keyIDOwner (20 bytes) + assert(ownerAddress!.value != collateralAddress!.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 + + // 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) + if (votingAddress == payoutAddress) { + throw Exception("Voting address and payout address cannot be the same."); + } else if (votingAddress == collateralAddress!.value) { + 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 votingAddressBytes = base58Decode(votingAddress); + assert(votingAddressBytes.length == 21); // should be infallible + registrationTx.add(votingAddressBytes.sublist(1)); // remove version byte + } else { + registrationTx.add(ownerAddressBytes.sublist(1)); // remove version byte + } + + // nOperatorReward (16 bit); the operator gets nOperatorReward/10,000 of the reward. + if (operatorReward < 0 || operatorReward > 10000) { + throw Exception("Invalid operator reward: $operatorReward"); + } + registrationTx.add( + (ByteData( + 2, + )..setInt16(0, operatorReward, Endian.little)).buffer.asUint8List(), + ); + + // scriptPayout (variable) + if (!cryptoCurrency.validateAddress(payoutAddress)) { + throw Exception("Invalid payout address: $payoutAddress"); + } + 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); + + 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, + addressType: AddressType.p2pkh, + amount: Amount.fromDecimal( + Decimal.fromInt(1000), + fractionDigits: cryptoCurrency.fractionDigits, + ), + isChange: false, + ), + ], + ); + + final partialTx = await coinSelection( + txData: partialTxData, + coinControl: false, + isSendAll: false, + isSendAllCoinControlUtxos: false, + ); + + // Calculate inputsHash (32 bytes). + 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 + .toUint8ListFromHex + .reversed + .toList(); + inputsHashInput.add(reversedTxidBytes); + inputsHashInput.add( + (ByteData(4)..setInt32(0, standardInput.utxo.vout, Endian.little)) + .buffer + .asUint8List(), + ); + } + final inputsHash = Cryptography.sha256 + .convert(inputsHashInput.toBytes()) + .bytes; + final inputsHashHash = Cryptography.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); + + final finalTxData = partialTx.copyWith( + vExtraData: registrationTx.toBytes(), + ); + final finalTx = await buildTransaction( + txData: finalTxData, + inputsWithKeys: partialTx.usedUTXOs!, + ); + + final finalTransactionHex = finalTx.raw!; + assert(finalTransactionHex.contains(registrationTx.toBytes().toHex)); + + final broadcastedTxHash = await electrumXClient.broadcastTransaction( + rawTx: finalTransactionHex, + ); + if (broadcastedTxHash.toUint8ListFromHex.length != 32) { + throw Exception("Failed to broadcast transaction: $broadcastedTxHash"); + } + Logging.instance.i( + "Successfully broadcasted masternode registration transaction: $finalTransactionHex (txid $broadcastedTxHash)", + ); + + await updateSentCachedTxData(txData: finalTx); + + return broadcastedTxHash; + } + + Future> getMyMasternodes() async { + final proTxHashes = await getMyMasternodeProTxHashes(); + + return (await Future.wait( + proTxHashes.map( + (e) => Future(() async { + try { + final info = await electrumXClient.request( + command: 'protx.info', + args: [e], + ); + return MasternodeInfo( + proTxHash: info["proTxHash"] as String, + collateralHash: info["collateralHash"] as String, + collateralIndex: info["collateralIndex"] as int, + collateralAddress: info["collateralAddress"] as String, + operatorReward: info["operatorReward"] as int, + serviceAddr: (info["state"]["service"] as String).substring( + 0, + (info["state"]["service"] as String).lastIndexOf(":"), + ), + servicePort: int.parse( + (info["state"]["service"] as String).substring( + (info["state"]["service"] as String).lastIndexOf(":") + 1, + ), + ), + registeredHeight: info["state"]["registeredHeight"] as int, + lastPaidHeight: info["state"]["lastPaidHeight"] as int, + posePenalty: info["state"]["PoSePenalty"] as int, + poseRevivedHeight: info["state"]["PoSeRevivedHeight"] as int, + poseBanHeight: info["state"]["PoSeBanHeight"] as int, + revocationReason: info["state"]["revocationReason"] as int, + ownerAddress: info["state"]["ownerAddress"] as String, + votingAddress: info["state"]["votingAddress"] as String, + payoutAddress: info["state"]["payoutAddress"] as String, + pubKeyOperator: info["state"]["pubKeyOperator"] as String, + ); + } catch (err) { + // getMyMasternodeProTxHashes() may give non-masternode txids, so only log as info. + Logging.instance.i("Error getting masternode info for $e: $err"); + return null; + } + }), + ), + )).where((e) => e != null).map((e) => e!).toList(); + } + + 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 = []; + + for (final utxo in utxos) { + if (utxo.value != cryptoCurrency.satsPerCoin.toInt() * 1000) { + continue; + } + + // A duplicate could occur if a protx transaction has a non-collateral 1000 FIRO vout. + if (r.contains(utxo.txid)) { + continue; + } + + r.add(utxo.txid); + } + + return r; + } } diff --git a/lib/wallets/wallet/wallet.dart b/lib/wallets/wallet/wallet.dart index 0fa72d682..85002c110 100644 --- a/lib/wallets/wallet/wallet.dart +++ b/lib/wallets/wallet/wallet.dart @@ -53,11 +53,11 @@ import 'impl/wownero_wallet.dart'; import 'impl/xelis_wallet.dart'; import 'intermediate/cryptonote_wallet.dart'; import 'wallet_mixin_interfaces/electrumx_interface.dart'; +import 'wallet_mixin_interfaces/spark_interface.dart'; import 'wallet_mixin_interfaces/mnemonic_interface.dart'; import 'wallet_mixin_interfaces/multi_address_interface.dart'; import 'wallet_mixin_interfaces/paynym_interface.dart'; import 'wallet_mixin_interfaces/private_key_interface.dart'; -import 'wallet_mixin_interfaces/spark_interface.dart'; import 'wallet_mixin_interfaces/view_only_option_interface.dart'; abstract class Wallet { @@ -244,11 +244,10 @@ abstract class Wallet { required NodeService nodeService, required Prefs prefs, }) async { - final walletInfo = - await mainDB.isar.walletInfo - .where() - .walletIdEqualTo(walletId) - .findFirst(); + final walletInfo = await mainDB.isar.walletInfo + .where() + .walletIdEqualTo(walletId) + .findFirst(); Logging.instance.i( "Wallet.load loading" @@ -438,10 +437,9 @@ abstract class Wallet { final bool hasNetwork = await pingCheck(); if (_isConnected != hasNetwork) { - final NodeConnectionStatus status = - hasNetwork - ? NodeConnectionStatus.connected - : NodeConnectionStatus.disconnected; + final NodeConnectionStatus status = hasNetwork + ? NodeConnectionStatus.connected + : NodeConnectionStatus.disconnected; if (!doNotFireRefreshEvents) { GlobalEventBus.instance.fire( NodeConnectionStatusChangedEvent(status, walletId, cryptoCurrency), @@ -756,11 +754,10 @@ abstract class Wallet { // Check if there's another wallet of this coin on the sync list. final List walletIds = []; for (final id in prefs.walletIdsSyncOnStartup) { - final wallet = - mainDB.isar.walletInfo - .where() - .walletIdEqualTo(id) - .findFirstSync()!; + final wallet = mainDB.isar.walletInfo + .where() + .walletIdEqualTo(id) + .findFirstSync()!; if (wallet.coin == cryptoCurrency) { walletIds.add(id); diff --git a/lib/wallets/wallet/wallet_mixin_interfaces/electrumx_interface.dart b/lib/wallets/wallet/wallet_mixin_interfaces/electrumx_interface.dart index 98fd505ff..e963566b6 100644 --- a/lib/wallets/wallet/wallet_mixin_interfaces/electrumx_interface.dart +++ b/lib/wallets/wallet/wallet_mixin_interfaces/electrumx_interface.dart @@ -659,7 +659,10 @@ mixin ElectrumXInterface final List prevOuts = []; coinlib.Transaction clTx = coinlib.Transaction( - version: txData.type.isMweb() ? 2 : cryptoCurrency.transactionVersion, + vExtraData: txData.vExtraData, + version: + txData.overrideVersion ?? + (txData.type.isMweb() ? 2 : cryptoCurrency.transactionVersion), inputs: [], outputs: [], ); diff --git a/lib/wallets/wallet/wallet_mixin_interfaces/spark_interface.dart b/lib/wallets/wallet/wallet_mixin_interfaces/spark_interface.dart index 5a2a025e1..367476a48 100644 --- a/lib/wallets/wallet/wallet_mixin_interfaces/spark_interface.dart +++ b/lib/wallets/wallet/wallet_mixin_interfaces/spark_interface.dart @@ -218,7 +218,10 @@ mixin SparkInterface isTestNet: args.isTestNet_, ); } catch (e) { - Logging.instance.e("Failed to identify coin", error: e); + Logging.instance.e( + "Error identifying coin in tx $txHash (this is not expected)", + error: e, + ); continue; } @@ -269,7 +272,12 @@ mixin SparkInterface Future hashTag(String tag) async { try { - return await computeWithLibSparkLogging(_hashTag, tag); + return await computeWithLibSparkLogging((t) { + final components = t.split(","); + final x = components[0].substring(1); + final y = components[1].substring(0, components[1].length - 1); + return libSpark.hashTag(x, y); + }, tag); } catch (_) { throw ArgumentError("Invalid tag string format", "tag"); } @@ -1339,6 +1347,15 @@ mixin SparkInterface } } + Future recoverViewOnlyWallet() async { + await recoverSparkWallet(latestSparkCoinId: 0); + } + + Future<({String address, int validUntil, String additionalInfo})> + getSparkNameData({required String sparkName}) async { + return await electrumXClient.getSparkNameData(sparkName: sparkName); + } + Future refreshSparkNames() async { try { Logging.instance.i("Refreshing spark names for $walletId ${info.name}"); @@ -1398,9 +1415,7 @@ mixin SparkInterface data = []; for (final name in names) { - final info = await electrumXClient.getSparkNameData( - sparkName: name.name, - ); + final info = await getSparkNameData(sparkName: name.name); data.add(( name: name.name,