Add support for creating Firo masternodes.
What changed, and why it matters
This commit adds a new feature to Stack Wallet that lets Firo cryptocurrency users create and manage masternodes directly from the app. A masternode is a special server that helps run the Firo network and requires locking up 1,000 FIRO as collateral. The change adds new screens, wallet logic, and transaction-building code to support this. It is a feature-add commit, not a stated security fix, but it touches sensitive areas like transaction construction, address validation, and balance checks.
Treat this as a high-risk feature addition requiring focused review and testing before release. Specifically: (1) audit the byte-level serialization in `registerMasternode()` against the Firo DIP-3/DIP-6 provider-registration transaction spec, (2) verify `overrideVersion` encoding is accepted by Firo consensus and ElectrumX servers, (3) test edge cases in address validation, operator reward bounds, and IP/port parsing, (4) ensure the 10x fee heuristic cannot overpay or underpay in realistic network conditions, (5) review `getMyMasternodeProTxHashes()` false-positive handling so users are not misled, and (6) run integration tests on testnet/mainnet with real protx broadcasts. No immediate patch is indicated because no vulnerability is proven from the diff alone.
Security signals we found
New transaction serialization path with custom byte-level payload construction in `registerMasternode()`
Use of `overrideVersion` to set a non-standard transaction version (3 + (1 << 16))
Custom `vExtraData` passed into coinlib transaction builder
Address validation is performed on owner, voting, and payout addresses before use
Balance threshold check requires at least 1000 FIRO spendable before registration
Hard-coded fee multiplier (`defaultFeeRate * 10`) chosen because coin selection heuristic does not account for vExtraData size
Inputs hash computed via double SHA-256 over reversed txid bytes and little-endian vout
Commented assumptions and known limitations in `getMyMasternodeProTxHashes()` (e.g., false positives for 1000 FIRO UTXOs, shared-seed edge cases)
No explicit security disclosure, CVE, or researcher attribution in commit or supplied references
Evidence from the diff
The commit introduces Firo masternode registration and listing. It adds a new MasternodesHomeView UI, route wiring, and a registerMasternode() method in FiroWallet that builds a provider-registration transaction (nVersion=3, nType=1) with serialized extra payload data (vExtraData). TxData is extended with vExtraData and overrideVersion fields, and ElectrumXInterface.buildTransaction() now passes these into the underlying coinlib.Transaction. The code also adds getMyMasternodes() / getMyMasternodeProTxHashes() helpers that scan the wallet’s UTXOs for 1,000 FIRO outputs and query protx.info. Several unrelated cleanups appear (Spark name lookup moved to a wrapper, logging text changes, formatting).
Changed components
lib/pages/masternodes/masternodes_home_view.dartlib/pages/wallet_view/wallet_view.dartlib/pages_desktop_specific/my_stack_view/wallet_view/sub_widgets/desktop_wallet_features.dartlib/pages_desktop_specific/my_stack_view/wallet_view/sub_widgets/firo_desktop_wallet_summary.dartlib/route_generator.dartlib/wallets/models/tx_data.dartlib/wallets/wallet/impl/firo_wallet.dartlib/wallets/wallet/wallet.dartlib/wallets/wallet/wallet_mixin_interfaces/electrumx_interface.dartlib/wallets/wallet/wallet_mixin_interfaces/spark_interface.dartInspect captured patch +1313 / −52
diff --git a/lib/pages/masternodes/masternodes_home_view.dart b/lib/pages/masternodes/masternodes_home_view.dart
new file mode 100644
index 0000000..e0b5dbe
--- /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<MasternodesHomeView> createState() =>
+ _MasternodesHomeViewState();
+}
+
+class _MasternodesHomeViewState extends ConsumerState<MasternodesHomeView> {
+ late Future<List<MasternodeInfo>> _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<StackColors>()!.popupBG,
+ leading: Row(
+ children: [
+ Padding(
+ padding: const EdgeInsets.only(left: 24, right: 20),
+ child: AppBarIconButton(
+ size: 32,
+ color: Theme.of(
+ context,
+ ).extension<StackColors>()!.textFieldDefaultBG,
+ shadows: const [],
+ icon: SvgPicture.asset(
+ Assets.svg.arrowLeft,
+ width: 18,
+ height: 18,
+ colorFilter: ColorFilter.mode(
+ Theme.of(
+ context,
+ ).extension<StackColors>()!.topNavIconPrimary,
+ BlendMode.srcIn,
+ ),
+ ),
+ onPressed: Navigator.of(context).pop,
+ ),
+ ),
+ SvgPicture.asset(
+ Assets.svg.robotHead,
+ width: 32,
+ height: 32,
+ colorFilter: ColorFilter.mode(
+ Theme.of(context).extension<StackColors>()!.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<StackColors>()!.buttonBackPrimary,
+ foregroundColor: Theme.of(
+ context,
+ ).extension<StackColors>()!.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<List<MasternodeInfo>>(
+ 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 <MasternodeInfo>[];
+ 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<StackColors>()!.buttonBackPrimary,
+ foregroundColor: Theme.of(
+ context,
+ ).extension<StackColors>()!.buttonTextPrimary,
+ ),
+ icon: const Icon(Icons.add),
+ label: const Text('Create Your First Masternode'),
+ ),
+ ],
+ ),
+ );
+ }
+
+ final isDesktop = Util.isDesktop;
+ final stack = Theme.of(context).extension<StackColors>()!;
+
+ if (isDesktop) {
+ return _buildDesktopTable(nodes, stack);
+ } else {
+ return _buildMobileTable(nodes, stack);
+ }
+ },
+ );
+ }
+
+ Widget _buildDesktopTable(List<MasternodeInfo> 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<MasternodeInfo> 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<StackColors>()!.textSubtitle1,
+ ),
+ ),
+ ),
+ ),
+ Expanded(
+ child: Align(
+ alignment: Alignment.centerLeft,
+ child: Text(value, style: STextStyles.w500_12(context)),
+ ),
+ ),
+ ],
+ ),
+ );
+ }
+
+ void _showCreateMasternodeDialog() {
+ showDialog<void>(
+ context: context,
+ barrierDismissible: true,
+ builder: (context) => _CreateMasternodeDialog(wallet: _wallet),
+ );
+ }
+
+ void _showMasternodeInfoDialog(MasternodeInfo node) {
+ showDialog<void>(
+ 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<FormState> _formKey = GlobalKey<FormState>();
+ 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<StackColors>()!;
+ 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<void> _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<void>(
+ 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<StackColors>()!;
+ 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<StackColors>()!.textSubtitle1,
+ ),
+ ),
+ const SizedBox(height: 4),
+ Container(
+ width: double.infinity,
+ padding: const EdgeInsets.all(12),
+ decoration: BoxDecoration(
+ color: Theme.of(
+ context,
+ ).extension<StackColors>()!.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 7a2d179..6ccc76f 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<BuySparkNameOptionWidget> {
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 b3a3338..ef61192 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<WalletView> {
);
},
),
+ 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<StackColors>()!.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 e052df5..81a9b83 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<DesktopWalletFeatures> {
);
}
+ void _onMasternodesPressed() {
+ Navigator.of(
+ context,
+ ).pushNamed(MasternodesHomeView.routeName, arguments: widget.walletId);
+ }
+
List<(WalletFeature, String, FutureOr<void> Function())> _getOptions(
Wallet wallet,
bool showExchange,
@@ -496,6 +504,9 @@ class _DesktopWalletFeaturesState extends ConsumerState<DesktopWalletFeatures> {
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 650f67f..d1a51ba 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 35bf76b..4961b23 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 6db4310..0ac1d29 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 701f4a4..49319c4 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<T extends ElectrumXCurrencyInterface> extends Bip39HDWallet<T>
with
@@ -868,4 +914,309 @@ class FiroWallet<T extends ElectrumXCurrencyInterface> extends Bip39HDWallet<T>
int estimateTxFee({required int vSize, required BigInt feeRatePerKB}) {
return (feeRatePerKB * BigInt.from(vSize) ~/ BigInt.from(1000)).toInt();
}
+
+ Future<String> 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<List<MasternodeInfo>> 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<List<String>> 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<String> 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 0fa72d6..85002c1 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<T extends CryptoCurrency> {
@@ -244,11 +244,10 @@ abstract class Wallet<T extends CryptoCurrency> {
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<T extends CryptoCurrency> {
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<T extends CryptoCurrency> {
// Check if there's another wallet of this coin on the sync list.
final List<String> 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 98fd505..e963566 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<T extends ElectrumXCurrencyInterface>
final List<coinlib.Output> 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 d759107..1e097b5 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<T extends ElectrumXCurrencyInterface>
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<T extends ElectrumXCurrencyInterface>
Future<String> 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");
}
@@ -1380,6 +1388,15 @@ mixin SparkInterface<T extends ElectrumXCurrencyInterface>
}
}
+ Future<void> 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<void> refreshSparkNames() async {
try {
Logging.instance.i("Refreshing spark names for $walletId ${info.name}");
@@ -1439,9 +1456,7 @@ mixin SparkInterface<T extends ElectrumXCurrencyInterface>
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,
Why this scored 34/100
Community notes
Notes can correct, qualify, or add evidence to the AI analysis. Every note shown here has been validated by a human moderator.
The AI analysis stands alone for now. Submit a note if you can add evidence or important context.