diff --git a/miner-app/lib/features/miner/miner_controls.dart b/miner-app/lib/features/miner/miner_controls.dart index 70b9c5f5..ccd49df7 100644 --- a/miner-app/lib/features/miner/miner_controls.dart +++ b/miner-app/lib/features/miner/miner_controls.dart @@ -6,6 +6,7 @@ import 'package:quantus_miner/src/config/miner_config.dart'; import 'package:quantus_miner/src/services/miner_wallet_service.dart'; import 'package:quantus_miner/src/services/mining_orchestrator.dart'; import 'package:quantus_miner/src/services/mining_stats_service.dart'; +import 'package:quantus_miner/src/services/pair_compatibility_service.dart'; import 'package:quantus_miner/src/shared/extensions/snackbar_extensions.dart'; import 'package:quantus_miner/src/utils/app_logger.dart'; @@ -211,6 +212,23 @@ class _MinerControlsState extends State { return; } + // Check the pair speaks the same miner-auth protocol before starting, the same probe the + // official script runs. A mixed pair fails at connection time with only a generic crash. + final nodeBinPath = await BinaryManager.getNodeBinaryFilePath(); + final pair = await PairCompatibilityService.check(nodeBinPath: nodeBinPath, minerBinPath: minerBinPath); + if (pair != null && pair != PairCompatibility.compatible) { + final nodeVersion = (await BinaryManager.getNodeBinaryVersion())?.version ?? 'installed'; + final minerVersion = (await BinaryManager.getMinerBinaryVersion())?.version ?? 'installed'; + final message = pair == PairCompatibility.minerTooOld + ? 'Miner $minerVersion predates the authentication node $nodeVersion requires. Update the miner.' + : 'Node $nodeVersion predates the authentication miner $minerVersion expects. Update the node.'; + _log.w('Refusing to start miner: $pair (node $nodeVersion, miner $minerVersion)'); + if (mounted) { + context.showWarningSnackbar(title: 'Node and miner versions do not match', message: message); + } + return; + } + try { // Update settings in case they changed while miner was stopped widget.orchestrator!.updateMinerSettings(cpuWorkers: _cpuWorkers, gpuDevices: _gpuDevices); diff --git a/miner-app/lib/features/setup/node_setup_screen.dart b/miner-app/lib/features/setup/node_setup_screen.dart index 6d89ca02..f3ad71b0 100644 --- a/miner-app/lib/features/setup/node_setup_screen.dart +++ b/miner-app/lib/features/setup/node_setup_screen.dart @@ -1,6 +1,8 @@ import 'package:flutter/material.dart'; import 'package:go_router/go_router.dart'; import 'package:quantus_miner/src/services/binary_manager.dart'; +import 'package:quantus_miner/src/config/miner_config.dart'; +import 'package:quantus_miner/src/services/disk_space_service.dart'; import 'dart:io'; import 'package:flutter_svg/flutter_svg.dart'; @@ -66,6 +68,20 @@ class _NodeSetupScreenState extends State { try { // Install node binary first if (!_isNodeInstalled) { + // A fresh node install commits this volume to roughly 100 GB of chain data during sync. + // Refuse here rather than fail an hour into syncing. An unknown probe result passes. + final quantusHome = await BinaryManager.getQuantusHomeDirectoryPath(); + final freeBytes = await DiskSpaceService.freeBytesForPath(quantusHome); + if (freeBytes != null && freeBytes < MinerConfig.minNodeDiskBytes) { + final freeGb = (freeBytes / MinerConfig.bytesPerGb).toStringAsFixed(0); + final minGb = MinerConfig.minNodeDiskBytes ~/ MinerConfig.bytesPerGb; + final recommendedGb = MinerConfig.recommendedNodeDiskBytes ~/ MinerConfig.bytesPerGb; + throw Exception( + 'Not enough free disk space for a Quantus node: $freeGb GB free at $quantusHome, ' + '$minGb GB required ($recommendedGb GB recommended). The node stores chain data there while syncing.', + ); + } + final nodeVersion = await BinaryManager.getLatestNodeVersion(); setState(() { diff --git a/miner-app/lib/src/config/miner_config.dart b/miner-app/lib/src/config/miner_config.dart index 60684c26..e6021b97 100644 --- a/miner-app/lib/src/config/miner_config.dart +++ b/miner-app/lib/src/config/miner_config.dart @@ -197,6 +197,19 @@ class MinerConfig { /// Number of ports to try when finding an alternative static const int portSearchRange = 10; + + // ============================================================ + // Disk Space + // ============================================================ + + /// Bytes in one gibibyte, for disk arithmetic + static const int bytesPerGb = 1024 * 1024 * 1024; + + /// Minimum free space required before a fresh node install, per chain/MINING.md + static const int minNodeDiskBytes = 100 * bytesPerGb; + + /// Recommended free space for a node, per chain/MINING.md + static const int recommendedNodeDiskBytes = 500 * bytesPerGb; } /// Configuration for a blockchain network. diff --git a/miner-app/lib/src/services/disk_space_service.dart b/miner-app/lib/src/services/disk_space_service.dart new file mode 100644 index 00000000..36813150 --- /dev/null +++ b/miner-app/lib/src/services/disk_space_service.dart @@ -0,0 +1,48 @@ +import 'dart:io'; + +import 'package:quantus_miner/src/utils/app_logger.dart'; + +final _log = log.withTag('DiskSpace'); + +/// Reports free disk space for the volume that holds a given path. +class DiskSpaceService { + /// Free bytes on the volume containing [path], or null if it could not be determined. + /// + /// Returns null instead of throwing so a failed probe never blocks setup by itself; + /// callers decide whether an unknown value should be treated as a pass. + static Future freeBytesForPath(String path) async { + try { + if (Platform.isWindows) { + final escaped = path.replaceAll("'", "''"); + final result = await Process.run('powershell', [ + '-NoProfile', + '-NonInteractive', + '-Command', + "(Get-Item -LiteralPath '$escaped').PSDrive.Free", + ]); + if (result.exitCode != 0) { + _log.w('Free space probe failed for $path: ${result.stderr}'); + return null; + } + return int.tryParse(result.stdout.toString().trim()); + } + + // POSIX output format keeps one filesystem per line with a fixed column order: + // Filesystem, 1024-blocks, Used, Available, Capacity, Mounted on. + final result = await Process.run('df', ['-kP', path]); + if (result.exitCode != 0) { + _log.w('Free space probe failed for $path: ${result.stderr}'); + return null; + } + final lines = result.stdout.toString().trim().split('\n'); + if (lines.length < 2) return null; + final cols = lines.last.trim().split(RegExp(r'\s+')); + if (cols.length < 4) return null; + final availableKb = int.tryParse(cols[3]); + return availableKb == null ? null : availableKb * 1024; + } catch (e) { + _log.w('Free space probe failed for $path: $e'); + return null; + } + } +} diff --git a/miner-app/lib/src/services/pair_compatibility_service.dart b/miner-app/lib/src/services/pair_compatibility_service.dart new file mode 100644 index 00000000..892c4453 --- /dev/null +++ b/miner-app/lib/src/services/pair_compatibility_service.dart @@ -0,0 +1,45 @@ +import 'dart:io'; + +import 'package:quantus_miner/src/utils/app_logger.dart'; + +final _log = log.withTag('PairCompat'); + +/// Whether the installed node and miner speak the same miner-auth protocol. +enum PairCompatibility { + /// Both advertise miner QUIC auth, or neither does. + compatible, + + /// The node requires auth but the miner predates it. The miner exits on the unknown flags. + minerTooOld, + + /// The miner expects auth but the node predates it. The handshake fails with no application protocol. + nodeTooOld, +} + +/// Probes the installed binaries the same way the official mining script does. A node whose help +/// lists `--miner-auth-token-file` and a miner whose help lists `--auth-token-file` and +/// `--tls-cert-sha256-file` are an auth pair. Mixing one of each fails at connection time with a +/// generic crash, so it is checked before the miner is started. +class PairCompatibilityService { + /// Returns null if either probe fails to run, so a broken or wrong-architecture binary is left to + /// the caller's existing checks instead of being misreported as a version mismatch. + static Future check({required String nodeBinPath, required String minerBinPath}) async { + try { + final node = await Process.run(nodeBinPath, ['--help']); + final miner = await Process.run(minerBinPath, ['serve', '--help']); + if (node.exitCode != 0 || miner.exitCode != 0) { + _log.w('Pair probe failed: node exit ${node.exitCode}, miner exit ${miner.exitCode}'); + return null; + } + final nodeHelp = '${node.stdout}${node.stderr}'; + final minerHelp = '${miner.stdout}${miner.stderr}'; + final nodeAuth = nodeHelp.contains('miner-auth-token-file'); + final minerAuth = minerHelp.contains('auth-token-file') && minerHelp.contains('tls-cert-sha256-file'); + if (nodeAuth == minerAuth) return PairCompatibility.compatible; + return nodeAuth ? PairCompatibility.minerTooOld : PairCompatibility.nodeTooOld; + } catch (e) { + _log.w('Pair probe failed: $e'); + return null; + } + } +}