diff --git a/.cargo/config.toml.example b/.cargo/config.toml.example new file mode 100644 index 00000000..dc320563 --- /dev/null +++ b/.cargo/config.toml.example @@ -0,0 +1,9 @@ +# Copy to config.toml and set your OpenCL.lib path (Windows MSVC build). +# CI uses RUSTFLAGS instead; this file is for local dev builds. +# +# copy .cargo\config.toml.example .cargo\config.toml +# +# Or run: scripts\mining-amd\INSTALL-OPENCL-SDK.bat + +[target.x86_64-pc-windows-msvc] +rustflags = ["-Clink-arg=/LIBPATH:C:\\path\\to\\vcpkg\\installed\\x64-windows\\lib"] \ No newline at end of file diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 00000000..758e3702 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,31 @@ +name: CI + +on: + push: + branches: [main, master, develop] + pull_request: + branches: [main, master, develop] + +env: + CARGO_TERM_COLOR: always + +jobs: + test-app: + name: Tests (app crate) + runs-on: windows-latest + steps: + - uses: actions/checkout@v4 + - uses: dtolnay/rust-toolchain@stable + - uses: Swatinem/rust-cache@v2 + - name: Run app tests + run: cargo test -p app --lib + + build-miner-panel: + name: Build miner-panel (no OpenCL link) + runs-on: windows-latest + steps: + - uses: actions/checkout@v4 + - uses: dtolnay/rust-toolchain@stable + - uses: Swatinem/rust-cache@v2 + - name: Build GUI + run: cargo build --release -p miner-panel \ No newline at end of file diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 00000000..1180be96 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,95 @@ +name: Release (Windows miner) + +on: + push: + tags: + - "v*" + workflow_dispatch: + +permissions: + contents: write + +env: + CARGO_TERM_COLOR: always + RUST_BACKTRACE: 1 + +jobs: + build-windows: + name: Build & package (Windows x64) + runs-on: windows-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Install Rust + uses: dtolnay/rust-toolchain@stable + + - name: Cache cargo + uses: Swatinem/rust-cache@v2 + with: + shared-key: "release-windows-ocl" + + - name: Install OpenCL.lib (vcpkg) + shell: pwsh + run: | + $vcpkgRoot = "$env:GITHUB_WORKSPACE\vcpkg-ci" + git clone --depth 1 https://github.com/microsoft/vcpkg.git $vcpkgRoot + & "$vcpkgRoot\bootstrap-vcpkg.bat" -disableMetrics + & "$vcpkgRoot\vcpkg.exe" install opencl:x64-windows + $lib = "$vcpkgRoot\installed\x64-windows\lib" + "LIB=$lib" | Out-File -FilePath $env:GITHUB_ENV -Append + "RUSTFLAGS=-Clink-arg=/LIBPATH:$lib" | Out-File -FilePath $env:GITHUB_ENV -Append + + - name: Build miners + fullnode (OpenCL) + run: cargo build --release --features ocl + + - name: Build miner-panel (GUI) + run: cargo build --release -p miner-panel + + - name: Package release ZIPs (full + miner-only) + shell: pwsh + run: | + $version = "${{ github.ref_name }}" + if ($version -eq "${{ github.ref }}") { $version = "manual" } + & "$env:GITHUB_WORKSPACE\scripts\pack-release.ps1" -Version $version + + - name: Upload artifacts + uses: actions/upload-artifact@v4 + with: + name: hacash-miner-windows-x64 + path: dist/*.zip + retention-days: 30 + + - name: Publish GitHub Release + if: startsWith(github.ref, 'refs/tags/v') + uses: softprops/action-gh-release@v2 + with: + files: dist/*.zip + generate_release_notes: true + body: | + ## HAC Miner — Windows x64 · By Mosky + + Choose the package that fits your PC: + + | Download | When to use | + |----------|-------------| + | **`hacash-miner-full-*.zip`** | **Clean PC** — fullnode + miners + GUI + `SETUP.bat` | + | **`hacash-miner-only-*.zip`** | You **already have** `hacash.exe` + `hacash.config.ini` | + + ### Full package (recommended for new users) + 1. Extract `hacash-miner-full-*.zip` + 2. Run **`SETUP.bat`** + 3. Open **`miner-panel.exe`** → Settings → wallet → Start + + ### Miner only (existing fullnode) + 1. Extract `hacash-miner-only-*.zip` next to your fullnode **or** any folder + 2. Run **`SETUP-MINER.bat`** + 3. Ensure fullnode RPC is on `127.0.0.1:8080` + 4. Open **`miner-panel.exe`** + + ### Requirements + - Windows 10/11 x64 + - AMD Adrenalin or NVIDIA drivers (GPU / OpenCL) + - Visual C++ Redistributable 2015–2022 x64 (setup scripts can install) + + [docs/MINING-AMD.md](docs/MINING-AMD.md) \ No newline at end of file diff --git a/.gitignore b/.gitignore index c52c81de..635bb708 100644 --- a/.gitignore +++ b/.gitignore @@ -10,6 +10,32 @@ vscode-*/ vendor/ dist/ +.cargo/config.toml +.tools/ +vcpkg-ci/ + +# Local dev / test noise +*.log +*.err +*.err.log +*.out.log +build*.txt +*_output.txt +*_out.txt +*_err.txt +pw*.log +pw*.err +target2/ +*.vbs +x16rs/opencl/*.bin +demo_*.txt +test*.txt +lib_out.txt +rebuild_output.txt +cargo_build_output.txt +build-miner-panel.log +build-panel.log +miner-panel-build.log *.geany *.config.ini diff --git a/Cargo.toml b/Cargo.toml index 7978634d..57c9b6f7 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -8,6 +8,7 @@ edition = "2024" resolver = "2" members = [ ".", + "miner-panel", "app", "node", "chain", diff --git a/LIST-OPENCL.bat b/LIST-OPENCL.bat new file mode 100644 index 00000000..edf67f0c --- /dev/null +++ b/LIST-OPENCL.bat @@ -0,0 +1,13 @@ +@echo off +setlocal +title OpenCL devices +cd /d "%~dp0" +if not exist "list_opencl.exe" ( + echo list_opencl.exe not found. + pause + exit /b 1 +) +list_opencl.exe +echo. +pause +exit /b 0 \ No newline at end of file diff --git a/README-MINER-ONLY.txt b/README-MINER-ONLY.txt new file mode 100644 index 00000000..d21a5c93 --- /dev/null +++ b/README-MINER-ONLY.txt @@ -0,0 +1,30 @@ +HAC Miner — WORKERS ONLY (Windows x64) +By Mosky +====================================== + +FOR: You already run hacash.exe fullnode + hacash.config.ini + +QUICK START +----------- +1. Extract ZIP to a folder +2. Run SETUP-MINER.bat +3. Open miner-panel.exe → Settings → Start + +INCLUDED +-------- + miner-panel.exe GUI + poworker.exe HAC miner + diaworker.exe HACD miner + list_opencl.exe GPU device list + x16rs/opencl/ OpenCL kernels + +NOT INCLUDED (you must have these) +---------------------------------- + hacash.exe Fullnode + hacash.config.ini Wallet + [server] RPC port 8080 + +Default connect: 127.0.0.1:8080 + +CLEAN PC? +--------- +Download hacash-miner-FULL-windows-x64.zip instead. \ No newline at end of file diff --git a/README-RELEASE.txt b/README-RELEASE.txt new file mode 100644 index 00000000..78cc6aa8 --- /dev/null +++ b/README-RELEASE.txt @@ -0,0 +1,31 @@ +HAC Miner — FULL PACKAGE (Windows x64) +By Mosky +====================================== + +FOR: Clean PC — everything in one ZIP + +QUICK START +----------- +1. Extract ZIP to a folder (e.g. C:\HacashMiner) +2. Run SETUP.bat +3. Open miner-panel.exe → Settings → wallet → Start + +INCLUDED +-------- + hacash.exe Fullnode (solo RPC port 8080) + miner-panel.exe GUI setup + dashboard + poworker.exe HAC block miner + diaworker.exe HACD diamond miner + list_opencl.exe GPU platform/device list + SETUP.bat First-time setup + x16rs/opencl/ OpenCL kernels + +ALREADY HAVE FULLNODE? +---------------------- +Download hacash-miner-ONLY-windows-x64.zip (smaller, no hacash.exe) + +REQUIREMENTS +------------ + Windows 10/11 x64 + GPU drivers with OpenCL (AMD Adrenalin or NVIDIA) + Visual C++ Redistributable 2015-2022 x64 (SETUP.bat can install) \ No newline at end of file diff --git a/README.md b/README.md index da76c058..7e555b9e 100644 --- a/README.md +++ b/README.md @@ -1,7 +1,24 @@ -Hacash Fullnode +HAC Miner Panel · By Mosky === +Hacash fullnode + **OpenCL miners** (AMD / NVIDIA) + **GUI panel** for easy setup. +### Downloads (GitHub Releases) + +| Package | Use when | +|---------|----------| +| **`hacash-miner-full-windows-x64.zip`** | Clean PC — includes `hacash.exe`, workers, panel, `SETUP.bat` | +| **`hacash-miner-only-windows-x64.zip`** | You already run the fullnode — workers + panel only | + +After extract: run **`SETUP.bat`** (full) or **`SETUP-MINER.bat`** (miner-only), then **`miner-panel.exe`**. + +### AMD / Ryzen mining (HAC + HACD) + +Official miners use **OpenCL** (AMD Radeon + Ryzen CPU). Not CUDA. + +See **[docs/MINING-AMD.md](docs/MINING-AMD.md)** and `scripts/mining-amd/` for build scripts, GPU configs, and `list_opencl` device discovery. + +**Maintainers:** `git tag v0.4.0 && git push origin v0.4.0` → GitHub Actions builds both ZIPs (`.github/workflows/release.yml`). ### Module Architecture diff --git a/SETUP-MINER.bat b/SETUP-MINER.bat new file mode 100644 index 00000000..15b97517 --- /dev/null +++ b/SETUP-MINER.bat @@ -0,0 +1,113 @@ +@echo off +setlocal EnableDelayedExpansion +title HAC Miner - Workers Setup (no fullnode) +cd /d "%~dp0" + +echo. +echo ============================================ +echo HAC Miner - WORKERS ONLY +echo By Mosky +echo ============================================ +echo. +echo Use this package if you ALREADY have: +echo - hacash.exe fullnode running with RPC on port 8080 +echo - hacash.config.ini with your wallet +echo. +echo Clean PC? Use the FULL package instead. +echo. + +set "BIN=%~dp0" +cd /d "%BIN%" + +set "MISSING=0" +for %%E in (poworker.exe diaworker.exe list_opencl.exe miner-panel.exe) do ( + if not exist "%BIN%%%E" ( + echo [MISSING] %%E + set "MISSING=1" + ) +) +if "!MISSING!"=="1" ( + echo Download: hacash-miner-only-windows-x64.zip + pause + exit /b 1 +) +echo [OK] Miner executables found. +echo. + +if not exist "%BIN%x16rs\opencl\x16rs_main.cl" ( + echo [ERROR] Missing x16rs\opencl\ kernels. + pause + exit /b 1 +) +echo [OK] OpenCL kernels found. +echo. + +if not exist "%BIN%poworker.config.ini" ( + call :write_default_poworker_ini + echo [CREATED] poworker.config.ini +) +if not exist "%BIN%diaworker.config.ini" ( + copy /Y "%BIN%poworker.config.ini" "%BIN%diaworker.config.ini" >nul + echo [CREATED] diaworker.config.ini +) + +powershell -NoProfile -Command ^ + "$dir='%BIN:\=\\%';" ^ + "foreach($f in @('poworker.config.ini','diaworker.config.ini')){" ^ + " $p=Join-Path $dir $f; if(Test-Path $p){" ^ + " $t=Get-Content $p -Raw;" ^ + " $t=$t -replace '(?m)^opencl_dir\s*=.*','opencl_dir = x16rs/opencl/';" ^ + " $t=$t -replace '(?m)^connect\s*=.*','connect = 127.0.0.1:8080';" ^ + " Set-Content -Path $p -Value $t -NoNewline}}" + +set "VCRUNTIME_OK=0" +if exist "%SystemRoot%\System32\vcruntime140.dll" set "VCRUNTIME_OK=1" +if "!VCRUNTIME_OK!"=="0" ( + set /p "VCREDIST= Install VC++ Redistributable x64? [Y/N]: " + if /i "!VCREDIST!"=="Y" call :install_vcredist +) else ( + echo [OK] Visual C++ Runtime detected. +) +echo. + +echo Checking OpenCL... +echo ---------------------------------------- +"%BIN%list_opencl.exe" +echo ---------------------------------------- +echo. + +echo Setup complete. Ensure fullnode RPC is at 127.0.0.1:8080 +echo Then open miner-panel.exe and press Start. +echo. +set /p "LAUNCH= Open miner-panel now? [Y/N]: " +if /i "!LAUNCH!"=="Y" start "" "%BIN%miner-panel.exe" +pause +exit /b 0 + +:write_default_poworker_ini +( + echo connect = 127.0.0.1:8080 + echo supervene = 6 + echo. + echo [efficiency] + echo mode = profit + echo stats_file = miner-stats.json + echo. + echo [gpu] + echo use_opencl = true + echo gpu_profile = amd_profit + echo platform_id = 0 + echo device_ids = 0 + echo opencl_dir = x16rs/opencl/ + echo work_groups = 1536 + echo local_size = 256 + echo unit_size = 96 +) > "%BIN%poworker.config.ini" +exit /b 0 + +:install_vcredist +set "VCR_TMP=%TEMP%\vc_redist.x64.exe" +powershell -NoProfile -Command "Invoke-WebRequest -Uri 'https://aka.ms/vs/17/release/vc_redist.x64.exe' -OutFile '%VCR_TMP%' -UseBasicParsing" +start /wait "" "%VCR_TMP%" /install /quiet /norestart +del "%VCR_TMP%" >nul 2>&1 +exit /b 0 \ No newline at end of file diff --git a/SETUP.bat b/SETUP.bat new file mode 100644 index 00000000..b209a1e2 --- /dev/null +++ b/SETUP.bat @@ -0,0 +1,234 @@ +@echo off +setlocal EnableDelayedExpansion +title HAC Miner - First-Time Setup +cd /d "%~dp0" + +echo. +echo ============================================ +echo HAC Miner Setup (Windows) +echo By Mosky +echo ============================================ +echo. + +:: --- Locate miner folder (release zip OR dev build) --- +set "BIN=%~dp0" +if not exist "%BIN%miner-panel.exe" ( + if exist "%~dp0target\release\miner-panel.exe" ( + set "BIN=%~dp0target\release\" + ) else if exist "%~dp0target\debug\miner-panel.exe" ( + set "BIN=%~dp0target\debug\" + ) +) +cd /d "%BIN%" +echo Working folder: %BIN% +echo. + +set "SCRIPT_AMD=%~dp0scripts\mining-amd" +if not exist "%SCRIPT_AMD%" set "SCRIPT_AMD=%~dp0" + +:: --- 1. Check required executables --- +set "MISSING=0" +for %%E in (hacash.exe poworker.exe diaworker.exe list_opencl.exe miner-panel.exe) do ( + if not exist "%BIN%%%E" ( + echo [MISSING] %%E + set "MISSING=1" + ) +) +if "!MISSING!"=="1" ( + echo. + echo ERROR: Incomplete package. + echo Download hacash-miner-FULL-windows-x64.zip from GitHub Releases + echo or build from source: + echo scripts\mining-amd\BUILD-AMD-MINER.bat + echo scripts\mining-amd\BUILD-MINER-PANEL.bat + echo. + pause + exit /b 1 +) +echo [OK] All miner executables found. +echo. + +:: --- 2. Check OpenCL kernel files --- +if not exist "%BIN%x16rs\opencl\x16rs_main.cl" ( + echo [ERROR] Missing folder: x16rs\opencl\ + echo GPU mining will not work without the .cl kernel files. + echo. + pause + exit /b 1 +) +echo [OK] OpenCL kernels found. +echo. + +:: --- 3. Worker configs --- +if not exist "%BIN%poworker.config.ini" ( + if exist "%SCRIPT_AMD%poworker.amd.ini.example" ( + copy /Y "%SCRIPT_AMD%poworker.amd.ini.example" "%BIN%poworker.config.ini" >nul + ) else ( + call :write_default_poworker_ini + ) + echo [CREATED] poworker.config.ini +) else ( + echo [OK] poworker.config.ini exists. +) + +if not exist "%BIN%diaworker.config.ini" ( + if exist "%SCRIPT_AMD%diaworker.amd.ini.example" ( + copy /Y "%SCRIPT_AMD%diaworker.amd.ini.example" "%BIN%diaworker.config.ini" >nul + ) else ( + copy /Y "%BIN%poworker.config.ini" "%BIN%diaworker.config.ini" >nul 2>nul + ) + echo [CREATED] diaworker.config.ini +) else ( + echo [OK] diaworker.config.ini exists. +) + +:: Fix opencl_dir for flat release layout +powershell -NoProfile -Command ^ + "$dir='%BIN:\=\\%';" ^ + "foreach($f in @('poworker.config.ini','diaworker.config.ini')){" ^ + " $p=Join-Path $dir $f; if(Test-Path $p){" ^ + " $t=Get-Content $p -Raw;" ^ + " $t=$t -replace '(?m)^opencl_dir\s*=.*','opencl_dir = x16rs/opencl/';" ^ + " Set-Content -Path $p -Value $t -NoNewline}}" + +:: --- 4. Fullnode config template --- +if not exist "%BIN%hacash.config.ini" ( + if exist "%~dp0hacash.config.ini" ( + copy /Y "%~dp0hacash.config.ini" "%BIN%hacash.config.ini" >nul + echo [CREATED] hacash.config.ini (from template) + ) else ( + call :write_default_hacash_ini + echo [CREATED] hacash.config.ini (edit your wallet!) + ) +) else ( + echo [OK] hacash.config.ini exists. +) +echo. + +:: --- 5. Visual C++ Redistributable (required for MSVC-built .exe) --- +set "VCRUNTIME_OK=0" +where vcruntime140.dll >nul 2>&1 && set "VCRUNTIME_OK=1" +if exist "%SystemRoot%\System32\vcruntime140.dll" set "VCRUNTIME_OK=1" + +if "!VCRUNTIME_OK!"=="0" ( + echo [WARN] Visual C++ Runtime may be missing. + set /p "VCREDIST= Install VC++ Redistributable 2015-2022 x64 now? [Y/N]: " + if /i "!VCREDIST!"=="Y" call :install_vcredist +) else ( + echo [OK] Visual C++ Runtime detected. +) +echo. + +:: --- 6. OpenCL / GPU driver check --- +echo Checking OpenCL (GPU drivers)... +echo ---------------------------------------- +"%BIN%list_opencl.exe" +set "OCL_ERR=!ERRORLEVEL!" +echo ---------------------------------------- +if not "!OCL_ERR!"=="0" ( + echo. + echo [WARN] OpenCL not available or no GPU detected. + echo. + echo For GPU mining install: + echo AMD - Adrenalin drivers https://www.amd.com/en/support + echo NVIDIA - Game Ready driver https://www.nvidia.com/drivers + echo. + echo CPU-only fallback: set use_opencl = false in poworker.config.ini + echo. + set /p "OPEN_DRV= Open GPU driver download page in browser? [Y/N]: " + if /i "!OPEN_DRV!"=="Y" start https://www.amd.com/en/support/download/drivers.html +) else ( + echo. + echo [OK] OpenCL is working. Note platform_id and device_ids above. + echo Set them in miner-panel Settings or in poworker.config.ini +) +echo. + +:: --- 7. Copy logo if present --- +if exist "%~dp0miner-panel\assets\hhh.png" ( + if not exist "%BIN%hhh.png" copy /Y "%~dp0miner-panel\assets\hhh.png" "%BIN%hhh.png" >nul +) + +:: --- Done --- +echo ============================================ +echo Setup complete! +echo ============================================ +echo. +echo Next steps: +echo 1. Open miner-panel.exe +echo 2. Settings - pick CPU/GPU, enter wallet, Save +echo 3. Start mining (panel can auto-start fullnode) +echo. +echo Solo mining needs hacash.exe running with RPC on port 8080. +echo Edit hacash.config.ini - set [miner] reward wallet before first run. +echo. + +set /p "LAUNCH= Open HAC Miner Panel now? [Y/N]: " +if /i "!LAUNCH!"=="Y" ( + start "" "%BIN%miner-panel.exe" +) + +pause +exit /b 0 + +:: ---------- helpers ---------- + +:write_default_poworker_ini +( + echo connect = 127.0.0.1:8080 + echo supervene = 6 + echo. + echo [efficiency] + echo mode = profit + echo power_cost_kwh = 0.15 + echo stats_file = miner-stats.json + echo. + echo [gpu] + echo use_opencl = true + echo cpu_assist = true + echo gpu_profile = amd_profit + echo platform_id = 0 + echo device_ids = 0 + echo opencl_dir = x16rs/opencl/ + echo work_groups = 1536 + echo local_size = 256 + echo unit_size = 96 +) > "%BIN%poworker.config.ini" +exit /b 0 + +:write_default_hacash_ini +( + echo [server] + echo enable = true + echo listen = 8080 + echo diamond_form = true + echo. + echo [miner] + echo enable = true + echo reward = YOUR_HAC_WALLET_ADDRESS + echo. + echo [diamondminer] + echo enable = false + echo reward = YOUR_HACD_PRIVAKEY_3x +) > "%BIN%hacash.config.ini" +exit /b 0 + +:install_vcredist +set "VCR_TMP=%TEMP%\vc_redist.x64.exe" +echo Downloading VC++ Redistributable... +powershell -NoProfile -Command ^ + "try { Invoke-WebRequest -Uri 'https://aka.ms/vs/17/release/vc_redist.x64.exe' -OutFile '%VCR_TMP%' -UseBasicParsing; exit 0 } catch { exit 1 }" +if errorlevel 1 ( + echo [ERROR] Download failed. Install manually: + echo https://aka.ms/vs/17/release/vc_redist.x64.exe + exit /b 0 +) +echo Installing (may need Administrator)... +start /wait "" "%VCR_TMP%" /install /quiet /norestart +if errorlevel 1 ( + echo [WARN] Silent install failed. Running interactive installer... + start /wait "" "%VCR_TMP%" +) +del "%VCR_TMP%" >nul 2>&1 +echo [OK] VC++ Redistributable install finished. +exit /b 0 \ No newline at end of file diff --git a/START-MINER-PANEL.bat b/START-MINER-PANEL.bat new file mode 100644 index 00000000..1e9e3152 --- /dev/null +++ b/START-MINER-PANEL.bat @@ -0,0 +1,12 @@ +@echo off +setlocal +title HAC Miner Panel +cd /d "%~dp0" +if not exist "miner-panel.exe" ( + echo miner-panel.exe not found in this folder. + echo Run SETUP.bat first or extract the full release ZIP. + pause + exit /b 1 +) +start "" "%~dp0miner-panel.exe" +exit /b 0 \ No newline at end of file diff --git a/app/Cargo.toml b/app/Cargo.toml index da90f07b..d78b8d82 100644 --- a/app/Cargo.toml +++ b/app/Cargo.toml @@ -12,7 +12,7 @@ protocol = {path = "../protocol"} mint = {path = "../mint"} hex = "0.4.3" axum = "0.7.9" -serde = "1.0.215" +serde = { version = "1.0.215", features = ["derive"] } serde_json = "1.0.133" getrandom = "0.3.2" ctrlc = "3.4.5" @@ -25,4 +25,5 @@ ocl = ["dep:ocl"] [dev-dependencies] x16rs-sys = "0.1.1" testkit = { path = "../testkit" } +sys = { path = "../sys" } diff --git a/app/src/diaworker.rs b/app/src/diaworker.rs index da2156c1..7d076f10 100644 --- a/app/src/diaworker.rs +++ b/app/src/diaworker.rs @@ -1,4 +1,3 @@ -#[cfg(feature = "ocl")] use std::sync::Arc; use std::sync::atomic::{AtomicU32, Ordering::*}; use std::sync::{RwLock, mpsc}; @@ -9,6 +8,8 @@ use std::time::*; use reqwest::blocking::Client as HttpClient; use serde_json::Value as JV; +use crate::efficiency::*; + use basis::difficulty::*; use field::*; use mint::action::*; @@ -37,25 +38,38 @@ pub struct DiaWorkConf { pub debug: u32, // enable debug mode pub platformid: u32, // opencl platform id pub deviceids: String, // opencl device id list + pub cpu_assist: bool, + pub gpu_profile: String, + pub efficiency: EfficiencyConf, + pub runtime: Arc, } impl DiaWorkConf { pub fn new(ini: &IniObj) -> DiaWorkConf { let sec = &ini_section(ini, "default"); // default = root let sec_gpu = &ini_section(ini, "gpu"); + let efficiency = EfficiencyConf::from_ini(ini); + let tuning = resolve_gpu_tuning(sec_gpu, &efficiency); + let configured_supervene = ini_must_u64(sec, "supervene", 2) as u32; + let active = efficiency.initial_active_supervene(configured_supervene); + let runtime = MiningRuntimeState::new(tuning.workgroups, active); let cnf = DiaWorkConf { rpcaddr: ini_must(sec, "connect", "127.0.0.1:8081"), - supervene: ini_must_u64(sec, "supervene", 2) as u32, + supervene: configured_supervene, bidaddr: Address::default(), rewardaddr: Address::default(), useopencl: ini_must_bool(sec_gpu, "use_opencl", false) as bool, - workgroups: ini_must_u64(sec_gpu, "work_groups", 1024) as u32, + workgroups: tuning.workgroups, localsize: ini_must_u64(sec_gpu, "local_size", 256) as u32, - unitsize: ini_must_u64(sec_gpu, "unit_size", 128) as u32, + unitsize: tuning.unitsize, opencldir: ini_must(sec_gpu, "opencl_dir", "opencl/"), debug: ini_must_u64(sec_gpu, "debug", 0) as u32, platformid: ini_must_u64(sec_gpu, "platform_id", 0) as u32, deviceids: ini_must(sec_gpu, "device_ids", ""), + cpu_assist: ini_must_bool(sec_gpu, "cpu_assist", true) as bool, + gpu_profile: tuning.profile, + efficiency, + runtime, }; cnf } @@ -84,16 +98,21 @@ struct DiamondMiningResult { dia_str: [u8; 16], is_success: Option, use_secs: f64, + is_gpu: bool, + gpu_batch_ok: bool, } /* * Diamond worker */ pub fn diaworker() { - // config let cnfp = "./diaworker.config.ini".to_string(); - let inicnf = sys::load_config(cnfp); + let inicnf = sys::load_config(cnfp.clone()); let mut cnf = DiaWorkConf::new(&inicnf); + if cnf.efficiency.benchmark_seconds > 0 { + run_diamond_mining_benchmark(&cnf, &cnfp); + return; + } // test start // cnf.supervene = 1; @@ -123,9 +142,14 @@ pub fn diaworker() { // Calculate device/cpu quantity #[cfg(feature = "ocl")] let vene: u32 = if cnf.useopencl { - opencl_resources.len() as u32 + let gpu = opencl_resources.len() as u32; + if cnf.cpu_assist { + gpu.saturating_add(cnf.efficiency.spawn_supervene(cnf.supervene)) + } else { + gpu + } } else { - cnf.supervene + cnf.efficiency.clamp_supervene(cnf.supervene) }; #[cfg(not(feature = "ocl"))] let vene: u32 = cnf.supervene; @@ -170,7 +194,7 @@ pub fn diaworker() { println!( "[Warning] use_opencl=true but app built without feature 'ocl'; fallback to CPU mining." ); - let thrnum = cnf.supervene as usize; + let thrnum = cnf.efficiency.clamp_supervene(cnf.supervene) as usize; println!("\n[Start] Create #{} diamond miner worker thread.", thrnum); for thrid in 0..thrnum { let cnf2 = cnf.clone(); @@ -183,8 +207,29 @@ pub fn diaworker() { }); } } + + if cnf.cpu_assist && cnf.supervene > 0 { + #[cfg(feature = "ocl")] + { + let thrnum = cnf.efficiency.spawn_supervene(cnf.supervene) as usize; + println!( + "\n[Start] Create #{} Ryzen CPU assist threads for diamonds (hybrid).", + thrnum + ); + for thrid in 0..thrnum { + let cnf2 = cnf.clone(); + let rstx = res_tx.clone(); + spawn(move || { + loop { + run_diamond_worker_thread(&cnf2, thrid, rstx.clone()); + delay_continue_ms!(9); + } + }); + } + } + } } else { - let thrnum = cnf.supervene as usize; + let thrnum = cnf.efficiency.clamp_supervene(cnf.supervene) as usize; println!("\n[Start] Create #{} diamond miner worker thread.", thrnum); for thrid in 0..thrnum { let cnf2 = cnf.clone(); @@ -200,6 +245,23 @@ pub fn diaworker() { // pull loop loop { + if !is_within_idle_schedule( + cnf.efficiency.idle_start_hour, + cnf.efficiency.idle_end_hour, + ) { + delay_continue!(5); + continue; + } + if cnf.runtime.paused_unprofitable.load(Relaxed) { + delay_continue!(3); + continue; + } + cnf.runtime.apply_thermal_throttle( + cnf.efficiency.max_temp_c, + cnf.efficiency.throttle_workgroups, + &cnf.efficiency.thermal_file, + cnf.efficiency.thermal_gpu_index, + ); pull_and_push_diamond(&cnf); delay_continue!(MINING_INTERVAL as u64); } @@ -215,11 +277,18 @@ fn deal_diamond_mining_results( let mut most = DiamondMiningResult::default(); most.dia_str = [b'w'; 16]; let mut total_nonce_space = 0u64; + let mut gpu_nonce_space = 0u64; + let mut cpu_nonce_space = 0u64; let mut total_use_secs = 0.0; let mut recv_count = 0; while let Ok(res) = result_ch_rx.try_recv() { deal_number = res.number; total_nonce_space += res.nonce_space as u64; + if res.is_gpu { + gpu_nonce_space += res.nonce_space as u64; + } else { + cpu_nonce_space += res.nonce_space as u64; + } total_use_secs += res.use_secs; if diamond_more_power(&res.dia_str, &most.dia_str) { most = res.clone(); @@ -249,16 +318,39 @@ fn deal_diamond_mining_results( } else { 0.0 }; + let active_cpu = cnf.runtime.active_cpu_assist.load(Relaxed); + cnf.runtime.maybe_adjust_supervene(&cnf.efficiency, gpu_nonce_space, cpu_nonce_space); + if should_pause_for_diamond_profit(&cnf.efficiency, &cnf.gpu_profile, active_cpu) { + cnf.runtime.paused_unprofitable.store(true, Relaxed); + println!( + "\n[efficiency] HACD mining paused — daily power cost exceeds configured revenue target (hac_price)." + ); + } else { + cnf.runtime.paused_unprofitable.store(false, Relaxed); + } + let paused = cnf.runtime.paused_unprofitable.load(Relaxed); + let gpu_w = cnf.efficiency.estimate_gpu_watts(&cnf.gpu_profile); + let watts = gpu_w + active_cpu as f64 * cnf.efficiency.cpu_watts_per_thread; let hashrate_show = rates_to_show(nonce_rates); flush!( - "[{}] {} {}, {} {}, {}. \r", + "[{}] {} | {}W | {} | {} -> {}. \r", deal_number, - most.nonce_start, - total_nonce_space, + hashrate_show, + watts as u32, diastr, most_diastr, - hashrate_show + cnf.gpu_profile + ); + let stats = build_diamond_mining_stats( + nonce_rates, + &cnf.efficiency, + &cnf.gpu_profile, + active_cpu, + deal_number, + &most_diastr, + paused, ); + write_mining_stats(&cnf.efficiency.stats_file, &stats); // print next may_print_turn_to_nex_diamond_mining(deal_number, Some(most_dia_str)); @@ -283,13 +375,25 @@ fn may_print_turn_to_nex_diamond_mining(curr_number: u32, most_dia_str: Option<& // fn run_diamond_worker_thread( cnf: &DiaWorkConf, - _thrid: usize, + thrid: usize, result_ch_tx: mpsc::Sender, ) { + if mining_is_gated(&cnf.runtime, &cnf.efficiency) { + delay_return_ms!(2000); + return; + } let cmdn = MINING_DIAMOND_NUM.load(Relaxed); if cmdn == 0 { delay_return_ms!(99); // not yet } + #[cfg(feature = "ocl")] + if cnf.useopencl && cnf.cpu_assist { + let active = cnf.runtime.active_cpu_assist.load(Relaxed); + if (thrid as u32) >= active { + delay_return_ms!(400); + return; + } + } let rwd_addr = cnf.rewardaddr.clone(); @@ -321,6 +425,8 @@ fn run_diamond_worker_thread( // println!("do_diamond_group_mining: {:?}", &result); let use_secs = Instant::now().duration_since(ctn).as_millis() as f64 / 1000.0; result.use_secs = use_secs; + result.is_gpu = false; + result.gpu_batch_ok = true; result_ch_tx.send(result).unwrap(); // channel send let ns = nonce_start.checked_add(nonce_space); if let None = ns { @@ -346,13 +452,16 @@ fn run_diamond_worker_thread_opencl( result_ch_tx: mpsc::Sender, opencl: Arc, ) { + if mining_is_gated(&cnf.runtime, &cnf.efficiency) { + delay_return_ms!(2000); + return; + } let cmdn = MINING_DIAMOND_NUM.load(Relaxed); if cmdn == 0 { delay_return_ms!(99); // not yet } let rwd_addr = cnf.rewardaddr.clone(); - let nonce_space: u64 = (cnf.workgroups * cnf.localsize * cnf.unitsize) as u64; let current_mining_number: u32 = cmdn; let current_mining_block_hash: Hash = { MINING_DIAMOND_STUFF.read().unwrap().clone() }; @@ -362,6 +471,12 @@ fn run_diamond_worker_thread_opencl( let mut nonce_start = 0; loop { + let wg_eff = cnf + .runtime + .workgroups(opencl.workgroups.min(cnf.workgroups)); + let gpu_nonce_space = (wg_eff as u64) + .saturating_mul(cnf.localsize as u64) + .saturating_mul(cnf.unitsize as u64); let ctn = Instant::now(); let mut result = do_diamond_group_mining_opencl( &opencl, @@ -370,16 +485,27 @@ fn run_diamond_worker_thread_opencl( &rwd_addr, &custom_nonce, nonce_start, - nonce_space, - cnf.workgroups, + gpu_nonce_space, + wg_eff, cnf.localsize, cnf.unitsize, ); + result.is_gpu = true; + result.nonce_space = gpu_nonce_space; let use_secs = Instant::now().duration_since(ctn).as_millis() as f64 / 1000.0; result.use_secs = use_secs; + if !result.gpu_batch_ok { + let wg_cap = cnf + .runtime + .workgroups(opencl.workgroups.min(cnf.workgroups)); + cnf.runtime + .record_gpu_error(wg_cap, cnf.efficiency.oom_fallback); + delay_return_ms!(50); + return; + } result_ch_tx.send(result).unwrap(); - let ns = nonce_start.checked_add(nonce_space); + let ns = nonce_start.checked_add(gpu_nonce_space); if let None = ns { break; } @@ -416,6 +542,8 @@ fn do_diamond_group_mining( dia_str: [b'W'; 16], is_success: None, use_secs: 0.0, + is_gpu: false, + gpu_batch_ok: true, }; let mut most_firhx = [0u8; HASH_WIDTH]; let mut most_resxh = [0u8; HASH_WIDTH]; @@ -602,3 +730,98 @@ fn push_diamond_mining_success(cnf: &DiaWorkConf, success: DiamondMint) { tx_hash ); } + +fn run_diamond_mining_benchmark(cnf: &DiaWorkConf, config_path: &str) { + #[cfg(not(feature = "ocl"))] + { + let _ = (cnf, config_path); + println!("[benchmark] Rebuild diaworker with --features ocl"); + return; + } + #[cfg(feature = "ocl")] + { + if !cnf.useopencl { + println!("[benchmark] Set use_opencl=true in [gpu]"); + return; + } + println!("[benchmark] HACD: GPU tuning uses same profiles as HAC — run poworker benchmark or share ini."); + let opencl_resources = initialize_opencl( + true, + &cnf.opencldir, + &cnf.platformid, + &cnf.deviceids, + &cnf.workgroups, + &cnf.localsize, + &cnf.unitsize, + ); + if opencl_resources.is_empty() { + return; + } + let opencl = &opencl_resources[0]; + let profiles = benchmark_profiles_for_vendor(opencl.vendor); + let per = (cnf.efficiency.benchmark_seconds.max(15) as u64 / profiles.len() as u64).max(4); + let mut best_hps = 0.0f64; + let mut best_profit = 0.0f64; + let mut best_hps_profile = profiles[0]; + let mut best_profit_profile = profiles[0]; + let prev = Hash::default(); + let addr = cnf.rewardaddr.clone(); + let msg = Hash::default(); + for profile in profiles { + let (wg, us) = profile_tuning(profile); + let wg_eff = cnf.runtime.workgroups(opencl.workgroups.min(wg)); + let batch = wg_eff as u64 * cnf.localsize as u64 * us as u64; + let deadline = Instant::now() + Duration::from_secs(per); + let mut total = 0u64; + let mut secs = 0.0f64; + let mut nonce = 0u64; + while Instant::now() < deadline { + let ctn = Instant::now(); + let res = do_diamond_group_mining_opencl( + opencl, + 1, + &prev, + &addr, + &msg, + nonce, + batch, + wg_eff, + cnf.localsize, + us, + ); + if res.gpu_batch_ok { + total += batch; + } + secs += ctn.elapsed().as_secs_f64(); + nonce = nonce.wrapping_add(batch); + } + let hps = if secs > 0.0 { total as f64 / secs } else { 0.0 }; + let watts = cnf.efficiency.estimate_gpu_watts(profile); + let kh_per_j = if watts > 0.0 { + hps / watts / 1000.0 + } else { + 0.0 + }; + println!( + "[benchmark] HACD {}: {} ({:.1} kH/J)", + profile, + rates_to_show(hps), + kh_per_j + ); + if hps > best_hps { + best_hps = hps; + best_hps_profile = profile; + } + if kh_per_j > best_profit { + best_profit = kh_per_j; + best_profit_profile = profile; + } + } + let best_profile = match cnf.efficiency.mode { + EfficiencyMode::Max => best_hps_profile, + _ => best_profit_profile, + }; + let pick = BenchmarkPick::from_profile(best_profile); + let _ = apply_benchmark_pick(config_path, &pick); + } +} diff --git a/app/src/efficiency.rs b/app/src/efficiency.rs new file mode 100644 index 00000000..8201cd77 --- /dev/null +++ b/app/src/efficiency.rs @@ -0,0 +1,909 @@ +use std::collections::HashMap; +use std::fs; +use std::sync::atomic::{AtomicBool, AtomicU32, AtomicU64, Ordering::*}; +use std::sync::Arc; +use std::time::{SystemTime, UNIX_EPOCH}; + +use basis::difficulty::rates_to_show; +use sys::{ini_must, ini_must_bool, ini_must_f64, ini_must_u64}; + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum EfficiencyMode { + Max, + Profit, + Eco, +} + +impl EfficiencyMode { + pub fn from_str(s: &str) -> EfficiencyMode { + match s.trim().to_lowercase().as_str() { + "eco" | "amd_eco" => EfficiencyMode::Eco, + "profit" | "amd_profit" => EfficiencyMode::Profit, + _ => EfficiencyMode::Max, + } + } + + pub fn label(self) -> &'static str { + match self { + EfficiencyMode::Eco => "eco", + EfficiencyMode::Profit => "profit", + EfficiencyMode::Max => "max", + } + } +} + +#[derive(Clone, Debug)] +pub struct GpuTuning { + pub profile: String, + pub workgroups: u32, + pub unitsize: u32, +} + +#[derive(Clone, Debug)] +pub struct EfficiencyConf { + pub mode: EfficiencyMode, + pub power_cost_kwh: f64, + pub gpu_watts: f64, + pub cpu_watts_per_thread: f64, + pub hac_price: f64, + pub dynamic_supervene: bool, + pub supervene_min: u32, + pub supervene_max: u32, + pub oom_fallback: bool, + pub max_temp_c: u32, + pub throttle_workgroups: u32, + pub thermal_file: String, + pub idle_start_hour: u32, + pub idle_end_hour: u32, + pub pause_if_unprofitable: bool, + pub benchmark_seconds: u32, + /// Fine-grained work_groups sweep after profile pick (default on when benchmark >= 60s). + pub benchmark_fine_sweep: bool, + /// GPU index for nvidia-smi temperature (0 = first GPU). + pub thermal_gpu_index: u32, + /// JSON stats for miner-panel GUI (e.g. miner-stats.json) + pub stats_file: String, +} + +impl EfficiencyConf { + pub fn from_ini(ini: &sys::IniObj) -> EfficiencyConf { + let sec = sys::ini_section(ini, "efficiency"); + let mode_raw = ini_must(sec, "mode", "profit"); + let supervene_max = ini_must_u64(sec, "supervene_max", 0) as u32; + let supervene_min = ini_must_u64(sec, "supervene_min", 2) as u32; + EfficiencyConf { + mode: EfficiencyMode::from_str(&mode_raw), + power_cost_kwh: ini_must_f64(sec, "power_cost_kwh", 0.15), + gpu_watts: ini_must_f64(sec, "gpu_watts", 0.0), + cpu_watts_per_thread: ini_must_f64(sec, "cpu_watts_per_thread", 8.0), + hac_price: ini_must_f64(sec, "hac_price", 0.0), + dynamic_supervene: ini_must_bool(sec, "dynamic_supervene", true), + supervene_min: supervene_min.max(0), + supervene_max, + oom_fallback: ini_must_bool(sec, "oom_fallback", true), + max_temp_c: ini_must_u64(sec, "max_temp_c", 0) as u32, + throttle_workgroups: ini_must_u64(sec, "throttle_work_groups", 1024) as u32, + thermal_file: ini_must(sec, "thermal_file", ""), + idle_start_hour: ini_must_u64(sec, "idle_start_hour", 255) as u32, + idle_end_hour: ini_must_u64(sec, "idle_end_hour", 255) as u32, + pause_if_unprofitable: ini_must_bool(sec, "pause_if_unprofitable", false), + benchmark_seconds: ini_must_u64(sec, "benchmark_seconds", 0) as u32, + benchmark_fine_sweep: ini_must_bool(sec, "benchmark_fine_sweep", true), + thermal_gpu_index: ini_must_u64(sec, "thermal_gpu_index", 0) as u32, + stats_file: ini_must(sec, "stats_file", ""), + } + } + + pub fn wants_fine_sweep(&self) -> bool { + self.benchmark_fine_sweep && self.benchmark_seconds >= 60 + } + + pub fn clamp_supervene(&self, configured: u32) -> u32 { + let mut sv = configured.max(1); + if self.supervene_max > 0 { + sv = sv.min(self.supervene_max); + } + sv.max(self.supervene_min.max(1)) + } + + pub fn spawn_supervene(&self, configured: u32) -> u32 { + if self.dynamic_supervene && self.supervene_max > 0 { + self.clamp_supervene(self.supervene_max) + } else { + self.clamp_supervene(configured) + } + } + + pub fn initial_active_supervene(&self, configured: u32) -> u32 { + self.clamp_supervene(configured) + } + + pub fn estimate_gpu_watts(&self, profile: &str) -> f64 { + if self.gpu_watts > 0.0 { + return self.gpu_watts; + } + match profile { + "amd_eco" => 180.0, + "amd_balanced" => 220.0, + "amd_profit" => 260.0, + "amd_performance" => 300.0, + "amd_max" => 350.0, + "nvidia_eco" => 150.0, + "nvidia_balanced" => 190.0, + "nvidia_profit" => 230.0, + "nvidia_performance" => 280.0, + "nvidia_max" => 350.0, + "intel_balanced" => 75.0, + _ => 280.0, + } + } + + pub fn daily_power_cost_eur(&self, profile: &str, active_cpu_threads: u32) -> f64 { + let gpu_w = self.estimate_gpu_watts(profile); + let cpu_w = active_cpu_threads as f64 * self.cpu_watts_per_thread; + (gpu_w + cpu_w) * 24.0 / 1000.0 * self.power_cost_kwh + } + + pub fn hashes_per_joule(&self, hashrate: f64, profile: &str, active_cpu_threads: u32) -> f64 { + let gpu_w = self.estimate_gpu_watts(profile); + let cpu_w = active_cpu_threads as f64 * self.cpu_watts_per_thread; + let watts = gpu_w + cpu_w; + if watts <= 0.0 || !hashrate.is_finite() || hashrate <= 0.0 { + return 0.0; + } + hashrate / watts + } +} + +pub struct MiningRuntimeState { + pub base_workgroups: AtomicU32, + pub effective_workgroups: AtomicU32, + pub active_cpu_assist: AtomicU32, + pub gpu_errors: AtomicU32, + pub throttled: AtomicBool, + pub paused_unprofitable: AtomicBool, + pub adjust_counter: AtomicU64, +} + +impl MiningRuntimeState { + pub fn new(workgroups: u32, active_cpu: u32) -> Arc { + let wg = workgroups.max(1); + Arc::new(MiningRuntimeState { + base_workgroups: AtomicU32::new(wg), + effective_workgroups: AtomicU32::new(wg), + active_cpu_assist: AtomicU32::new(active_cpu.max(1)), + gpu_errors: AtomicU32::new(0), + throttled: AtomicBool::new(false), + paused_unprofitable: AtomicBool::new(false), + adjust_counter: AtomicU64::new(0), + }) + } + + pub fn workgroups(&self, configured: u32) -> u32 { + let eff = self.effective_workgroups.load(Relaxed).max(1); + eff.min(configured.max(1)) + } + + pub fn record_gpu_error(&self, configured: u32, oom_fallback: bool) -> u32 { + self.gpu_errors.fetch_add(1, Relaxed); + if !oom_fallback { + return self.workgroups(configured); + } + let cur = self.effective_workgroups.load(Relaxed).max(1); + let next = (cur / 2).max(256); + if next < cur { + eprintln!( + "[efficiency] OpenCL error — reducing work_groups {} -> {}", + cur, next + ); + self.effective_workgroups.store(next, Relaxed); + } + next + } + + pub fn apply_thermal_throttle( + &self, + max_temp_c: u32, + throttle_wg: u32, + thermal_file: &str, + gpu_index: u32, + ) -> bool { + if max_temp_c == 0 { + return false; + } + let Some(temp) = read_thermal_c_with_gpu(thermal_file, gpu_index) else { + return self.throttled.load(Relaxed); + }; + let temp_c = temp as u32; + if temp_c >= max_temp_c { + let wg = throttle_wg.max(256); + self.effective_workgroups.store(wg, Relaxed); + self.throttled.store(true, Relaxed); + return true; + } + if self.throttled.load(Relaxed) && temp_c + 5 < max_temp_c { + let base = self.base_workgroups.load(Relaxed).max(256); + self.effective_workgroups.store(base, Relaxed); + self.throttled.store(false, Relaxed); + println!( + "[efficiency] Thermal OK ({}C) — restored work_groups to {}", + temp_c, base + ); + } + false + } + + pub fn maybe_adjust_supervene( + &self, + eff: &EfficiencyConf, + gpu_nonce: u64, + cpu_nonce: u64, + ) { + if !eff.dynamic_supervene || eff.supervene_max == 0 { + return; + } + let n = self.adjust_counter.fetch_add(1, Relaxed); + if n % 12 != 0 { + return; + } + let total = gpu_nonce.saturating_add(cpu_nonce); + if total == 0 { + return; + } + let gpu_ratio = gpu_nonce as f64 / total as f64; + let cur = self.active_cpu_assist.load(Relaxed); + let min = eff.supervene_min.max(1); + let max = eff.supervene_max.max(min); + if gpu_ratio > 0.90 && cur > min { + self.active_cpu_assist.store(cur - 1, Relaxed); + } else if gpu_ratio < 0.70 && cur < max { + self.active_cpu_assist.store(cur + 1, Relaxed); + } + } +} + +pub fn resolve_gpu_tuning(sec_gpu: &HashMap>, eff: &EfficiencyConf) -> GpuTuning { + let profile_ini = ini_must(sec_gpu, "gpu_profile", ""); + let profile = if profile_ini.is_empty() { + match eff.mode { + EfficiencyMode::Eco => "amd_eco", + EfficiencyMode::Profit => "amd_profit", + EfficiencyMode::Max => "amd_performance", + } + .to_string() + } else { + profile_ini + }; + + let wg_ini = sec_gpu.get("work_groups").and_then(|v| v.as_ref()); + let us_ini = sec_gpu.get("unit_size").and_then(|v| v.as_ref()); + let mut workgroups = ini_must_u64(sec_gpu, "work_groups", 1024) as u32; + let mut unitsize = ini_must_u64(sec_gpu, "unit_size", 128) as u32; + // Apply profile defaults only when work_groups / unit_size are not set in ini + // (benchmark autotune writes explicit values that must be preserved). + if wg_ini.is_none() || us_ini.is_none() { + let (wg, us) = profile_tuning(&profile); + if wg_ini.is_none() { + workgroups = wg; + } + if us_ini.is_none() { + unitsize = us; + } + } + GpuTuning { + profile, + workgroups, + unitsize, + } +} + +/// Fixed work_groups / unit_size for named gpu_profile presets. +pub fn profile_tuning(profile: &str) -> (u32, u32) { + match profile { + "amd_eco" => (768, 128), + "amd_balanced" => (1024, 128), + "amd_profit" => (1536, 96), + "amd_performance" => (2048, 96), + "amd_max" => (4096, 128), + "nvidia_eco" => (512, 128), + "nvidia_balanced" => (1024, 128), + "nvidia_profit" => (1280, 96), + "nvidia_performance" => (1792, 96), + "nvidia_max" => (3584, 128), + "intel_balanced" => (512, 128), + _ => (1536, 96), + } +} + +/// Profiles to test during autotune for a given GPU vendor. +pub fn benchmark_profiles_for_vendor(vendor: crate::gpu_arch::GpuVendor) -> &'static [&'static str] { + match vendor { + crate::gpu_arch::GpuVendor::Nvidia => &[ + "nvidia_eco", + "nvidia_balanced", + "nvidia_profit", + "nvidia_performance", + "nvidia_max", + ], + crate::gpu_arch::GpuVendor::Intel => &[ + "intel_balanced", + "amd_eco", + "amd_balanced", + ], + _ => &[ + "amd_eco", + "amd_balanced", + "amd_profit", + "amd_performance", + "amd_max", + ], + } +} + +#[derive(Clone, Debug)] +pub struct BenchmarkPick { + pub profile: String, + pub workgroups: u32, + pub unitsize: u32, +} + +impl BenchmarkPick { + pub fn from_profile(profile: &str) -> BenchmarkPick { + let (wg, us) = profile_tuning(profile); + BenchmarkPick { + profile: profile.to_string(), + workgroups: wg, + unitsize: us, + } + } +} + +/// Candidate work_groups values for fine sweep around a base profile. +pub fn sweep_workgroup_candidates( + base_wg: u32, + vram_bytes: u64, + localsize: u32, + unitsize: u32, +) -> Vec { + let min = (base_wg / 2).max(256); + let max = base_wg.saturating_mul(3) / 2; + let mut wg = min; + let mut out = Vec::new(); + while wg <= max { + let clamped = if vram_bytes > 0 { + clamp_workgroups_for_vram(vram_bytes, localsize, unitsize, wg) + } else { + wg + }; + if clamped >= 256 && !out.contains(&clamped) { + out.push(clamped); + } + wg = wg.saturating_add(256); + } + if out.is_empty() { + out.push(base_wg.max(256)); + } + out +} + +/// Candidate unit_size values for fine benchmark sweep around a profile pick. +pub fn sweep_unitsize_candidates(base_us: u32, max_us: u32) -> Vec { + let cap = max_us.max(base_us).clamp(64, 160); + let mut raw = vec![ + base_us.saturating_sub(32).max(64), + base_us, + base_us.saturating_add(32).min(cap), + ]; + raw.sort_unstable(); + raw.dedup(); + raw.retain(|&us| us >= 64 && us <= cap); + if raw.is_empty() { + raw.push(base_us.max(64)); + } + raw +} + +/// Patch poworker/diaworker ini after benchmark autotune. +pub fn apply_benchmark_pick(path: &str, pick: &BenchmarkPick) -> std::io::Result<()> { + let content = fs::read_to_string(path)?; + let mut out = String::new(); + let mut in_gpu = false; + for line in content.lines() { + let trimmed = line.trim(); + if trimmed.starts_with('[') { + in_gpu = trimmed.eq_ignore_ascii_case("[gpu]"); + } + let mut replaced = line.to_string(); + if in_gpu && trimmed.starts_with("gpu_profile") && trimmed.contains('=') { + replaced = format!("gpu_profile = {}", pick.profile); + } else if trimmed.starts_with("benchmark_seconds") && trimmed.contains('=') { + replaced = "benchmark_seconds = 0".to_string(); + } else if in_gpu && trimmed.starts_with("work_groups") && trimmed.contains('=') { + replaced = format!("work_groups = {}", pick.workgroups); + } else if in_gpu && trimmed.starts_with("unit_size") && trimmed.contains('=') { + replaced = format!("unit_size = {}", pick.unitsize); + } + out.push_str(&replaced); + out.push('\n'); + } + fs::write(path, out)?; + println!( + "[benchmark] Applied gpu_profile={} (work_groups={}, unit_size={}) to {}", + pick.profile, pick.workgroups, pick.unitsize, path + ); + Ok(()) +} + +pub fn apply_benchmark_to_ini(path: &str, profile: &str) -> std::io::Result<()> { + apply_benchmark_pick(path, &BenchmarkPick::from_profile(profile)) +} + +pub fn estimate_vram_bytes(workgroups: u32, localsize: u32, unitsize: u32) -> u64 { + let wg = workgroups as u64; + let ls = localsize as u64; + let us = unitsize as u64; + let global_items = wg * ls; + let global_hashes = 32 * us * global_items; + let global_order = 4 * us * global_items; + let best_hashes = 32 * wg; + let best_nonces = 8 * wg; + let stuff = 512; + global_hashes + global_order + best_hashes + best_nonces + stuff + 64 * 1024 * 1024 +} + +pub fn clamp_workgroups_for_vram( + vram_bytes: u64, + localsize: u32, + unitsize: u32, + requested: u32, +) -> u32 { + if vram_bytes == 0 { + return requested.max(256); + } + let reserve = vram_bytes.saturating_mul(20) / 100; + let budget = vram_bytes.saturating_sub(reserve).max(256 * 1024 * 1024); + let mut wg = requested.max(256); + while wg >= 256 { + if estimate_vram_bytes(wg, localsize, unitsize) <= budget { + return wg; + } + wg /= 2; + } + 256 +} + +pub fn is_within_idle_schedule(start_hour: u32, end_hour: u32) -> bool { + if start_hour >= 24 || end_hour >= 24 { + return true; + } + let hour = local_hour(); + if start_hour <= end_hour { + hour >= start_hour && hour < end_hour + } else { + hour >= start_hour || hour < end_hour + } +} + +pub fn local_hour() -> u32 { + local_hour_impl().min(23) +} + +fn local_hour_impl() -> u32 { + #[cfg(windows)] + { + #[repr(C)] + struct SystemTimeWin { + year: u16, + month: u16, + day_of_week: u16, + day: u16, + hour: u16, + minute: u16, + second: u16, + milliseconds: u16, + } + unsafe extern "system" { + fn GetLocalTime(lpSystemTime: *mut SystemTimeWin); + } + let mut st = SystemTimeWin { + year: 0, + month: 0, + day_of_week: 0, + day: 0, + hour: 0, + minute: 0, + second: 0, + milliseconds: 0, + }; + unsafe { + GetLocalTime(&mut st); + } + return st.hour as u32; + } + #[cfg(not(windows))] + { + use std::process::Command; + if let Ok(out) = Command::new("date").arg("+%H").output() { + if out.status.success() { + if let Ok(h) = String::from_utf8_lossy(&out.stdout).trim().parse::() { + if h < 24 { + return h; + } + } + } + } + let secs = SystemTime::now() + .duration_since(UNIX_EPOCH) + .map(|d| d.as_secs()) + .unwrap_or(0); + ((secs / 3600) % 24) as u32 + } +} + +/// True when mining workers should sleep (outside idle window or profit-paused). +pub fn mining_is_gated(runtime: &MiningRuntimeState, eff: &EfficiencyConf) -> bool { + !is_within_idle_schedule(eff.idle_start_hour, eff.idle_end_hour) + || runtime.paused_unprofitable.load(Relaxed) +} + +fn parse_temp_line_celsius(text: &str) -> Option { + for token in text.split(|c: char| !c.is_ascii_digit() && c != '.' && c != '-') { + if token.is_empty() { + continue; + } + if let Ok(v) = token.parse::() { + if v.is_finite() && v > 0.0 && v < 120.0 { + return Some(v); + } + } + } + None +} + +fn read_gpu_temp_from_cmd(cmd: &str, args: &[&str]) -> Option { + use std::process::Command; + let out = Command::new(cmd).args(args).output().ok()?; + if !out.status.success() { + return None; + } + let text = String::from_utf8_lossy(&out.stdout); + parse_temp_line_celsius(&text) +} + +/// AMD GPU temperature via rocm-smi / amd-smi when installed (Linux or Windows AMD driver tools). +pub fn read_gpu_temp_amd_smi(gpu_index: u32) -> Option { + let idx = gpu_index.to_string(); + read_gpu_temp_from_cmd("rocm-smi", &["--showtemp", "-d", &idx]) + .or_else(|| read_gpu_temp_from_cmd("rocm-smi", &["-d", &idx, "--showtemp"])) + .or_else(|| read_gpu_temp_from_cmd("amd-smi", &["monitor", "-g", &idx])) + .or_else(|| read_gpu_temp_from_cmd("amd-smi", &["-g", &idx, "--showtemp"])) +} + +pub fn read_gpu_temp_nvidia_smi(gpu_index: u32) -> Option { + use std::process::Command; + let out = Command::new("nvidia-smi") + .args([ + "--query-gpu=temperature.gpu", + "--format=csv,noheader,nounits", + "-i", + &gpu_index.to_string(), + ]) + .output() + .ok()?; + if !out.status.success() { + return None; + } + let text = String::from_utf8_lossy(&out.stdout); + let v: f32 = text.trim().parse().ok()?; + if v.is_finite() && v > 0.0 && v < 120.0 { + Some(v) + } else { + None + } +} + +pub fn read_thermal_c(thermal_file: &str) -> Option { + read_thermal_c_with_gpu(thermal_file, 0) +} + +pub fn read_thermal_c_with_gpu(thermal_file: &str, gpu_index: u32) -> Option { + if !thermal_file.is_empty() { + if let Ok(raw) = fs::read_to_string(thermal_file) { + if let Ok(v) = raw.trim().parse::() { + if v.is_finite() && v > 0.0 { + return Some(v); + } + } + } + } + if let Some(t) = read_gpu_temp_nvidia_smi(gpu_index) { + return Some(t); + } + if let Some(t) = read_gpu_temp_amd_smi(gpu_index) { + return Some(t); + } + #[cfg(windows)] + { + read_thermal_wmi() + } + #[cfg(not(windows))] + { + None + } +} + +#[cfg(windows)] +fn read_thermal_wmi() -> Option { + use std::process::Command; + let out = Command::new("powershell") + .args([ + "-NoProfile", + "-Command", + "(Get-CimInstance -Namespace root/wmi -ClassName MSAcpi_ThermalZoneTemperature -ErrorAction SilentlyContinue | Select-Object -First 1).CurrentTemperature", + ]) + .output() + .ok()?; + let text = String::from_utf8_lossy(&out.stdout); + let raw: f32 = text.trim().parse().ok()?; + if raw <= 0.0 { + return None; + } + Some((raw / 10.0) - 273.15) +} + +pub fn format_efficiency_line( + hashrate: f64, + hac_per_day: f64, + network_pct: f64, + eff: &EfficiencyConf, + profile: &str, + active_cpu: u32, +) -> String { + let gpu_w = eff.estimate_gpu_watts(profile); + let cpu_w = active_cpu as f64 * eff.cpu_watts_per_thread; + let watts = gpu_w + cpu_w; + let hpj = if watts > 0.0 { + hashrate / watts / 1000.0 + } else { + 0.0 + }; + let daily_cost = eff.daily_power_cost_eur(profile, active_cpu); + let mut line = format!( + "{} | {:.0}W | {:.1}kH/J | {:.4}HAC/d {:.4}%", + rates_to_show(hashrate), + watts, + hpj, + hac_per_day, + network_pct + ); + if eff.hac_price > 0.0 { + let revenue = hac_per_day * eff.hac_price; + let net = revenue - daily_cost; + line.push_str(&format!(" | net {:.2}EUR/d", net)); + } else if daily_cost > 0.0 { + line.push_str(&format!(" | cost {:.2}EUR/d", daily_cost)); + } + line +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn eco_mode_profile_values() { + let eff = EfficiencyConf { + mode: EfficiencyMode::Eco, + power_cost_kwh: 0.15, + gpu_watts: 0.0, + cpu_watts_per_thread: 8.0, + hac_price: 0.0, + dynamic_supervene: false, + supervene_min: 2, + supervene_max: 0, + oom_fallback: true, + max_temp_c: 0, + throttle_workgroups: 1024, + thermal_file: String::new(), + idle_start_hour: 255, + idle_end_hour: 255, + pause_if_unprofitable: false, + benchmark_seconds: 0, + benchmark_fine_sweep: false, + thermal_gpu_index: 0, + stats_file: String::new(), + }; + let sec = HashMap::new(); + let t = resolve_gpu_tuning(&sec, &eff); + assert_eq!(t.profile, "amd_eco"); + assert_eq!(t.workgroups, 768); + } + + #[test] + fn vram_clamp_reduces_workgroups() { + let wg = clamp_workgroups_for_vram(4 * 1024 * 1024 * 1024, 256, 128, 4096); + assert!(wg < 4096); + assert!(wg >= 256); + } + + #[test] + fn nvidia_profile_tuning_differs_from_amd() { + let amd = profile_tuning("amd_profit"); + let nvidia = profile_tuning("nvidia_profit"); + assert_ne!(amd, nvidia); + } + + #[test] + fn sweep_generates_multiple_candidates() { + let c = sweep_workgroup_candidates(1536, 0, 256, 96); + assert!(c.len() >= 2); + assert!(c.contains(&1536) || c.iter().any(|&w| (1400..=1600).contains(&w))); + } + + #[test] + fn sweep_unitsize_candidates_respects_cap() { + let c = sweep_unitsize_candidates(96, 128); + assert!(c.contains(&96)); + assert!(c.iter().all(|&us| us <= 128)); + } +} + +#[derive(Clone, Debug, Default, serde::Serialize, serde::Deserialize)] +pub struct MiningStatsSnapshot { + pub status: String, + pub hashrate_hps: f64, + pub hashrate_display: String, + pub watts: f64, + pub kh_per_j: f64, + pub hac_per_day: f64, + pub network_pct: f64, + pub daily_cost_eur: f64, + pub daily_revenue_eur: f64, + pub daily_net_eur: f64, + pub height: u64, + pub gpu_profile: String, + pub active_cpu_threads: u32, + pub paused_unprofitable: bool, + /// `hac` or `hacd` + pub mining_kind: String, + pub diamond_number: u32, + pub diamond_best: String, + pub updated_unix_ms: u64, +} + +pub fn build_mining_stats( + hashrate: f64, + hac_per_day: f64, + network_pct: f64, + eff: &EfficiencyConf, + profile: &str, + active_cpu: u32, + height: u64, + paused: bool, +) -> MiningStatsSnapshot { + let gpu_w = eff.estimate_gpu_watts(profile); + let watts = gpu_w + active_cpu as f64 * eff.cpu_watts_per_thread; + let kh_per_j = if watts > 0.0 { + hashrate / watts / 1000.0 + } else { + 0.0 + }; + let daily_cost = eff.daily_power_cost_eur(profile, active_cpu); + let daily_revenue = hac_per_day * eff.hac_price; + let daily_net = daily_revenue - daily_cost; + let status = if paused { + "paused".to_string() + } else if hashrate > 0.0 { + "mining".to_string() + } else { + "idle".to_string() + }; + MiningStatsSnapshot { + status, + hashrate_hps: hashrate, + hashrate_display: rates_to_show(hashrate), + watts, + kh_per_j, + hac_per_day, + network_pct, + daily_cost_eur: daily_cost, + daily_revenue_eur: daily_revenue, + daily_net_eur: daily_net, + height, + gpu_profile: profile.to_string(), + active_cpu_threads: active_cpu, + paused_unprofitable: paused, + mining_kind: "hac".to_string(), + diamond_number: 0, + diamond_best: String::new(), + updated_unix_ms: SystemTime::now() + .duration_since(UNIX_EPOCH) + .map(|d| d.as_millis() as u64) + .unwrap_or(0), + } +} + +pub fn build_diamond_mining_stats( + hashrate: f64, + eff: &EfficiencyConf, + profile: &str, + active_cpu: u32, + diamond_number: u32, + diamond_best: &str, + paused: bool, +) -> MiningStatsSnapshot { + let gpu_w = eff.estimate_gpu_watts(profile); + let watts = gpu_w + active_cpu as f64 * eff.cpu_watts_per_thread; + let kh_per_j = if watts > 0.0 { + hashrate / watts / 1000.0 + } else { + 0.0 + }; + let daily_cost = eff.daily_power_cost_eur(profile, active_cpu); + let status = if paused { + "paused".to_string() + } else if hashrate > 0.0 { + "mining".to_string() + } else { + "idle".to_string() + }; + MiningStatsSnapshot { + status, + hashrate_hps: hashrate, + hashrate_display: rates_to_show(hashrate), + watts, + kh_per_j, + hac_per_day: 0.0, + network_pct: 0.0, + daily_cost_eur: daily_cost, + daily_revenue_eur: 0.0, + daily_net_eur: -daily_cost, + height: diamond_number as u64, + gpu_profile: profile.to_string(), + active_cpu_threads: active_cpu, + paused_unprofitable: paused, + mining_kind: "hacd".to_string(), + diamond_number, + diamond_best: diamond_best.to_string(), + updated_unix_ms: SystemTime::now() + .duration_since(UNIX_EPOCH) + .map(|d| d.as_millis() as u64) + .unwrap_or(0), + } +} + +pub fn write_mining_stats(path: &str, stats: &MiningStatsSnapshot) { + if path.is_empty() { + return; + } + if let Ok(json) = serde_json::to_string_pretty(stats) { + let _ = fs::write(path, json); + } +} + +pub fn should_pause_for_profit( + eff: &EfficiencyConf, + hac_per_day: f64, + profile: &str, + active_cpu: u32, +) -> bool { + if !eff.pause_if_unprofitable || eff.hac_price <= 0.0 { + return false; + } + let revenue = hac_per_day * eff.hac_price; + revenue < eff.daily_power_cost_eur(profile, active_cpu) +} + +/// HACD profit pause: when `hac_price` is set, treat it as minimum daily EUR revenue +/// target; pause if electricity cost exceeds that (no per-diamond market price yet). +pub fn should_pause_for_diamond_profit( + eff: &EfficiencyConf, + profile: &str, + active_cpu: u32, +) -> bool { + if !eff.pause_if_unprofitable || eff.hac_price <= 0.0 { + return false; + } + eff.daily_power_cost_eur(profile, active_cpu) > eff.hac_price +} + diff --git a/app/src/gpu_arch.rs b/app/src/gpu_arch.rs new file mode 100644 index 00000000..c436afcb --- /dev/null +++ b/app/src/gpu_arch.rs @@ -0,0 +1,172 @@ +//! GPU vendor / architecture detection for OpenCL tuning and kernel compile flags. + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum GpuVendor { + Amd, + Nvidia, + Intel, + Unknown, +} + +impl GpuVendor { + pub fn prefix(self) -> &'static str { + match self { + GpuVendor::Amd => "amd", + GpuVendor::Nvidia => "nvidia", + GpuVendor::Intel => "intel", + GpuVendor::Unknown => "gpu", + } + } +} + +/// Detect GPU vendor from OpenCL device vendor + name strings. +pub fn detect_vendor(vendor: &str, name: &str) -> GpuVendor { + let v = vendor.to_lowercase(); + let n = name.to_lowercase(); + if v.contains("nvidia") || n.contains("geforce") || n.contains("rtx ") || n.contains("gtx ") + || n.contains("quadro") + { + return GpuVendor::Nvidia; + } + if v.contains("amd") + || v.contains("advanced micro devices") + || n.contains("radeon") + || n.contains("gfx") + { + return GpuVendor::Amd; + } + if v.contains("intel") || n.contains("arc ") || n.contains("iris") || n.contains("uhd graphics") + { + return GpuVendor::Intel; + } + GpuVendor::Unknown +} + +/// Short architecture slug for kernel binary cache (safe filename fragment). +pub fn arch_slug(name: &str) -> String { + let n = name.to_lowercase(); + if let Some(idx) = n.find("gfx") { + let tail: String = n[idx..] + .chars() + .take_while(|c| c.is_ascii_alphanumeric()) + .collect(); + if tail.len() >= 5 { + return tail; + } + } + for token in ["rtx 5090", "rtx 5080", "rtx 5070", "rtx 5060", "rtx 4090", "rtx 4080", "rtx 4070", "rtx 4060", "rtx 3090", "rtx 3080", "rtx 3070", "rtx 3060", "rx 7900", "rx 7800", "rx 7700", "rx 7600", "rx 6900", "rx 6800", "rx 6700", "rx 6600"] { + if n.contains(token) { + return token.replace(' ', ""); + } + } + n.chars() + .map(|c| if c.is_ascii_alphanumeric() { c } else { '_' }) + .collect::() + .chars() + .take(24) + .collect() +} + +/// Map generic or cross-vendor profile name to vendor-specific profile. +pub fn normalize_profile(profile: &str, vendor: GpuVendor) -> String { + let p = profile.trim().to_lowercase(); + let tier = if p.contains("eco") { + "eco" + } else if p.contains("balanced") { + "balanced" + } else if p.contains("profit") { + "profit" + } else if p.contains("max") { + "max" + } else if p.contains("performance") || p.contains("perf") { + "performance" + } else { + return profile.to_string(); + }; + format!("{}_{}", vendor.prefix(), tier) +} + +/// Scale work_groups from device compute-unit count (waves per CU). +pub fn suggest_workgroups(requested: u32, compute_units: u32, vendor: GpuVendor) -> u32 { + if compute_units == 0 { + return requested.max(256); + } + let waves_per_cu = match vendor { + GpuVendor::Amd => 64u32, + GpuVendor::Nvidia => 48, + GpuVendor::Intel => 32, + GpuVendor::Unknown => 40, + }; + let target = compute_units.saturating_mul(waves_per_cu); + let mut wg = requested.min(target.max(256)); + wg = (wg / 64).max(4) * 64; + wg.clamp(256, 4096) +} + +/// OpenCL compiler `-D` flags for architecture-specific paths. +pub fn compile_defines(vendor: GpuVendor, slug: &str, amd_fast: bool) -> String { + let mut defs = String::from(" -cl-single-precision-constant"); + match vendor { + GpuVendor::Amd if amd_fast => defs.push_str(" -DNO_AMD_OPS=0"), + GpuVendor::Nvidia => { + defs.push_str(" -DNVIDIA_GPU=1 -DNO_AMD_OPS=1 -cl-denorms-are-zero"); + } + GpuVendor::Intel => defs.push_str(" -DINTEL_GPU=1 -DNO_AMD_OPS=1"), + _ => {} + } + if slug.starts_with("gfx") { + defs.push_str(&format!(" -DAMD_GFX_{}=1", slug.to_uppercase())); + } else if slug.starts_with("rtx") || slug.starts_with("rx") { + defs.push_str(&format!(" -DGPU_{}=1", slug.to_uppercase())); + } + defs +} + +/// Sanitize device name for use in binary cache filenames. +pub fn safe_device_filename(device_name: &str) -> String { + device_name + .chars() + .map(|c| if c.is_ascii_alphanumeric() { c } else { '_' }) + .collect() +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn detects_nvidia() { + assert_eq!( + detect_vendor("NVIDIA Corporation", "NVIDIA GeForce RTX 4070"), + GpuVendor::Nvidia + ); + } + + #[test] + fn detects_amd() { + assert_eq!( + detect_vendor("Advanced Micro Devices, Inc.", "gfx1100"), + GpuVendor::Amd + ); + } + + #[test] + fn normalizes_profile_for_nvidia() { + assert_eq!( + normalize_profile("amd_profit", GpuVendor::Nvidia), + "nvidia_profit" + ); + } + + #[test] + fn arch_slug_rtx() { + assert!(arch_slug("NVIDIA GeForce RTX 4090").contains("rtx4090")); + } + + #[test] + fn suggest_workgroups_scales_with_cu() { + let wg = suggest_workgroups(4096, 64, GpuVendor::Amd); + assert!(wg >= 256 && wg <= 4096); + assert!(wg % 64 == 0); + } +} \ No newline at end of file diff --git a/app/src/lib.rs b/app/src/lib.rs index 1c4d1cb5..f58ccf27 100644 --- a/app/src/lib.rs +++ b/app/src/lib.rs @@ -1,7 +1,12 @@ include! {"version.rs"} +pub mod efficiency; +pub mod gpu_arch; + pub mod diaworker; pub mod poworker; +#[cfg(feature = "ocl")] +pub mod opencl_list; // pub mod svrapi; // server api pub mod diabider; pub mod fullnode; diff --git a/app/src/opencl_common.rs b/app/src/opencl_common.rs index a3b9ca3c..a2fd9d97 100644 --- a/app/src/opencl_common.rs +++ b/app/src/opencl_common.rs @@ -2,11 +2,82 @@ use std::ffi::CString; use std::path::Path; use std::fs::{self, File}; use std::io::{Read, Write}; -use ocl::enums::{ProgramInfoResult, ProgramInfo}; -use ocl::{Buffer, Context, Device, EventList, Kernel, Platform, Program, Queue}; +use std::sync::Mutex; +use crate::gpu_arch::{self, GpuVendor}; +use ocl::enums::{DeviceInfo, DeviceInfoResult, ProgramInfoResult, ProgramInfo}; +use ocl::flags::{CommandQueueProperties, MemFlags}; +use ocl::{Buffer, Context, Device, Event, Kernel, Platform, Program, Queue}; #[allow(dead_code)] +const STUFF_BUFFER_CAP: usize = 512; + +fn pinned_host_write_flags() -> MemFlags { + MemFlags::new() + .alloc_host_ptr() + .read_only() + .host_write_only() +} + +fn pinned_host_read_flags() -> MemFlags { + MemFlags::new() + .alloc_host_ptr() + .write_only() + .host_read_only() +} + +fn create_command_queue(context: &Context, device: &Device) -> (Queue, bool) { + let ooo = CommandQueueProperties::new().out_of_order(); + match Queue::new(context, device.clone(), Some(ooo)) { + Ok(queue) => { + println!("[OpenCL] Out-of-order command queue enabled"); + (queue, true) + } + Err(_) => { + let queue = Queue::new(context, device.clone(), None) + .expect("Can't create OpenCL event queue"); + println!("[OpenCL] In-order command queue (OOO not supported)"); + (queue, false) + } + } +} + +fn write_stuff_to_gpu( + opencl: &OpenCLResources, + data: &[u8], + wait: Option<&Event>, +) -> std::result::Result { + if data.len() > STUFF_BUFFER_CAP { + return Err(format!( + "OpenCL stuff buffer overflow ({} > {})", + data.len(), + STUFF_BUFFER_CAP + )); + } + let mut padded = vec![0u8; STUFF_BUFFER_CAP]; + padded[..data.len()].copy_from_slice(data); + let mut write_event = Event::empty(); + let mut cmd = opencl + .buffer_stuff + .write(&padded) + .enew(&mut write_event); + if let Some(dep) = wait { + cmd = cmd.ewait(dep); + } + cmd.enq() + .map_err(|e| format!("stuff buffer write: {}", e))?; + Ok(write_event) +} + struct OpenCLResources { + /// Effective work_groups after VRAM clamp for this device. + workgroups: u32, + /// GPU buffers sized for this unit_size (runtime values must not exceed it). + allocated_unitsize: u32, + vendor: GpuVendor, + compute_units: u32, + vram_bytes: u64, + diamond: bool, + out_of_order: bool, program: Program, queue: Queue, buffer_best_nonces: Buffer::, @@ -14,6 +85,15 @@ struct OpenCLResources { buffer_global_hashes: Buffer::, buffer_global_order: Buffer::, buffer_best_hashes: Buffer::, + /// Reused input buffer — avoids per-kernel GPU allocation. + buffer_stuff: Buffer::, + /// Cached OpenCL kernel — rebuilt only when `unit_size` changes. + kernel_slot: Mutex, +} + +struct KernelSlot { + kernel: Option, + unit_size: u32, } fn initialize_opencl( @@ -72,15 +152,37 @@ fn initialize_opencl( devices.push(device); } - let num_work_items = workgroups * localsize; - let global_work_size = num_work_items; - let mut opencl_resource_devices = Vec::with_capacity(devices.len() as usize); for (idx, &device) in devices.iter().enumerate() { + let device_name = device.name().expect("Can't get device name"); + let device_vendor = device.vendor().unwrap_or_default(); + let vendor = gpu_arch::detect_vendor(&device_vendor, &device_name); + let vram_bytes = device_global_mem_bytes(&device); + let compute_units = device_compute_units(&device); + let mut wg = gpu_arch::suggest_workgroups(*workgroups, compute_units, vendor); + if compute_units > 0 { + println!( + "[OpenCL] CU={} suggested work_groups={} (config {})", + compute_units, wg, workgroups + ); + } + if vram_bytes > 0 { + let clamped = clamp_workgroups_for_vram(vram_bytes, *localsize, *unitsize, wg); + if clamped < wg { + println!( + "[efficiency] VRAM clamp: work_groups {} -> {} ({} MB available)", + wg, + clamped, + vram_bytes / (1024 * 1024) + ); + wg = clamped; + } + } + let num_work_items = wg * localsize; + let global_work_size = num_work_items; println!("-----------------------------------------"); - let name = device.name().expect("Error"); - println!("Device {}: {}", cnf_devices[idx], name); + println!("Device {}: {}", cnf_devices[idx], device_name); println!("-----------------------------------------"); // Create context @@ -94,17 +196,28 @@ fn initialize_opencl( panic!("OpenCL dir not found: {}", opencldir); } - let device_name = device.name().expect("Can't get device name"); - let binary_file = format!(r"{}{}_{}{}.bin", opencldir, device_name, cnf_devices[idx], if diamond_mining { "_diamonds" } else { "" }); + let slug = gpu_arch::arch_slug(&device_name); + let amd_fast = vendor == GpuVendor::Amd; + if amd_fast { + println!("AMD fast-path: enabling OpenCL amd_bfe optimizations for this device"); + } + if vendor == GpuVendor::Nvidia { + println!("NVIDIA OpenCL path: arch={}", slug); + } + let safe_name = gpu_arch::safe_device_filename(&device_name); + let diamond_tag = if diamond_mining { "_dia" } else { "" }; + let binary_file = format!( + r"{}{}_{}_{}{}.bin", + opencldir, safe_name, cnf_devices[idx], slug, diamond_tag + ); let binary_path = Path::new(&binary_file); - // Check if kernel was changed since last time (and need recompile) + // Recompile when any .cl under opencldir is newer than the cached binary. let need_recompile = if binary_path.exists() { let binary_modified = fs::metadata(&binary_path) .and_then(|meta| meta.modified()) .expect("Can't find binary file last edit time"); - let kernel_modified = fs::metadata(&kernel_path) - .and_then(|meta| meta.modified()) + let kernel_modified = newest_opencl_source_mtime(&opencldir, kernel_path) .expect("Can't find kernel file last edit time"); kernel_modified > binary_modified } else { @@ -130,65 +243,418 @@ fn initialize_opencl( } else { println!("Compiling..."); // Compile from source - compile_program_from_source(&context, &device, &kernel_path, &binary_path, opencldir.clone()) + compile_program_from_source( + &context, + &device, + &kernel_path, + &binary_path, + opencldir.clone(), + vendor, + &slug, + amd_fast, + ) }; - // Create new queue - let queue = Queue::new(&context, device.clone(), None) - .expect("Can't create OpenCL event queue"); - - opencl_resource_devices.push(OpenCLResources { - program: program.clone(), - queue: queue.clone(), - buffer_best_nonces: Buffer::::builder() - .queue(queue.clone()) - .flags(ocl::core::MEM_WRITE_ONLY) - .len(*workgroups) - .build() - .expect("Can't create buffer_best_nonces"), - buffer_best_nonces_diamond: Buffer::::builder() - .queue(queue.clone()) - .flags(ocl::core::MEM_WRITE_ONLY) - .len(*workgroups) - .build() - .expect("Can't create buffer_best_nonces_diamond"), - buffer_global_hashes: Buffer::::builder() - .queue(queue.clone()) - .flags(ocl::core::MEM_READ_WRITE) - .len(HASH_WIDTH * *unitsize as usize * global_work_size as usize) - .build() - .expect("Can't create buffer_global_hashes"), - buffer_global_order: Buffer::::builder() - .queue(queue.clone()) - .flags(ocl::core::MEM_READ_WRITE) - .len(*unitsize as usize * global_work_size as usize) - .build() - .expect("Can't create buffer_global_order"), - buffer_best_hashes: Buffer::::builder() - .queue(queue.clone()) - .flags(ocl::core::MEM_WRITE_ONLY) - .len(HASH_WIDTH * *workgroups as usize ) - .build() - .expect("Can't create buffer_best_hashes") - }); + let (queue, out_of_order) = create_command_queue(&context, &device); + + match build_opencl_resources( + &program, + &queue, + wg, + *unitsize, + global_work_size, + vendor, + compute_units, + vram_bytes, + diamond_mining, + out_of_order, + ) { + Ok(res) => opencl_resource_devices.push(res), + Err(e) => { + eprintln!("[efficiency] OpenCL buffer init failed at work_groups={}: {}", wg, e); + let mut reduced = wg / 2; + let mut built = false; + while reduced >= 256 { + let gws = reduced * localsize; + if let Ok(res) = build_opencl_resources( + &program, + &queue, + reduced, + *unitsize, + gws, + vendor, + compute_units, + vram_bytes, + diamond_mining, + out_of_order, + ) { + println!("[efficiency] Recovered with work_groups={}", reduced); + opencl_resource_devices.push(res); + built = true; + break; + } + reduced /= 2; + } + if !built { + eprintln!("[efficiency] Skipping device {} — insufficient VRAM", cnf_devices[idx]); + } + } + } } opencl_resource_devices } +fn device_global_mem_bytes(device: &Device) -> u64 { + match device.info(DeviceInfo::GlobalMemSize) { + Ok(DeviceInfoResult::GlobalMemSize(v)) => v, + _ => 0, + } +} + +fn device_compute_units(device: &Device) -> u32 { + match device.info(DeviceInfo::MaxComputeUnits) { + Ok(DeviceInfoResult::MaxComputeUnits(v)) => v, + _ => 0, + } +} + +fn build_block_kernel( + res: &OpenCLResources, + unit_size: u32, +) -> std::result::Result { + Kernel::builder() + .program(&res.program) + .name("x16rs_main") + .queue(res.queue.clone()) + .arg(&res.buffer_stuff) + .arg(0u32) + .arg(0u32) + .arg(unit_size) + .arg(&res.buffer_global_hashes) + .arg(&res.buffer_global_order) + .arg(&res.buffer_best_hashes) + .arg(&res.buffer_best_nonces) + .build() + .map_err(|e| format!("kernel build: {}", e)) +} + +fn build_diamond_kernel( + res: &OpenCLResources, + unit_size: u32, +) -> std::result::Result { + Kernel::builder() + .program(&res.program) + .name("x16rs_diamond") + .queue(res.queue.clone()) + .arg(&res.buffer_stuff) + .arg(0u64) + .arg(0u32) + .arg(unit_size) + .arg(&res.buffer_global_hashes) + .arg(&res.buffer_global_order) + .arg(&res.buffer_best_hashes) + .arg(&res.buffer_best_nonces_diamond) + .build() + .map_err(|e| format!("kernel build: {}", e)) +} + +fn run_cached_kernel( + res: &OpenCLResources, + unit_size: u32, + num_work_groups: u32, + local_work_size: u32, + wait: Option<&Event>, + update: impl FnOnce(&mut Kernel) -> std::result::Result<(), String>, +) -> std::result::Result { + if unit_size > res.allocated_unitsize { + return Err(format!( + "unit_size {} exceeds allocated buffer size {}", + unit_size, res.allocated_unitsize + )); + } + let global_work_size = num_work_groups.saturating_mul(local_work_size); + let mut slot = res.kernel_slot.lock().map_err(|e| e.to_string())?; + if slot.kernel.is_none() || slot.unit_size != unit_size { + let k = if res.diamond { + build_diamond_kernel(res, unit_size)? + } else { + build_block_kernel(res, unit_size)? + }; + slot.kernel = Some(k); + slot.unit_size = unit_size; + } + let kernel = slot + .kernel + .as_mut() + .ok_or_else(|| "kernel cache empty".to_string())?; + update(kernel)?; + let mut kernel_event = Event::empty(); + unsafe { + let mut cmd = kernel + .cmd() + .global_work_size(global_work_size) + .local_work_size(local_work_size) + .enew(&mut kernel_event); + if let Some(dep) = wait { + cmd = cmd.ewait(dep); + } + cmd.enq() + .map_err(|e| format!("kernel enqueue: {}", e))?; + } + Ok(kernel_event) +} + +fn wait_event(event: &Event, label: &str) -> std::result::Result<(), String> { + event + .wait_for() + .map_err(|e| format!("{} wait: {}", label, e)) +} + +fn read_block_gpu_results( + res: &OpenCLResources, + wait: &Event, + hashes: &mut [u8], + nonces: &mut [u32], +) -> std::result::Result<(), String> { + let mut hash_event = Event::empty(); + let mut nonce_event = Event::empty(); + res.buffer_best_hashes + .read(hashes) + .ewait(wait) + .enew(&mut hash_event) + .enq() + .map_err(|e| format!("read hashes enqueue: {}", e))?; + res.buffer_best_nonces + .read(nonces) + .ewait(wait) + .enew(&mut nonce_event) + .enq() + .map_err(|e| format!("read nonces enqueue: {}", e))?; + wait_event(&hash_event, "hash read")?; + wait_event(&nonce_event, "nonce read")?; + Ok(()) +} + +fn read_diamond_gpu_results( + res: &OpenCLResources, + wait: &Event, + hashes: &mut [u8], + nonces: &mut [u64], +) -> std::result::Result<(), String> { + let mut hash_event = Event::empty(); + let mut nonce_event = Event::empty(); + res.buffer_best_hashes + .read(hashes) + .ewait(wait) + .enew(&mut hash_event) + .enq() + .map_err(|e| format!("read hashes enqueue: {}", e))?; + res.buffer_best_nonces_diamond + .read(nonces) + .ewait(wait) + .enew(&mut nonce_event) + .enq() + .map_err(|e| format!("read nonces enqueue: {}", e))?; + wait_event(&hash_event, "hash read")?; + wait_event(&nonce_event, "nonce read")?; + Ok(()) +} + +/// Block mining kernel (u32 nonce). +fn enqueue_mining_kernel( + res: &OpenCLResources, + nonce_start: u32, + repeat: u32, + unit_size: u32, + num_work_groups: u32, + local_work_size: u32, + wait: Option<&Event>, +) -> std::result::Result { + run_cached_kernel( + res, + unit_size, + num_work_groups, + local_work_size, + wait, + |kernel| { + kernel + .set_arg(1, nonce_start) + .map_err(|e| format!("set_arg nonce: {}", e))?; + kernel + .set_arg(2, repeat) + .map_err(|e| format!("set_arg repeat: {}", e))?; + kernel + .set_arg(3, unit_size) + .map_err(|e| format!("set_arg unit_size: {}", e))?; + Ok(()) + }, + ) +} + +/// Diamond mining kernel (u64 nonce). +fn enqueue_diamond_kernel( + res: &OpenCLResources, + nonce_start: u64, + repeat: u32, + unit_size: u32, + num_work_groups: u32, + local_work_size: u32, + wait: Option<&Event>, +) -> std::result::Result { + run_cached_kernel( + res, + unit_size, + num_work_groups, + local_work_size, + wait, + |kernel| { + kernel + .set_arg(1, nonce_start) + .map_err(|e| format!("set_arg nonce: {}", e))?; + kernel + .set_arg(2, repeat) + .map_err(|e| format!("set_arg repeat: {}", e))?; + kernel + .set_arg(3, unit_size) + .map_err(|e| format!("set_arg unit_size: {}", e))?; + Ok(()) + }, + ) +} + +fn build_opencl_resources( + program: &Program, + queue: &Queue, + workgroups: u32, + unitsize: u32, + global_work_size: u32, + vendor: GpuVendor, + compute_units: u32, + vram_bytes: u64, + diamond: bool, + out_of_order: bool, +) -> std::result::Result { + let readback_flags = pinned_host_read_flags(); + let buffer_best_nonces = Buffer::::builder() + .queue(queue.clone()) + .flags(readback_flags) + .len(workgroups as usize) + .build() + .map_err(|e| format!("buffer_best_nonces: {}", e))?; + let buffer_best_nonces_diamond = Buffer::::builder() + .queue(queue.clone()) + .flags(readback_flags) + .len(workgroups as usize) + .build() + .map_err(|e| format!("buffer_best_nonces_diamond: {}", e))?; + let buffer_global_hashes = Buffer::::builder() + .queue(queue.clone()) + .flags(ocl::core::MEM_READ_WRITE) + .len(HASH_WIDTH * unitsize as usize * global_work_size as usize) + .build() + .map_err(|e| format!("buffer_global_hashes: {}", e))?; + let buffer_global_order = Buffer::::builder() + .queue(queue.clone()) + .flags(ocl::core::MEM_READ_WRITE) + .len(unitsize as usize * global_work_size as usize) + .build() + .map_err(|e| format!("buffer_global_order: {}", e))?; + let buffer_best_hashes = Buffer::::builder() + .queue(queue.clone()) + .flags(readback_flags) + .len(HASH_WIDTH * workgroups as usize) + .build() + .map_err(|e| format!("buffer_best_hashes: {}", e))?; + let buffer_stuff = Buffer::::builder() + .queue(queue.clone()) + .flags(pinned_host_write_flags()) + .len(STUFF_BUFFER_CAP) + .build() + .map_err(|e| format!("buffer_stuff: {}", e))?; + if out_of_order { + println!("[OpenCL] Pinned host buffers enabled for stuff + readback"); + } + Ok(OpenCLResources { + workgroups, + allocated_unitsize: unitsize, + vendor, + compute_units, + vram_bytes, + diamond, + out_of_order, + program: program.clone(), + queue: queue.clone(), + buffer_best_nonces, + buffer_best_nonces_diamond, + buffer_global_hashes, + buffer_global_order, + buffer_best_hashes, + buffer_stuff, + kernel_slot: Mutex::new(KernelSlot { + kernel: None, + unit_size: 0, + }), + }) +} + +fn newest_opencl_source_mtime( + opencldir: &str, + kernel_path: &Path, +) -> std::io::Result { + use std::time::SystemTime; + let mut newest = fs::metadata(kernel_path)?.modified()?; + let dir = Path::new(opencldir); + if dir.is_dir() { + let stack = [dir.to_path_buf()]; + let mut pending = stack.to_vec(); + while let Some(path) = pending.pop() { + let entries = match fs::read_dir(&path) { + Ok(e) => e, + Err(_) => continue, + }; + for entry in entries.flatten() { + let p = entry.path(); + if p.is_dir() { + pending.push(p); + continue; + } + if p.extension().and_then(|e| e.to_str()) != Some("cl") { + continue; + } + if let Ok(meta) = fs::metadata(&p) { + if let Ok(m) = meta.modified() { + if m > newest { + newest = m; + } + } + } + } + } + } + Ok(newest) +} + fn compile_program_from_source( context: &Context, device: &Device, kernel_path: &Path, binary_path: &Path, opencldir: String, + vendor: GpuVendor, + arch_slug: &str, + amd_fast: bool, ) -> Program { // Create program from source files let kernel_src = fs::read_to_string(kernel_path) .expect("Can't find kernel file"); - // Compile - let compile_options = format!(r"-cl-std=CL2.0 -I {}", opencldir); + let arch_defs = gpu_arch::compile_defines(vendor, arch_slug, amd_fast); + let compile_options = format!( + r"-cl-std=CL2.0 -cl-fast-relaxed-math -cl-mad-enable -cl-uniform-work-group-size -I {}{}", + opencldir, arch_defs + ); + println!("[OpenCL] compile opts:{}", arch_defs); let program_build = Program::builder() .src(&kernel_src) .devices(device) diff --git a/app/src/opencl_dia.rs b/app/src/opencl_dia.rs index a6093dc9..8814e912 100644 --- a/app/src/opencl_dia.rs +++ b/app/src/opencl_dia.rs @@ -3,7 +3,7 @@ use x16rs::diamond_hash; fn do_diamond_group_mining_opencl( opencl: &OpenCLResources, number: u32, - prevblockhash: &Hash, + prevblockhash: &Hash, rwdaddr: &Address, custom_message: &Hash, nonce_start: u64, @@ -28,59 +28,50 @@ fn do_diamond_group_mining_opencl( dia_str: [b'W'; 16], is_success: None, use_secs: 0.0, + is_gpu: true, + gpu_batch_ok: false, }; - let global_work_size = num_work_groups * local_work_size; let repeat = x16rs::mine_diamond_hash_repeat(number) as u32; let stuff = [ prevhash.to_vec(), [0u8; 8].to_vec(), address.to_vec(), custom_nonce.as_ref().to_vec(), - ].concat(); + ] + .concat(); - let buffer_block_intro = Buffer::::builder() - .queue(opencl.queue.clone()) - .flags(ocl::core::MEM_READ_ONLY) - .len(stuff.len()) - .copy_host_slice(&stuff) - .build() - .expect("Unable to create buffer_block_intro"); - - let kernel = Kernel::builder() - .program(&opencl.program) - .name("x16rs_diamond") - .queue(opencl.queue.clone()) - .global_work_size(global_work_size) - .local_work_size(local_work_size) - .arg(&buffer_block_intro) - .arg(nonce_start) - .arg(repeat) - .arg(unit_size) - .arg(&opencl.buffer_global_hashes) - .arg(&opencl.buffer_global_order) - .arg(&opencl.buffer_best_hashes) - .arg(&opencl.buffer_best_nonces_diamond) - .build() - .unwrap(); + let write_event = match write_stuff_to_gpu(opencl, &stuff, None) { + Ok(ev) => ev, + Err(e) => { + eprintln!("[OpenCL] stuff upload failed: {}", e); + most.gpu_batch_ok = false; + return most; + } + }; - let mut kernel_event = EventList::new(); - unsafe { - kernel.cmd().enew(&mut kernel_event).enq().expect("Unable to queue OpenCL kernel"); - } + let kernel_event = match enqueue_diamond_kernel( + opencl, + nonce_start, + repeat, + unit_size, + num_work_groups, + local_work_size, + Some(&write_event), + ) { + Ok(ev) => ev, + Err(e) => { + eprintln!("[OpenCL] diamond kernel failed: {}", e); + most.gpu_batch_ok = false; + return most; + } + }; let mut hashes = vec![0u8; opencl.buffer_best_hashes.len()]; - opencl.buffer_best_hashes - .read(&mut hashes) - .ewait(&kernel_event) - .enq() - .expect("Can't read buffer_best_hashes"); - let mut nonces = vec![0u64; opencl.buffer_best_nonces_diamond.len()]; - opencl.buffer_best_nonces_diamond - .read(&mut nonces) - .ewait(&kernel_event) - .enq() - .expect("Can't read buffer_best_nonces_diamond"); + if read_diamond_gpu_results(opencl, &kernel_event, &mut hashes, &mut nonces).is_err() { + most.gpu_batch_ok = false; + return most; + } for i in 0..num_work_groups as usize { let hash_bytes = &hashes[i * 32..(i * 32) + 32].try_into().unwrap(); @@ -91,9 +82,10 @@ fn do_diamond_group_mining_opencl( nonce_bytes.as_slice(), address.as_slice(), custom_message.as_ref(), - ].concat(); + ] + .concat(); let ssshash: [u8; 32] = calculate_hash(stuff); - + if let Some(dia_name) = check_diamer_success(number, ssshash, *hash_bytes, dia_str) { let name = DiamondName::from(dia_name); let number = DiamondNumber::from(number); @@ -105,11 +97,13 @@ fn do_diamond_group_mining_opencl( most.dia_str = dia_str; most.u64_nonce = nonces[i]; most.is_success = Some(diamint); + most.gpu_batch_ok = true; return most; } else if diamond_more_power(&dia_str, &most.dia_str) { most.dia_str = dia_str; most.u64_nonce = nonces[i]; } } + most.gpu_batch_ok = true; most -} +} \ No newline at end of file diff --git a/app/src/opencl_list.rs b/app/src/opencl_list.rs new file mode 100644 index 00000000..982a0b8b --- /dev/null +++ b/app/src/opencl_list.rs @@ -0,0 +1,45 @@ +//! List OpenCL platforms/devices — used by `list_opencl` binary for AMD miner setup. + +use ocl::{Device, Platform}; + +pub fn list_opencl_devices() { + let platforms = Platform::list(); + if platforms.is_empty() { + println!("No OpenCL platforms found."); + return; + } + println!("OpenCL platforms and devices:\n"); + for (pi, platform) in platforms.iter().enumerate() { + let name = platform.name().unwrap_or_else(|_| "?".into()); + let vendor = platform.vendor().unwrap_or_else(|_| "?".into()); + let version = platform.version().unwrap_or_else(|_| "?".into()); + let amd_platform = vendor.to_lowercase().contains("amd"); + println!( + "Platform {pi}: {name} vendor={vendor} version={version}{}", + if amd_platform { " [AMD]" } else { "" } + ); + let devices = Device::list_all(platform).unwrap_or_default(); + if devices.is_empty() { + println!(" (no devices)"); + continue; + } + for (di, device) in devices.iter().enumerate() { + let dname = device.name().unwrap_or_else(|_| "?".into()); + let dvendor = device.vendor().unwrap_or_else(|_| "?".into()); + let amd = dvendor.to_lowercase().contains("amd") + || dname.to_lowercase().contains("radeon") + || dname.to_lowercase().contains("gfx"); + println!( + " device {di}: {dname} vendor={dvendor}{}", + if amd { " [AMD GPU — use in poworker/diaworker gpu section]" } else { "" } + ); + } + println!(); + } + println!("Config hints (poworker.config.ini / diaworker.config.ini):"); + println!(" [gpu]"); + println!(" use_opencl = true"); + println!(" platform_id = "); + println!(" device_ids = "); + println!(" opencl_dir = ../../x16rs/opencl/ (when running from target/debug)"); +} \ No newline at end of file diff --git a/app/src/opencl_pow.rs b/app/src/opencl_pow.rs index 72bb2ba0..7ecae7d9 100644 --- a/app/src/opencl_pow.rs +++ b/app/src/opencl_pow.rs @@ -6,55 +6,26 @@ fn do_group_block_mining_opencl( num_work_groups: u32, local_work_size: u32, unit_size: u32, -) -> (u32, [u8; 32]) { +) -> std::result::Result<(u32, [u8; 32]), String> { let mut most_nonce = 0u32; let mut most_hash = [255u8; 32]; - let global_work_size = num_work_groups * local_work_size; let repeat = x16rs::block_hash_repeat(height) as u32; - let buffer_block_intro = Buffer::::builder() - .queue(opencl.queue.clone()) - .flags(ocl::core::MEM_READ_ONLY) - .len(block_intro.len()) - .copy_host_slice(&block_intro) - .build() - .expect("Unable to create buffer_block_intro"); + let write_event = write_stuff_to_gpu(opencl, &block_intro, None)?; - let kernel = Kernel::builder() - .program(&opencl.program) - .name("x16rs_main") - .queue(opencl.queue.clone()) - .global_work_size(global_work_size) - .local_work_size(local_work_size) - .arg(&buffer_block_intro) - .arg(nonce_start) - .arg(repeat) - .arg(unit_size) - .arg(&opencl.buffer_global_hashes) - .arg(&opencl.buffer_global_order) - .arg(&opencl.buffer_best_hashes) - .arg(&opencl.buffer_best_nonces) - .build() - .unwrap(); - - let mut kernel_event = EventList::new(); - unsafe { - kernel.cmd().enew(&mut kernel_event).enq().expect("Unable to queue OpenCL kernel"); - } + let kernel_event = enqueue_mining_kernel( + opencl, + nonce_start, + repeat, + unit_size, + num_work_groups, + local_work_size, + Some(&write_event), + )?; let mut hashes = vec![0u8; opencl.buffer_best_hashes.len()]; - opencl.buffer_best_hashes - .read(&mut hashes) - .ewait(&kernel_event) - .enq() - .expect("Can't read buffer_best_hashes"); - let mut nonces = vec![0u32; opencl.buffer_best_nonces.len()]; - opencl.buffer_best_nonces - .read(&mut nonces) - .ewait(&kernel_event) - .enq() - .expect("Can't read buffer_best_nonces"); + read_block_gpu_results(opencl, &kernel_event, &mut hashes, &mut nonces)?; for i in 0..num_work_groups as usize { let hash_bytes = &hashes[i * 32..(i * 32) + 32]; @@ -63,6 +34,6 @@ fn do_group_block_mining_opencl( most_nonce = nonces[i]; } } - - (most_nonce, most_hash) -} + + Ok((most_nonce, most_hash)) +} \ No newline at end of file diff --git a/app/src/poworker.rs b/app/src/poworker.rs index 59c4a188..eaaf5442 100644 --- a/app/src/poworker.rs +++ b/app/src/poworker.rs @@ -7,6 +7,8 @@ use std::time::*; use reqwest::blocking::Client as HttpClient; use serde_json::Value as JV; +use crate::efficiency::*; + use basis::difficulty::*; use basis::interface::*; use field::*; @@ -24,7 +26,7 @@ include! {"opencl_pow.rs"} #[derive(Clone)] enum MinerBackend { - Cpu, + Cpu { assist_idx: Option }, #[cfg(feature = "ocl")] Opencl(Arc), } @@ -34,7 +36,7 @@ enum MinerBackend { #[derive(Clone)] pub struct PoWorkConf { pub rpcaddr: String, - pub supervene: u32, // cpu core + pub supervene: u32, // cpu core (configured) pub noncemax: u32, pub noticewait: u64, // new block notice wait pub useopencl: bool, // use opencl miner @@ -45,26 +47,59 @@ pub struct PoWorkConf { pub debug: u32, // enable debug mode pub platformid: u32, // opencl platform id pub deviceids: String, // opencl device id list + /// When OpenCL is on, also run Ryzen CPU miner threads (hybrid). + pub cpu_assist: bool, + pub gpu_profile: String, + pub efficiency: EfficiencyConf, + pub runtime: Arc, } impl PoWorkConf { pub fn new(ini: &IniObj) -> PoWorkConf { let sec = &ini_section(ini, "default"); // default = root let sec_gpu = &ini_section(ini, "gpu"); + let efficiency = EfficiencyConf::from_ini(ini); + let tuning = resolve_gpu_tuning(sec_gpu, &efficiency); + let configured_supervene = ini_must_u64(sec, "supervene", 2) as u32; + let active = efficiency.initial_active_supervene(configured_supervene); + let runtime = MiningRuntimeState::new(tuning.workgroups, active); let cnf = PoWorkConf { rpcaddr: ini_must(sec, "connect", "127.0.0.1:8081"), - supervene: ini_must_u64(sec, "supervene", 2) as u32, + supervene: configured_supervene, noncemax: ini_must_u64(sec, "nonce_max", u32::MAX as u64) as u32, noticewait: ini_must_u64(sec, "notice_wait", 45), useopencl: ini_must_bool(sec_gpu, "use_opencl", false) as bool, - workgroups: ini_must_u64(sec_gpu, "work_groups", 1024) as u32, + workgroups: tuning.workgroups, localsize: ini_must_u64(sec_gpu, "local_size", 256) as u32, - unitsize: ini_must_u64(sec_gpu, "unit_size", 128) as u32, + unitsize: tuning.unitsize, opencldir: ini_must(sec_gpu, "opencl_dir", "opencl/"), debug: ini_must_u64(sec_gpu, "debug", 0) as u32, platformid: ini_must_u64(sec_gpu, "platform_id", 0) as u32, deviceids: ini_must(sec_gpu, "device_ids", ""), + cpu_assist: ini_must_bool(sec_gpu, "cpu_assist", true) as bool, + gpu_profile: tuning.profile.clone(), + efficiency, + runtime, }; + println!( + "[efficiency] mode={} profile={} work_groups={} unit_size={} dynamic_supervene={}", + cnf.efficiency.mode.label(), + cnf.gpu_profile, + cnf.workgroups, + cnf.unitsize, + cnf.efficiency.dynamic_supervene + ); + cnf + } + + /// Minimal config for integration tests. + pub fn test_defaults(rpcaddr: String, supervene: u32, noncemax: u32) -> PoWorkConf { + let mut cnf = PoWorkConf::new(&IniObj::new()); + cnf.rpcaddr = rpcaddr; + cnf.supervene = supervene; + cnf.noncemax = noncemax; + cnf.useopencl = false; + cnf.cpu_assist = false; cnf } } @@ -99,11 +134,14 @@ struct BlockMiningResult { height: u64, nonce_start: u32, nonce_space: u32, + gpu_nonce_space: u32, + cpu_nonce_space: u32, head_nonce: u32, coinbase_nonce: Vec, result_hash: Vec, target_hash: Vec, use_secs: f64, + is_gpu: bool, } impl BlockMiningResult { @@ -115,10 +153,13 @@ impl BlockMiningResult { } pub fn poworker() { - // config let cnfp = "./poworker.config.ini".to_string(); - let inicnf = sys::load_config(cnfp); + let inicnf = sys::load_config(cnfp.clone()); let cnf = PoWorkConf::new(&inicnf); + if cnf.efficiency.benchmark_seconds > 0 { + run_block_mining_benchmark(&cnf, &cnfp); + return; + } poworker_with_conf(cnf); } @@ -127,12 +168,6 @@ pub fn poworker_with_conf(cnf: PoWorkConf) { } pub fn poworker_with_stop(cnf: PoWorkConf, stop_flag: Option>) { - // test start - // cnfobj.supervene = 1; - // cnfobj.noncemax = u32::MAX / 200; - // cnfobj.noticewait = 5; - // test end - let (res_tx, res_rx) = mpsc::channel(); let miner_backends = build_miner_backends(&cnf); @@ -173,6 +208,23 @@ pub fn poworker_with_stop(cnf: PoWorkConf, stop_flag: Option>) { if should_stop(&stop_flag) { return; } + if !is_within_idle_schedule( + cnf.efficiency.idle_start_hour, + cnf.efficiency.idle_end_hour, + ) { + delay_continue_ms!(5000); + continue; + } + if cnf.runtime.paused_unprofitable.load(Relaxed) { + delay_continue_ms!(3000); + continue; + } + cnf.runtime.apply_thermal_throttle( + cnf.efficiency.max_temp_c, + cnf.efficiency.throttle_workgroups, + &cnf.efficiency.thermal_file, + cnf.efficiency.thermal_gpu_index, + ); pull_pending_block_stuff(&cnf); delay_continue_ms!(25); } @@ -214,16 +266,30 @@ fn build_miner_backends(cnf: &PoWorkConf) -> Vec { "\n[Warn] use_opencl=true but app built without `ocl` feature, fallback to CPU miner." ); } + + if cnf.cpu_assist && cnf.supervene > 0 && !backends.is_empty() { + let thrnum = cnf.efficiency.spawn_supervene(cnf.supervene) as usize; + println!( + "\n[Start] Create #{} Ryzen CPU assist threads (hybrid GPU+CPU, active={}).", + thrnum, + cnf.runtime.active_cpu_assist.load(Relaxed) + ); + for i in 0..thrnum { + backends.push(MinerBackend::Cpu { + assist_idx: Some(i as u32), + }); + } + } } if backends.is_empty() { - let thrnum = cnf.supervene.max(1) as usize; + let thrnum = cnf.efficiency.clamp_supervene(cnf.supervene.max(1)) as usize; println!( "\n[Start] Create #{} CPU block miner worker thread.", thrnum ); for _ in 0..thrnum { - backends.push(MinerBackend::Cpu); + backends.push(MinerBackend::Cpu { assist_idx: None }); } } @@ -236,6 +302,11 @@ fn run_block_mining_item( result_ch_tx: mpsc::Sender>, backend: MinerBackend, ) { + if mining_is_gated(&_cnf.runtime, &_cnf.efficiency) { + delay_return_ms!(2000); + return; + } + let mining_hei = MINING_BLOCK_HEIGHT.load(Relaxed); if mining_hei == 0 { delay_return_ms!(111); // not yet @@ -248,13 +319,30 @@ fn run_block_mining_item( // each thread/task has been assigned a random coinbase_nonce above, // so block_intro (block header hash) differs; even with the same nonce_start, // the actual search hash space is disjoint and no hashrate conflict occurs. + if let MinerBackend::Cpu { assist_idx: Some(idx) } = &backend { + let active = _cnf.runtime.active_cpu_assist.load(Relaxed); + if *idx >= active { + delay_return_ms!(400); + return; + } + } + let mut nonce_start: u32 = 0; let nonce_limit = _cnf.noncemax.max(1); - let mut nonce_space: u32 = match backend { - MinerBackend::Cpu => 100000, + let mut nonce_space: u32 = match &backend { + MinerBackend::Cpu { .. } => 100000, #[cfg(feature = "ocl")] - MinerBackend::Opencl(_) => _cnf.workgroups * _cnf.localsize * _cnf.unitsize, + MinerBackend::Opencl(res) => { + let wg = _cnf + .runtime + .workgroups(res.workgroups.min(_cnf.workgroups)); + wg * _cnf.localsize * _cnf.unitsize + } }; + #[cfg(feature = "ocl")] + let is_gpu_backend = matches!(backend, MinerBackend::Opencl(_)); + #[cfg(not(feature = "ocl"))] + let is_gpu_backend = false; // stuff data let stuff = { MINING_BLOCK_STUFF.read().unwrap().clone() }; let height = stuff.height; @@ -275,24 +363,43 @@ fn run_block_mining_item( let ctn = Instant::now(); let block_intro_bin = block_intro.serialize(); - let (head_nonce, result_hash) = match &backend { - MinerBackend::Cpu => { - do_group_block_mining(height, block_intro_bin, nonce_start, current_nonce_space) + let (head_nonce, result_hash, gpu_ns, cpu_ns) = match &backend { + MinerBackend::Cpu { .. } => { + let (hn, rh) = + do_group_block_mining(height, block_intro_bin, nonce_start, current_nonce_space); + (hn, rh, 0u32, current_nonce_space) } #[cfg(feature = "ocl")] MinerBackend::Opencl(opencl) => { + let wg_cap = _cnf + .runtime + .workgroups(opencl.workgroups.min(_cnf.workgroups)); let unit_batch = (_cnf.localsize as u64) * (_cnf.unitsize as u64); - if _cnf.workgroups == 0 || unit_batch == 0 { - do_group_block_mining(height, block_intro_bin, nonce_start, current_nonce_space) + if wg_cap == 0 || unit_batch == 0 { + let (hn, rh) = do_group_block_mining( + height, + block_intro_bin, + nonce_start, + current_nonce_space, + ); + (hn, rh, 0u32, current_nonce_space) } else { let workgroups_by_space = (current_nonce_space as u64 / unit_batch) as u32; - let workgroups_eff = workgroups_by_space.min(_cnf.workgroups); + let workgroups_eff = workgroups_by_space.min(wg_cap); let gpu_nonce_space = workgroups_eff .saturating_mul(_cnf.localsize) .saturating_mul(_cnf.unitsize); - let mut best = if workgroups_eff > 0 { - do_group_block_mining_opencl( + if workgroups_eff == 0 { + let (hn, rh) = do_group_block_mining( + height, + block_intro_bin, + nonce_start, + current_nonce_space, + ); + (hn, rh, 0u32, current_nonce_space) + } else { + match do_group_block_mining_opencl( opencl, height, block_intro_bin.clone(), @@ -300,44 +407,62 @@ fn run_block_mining_item( workgroups_eff, _cnf.localsize, _cnf.unitsize, - ) - } else { - (0u32, [255u8; 32]) - }; - - let tail_space = current_nonce_space.saturating_sub(gpu_nonce_space); - if tail_space > 0 { - let tail_start = nonce_start.saturating_add(gpu_nonce_space); - let cpu_tail = - do_group_block_mining(height, block_intro_bin, tail_start, tail_space); - if hash_more_power(&cpu_tail.1, &best.1) { - best = cpu_tail; + ) { + Err(e) => { + eprintln!("[efficiency] GPU batch failed: {}", e); + _cnf.runtime.record_gpu_error( + wg_cap, + _cnf.efficiency.oom_fallback, + ); + let (hn, rh) = do_group_block_mining( + height, + block_intro_bin, + nonce_start, + current_nonce_space, + ); + (hn, rh, 0u32, current_nonce_space) + } + Ok(mut best) => { + let tail_space = + current_nonce_space.saturating_sub(gpu_nonce_space); + if tail_space > 0 { + let tail_start = nonce_start.saturating_add(gpu_nonce_space); + let cpu_tail = do_group_block_mining( + height, + block_intro_bin, + tail_start, + tail_space, + ); + if hash_more_power(&cpu_tail.1, &best.1) { + best = cpu_tail; + } + } + (best.0, best.1, gpu_nonce_space, tail_space) + } } } - - best } } }; let use_secs = Instant::now().duration_since(ctn).as_millis() as f64 / 1000.0; - // record result let mlres = BlockMiningResult { height, nonce_start, nonce_space: current_nonce_space, + gpu_nonce_space: gpu_ns, + cpu_nonce_space: cpu_ns, head_nonce, coinbase_nonce: coinbase_nonce.to_vec(), result_hash: result_hash.to_vec(), target_hash: stuff.target_hash.to_vec(), use_secs, + is_gpu: is_gpu_backend, }; result_ch_tx.send(mlres.into()).unwrap(); - if matches!(backend, MinerBackend::Cpu) { - if use_secs > 0.0 { - nonce_space = (current_nonce_space as f64 * MINING_INTERVAL / use_secs) as u32; - } + if use_secs > 0.0 { + nonce_space = (current_nonce_space as f64 * MINING_INTERVAL / use_secs) as u32; nonce_space = nonce_space.max(1); } @@ -389,11 +514,21 @@ fn deal_block_mining_results( let mut deal_hei = 0u64; let mut most = Arc::new(BlockMiningResult::new()); let mut total_nonce_space = 0u64; + let mut gpu_nonce_space = 0u64; + let mut cpu_nonce_space = 0u64; let mut total_use_secs = 0.0; let mut recv_count = 0; while let Ok(res) = result_ch_rx.try_recv() { deal_hei = res.height; total_nonce_space += res.nonce_space as u64; + if res.gpu_nonce_space > 0 || res.cpu_nonce_space > 0 { + gpu_nonce_space += res.gpu_nonce_space as u64; + cpu_nonce_space += res.cpu_nonce_space as u64; + } else if res.is_gpu { + gpu_nonce_space += res.nonce_space as u64; + } else { + cpu_nonce_space += res.nonce_space as u64; + } total_use_secs += res.use_secs; // Accumulated total time if hash_more_power(&res.result_hash, &most.result_hash) { most = res.clone(); @@ -430,16 +565,43 @@ fn deal_block_mining_results( mnper = 1.0; } let hac1day = mnper * ONEDAY_BLOCK_NUM * block_reward_number(deal_hei) as f64; + let active_cpu = cnf.runtime.active_cpu_assist.load(Relaxed); + cnf.runtime.maybe_adjust_supervene(&cnf.efficiency, gpu_nonce_space, cpu_nonce_space); + if should_pause_for_profit(&cnf.efficiency, hac1day, &cnf.gpu_profile, active_cpu) { + cnf.runtime.paused_unprofitable.store(true, Relaxed); + println!( + "\n[efficiency] Mining paused — estimated cost exceeds HAC revenue. Set pause_if_unprofitable=false or lower power draw." + ); + } else { + cnf.runtime.paused_unprofitable.store(false, Relaxed); + } + let eff_line = format_efficiency_line( + nonce_rates, + hac1day, + mnper * 100.0, + &cnf.efficiency, + &cnf.gpu_profile, + active_cpu, + ); flush!( - "{} {}, {} {}, ≈{:.4}HAC/day {:.6}%, {}. \r", + "{} {} | {} | best {}. \r", most.nonce_start, total_nonce_space, - hex::encode(hash_left_zero_pad(&most.result_hash, 2)), - hex::encode(hash_left_zero_pad3(&most_hash)), + eff_line, + hex::encode(hash_left_zero_pad3(&most_hash)) + ); + let paused = cnf.runtime.paused_unprofitable.load(Relaxed); + let stats = build_mining_stats( + nonce_rates, hac1day, mnper * 100.0, - rates_to_show(nonce_rates) + &cnf.efficiency, + &cnf.gpu_profile, + active_cpu, + deal_hei, + paused, ); + write_mining_stats(&cnf.efficiency.stats_file, &stats); // check success if cnf.debug == 1 || hash_more_power(&most.result_hash, &most.target_hash) { push_block_mining_success(cnf, &most); @@ -612,6 +774,223 @@ fn push_block_mining_success(cnf: &PoWorkConf, success: &BlockMiningResult) { println!("▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔") } +#[cfg(feature = "ocl")] +fn bench_block_hps( + opencl: &OpenCLResources, + cnf: &PoWorkConf, + wg_eff: u32, + us: u32, + seconds: u64, +) -> f64 { + let height = 1u64; + let block_intro = BlockIntro::default().serialize(); + let batch = wg_eff.saturating_mul(cnf.localsize).saturating_mul(us); + let deadline = Instant::now() + Duration::from_secs(seconds.max(3)); + let mut total_hashes = 0u64; + let mut total_secs = 0.0f64; + let mut nonce = 0u32; + while Instant::now() < deadline { + let ctn = Instant::now(); + if do_group_block_mining_opencl( + opencl, + height, + block_intro.clone(), + nonce, + wg_eff, + cnf.localsize, + us, + ) + .is_ok() + { + total_hashes += batch as u64; + } + let used = ctn.elapsed().as_secs_f64(); + if used > 0.0 { + total_secs += used; + } + nonce = nonce.wrapping_add(batch); + } + if total_secs > 0.0 { + total_hashes as f64 / total_secs + } else { + 0.0 + } +} + +fn run_block_mining_benchmark(cnf: &PoWorkConf, config_path: &str) { + #[cfg(not(feature = "ocl"))] + { + let _ = (cnf, config_path); + println!("[benchmark] Rebuild with --features ocl and use_opencl=true"); + return; + } + #[cfg(feature = "ocl")] + { + if !cnf.useopencl { + println!("[benchmark] Set use_opencl=true in [gpu]"); + return; + } + let total_secs = cnf.efficiency.benchmark_seconds.max(15) as u64; + let fine = cnf.efficiency.wants_fine_sweep(); + let profile_secs = if fine { + (total_secs * 70 / 100).max(20) + } else { + total_secs + }; + let sweep_secs = if fine { + total_secs.saturating_sub(profile_secs).max(10) + } else { + 0 + }; + + let init_unitsize = if fine { + cnf.unitsize.max(128) + } else { + cnf.unitsize + }; + let opencl_resources = initialize_opencl( + false, + &cnf.opencldir, + &cnf.platformid, + &cnf.deviceids, + &cnf.workgroups, + &cnf.localsize, + &init_unitsize, + ); + if opencl_resources.is_empty() { + println!("[benchmark] No OpenCL devices"); + return; + } + + for (dev_i, opencl) in opencl_resources.iter().enumerate() { + let profiles = benchmark_profiles_for_vendor(opencl.vendor); + let per = (profile_secs / profiles.len() as u64).max(4); + println!( + "[benchmark] Device #{}: {}s x {} profiles{}", + dev_i, + per, + profiles.len(), + if fine { " + fine sweep" } else { "" } + ); + + let mut best_hps = 0.0f64; + let mut best_profit = 0.0f64; + let mut best_hps_profile = profiles[0]; + let mut best_profit_profile = profiles[0]; + + for profile in profiles { + let (wg, us) = profile_tuning(profile); + let wg_eff = cnf.runtime.workgroups(opencl.workgroups.min(wg)); + let hps = bench_block_hps(opencl, cnf, wg_eff, us, per); + let watts = cnf.efficiency.estimate_gpu_watts(profile); + let kh_per_j = if watts > 0.0 { + hps / watts / 1000.0 + } else { + 0.0 + }; + println!( + "[benchmark] dev{} {}: {} ({:.1} kH/J, wg={})", + dev_i, + profile, + rates_to_show(hps), + kh_per_j, + wg_eff + ); + if hps > best_hps { + best_hps = hps; + best_hps_profile = profile; + } + if kh_per_j > best_profit { + best_profit = kh_per_j; + best_profit_profile = profile; + } + } + + let base_profile = match cnf.efficiency.mode { + EfficiencyMode::Max => best_hps_profile, + _ => best_profit_profile, + }; + let (base_wg, us) = profile_tuning(base_profile); + let mut pick = BenchmarkPick::from_profile(base_profile); + + if fine && sweep_secs > 0 { + let vram = opencl.vram_bytes; + let wg_sweep_secs = sweep_secs / 2; + let us_sweep_secs = sweep_secs.saturating_sub(wg_sweep_secs).max(6); + let candidates = sweep_workgroup_candidates(base_wg, vram, cnf.localsize, us); + let per_wg = (wg_sweep_secs / candidates.len() as u64).max(3); + let mut best_sweep_hps = 0.0f64; + let mut best_sweep_wg = pick.workgroups; + println!( + "[benchmark] dev{} fine wg sweep: {} candidates x {}s", + dev_i, + candidates.len(), + per_wg + ); + for wg_try in candidates { + let wg_eff = cnf.runtime.workgroups(opencl.workgroups.min(wg_try)); + let hps = bench_block_hps(opencl, cnf, wg_eff, us, per_wg); + println!( + "[benchmark] dev{} wg={}: {}", + dev_i, + wg_eff, + rates_to_show(hps) + ); + if hps > best_sweep_hps { + best_sweep_hps = hps; + best_sweep_wg = wg_eff; + } + } + pick.workgroups = best_sweep_wg; + + let us_candidates = + sweep_unitsize_candidates(pick.unitsize, opencl.allocated_unitsize); + let per_us = (us_sweep_secs / us_candidates.len() as u64).max(3); + let mut best_us_hps = 0.0f64; + let mut best_us = pick.unitsize; + println!( + "[benchmark] dev{} fine unit_size sweep: {:?} x {}s", + dev_i, + us_candidates, + per_us + ); + for us_try in us_candidates { + let hps = bench_block_hps(opencl, cnf, pick.workgroups, us_try, per_us); + println!( + "[benchmark] dev{} unit_size={}: {}", + dev_i, + us_try, + rates_to_show(hps) + ); + if hps > best_us_hps { + best_us_hps = hps; + best_us = us_try; + } + } + pick.unitsize = best_us; + } + + println!( + "[benchmark] dev{} pick: profile={} work_groups={} unit_size={} (mode={})", + dev_i, + pick.profile, + pick.workgroups, + pick.unitsize, + cnf.efficiency.mode.label() + ); + + if dev_i == 0 { + match apply_benchmark_pick(config_path, &pick) { + Ok(()) => { + println!("[benchmark] Config updated — restart mining with the new tuning.") + } + Err(e) => println!("[benchmark] Could not patch ini: {}", e), + } + } + } + } +} + #[cfg(test)] mod tests { use super::*; diff --git a/app/src/util.rs b/app/src/util.rs index 8d94cdcc..9e5512a7 100644 --- a/app/src/util.rs +++ b/app/src/util.rs @@ -82,3 +82,5 @@ fn diamond_more_power(dst: &[u8], src: &[u8]) -> bool { false } + + diff --git a/app/tests/efficiency_test.rs b/app/tests/efficiency_test.rs new file mode 100644 index 00000000..7b056285 --- /dev/null +++ b/app/tests/efficiency_test.rs @@ -0,0 +1,34 @@ +use app::poworker::PoWorkConf; +use std::collections::HashMap; +use sys::IniObj; + +#[test] +fn efficiency_section_loads_defaults() { + let cnf = PoWorkConf::test_defaults("127.0.0.1:8080".to_string(), 4, 1024); + assert_eq!(cnf.efficiency.power_cost_kwh, 0.15); + assert_eq!(cnf.gpu_profile, "amd_profit"); +} + +#[test] +fn eco_mode_selects_eco_profile() { + let mut ini: IniObj = HashMap::new(); + let mut eff = HashMap::new(); + eff.insert("mode".to_string(), Some("eco".to_string())); + ini.insert("efficiency".to_string(), eff); + let cnf = PoWorkConf::new(&ini); + assert_eq!(cnf.gpu_profile, "amd_eco"); + assert_eq!(cnf.workgroups, 768); +} + +#[test] +fn benchmark_tuned_workgroups_are_preserved() { + let mut ini: IniObj = HashMap::new(); + let mut gpu = HashMap::new(); + gpu.insert("gpu_profile".to_string(), Some("amd_max".to_string())); + gpu.insert("work_groups".to_string(), Some("2000".to_string())); + gpu.insert("unit_size".to_string(), Some("96".to_string())); + ini.insert("gpu".to_string(), gpu); + let cnf = PoWorkConf::new(&ini); + assert_eq!(cnf.workgroups, 2000); + assert_eq!(cnf.unitsize, 96); +} \ No newline at end of file diff --git a/app/tests/poworker_cpu_sim.rs b/app/tests/poworker_cpu_sim.rs index a1aab55e..198515bb 100644 --- a/app/tests/poworker_cpu_sim.rs +++ b/app/tests/poworker_cpu_sim.rs @@ -20,20 +20,7 @@ fn poworker_cpu_mining_submit_success_with_sim_miner_api() { let sim = MinerApiSim::start(MinerPendingStuff::easy_for_test(1)); let stop = Arc::new(AtomicBool::new(false)); - let cnf = PoWorkConf { - rpcaddr: sim.rpcaddr().to_string(), - supervene: 1, - noncemax: 2048, - noticewait: 1, - useopencl: false, - workgroups: 1, - localsize: 256, - unitsize: 64, - opencldir: "x16rs/opencl/".to_string(), - debug: 0, - platformid: 0, - deviceids: String::new(), - }; + let cnf = PoWorkConf::test_defaults(sim.rpcaddr().to_string(), 1, 2048); let stop2 = stop.clone(); let worker = thread::spawn(move || { diff --git a/basis/src/interface/hnoder.rs b/basis/src/interface/hnoder.rs index 64e5b35d..874ebd6a 100644 --- a/basis/src/interface/hnoder.rs +++ b/basis/src/interface/hnoder.rs @@ -20,6 +20,9 @@ pub trait HNoder: Send + Sync { fn all_peer_prints(&self) -> Vec { never!() } + /// Prometheus-style lines for post-quantum metrics (empty when unsupported). + fn pqc_metrics_prometheus(&self) -> Vec { Vec::new() } + fn exit(&self) {} } diff --git a/basis/src/interface/transaction.rs b/basis/src/interface/transaction.rs index 7a8ea770..05a44b99 100644 --- a/basis/src/interface/transaction.rs +++ b/basis/src/interface/transaction.rs @@ -1,4 +1,5 @@ static TX_NIL_SIGNS: OnceLock> = OnceLock::new(); +static TX_NIL_HYBRID_SIGNS: OnceLock> = OnceLock::new(); static TX_NIL_ACTIS: OnceLock>> = OnceLock::new(); pub trait TxExec { @@ -69,6 +70,9 @@ pub trait TransactionRead: Serialize + TxExec + Send + Sync + DynClone + std::fm fn signs(&self) -> &Vec { TX_NIL_SIGNS.get_or_init(|| vec![]) } + fn hybrid_signs(&self) -> &Vec { + TX_NIL_HYBRID_SIGNS.get_or_init(|| vec![]) + } fn req_sign(&self) -> Ret> { errf!("cannot request signature") @@ -91,6 +95,12 @@ pub trait Transaction: TransactionRead + Field + Send + Sync { fn push_sign(&mut self, _: Sign) -> Rerr { errf!("never") } + fn fill_hybrid_sign(&mut self, _: &sys::HybridAccount) -> Ret { + errf!("never") + } + fn push_hybrid_sign(&mut self, _: HybridSign) -> Rerr { + errf!("never") + } fn push_action(&mut self, _: Box) -> Rerr { errf!("never") } diff --git a/basis/src/method/hybrid_signature.rs b/basis/src/method/hybrid_signature.rs new file mode 100644 index 00000000..71393e4b --- /dev/null +++ b/basis/src/method/hybrid_signature.rs @@ -0,0 +1,64 @@ +use field::sign_alg; +use field::{Address, Hash, HybridSign}; +use sys::Account; + +pub fn verify_hybrid_signature(hash: &Hash, addr: &Address, sign: &HybridSign) -> bool { + if sign.check_wire().is_err() { + return false; + } + let alg = sign.alg_id(); + let body = sign.body_bytes(); + let Ok(curaddr) = sys::address_from_hybrid_sign_body(alg, body) else { + return false; + }; + if addr.as_array() != &curaddr { + return false; + } + let msg = hash.as_array(); + match (alg, addr.version()) { + (sign_alg::LEGACY_SECP, v) if v == Address::PRIVAKEY => { + if body.len() != sign_alg::BODY_LEGACY_SECP { + return false; + } + let mut pk = [0u8; sign_alg::SECP_PK_SIZE]; + pk.copy_from_slice(&body[..sign_alg::SECP_PK_SIZE]); + let sig: [u8; sign_alg::SECP_SIG_SIZE] = body[sign_alg::SECP_PK_SIZE..] + .try_into() + .unwrap_or([0u8; sign_alg::SECP_SIG_SIZE]); + Account::verify_signature(msg, &pk, &sig) + } + (sign_alg::MLDSA65, v) if v == Address::PQCKEY => { + let pk = &body[..sign_alg::MLDSA65_PK_SIZE]; + let sig = &body[sign_alg::MLDSA65_PK_SIZE..]; + sys::verify_mldsa65_detached(msg, pk, sig) + } + (sign_alg::HYBRID_SECP_MLDSA65, v) if v == Address::HYBRID => { + let secp_pk: [u8; sign_alg::SECP_PK_SIZE] = + match body[..sign_alg::SECP_PK_SIZE].try_into() { + Ok(v) => v, + Err(_) => return false, + }; + let secp_sig: [u8; sign_alg::SECP_SIG_SIZE] = match body + [sign_alg::SECP_PK_SIZE..sign_alg::SECP_PK_SIZE + sign_alg::SECP_SIG_SIZE] + .try_into() + { + Ok(v) => v, + Err(_) => return false, + }; + if !Account::verify_signature(msg, &secp_pk, &secp_sig) { + return false; + } + let mldsa_off = sign_alg::SECP_PK_SIZE + sign_alg::SECP_SIG_SIZE; + let mldsa_pk = &body[mldsa_off..mldsa_off + sign_alg::MLDSA65_PK_SIZE]; + let mldsa_sig = &body[mldsa_off + sign_alg::MLDSA65_PK_SIZE..]; + sys::verify_mldsa65_detached(msg, mldsa_pk, mldsa_sig) + } + _ => false, + } +} + +pub fn hybrid_sign_address(sign: &HybridSign) -> Ret
{ + sign.check_wire()?; + let addr = sys::address_from_hybrid_sign_body(sign.alg_id(), sign.body_bytes())?; + Ok(Address::from(addr)) +} \ No newline at end of file diff --git a/basis/src/method/mod.rs b/basis/src/method/mod.rs index e71d085d..759f0580 100644 --- a/basis/src/method/mod.rs +++ b/basis/src/method/mod.rs @@ -2,5 +2,6 @@ use field::*; use sys::*; include! {"signature.rs"} +include! {"hybrid_signature.rs"} include! {"store.rs"} // include!{"tokio.rs"} diff --git a/docs/MINING-AMD.md b/docs/MINING-AMD.md new file mode 100644 index 00000000..44e315b5 --- /dev/null +++ b/docs/MINING-AMD.md @@ -0,0 +1,148 @@ +# AMD GPU + Ryzen CPU mining (HAC & HACD) + +Official Hacash miners use **OpenCL** (not CUDA). AMD Radeon and Ryzen are supported. + +Community miners that only support NVIDIA CUDA are **separate projects** — this stack works on AMD out of the box when built with the `ocl` feature. + +## What mines what + +| Asset | Worker | Algorithm | +|-------|--------|-----------| +| **HAC** (blocks) | `poworker` | SHA3-256 + x16rs PoW | +| **HACD** (diamonds) | `diaworker` | SHA3-256 + x16rs + diamond filter | + +## Quick start (Windows) + +**End users (GitHub Releases):** + +- **`hacash-miner-full-windows-x64.zip`** — clean PC: fullnode + miners + panel → run `SETUP.bat` +- **`hacash-miner-only-windows-x64.zip`** — you already have fullnode → run `SETUP-MINER.bat` + +**Maintainers:** `git tag v0.4.0 && git push origin v0.4.0` triggers `.github/workflows/release.yml`. Manual build: **Actions → Release (Windows miner) → Run workflow** (artifact only, no Release page). + +1. Install **AMD Adrenalin** drivers (includes OpenCL runtime) or ROCm OpenCL on Linux. +2. Build miners with OpenCL: + ```bat + scripts\mining-amd\BUILD-AMD-MINER.bat + ``` +3. Install AMD-tuned configs: + ```bat + scripts\mining-amd\INSTALL-CONFIGS.bat + ``` +4. List your GPU platform/device IDs: + ```bat + scripts\mining-amd\LIST-OPENCL-DEVICES.bat + ``` +5. Edit `target\release\poworker.config.ini` and `diaworker.config.ini` (copied from `*.amd.ini.example`): + - `[gpu] platform_id` — usually `0` for AMD on Windows + - `[gpu] device_ids` — GPU index from step 4 + - `supervene` — Ryzen CPU threads (e.g. `4`–`8`) +6. Run fullnode (`hacash.exe`) with RPC enabled (`[server] enable = true`). +7. Start mining: + ```bat + scripts\mining-amd\START-AMD-HAC-MINING.bat + scripts\mining-amd\START-AMD-HACD-MINING.bat + ``` + +## Fullnode config + +### HAC block rewards + +```ini +[miner] +enable = true +reward = +``` + +### HACD diamonds (required for diaworker) + +```ini +[diamondminer] +enable = true +reward = +``` + +## GPU section reference + +```ini +[gpu] +use_opencl = true +cpu_assist = true ; Ryzen CPU threads + GPU (hybrid) +gpu_profile = amd_performance ; amd_balanced | amd_performance | amd_max +platform_id = 0 +device_ids = 0 +opencl_dir = ../../x16rs/opencl/ +work_groups = 2048 +local_size = 256 ; must stay 256 (kernel requirement) +unit_size = 96 +``` + +### Efficiency presets (`gpu_profile`) + +| Profile | work_groups | unit_size | Use when | +|---------|-------------|-----------|----------| +| `amd_eco` | 768 | 128 | Lowest power / 8GB VRAM | +| `amd_balanced` | 1024 | 128 | Default / 8GB VRAM | +| `amd_profit` | 1536 | 96 | **Default** — best HAC per kWh | +| `amd_performance` | 2048 | 96 | RX 6000/7000 — max stable H/s | +| `amd_max` | 4096 | 128 | 12GB+ VRAM, watch GPU temp | + +### Cost-aware mining (`[efficiency]`) + +```ini +[efficiency] +mode = profit ; eco | profit | max +power_cost_kwh = 0.15 +gpu_watts = 0 ; 0 = estimate from profile +hac_price = 0 ; set for net EUR/day in console +dynamic_supervene = true ; auto CPU assist tuning +supervene_min = 2 +supervene_max = 10 +oom_fallback = true ; reduce work_groups on GPU errors +max_temp_c = 0 ; throttle if temp exceeded (WMI/file) +idle_start_hour = 255 ; 22 + idle_end_hour 6 = mine nights only +benchmark_seconds = 0 ; set 45 + run poworker for autotune +``` + +Console shows: `MH/s | Watts | kH/J | HAC/day | cost or net EUR/day`. + +Scripts: +- `CONFIGURE-MINING.bat` — pick CPU + GPU +- `BENCHMARK-AMD.bat` — recommend `gpu_profile` + +Run `scripts/mining-amd/TUNE-AMD-EFFICIENCY.bat` for basic `supervene` (or use CONFIGURE-MINING presets). + +### Hybrid mining + +With `cpu_assist = true`, the GPU runs OpenCL and Ryzen threads mine on CPU **in parallel** — better total hashrate than GPU-only. + +## AMD optimizations + +When an AMD GPU is detected, the OpenCL compiler enables `amd_bfe` fast paths (`NO_AMD_OPS=0`). Kernel binaries are cached as `DeviceName__amd.bin` under `x16rs/opencl/`. + +## CPU-only (Ryzen, no GPU) + +Build without `ocl` or set `use_opencl = false` and increase `supervene` to your core count: + +```ini +supervene = 8 +``` + +## Troubleshooting + +| Problem | Fix | +|---------|-----| +| `no OpenCL platforms` | Install AMD GPU drivers / OpenCL ICD | +| `use_opencl=true but no ocl feature` | Rebuild with `--features ocl` | +| `OpenCL dir not found` | Run miners from `target/debug` or `target/release`; check `opencl_dir` | +| diaworker idle | Enable `[diamondminer]` on fullnode | +| Low hashrate | Tune `work_groups`; first run compiles kernels (~1 min) | + +## Manual build + +```bash +cargo build --release --features ocl +./target/release/list_opencl +./target/release/poworker +./target/release/diaworker +``` \ No newline at end of file diff --git a/field/src/component/hybrid_sign.rs b/field/src/component/hybrid_sign.rs new file mode 100644 index 00000000..505ac8a6 --- /dev/null +++ b/field/src/component/hybrid_sign.rs @@ -0,0 +1,92 @@ +/// Hybrid / PQC signature algorithm identifiers (wire `alg` field). +pub mod sign_alg { + /// Legacy secp256k1 embedded in Type4 hybrid sign list (33-byte pk + 64-byte sig). + pub const LEGACY_SECP: u8 = 0; + /// ML-DSA-65 only (PQCKEY v6 addresses). + pub const MLDSA65: u8 = 1; + /// secp256k1 + ML-DSA-65 hybrid (HYBRID v7 addresses). + pub const HYBRID_SECP_MLDSA65: u8 = 3; + + pub const SECP_PK_SIZE: usize = 33; + pub const SECP_SIG_SIZE: usize = 64; + pub const MLDSA65_PK_SIZE: usize = 1952; + pub const MLDSA65_SIG_SIZE: usize = 3309; + + pub const BODY_LEGACY_SECP: usize = SECP_PK_SIZE + SECP_SIG_SIZE; + pub const BODY_MLDSA65: usize = MLDSA65_PK_SIZE + MLDSA65_SIG_SIZE; + pub const BODY_HYBRID: usize = + SECP_PK_SIZE + SECP_SIG_SIZE + MLDSA65_PK_SIZE + MLDSA65_SIG_SIZE; +} + +combi_struct! { HybridSign, + alg: Uint1 + body: BytesW2 +} + +impl HybridSign { + pub fn alg_id(&self) -> u8 { + *self.alg + } + + pub fn expected_body_len(alg: u8) -> Ret { + use sign_alg::*; + match alg { + LEGACY_SECP => Ok(BODY_LEGACY_SECP), + MLDSA65 => Ok(BODY_MLDSA65), + HYBRID_SECP_MLDSA65 => Ok(BODY_HYBRID), + _ => errf!("hybrid sign alg {} not supported", alg), + } + } + + pub fn check_wire(&self) -> Rerr { + let alg = self.alg_id(); + let expect = Self::expected_body_len(alg)?; + let got = self.body.length(); + maybe!( + got == expect, + Ok(()), + errf!( + "hybrid sign alg {} body length {} expected {}", + alg, + got, + expect + ) + ) + } + + pub fn body_bytes(&self) -> &[u8] { + self.body.as_ref() + } +} + +combi_list!(HybridSignW1, Uint1, HybridSign); +combi_list!(HybridSignW2, Uint2, HybridSign); + +#[cfg(test)] +mod hybrid_sign_tests { + use super::*; + + #[test] + fn expected_body_lengths_match_mldsa65_wire() { + use sign_alg::*; + assert_eq!(BODY_LEGACY_SECP, 97); + assert_eq!(BODY_MLDSA65, 5261); + assert_eq!(BODY_HYBRID, 5358); + } + + #[test] + fn check_wire_rejects_unknown_alg() { + let mut sign = HybridSign::default(); + sign.alg = Uint1::from(2u8); + sign.body = BytesW2::from(vec![0u8; 10]).unwrap(); + assert!(sign.check_wire().is_err()); + } + + #[test] + fn check_wire_rejects_wrong_body_len() { + let mut sign = HybridSign::default(); + sign.alg = Uint1::from(sign_alg::MLDSA65); + sign.body = BytesW2::from(vec![0u8; 100]).unwrap(); + assert!(sign.check_wire().is_err()); + } +} \ No newline at end of file diff --git a/field/src/core/address.rs b/field/src/core/address.rs index 73ae0318..c6aebfb7 100644 --- a/field/src/core/address.rs +++ b/field/src/core/address.rs @@ -189,6 +189,8 @@ address_version_define!{ PRIVAKEY : privakey, 0 // leading symbol: 1 CONTRACT : contract, 1 // leading symbol: Q-Z, a-k, m-o SCRIPTMH : scriptmh, 5 // leading symbol: 3 + PQCKEY : pqckey, 6 // post-quantum ML-DSA-65 key hash + HYBRID : hybrid, 7 // secp256k1 + ML-DSA-65 hybrid key hash } @@ -213,6 +215,11 @@ impl Address { /// - ADDRESS_ZERO (value 0) — blackhole /// - ADDRESS_ONEX (value 1) — TEX settlement escrow /// - ADDRESS_TWOX (value 2) — reserved + /// User-controlled signing address: legacy privakey, PQC-only, or hybrid. + pub fn is_user_signing_address(&self) -> bool { + self.is_privakey() || self.is_pqckey() || self.is_hybrid() + } + pub fn is_privakey_unknown(&self) -> bool { let b = self.as_ref(); // 21 bytes // Value < u32::MAX → top 17 bytes must be 0. diff --git a/field/src/core/amount.rs b/field/src/core/amount.rs index 7df577aa..a0ee0548 100644 --- a/field/src/core/amount.rs +++ b/field/src/core/amount.rs @@ -1290,4 +1290,22 @@ mod amount_tests { assert!(back.equal(&a)); } } + + #[test] + fn mined_balance_send_and_fee_sub() { + let mut bal = Amount::zero(); + for _ in 0..105 { + bal = bal.add_mode_u128(&Amount::small_mei(1)).expect("add reward"); + } + // Wallet wire "45:0" is parsed as fin(value:unit) -> unit 0, causing 10^248 overflow vs mei balance. + assert!(bal.sub_mode_u128(&Amount::from("45:0").unwrap()).is_err()); + // Dot mei strings align units correctly. + let send = Amount::from("45.0").unwrap(); + let fee = Amount::from("1.244").unwrap(); + bal.sub_mode_u128(&send).expect("sub send"); + bal.sub_mode_u128(&send) + .unwrap() + .sub_mode_u128(&fee) + .expect("sub fee"); + } } diff --git a/field/src/lib.rs b/field/src/lib.rs index 8d2d299a..e2ca0f69 100644 --- a/field/src/lib.rs +++ b/field/src/lib.rs @@ -57,6 +57,7 @@ include! {"core/asset.rs"} // component include! {"component/sign.rs"} +include! {"component/hybrid_sign.rs"} include! {"component/asset.rs"} include! {"component/balance.rs"} include! {"component/total.rs"} diff --git a/miner-panel/Cargo.toml b/miner-panel/Cargo.toml new file mode 100644 index 00000000..1538ef50 --- /dev/null +++ b/miner-panel/Cargo.toml @@ -0,0 +1,22 @@ +[package] +name = "miner-panel" +version = "0.1.0" +edition = "2024" +description = "Simple HAC AMD mining setup and dashboard" + +[[bin]] +name = "miner-panel" +path = "src/main.rs" + +[dependencies] +eframe = "0.30" +egui = "0.30" +app = { path = "../app" } +field = { path = "../field" } +sys = { path = "../sys" } +serde = { version = "1", features = ["derive"] } +serde_json = "1" +image = { version = "0.25", default-features = false, features = ["png"] } + +[features] +default = [] \ No newline at end of file diff --git a/miner-panel/assets/hhh.png b/miner-panel/assets/hhh.png new file mode 100644 index 00000000..43e4a1cc Binary files /dev/null and b/miner-panel/assets/hhh.png differ diff --git a/miner-panel/src/assets.rs b/miner-panel/src/assets.rs new file mode 100644 index 00000000..c3b9e102 --- /dev/null +++ b/miner-panel/src/assets.rs @@ -0,0 +1,54 @@ +use std::path::Path; + +use eframe::egui::{self, ColorImage, TextureHandle, TextureOptions, Vec2}; + +const EMBEDDED_LOGO: &[u8] = include_bytes!("../assets/hhh.png"); + +pub fn load_logo(ctx: &egui::Context, work_dir: &Path) -> Option { + let external = work_dir.join("hhh.png"); + if external.is_file() { + if let Ok(bytes) = std::fs::read(&external) { + if let Some(tex) = texture_from_png(ctx, &bytes, "app_logo") { + return Some(tex); + } + } + } + texture_from_png(ctx, EMBEDDED_LOGO, "app_logo") +} + +fn texture_from_png(ctx: &egui::Context, bytes: &[u8], name: &str) -> Option { + let img = image::load_from_memory(bytes).ok()?; + let mut rgba = img.to_rgba8(); + strip_near_white_background(&mut rgba); + let size = [rgba.width() as usize, rgba.height() as usize]; + let color_image = ColorImage::from_rgba_unmultiplied(size, rgba.as_raw()); + Some(ctx.load_texture(name, color_image, TextureOptions::LINEAR)) +} + +/// Turn solid/near-white PNG backgrounds transparent (keeps soft edges on the logo). +fn strip_near_white_background(rgba: &mut image::RgbaImage) { + const HARD_CUTOFF: f32 = 248.0; + const SOFT_START: f32 = 220.0; + + for pixel in rgba.pixels_mut() { + let [r, g, b, a] = pixel.0; + if a == 0 { + continue; + } + let lum = (r as f32 + g as f32 + b as f32) / 3.0; + if lum >= HARD_CUTOFF { + pixel.0[3] = 0; + } else if lum > SOFT_START { + let fade = (HARD_CUTOFF - lum) / (HARD_CUTOFF - SOFT_START); + pixel.0[3] = ((a as f32) * fade.clamp(0.0, 1.0)) as u8; + } + } +} + +pub fn logo_size_for_height(texture: &TextureHandle, height: f32) -> Vec2 { + let native = texture.size_vec2(); + if native.y <= 0.0 { + return Vec2::new(height, height); + } + Vec2::new(height * native.x / native.y, height) +} \ No newline at end of file diff --git a/miner-panel/src/config.rs b/miner-panel/src/config.rs new file mode 100644 index 00000000..e829adb0 --- /dev/null +++ b/miner-panel/src/config.rs @@ -0,0 +1,348 @@ +use std::collections::HashMap; +use std::path::Path; + +use app::efficiency::EfficiencyMode; + +use crate::presets::{gpu_idx_for_profile, tuning_for_profile, CpuPreset, GpuPreset}; + +pub struct PanelSettings { + pub cpu: CpuPreset, + pub gpu: GpuPreset, + pub mode: EfficiencyMode, + pub power_cost_kwh: f64, + pub hac_price: f64, + pub platform_id: u32, + pub device_id: u32, + pub connect: String, + pub stats_file: String, + pub opencl_dir: String, + pub max_temp_c: u32, + pub pause_if_unprofitable: bool, + pub benchmark_seconds: u32, + pub idle_start_hour: u32, + pub idle_end_hour: u32, + pub benchmark_fine_sweep: bool, + pub thermal_gpu_index: u32, + pub work_groups: u32, + pub unit_size: u32, +} + +#[derive(Default)] +pub struct LoadedPanelIni { + pub supervene: Option, + pub gpu_profile: Option, + pub work_groups: Option, + pub unit_size: Option, + pub platform_id: Option, + pub device_id: Option, + pub connect: Option, + pub mode: Option, + pub power_cost_kwh: Option, + pub hac_price: Option, + pub max_temp_c: Option, + pub pause_if_unprofitable: Option, +} + +fn parse_u32(s: &str) -> Option { + s.trim().parse().ok() +} + +fn parse_f64(s: &str) -> Option { + s.trim().parse().ok() +} + +fn section_map(content: &str, section: &str) -> HashMap { + let tag = format!("[{section}]"); + let mut in_section = false; + let mut out = HashMap::new(); + for line in content.lines() { + let trimmed = line.trim(); + if trimmed.starts_with('[') && trimmed.ends_with(']') { + in_section = trimmed.eq_ignore_ascii_case(&tag); + continue; + } + if !in_section { + continue; + } + if let Some((k, v)) = trimmed.split_once('=') { + out.insert(k.trim().to_lowercase(), v.split(';').next().unwrap_or(v).trim().to_string()); + } + } + out +} + +fn root_value(content: &str, key: &str) -> Option { + let mut in_section = false; + for line in content.lines() { + let trimmed = line.trim(); + if trimmed.starts_with('[') && trimmed.ends_with(']') { + in_section = true; + continue; + } + if in_section { + continue; + } + if let Some((k, v)) = trimmed.split_once('=') { + if k.trim().eq_ignore_ascii_case(key) { + return Some(v.split(';').next().unwrap_or(v).trim().to_string()); + } + } + } + None +} + +pub fn load_panel_ini(path: &Path) -> LoadedPanelIni { + let Ok(content) = std::fs::read_to_string(path) else { + return LoadedPanelIni::default(); + }; + let gpu = section_map(&content, "gpu"); + let eff = section_map(&content, "efficiency"); + LoadedPanelIni { + supervene: root_value(&content, "supervene").and_then(|v| parse_u32(&v)), + gpu_profile: gpu.get("gpu_profile").cloned(), + work_groups: gpu.get("work_groups").and_then(|v| parse_u32(v)), + unit_size: gpu.get("unit_size").and_then(|v| parse_u32(v)), + platform_id: gpu.get("platform_id").and_then(|v| parse_u32(v)), + device_id: gpu + .get("device_ids") + .and_then(|v| v.split(',').next()) + .and_then(|v| parse_u32(v)), + connect: root_value(&content, "connect"), + mode: eff + .get("mode") + .map(|s| EfficiencyMode::from_str(s)), + power_cost_kwh: eff.get("power_cost_kwh").and_then(|v| parse_f64(v)), + hac_price: eff.get("hac_price").and_then(|v| parse_f64(v)), + max_temp_c: eff.get("max_temp_c").and_then(|v| parse_u32(v)), + pause_if_unprofitable: eff + .get("pause_if_unprofitable") + .map(|v| matches!(v.to_lowercase().as_str(), "true" | "1" | "yes")), + } +} + +pub fn apply_loaded_ini( + loaded: &LoadedPanelIni, + cpus: &[CpuPreset], + gpus: &[GpuPreset], + cpu_idx: &mut usize, + gpu_idx: &mut usize, + mode_idx: &mut usize, + work_groups: &mut u32, + unit_size: &mut u32, + platform_id: &mut u32, + device_id: &mut u32, + connect: &mut String, + power_cost: &mut f32, + hac_price: &mut f32, + max_temp_c: &mut u32, + pause_unprofitable: &mut bool, +) { + if let Some(sv) = loaded.supervene { + if let Some(i) = cpus.iter().position(|c| c.supervene == sv) { + *cpu_idx = i; + } + } + if let Some(ref profile) = loaded.gpu_profile { + if let Some(i) = gpu_idx_for_profile(gpus, profile) { + *gpu_idx = i; + } + } + if let Some(mode) = loaded.mode { + *mode_idx = match mode { + EfficiencyMode::Eco => 0, + EfficiencyMode::Max => 2, + EfficiencyMode::Profit => 1, + }; + } + if let Some(wg) = loaded.work_groups { + *work_groups = wg; + } else if let Some(ref profile) = loaded.gpu_profile { + let (wg, us) = tuning_for_profile(profile); + *work_groups = wg; + *unit_size = us; + } + if let Some(us) = loaded.unit_size { + *unit_size = us; + } + if let Some(p) = loaded.platform_id { + *platform_id = p; + } + if let Some(d) = loaded.device_id { + *device_id = d; + } + if let Some(ref c) = loaded.connect { + *connect = c.clone(); + } + if let Some(c) = loaded.power_cost_kwh { + *power_cost = c as f32; + } + if let Some(p) = loaded.hac_price { + *hac_price = p as f32; + } + if let Some(t) = loaded.max_temp_c { + *max_temp_c = t; + } + if let Some(p) = loaded.pause_if_unprofitable { + *pause_unprofitable = p; + } +} + +fn efficiency_section(s: &PanelSettings) -> String { + let mode = s.mode.label(); + format!( + r"[efficiency] +mode = {mode} +power_cost_kwh = {cost} +gpu_watts = {gpu_watts} +cpu_watts_per_thread = 8 +hac_price = {hac_price} +dynamic_supervene = true +supervene_min = 2 +supervene_max = {sv} +oom_fallback = true +max_temp_c = {max_temp} +throttle_work_groups = 1024 +idle_start_hour = {idle_start} +idle_end_hour = {idle_end} +pause_if_unprofitable = {pause_unprofitable} +benchmark_seconds = {benchmark_seconds} +benchmark_fine_sweep = {fine_sweep} +thermal_gpu_index = {thermal_gpu} +stats_file = {stats_file} +", + mode = mode, + cost = s.power_cost_kwh, + gpu_watts = s.gpu.watts, + hac_price = s.hac_price, + sv = s.cpu.supervene, + max_temp = s.max_temp_c, + idle_start = s.idle_start_hour, + idle_end = s.idle_end_hour, + pause_unprofitable = s.pause_if_unprofitable, + benchmark_seconds = s.benchmark_seconds, + fine_sweep = s.benchmark_fine_sweep, + thermal_gpu = s.thermal_gpu_index, + stats_file = s.stats_file, + ) +} + +pub fn write_poworker_config(path: &Path, s: &PanelSettings) -> std::io::Result<()> { + let cpu_only = s.gpu.slug == "none"; + let (wg, us) = if cpu_only { + (0, 0) + } else { + (s.work_groups, s.unit_size) + }; + let body = format!( + r"; Generated by miner-panel.exe +connect = {connect} +supervene = {sv} +nonce_max = 4294967295 +notice_wait = 45 + +{efficiency} +[gpu] +use_opencl = {use_ocl} +cpu_assist = {cpu_assist} +gpu_profile = {profile} +platform_id = {platform_id} +device_ids = {device_id} +opencl_dir = {opencl_dir} +work_groups = {wg} +local_size = 256 +unit_size = {us} +debug = 0 +", + connect = s.connect, + sv = s.cpu.supervene, + efficiency = efficiency_section(s), + use_ocl = if cpu_only { "false" } else { "true" }, + cpu_assist = if cpu_only { "false" } else { "true" }, + profile = s.gpu.profile, + platform_id = s.platform_id, + device_id = s.device_id, + opencl_dir = s.opencl_dir, + wg = wg, + us = us, + ); + if let Some(parent) = path.parent() { + std::fs::create_dir_all(parent)?; + } + std::fs::write(path, body) +} + +pub fn write_diaworker_config(path: &Path, s: &PanelSettings) -> std::io::Result<()> { + let cpu_only = s.gpu.slug == "none"; + let (wg, us) = if cpu_only { + (0, 0) + } else { + (s.work_groups, s.unit_size) + }; + let body = format!( + r"; Generated by miner-panel.exe (HACD / diamond mining) +connect = {connect} +supervene = {sv} + +{efficiency} +[gpu] +use_opencl = {use_ocl} +cpu_assist = {cpu_assist} +gpu_profile = {profile} +platform_id = {platform_id} +device_ids = {device_id} +opencl_dir = {opencl_dir} +work_groups = {wg} +local_size = 256 +unit_size = {us} +debug = 0 +", + connect = s.connect, + sv = s.cpu.supervene, + efficiency = efficiency_section(s), + use_ocl = if cpu_only { "false" } else { "true" }, + cpu_assist = if cpu_only { "false" } else { "true" }, + profile = s.gpu.profile, + platform_id = s.platform_id, + device_id = s.device_id, + opencl_dir = s.opencl_dir, + wg = wg, + us = us, + ); + if let Some(parent) = path.parent() { + std::fs::create_dir_all(parent)?; + } + std::fs::write(path, body) +} + +pub fn write_poworker_benchmark_config(path: &Path, s: &PanelSettings, seconds: u32) -> std::io::Result<()> { + let mut bench = s.clone_settings(); + bench.benchmark_seconds = seconds; + bench.benchmark_fine_sweep = seconds >= 60; + write_poworker_config(path, &bench) +} + +impl PanelSettings { + fn clone_settings(&self) -> PanelSettings { + PanelSettings { + cpu: self.cpu.clone(), + gpu: self.gpu.clone(), + mode: self.mode, + power_cost_kwh: self.power_cost_kwh, + hac_price: self.hac_price, + platform_id: self.platform_id, + device_id: self.device_id, + connect: self.connect.clone(), + stats_file: self.stats_file.clone(), + opencl_dir: self.opencl_dir.clone(), + max_temp_c: self.max_temp_c, + pause_if_unprofitable: self.pause_if_unprofitable, + benchmark_seconds: self.benchmark_seconds, + idle_start_hour: self.idle_start_hour, + idle_end_hour: self.idle_end_hour, + benchmark_fine_sweep: self.benchmark_fine_sweep, + thermal_gpu_index: self.thermal_gpu_index, + work_groups: self.work_groups, + unit_size: self.unit_size, + } + } +} \ No newline at end of file diff --git a/miner-panel/src/connect.rs b/miner-panel/src/connect.rs new file mode 100644 index 00000000..e4c6382f --- /dev/null +++ b/miner-panel/src/connect.rs @@ -0,0 +1,44 @@ +#[derive(Clone, Copy, PartialEq, Eq, Debug)] +pub enum ConnectMode { + /// Local hacash.exe fullnode (solo mining, rewards to your wallet). + Solo, + /// Remote server with Hacash miner RPC API (pool or shared fullnode). + Pool, +} + +impl ConnectMode { + pub fn from_u8(v: u8) -> ConnectMode { + if v == 1 { + ConnectMode::Pool + } else { + ConnectMode::Solo + } + } +} + +pub const SOLO_DEFAULT: &str = "127.0.0.1:8080"; + +/// Pools / services that expose the same miner HTTP RPC as a fullnode +/// (`/query/miner/pending`, `/query/miner/notice`, `/submit/miner/success`). +#[derive(Clone)] +pub struct PoolPreset { + pub label: &'static str, + pub host: &'static str, +} + +pub fn pool_presets() -> Vec { + vec![ + PoolPreset { + label: "Custom pool host", + host: "", + }, + PoolPreset { + label: "LAN fullnode / cluster", + host: "192.168.1.10:8080", + }, + ] +} + +/// CUDA-only miners (e.g. hacashdot) use a different protocol — not compatible. +pub const POOL_CUDA_NOTE: &str = + "RPC pool = same API as fullnode. CUDA pool miners (hacashdot) are not supported here."; \ No newline at end of file diff --git a/miner-panel/src/currency.rs b/miner-panel/src/currency.rs new file mode 100644 index 00000000..d12ad11f --- /dev/null +++ b/miner-panel/src/currency.rs @@ -0,0 +1,172 @@ +use crate::i18n::Lang; + +#[derive(Clone, Copy, PartialEq, Eq)] +pub enum Currency { + Eur, + Usd, + Try, + Cny, + Jpy, + Thb, + Rub, + Gbp, +} + +impl Currency { + pub const ALL: [Currency; 8] = [ + Currency::Eur, + Currency::Usd, + Currency::Gbp, + Currency::Try, + Currency::Cny, + Currency::Jpy, + Currency::Thb, + Currency::Rub, + ]; + + pub fn default_for_lang(lang: Lang) -> Self { + match lang { + Lang::El | Lang::Es | Lang::Fr => Currency::Eur, + Lang::En => Currency::Usd, + Lang::Tr => Currency::Try, + Lang::Zh => Currency::Cny, + Lang::Ja => Currency::Jpy, + Lang::Th => Currency::Thb, + Lang::Ru => Currency::Rub, + } + } + + pub fn from_code(s: &str) -> Option { + match s.trim().to_lowercase().as_str() { + "eur" | "€" => Some(Currency::Eur), + "usd" | "$" => Some(Currency::Usd), + "gbp" | "£" => Some(Currency::Gbp), + "try" | "₺" => Some(Currency::Try), + "cny" | "¥" | "rmb" => Some(Currency::Cny), + "jpy" => Some(Currency::Jpy), + "thb" | "฿" => Some(Currency::Thb), + "rub" | "₽" => Some(Currency::Rub), + _ => None, + } + } + + pub fn code(self) -> &'static str { + match self { + Currency::Eur => "eur", + Currency::Usd => "usd", + Currency::Gbp => "gbp", + Currency::Try => "try", + Currency::Cny => "cny", + Currency::Jpy => "jpy", + Currency::Thb => "thb", + Currency::Rub => "rub", + } + } + + pub fn name(self) -> &'static str { + match self { + Currency::Eur => "EUR €", + Currency::Usd => "USD $", + Currency::Gbp => "GBP £", + Currency::Try => "TRY ₺", + Currency::Cny => "CNY ¥", + Currency::Jpy => "JPY ¥", + Currency::Thb => "THB ฿", + Currency::Rub => "RUB ₽", + } + } + + pub fn symbol(self) -> &'static str { + match self { + Currency::Eur => "€", + Currency::Usd => "$", + Currency::Gbp => "£", + Currency::Try => "₺", + Currency::Cny => "¥", + Currency::Jpy => "¥", + Currency::Thb => "฿", + Currency::Rub => "₽", + } + } + + /// Units of this currency per 1 EUR (approximate, for UI conversion). + fn per_eur(self) -> f64 { + match self { + Currency::Eur => 1.0, + Currency::Usd => 1.08, + Currency::Gbp => 0.86, + Currency::Try => 35.0, + Currency::Cny => 7.8, + Currency::Jpy => 162.0, + Currency::Thb => 38.0, + Currency::Rub => 98.0, + } + } + + pub fn convert(amount: f64, from: Self, to: Self) -> f64 { + if from == to { + return amount; + } + let eur = amount / from.per_eur(); + eur * to.per_eur() + } + + pub fn power_cost_range(self) -> (f32, f32) { + match self { + Currency::Eur => (0.05, 0.45), + Currency::Usd => (0.05, 0.50), + Currency::Gbp => (0.04, 0.40), + Currency::Try => (1.0, 18.0), + Currency::Cny => (0.3, 2.5), + Currency::Jpy => (10.0, 80.0), + Currency::Thb => (2.0, 12.0), + Currency::Rub => (1.0, 12.0), + } + } + + pub fn default_power_cost(self) -> f32 { + match self { + Currency::Eur => 0.15, + Currency::Usd => 0.16, + Currency::Gbp => 0.13, + Currency::Try => 4.0, + Currency::Cny => 0.75, + Currency::Jpy => 28.0, + Currency::Thb => 5.0, + Currency::Rub => 2.8, + } + } + + pub fn slider_step(self) -> f32 { + match self { + Currency::Eur | Currency::Usd | Currency::Gbp => 0.01, + Currency::Try | Currency::Thb | Currency::Rub => 0.1, + Currency::Cny => 0.05, + Currency::Jpy => 1.0, + } + } + + pub fn format_amount(self, amount: f64) -> String { + let decimals = match self { + Currency::Eur | Currency::Usd | Currency::Gbp | Currency::Cny => 2, + Currency::Try | Currency::Thb | Currency::Rub => 2, + Currency::Jpy => 0, + }; + format!("{amount:.decimals$} {}", self.symbol()) + } +} + +pub fn load_currency(work_dir: &std::path::Path, lang: Lang) -> Currency { + let path = work_dir.join("miner-panel.currency"); + if let Ok(s) = std::fs::read_to_string(path) { + if let Some(c) = Currency::from_code(&s) { + return c; + } + } + Currency::default_for_lang(lang) +} + +pub fn save_currency(work_dir: &std::path::Path, currency: Currency) { + let path = work_dir.join("miner-panel.currency"); + let _ = std::fs::write(path, currency.code()); +} \ No newline at end of file diff --git a/miner-panel/src/fonts.rs b/miner-panel/src/fonts.rs new file mode 100644 index 00000000..c7490f66 --- /dev/null +++ b/miner-panel/src/fonts.rs @@ -0,0 +1,83 @@ +use std::path::PathBuf; +use std::sync::Arc; + +use eframe::egui::{self, FontData, FontDefinitions, FontFamily}; + +struct FontSource { + key: &'static str, + file: &'static str, + index: u32, +} + +/// System fonts used as fallbacks (tried in order per character). +const WIN_FONTS: &[FontSource] = &[ + FontSource { + key: "segoe_ui", + file: "segoeui.ttf", + index: 0, + }, + FontSource { + key: "msyh", + file: "msyh.ttc", + index: 0, + }, + FontSource { + key: "yugoth", + file: "YuGothR.ttc", + index: 0, + }, + FontSource { + key: "leelawadee", + file: "LeelawUI.ttf", + index: 0, + }, + FontSource { + key: "simsun", + file: "simsun.ttc", + index: 0, + }, +]; + +fn windows_fonts_dir() -> Option { + std::env::var_os("WINDIR").map(|d| PathBuf::from(d).join("Fonts")) +} + +fn load_font(path: &PathBuf, index: u32) -> Option> { + let bytes = std::fs::read(path).ok()?; + if bytes.len() < 4 { + return None; + } + let mut data = FontData::from_owned(bytes); + data.index = index; + Some(Arc::new(data)) +} + +pub fn setup_fonts(ctx: &egui::Context) { + ctx.input_mut(|i| i.max_texture_side = 8192); + + let mut fonts = FontDefinitions::default(); + let mut loaded: Vec<&'static str> = Vec::new(); + + if let Some(dir) = windows_fonts_dir() { + for src in WIN_FONTS { + let path = dir.join(src.file); + if let Some(data) = load_font(&path, src.index) { + fonts.font_data.insert(src.key.to_owned(), data); + loaded.push(src.key); + } + } + } + + if !loaded.is_empty() { + let family = fonts + .families + .entry(FontFamily::Proportional) + .or_default(); + family.clear(); + for key in &loaded { + family.push((*key).to_owned()); + } + } + + ctx.set_fonts(fonts); +} \ No newline at end of file diff --git a/miner-panel/src/hacash_config.rs b/miner-panel/src/hacash_config.rs new file mode 100644 index 00000000..2661c22b --- /dev/null +++ b/miner-panel/src/hacash_config.rs @@ -0,0 +1,303 @@ +use std::path::{Path, PathBuf}; + +use field::Address; + +pub fn find_hacash_config(work_dir: &Path) -> PathBuf { + let candidates = [ + work_dir.join("hacash.config.ini"), + work_dir.join("..").join("hacash.config.ini"), + work_dir.join("..").join("..").join("hacash.config.ini"), + ]; + for c in &candidates { + if c.is_file() { + return c.canonicalize().unwrap_or(c.clone()); + } + } + work_dir.join("..").join("..").join("hacash.config.ini") +} + +fn strip_comment(value: &str) -> String { + value.split(';').next().unwrap_or(value).trim().to_string() +} + +pub fn read_reward_wallet(path: &Path) -> String { + let Ok(content) = std::fs::read_to_string(path) else { + return String::new(); + }; + parse_miner_reward(&content) +} + +fn parse_miner_reward(content: &str) -> String { + let mut in_miner = false; + for line in content.lines() { + let trimmed = line.trim(); + if trimmed.starts_with('[') && trimmed.ends_with(']') { + in_miner = trimmed.eq_ignore_ascii_case("[miner]"); + continue; + } + if in_miner { + let key = trimmed.split('=').next().unwrap_or("").trim(); + if key.eq_ignore_ascii_case("reward") { + if let Some((_, val)) = line.split_once('=') { + return strip_comment(val); + } + } + } + } + String::new() +} + +pub fn validate_wallet(wallet: &str) -> Result<(), String> { + let trimmed = wallet.trim(); + if trimmed.is_empty() { + return Err("empty".to_string()); + } + Address::from_readable(trimmed).map_err(|e| e.to_string())?; + Ok(()) +} + +#[derive(Clone, Default)] +pub struct DiamondMinerSettings { + pub reward: String, + pub bid_password: String, + pub bid_min: String, + pub bid_max: String, + pub bid_step: String, +} + +pub fn read_diamond_miner(path: &Path) -> DiamondMinerSettings { + let Ok(content) = std::fs::read_to_string(path) else { + return DiamondMinerSettings { + bid_min: "1:0".to_string(), + bid_max: "31:0".to_string(), + bid_step: "0:5".to_string(), + ..Default::default() + }; + }; + DiamondMinerSettings { + reward: read_section_key(&content, "diamondminer", "reward"), + bid_password: read_section_key(&content, "diamondminer", "bid_password"), + bid_min: { + let v = read_section_key(&content, "diamondminer", "bid_min"); + if v.is_empty() { "1:0".into() } else { v } + }, + bid_max: { + let v = read_section_key(&content, "diamondminer", "bid_max"); + if v.is_empty() { "31:0".into() } else { v } + }, + bid_step: { + let v = read_section_key(&content, "diamondminer", "bid_step"); + if v.is_empty() { "0:5".into() } else { v } + }, + } +} + +fn read_section_key(content: &str, section: &str, key: &str) -> String { + let section_tag = format!("[{section}]"); + let mut in_section = false; + for line in content.lines() { + let trimmed = line.trim(); + if trimmed.starts_with('[') && trimmed.ends_with(']') { + in_section = trimmed.eq_ignore_ascii_case(§ion_tag); + continue; + } + if in_section { + let k = trimmed.split('=').next().unwrap_or("").trim(); + if k.eq_ignore_ascii_case(key) { + if let Some((_, val)) = line.split_once('=') { + return strip_comment(val); + } + } + } + } + String::new() +} + +pub fn write_miner_reward(path: &Path, wallet: &str) -> std::io::Result<()> { + let wallet = wallet.trim(); + let content = read_or_empty(path); + let updated = upsert_miner_reward(&content, wallet); + write_config(path, &updated) +} + +pub fn write_diamond_miner(path: &Path, wallet: &str, d: &DiamondMinerSettings) -> std::io::Result<()> { + let content = read_or_empty(path); + let mut updated = upsert_section_fields( + &content, + "diamondminer", + &[ + ("enable", "true"), + ("reward", wallet.trim()), + ("bid_password", d.bid_password.trim()), + ("bid_min", d.bid_min.trim()), + ("bid_max", d.bid_max.trim()), + ("bid_step", d.bid_step.trim()), + ], + ); + updated = upsert_section_fields( + &updated, + "miner", + &[("enable", "false")], + ); + write_config(path, &updated) +} + +pub fn write_hac_miner_only(path: &Path, wallet: &str) -> std::io::Result<()> { + let content = read_or_empty(path); + let mut updated = upsert_miner_reward(&content, wallet); + updated = upsert_section_fields(&updated, "diamondminer", &[("enable", "false")]); + write_config(path, &updated) +} + +fn read_or_empty(path: &Path) -> String { + std::fs::read_to_string(path).unwrap_or_default() +} + +fn write_config(path: &Path, content: &str) -> std::io::Result<()> { + if let Some(parent) = path.parent() { + std::fs::create_dir_all(parent)?; + } + std::fs::write(path, content) +} + +fn upsert_section_fields(content: &str, section: &str, fields: &[(&str, &str)]) -> String { + let section_tag = format!("[{section}]"); + let mut lines: Vec = if content.is_empty() { + Vec::new() + } else { + content.lines().map(|l| l.to_string()).collect() + }; + + let section_start = lines + .iter() + .position(|l| l.trim().eq_ignore_ascii_case(§ion_tag)); + + if let Some(start) = section_start { + let end = lines + .iter() + .enumerate() + .skip(start + 1) + .find(|(_, l)| { + let t = l.trim(); + t.starts_with('[') && t.ends_with(']') + }) + .map(|(i, _)| i) + .unwrap_or(lines.len()); + + let mut present: std::collections::HashSet = std::collections::HashSet::new(); + for line in &mut lines[start + 1..end] { + let key = line + .split('=') + .next() + .unwrap_or("") + .trim() + .to_lowercase(); + for (k, v) in fields { + if key == k.to_lowercase() { + *line = format!("{k} = {v}"); + present.insert(k.to_lowercase()); + } + } + } + let mut insert_pos = start + 1; + for (k, v) in fields { + if !present.contains(&k.to_lowercase()) { + lines.insert(insert_pos, format!("{k} = {v}")); + insert_pos += 1; + } + } + } else { + if !lines.is_empty() && !lines.last().map(|l| l.is_empty()).unwrap_or(true) { + lines.push(String::new()); + } + lines.push(section_tag); + for (k, v) in fields { + lines.push(format!("{k} = {v}")); + } + } + + let mut out = lines.join("\n"); + if !out.ends_with('\n') { + out.push('\n'); + } + out +} + +fn upsert_miner_reward(content: &str, wallet: &str) -> String { + let mut lines: Vec = if content.is_empty() { + Vec::new() + } else { + content.lines().map(|l| l.to_string()).collect() + }; + + let miner_start = lines + .iter() + .position(|l| l.trim().eq_ignore_ascii_case("[miner]")); + + if let Some(start) = miner_start { + let end = lines + .iter() + .enumerate() + .skip(start + 1) + .find(|(_, l)| { + let t = l.trim(); + t.starts_with('[') && t.ends_with(']') + }) + .map(|(i, _)| i) + .unwrap_or(lines.len()); + + let mut has_reward = false; + let mut has_enable = false; + for line in &mut lines[start + 1..end] { + let trimmed = line.trim(); + if trimmed.starts_with("reward") { + *line = format!("reward = {wallet}"); + has_reward = true; + } else if trimmed.starts_with("enable") { + *line = "enable = true".to_string(); + has_enable = true; + } + } + let mut insert_pos = start + 1; + if !has_reward { + lines.insert(insert_pos, format!("reward = {wallet}")); + insert_pos += 1; + } + if !has_enable { + lines.insert(insert_pos, "enable = true".to_string()); + } + } else { + if !lines.is_empty() && !lines.last().map(|l| l.is_empty()).unwrap_or(true) { + lines.push(String::new()); + } + lines.push("[miner]".to_string()); + lines.push("enable = true".to_string()); + lines.push(format!("reward = {wallet}")); + } + + let mut out = lines.join("\n"); + if !out.ends_with('\n') { + out.push('\n'); + } + out +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn upsert_existing_section() { + let input = "[node]\nlisten = 3033\n\n[miner]\nenable = true\nreward = old\n"; + let out = upsert_miner_reward(input, "1NewWalletAddr"); + assert!(out.contains("reward = 1NewWalletAddr")); + assert!(!out.contains("reward = old")); + } + + #[test] + fn upsert_new_section() { + let out = upsert_miner_reward("", "3xHYiddmZUtgY92pq7gGDoyHiGE7K47b4X"); + assert!(out.contains("[miner]")); + assert!(out.contains("reward = 3xHYiddmZUtgY92pq7gGDoyHiGE7K47b4X")); + } +} \ No newline at end of file diff --git a/miner-panel/src/help_options.rs b/miner-panel/src/help_options.rs new file mode 100644 index 00000000..e6bada14 --- /dev/null +++ b/miner-panel/src/help_options.rs @@ -0,0 +1,201 @@ +use crate::i18n::Lang; + +pub struct HelpSection { + pub title: &'static str, + pub lines: &'static [&'static str], +} + +pub fn option_reference(lang: Lang) -> &'static [HelpSection] { + match lang { + Lang::El => EL, + _ => EN, + } +} + +static EN: &[HelpSection] = &[ + HelpSection { + title: "miner-panel.exe (Settings tab)", + lines: &[ + "CPU preset — supervene threads (Ryzen / Intel / CPU-only).", + "GPU preset — amd_* / nvidia_* profile + estimated board watts.", + "Mode — eco (low power), profit (kH/J), max (raw hashrate).", + "Power cost — €/kWh (or local currency) for daily cost estimate.", + "HAC price — optional $/HAC for profit / pause-if-unprofitable.", + "Max temp — GPU throttle above this °C (0 = off). AMD: set thermal_file if auto-detect fails.", + "Pause if unprofitable — stop hashing when estimated power cost > revenue.", + "Benchmark — runs poworker autotune (~90s), writes best profile + work_groups + unit_size to ini.", + "Mining type — HAC (blocks) or HACD (diamonds + auto-bids).", + "Connect — solo fullnode host:port or pool RPC address.", + "Wallet — HAC: [miner] reward in hacash.config.ini. HACD: PRIVAKEY (3x...) reward.", + "OpenCL — platform_id + device_id from list_opencl.exe.", + "HACD bids — bid_password, bid_min / bid_max / bid_step (mei format, e.g. 1:0 = 1 HAC).", + ], + }, + HelpSection { + title: "poworker.config.ini / diaworker.config.ini — [default]", + lines: &[ + "connect — fullnode or pool miner RPC (default 127.0.0.1:8081).", + "supervene — configured CPU miner threads.", + "nonce_max — max nonce per batch (poworker only, default 4294967295).", + "notice_wait — seconds to wait for new-block notice (poworker, default 45).", + ], + }, + HelpSection { + title: "[gpu] section (poworker & diaworker)", + lines: &[ + "use_opencl — true = OpenCL GPU mining (AMD & NVIDIA; no CUDA).", + "cpu_assist — hybrid: GPU + extra CPU threads (Ryzen assist).", + "gpu_profile — amd_eco|balanced|profit|performance|max or nvidia_* / intel_balanced.", + "platform_id — OpenCL platform index (list_opencl.exe).", + "device_ids — device index or comma-separated list (e.g. 0 or 0,1).", + "opencl_dir — path to x16rs/opencl/ kernels (relative to exe folder).", + "work_groups — global work size / autotune result (VRAM-clamped at runtime).", + "local_size — must be 256 (kernel requirement).", + "unit_size — hashes per work item (64–160; autotune may tune).", + "debug — OpenCL debug level (0 = off).", + ], + }, + HelpSection { + title: "[efficiency] section", + lines: &[ + "mode — max | profit | eco (also amd_profit / amd_eco aliases).", + "power_cost_kwh — electricity price for profit estimates.", + "gpu_watts — override GPU power (0 = estimate from profile).", + "cpu_watts_per_thread — watts per CPU assist thread (default 8).", + "hac_price — HAC/USD for profit pause (0 = disable revenue side).", + "dynamic_supervene — auto adjust CPU assist from GPU/CPU ratio.", + "supervene_min / supervene_max — CPU thread bounds for dynamic assist.", + "oom_fallback — halve work_groups on OpenCL OOM (default true).", + "max_temp_c — throttle to throttle_work_groups above this temp (0 = off).", + "throttle_work_groups — work_groups when thermally throttled (default 1024).", + "thermal_file — path to plain-text GPU temp °C (optional; overrides auto-detect).", + "thermal_gpu_index — nvidia-smi / amd-smi GPU index (default 0).", + "idle_start_hour / idle_end_hour — local-time mining window (255 = always on).", + "pause_if_unprofitable — pause when power cost > mining revenue.", + "benchmark_seconds — >0 runs autotune then exits (panel uses 90).", + "benchmark_fine_sweep — tune work_groups + unit_size (default on if benchmark ≥ 60s).", + "stats_file — JSON path for miner-panel dashboard (e.g. miner-stats.json).", + ], + }, + HelpSection { + title: "hacash.config.ini — [miner] (HAC)", + lines: &[ + "enable — true for block mining reward.", + "reward — wallet address receiving block rewards.", + ], + }, + HelpSection { + title: "hacash.config.ini — [diamondminer] (HACD)", + lines: &[ + "enable — true for diamond auto-bidding.", + "reward — PRIVAKEY address (3x...) for diamond rewards.", + "bid_password — wallet password for automatic bids.", + "bid_min / bid_max / bid_step — bid range in mei (1:0 = 1 HAC).", + "Fullnode also needs diamond_form = true in node config.", + ], + }, + HelpSection { + title: "Executables in release folder", + lines: &[ + "miner-panel.exe — GUI: settings, dashboard, help.", + "poworker.exe — HAC block miner (reads poworker.config.ini).", + "diaworker.exe — HACD diamond miner (reads diaworker.config.ini).", + "list_opencl.exe — list OpenCL platforms/devices and config hints.", + "hacash.exe / fullnode.exe — Hacash full node (miner RPC + diamond bids).", + ], + }, +]; + +static EL: &[HelpSection] = &[ + HelpSection { + title: "miner-panel.exe (καρτέλα Ρυθμίσεις)", + lines: &[ + "CPU preset — νήματα supervene (Ryzen / Intel / μόνο CPU).", + "GPU preset — προφίλ amd_* / nvidia_* + εκτιμώμενα watt πλακέτας.", + "Mode — eco (χαμηλή κατανάλωση), profit (kH/J), max (μέγιστο hashrate).", + "Κόστος ρεύματος — €/kWh για εκτίμηση ημερήσιου κόστους.", + "Τιμή HAC — προαιρετική τιμή $/HAC για κέρδος / pause-if-unprofitable.", + "Μέγ. θερμοκρ. — throttle GPU πάνω από αυτό το °C (0 = απενεργ.). Για AMD: βάλε thermal_file αν δεν ανιχνεύεται αυτόματα.", + "Pause if unprofitable — σταματά mining όταν κόστος ρεύματος > έσοδα.", + "Benchmark — τρέχει autotune στο poworker (~90s), γράφει καλύτερο profile + work_groups + unit_size στο ini.", + "Τύπος mining — HAC (blocks) ή HACD (diamonds + αυτόματα bids).", + "Connect — solo fullnode host:port ή pool RPC.", + "Wallet — HAC: reward στο [miner] του hacash.config.ini. HACD: PRIVAKEY (3x...).", + "OpenCL — platform_id + device_id από list_opencl.exe.", + "HACD bids — bid_password, bid_min / bid_max / bid_step (μορφή mei, π.χ. 1:0 = 1 HAC).", + ], + }, + HelpSection { + title: "poworker.config.ini / diaworker.config.ini — [default]", + lines: &[ + "connect — RPC fullnode ή pool (default 127.0.0.1:8081).", + "supervene — ρυθμισμένα CPU threads.", + "nonce_max — μέγιστο nonce ανά batch (μόνο poworker, default 4294967295).", + "notice_wait — αναμονή ειδοποίησης νέου block σε sec (poworker, default 45).", + ], + }, + HelpSection { + title: "[gpu] (poworker & diaworker)", + lines: &[ + "use_opencl — true = GPU mining με OpenCL (AMD & NVIDIA· όχι CUDA).", + "cpu_assist — hybrid: GPU + επιπλέον CPU threads.", + "gpu_profile — amd_eco|balanced|profit|performance|max ή nvidia_* / intel_balanced.", + "platform_id — δείκτης OpenCL platform (list_opencl.exe).", + "device_ids — δείκτης συσκευής ή λίστα (π.χ. 0 ή 0,1).", + "opencl_dir — διαδρομή kernels x16rs/opencl/ (σχετική με τον φάκελο exe).", + "work_groups — global work size / αποτέλεσμα autotune (VRAM clamp στο runtime).", + "local_size — πρέπει να είναι 256 (απαίτηση kernel).", + "unit_size — hashes ανά work item (64–160).", + "debug — επίπεδο debug OpenCL (0 = off).", + ], + }, + HelpSection { + title: "[efficiency]", + lines: &[ + "mode — max | profit | eco.", + "power_cost_kwh — τιμή ρεύματος για εκτίμηση κέρδους.", + "gpu_watts — override ισχύος GPU (0 = εκτίμηση από profile).", + "cpu_watts_per_thread — watt ανά CPU thread (default 8).", + "hac_price — HAC/USD για profit pause (0 = χωρίς έσοδα).", + "dynamic_supervene — αυτόματη ρύθμιση CPU assist από αναλογία GPU/CPU.", + "supervene_min / supervene_max — όρια CPU threads.", + "oom_fallback — μειώνει work_groups στο OpenCL OOM (default true).", + "max_temp_c — throttle σε throttle_work_groups πάνω από αυτή τη θερμοκρ. (0 = off).", + "throttle_work_groups — work_groups όταν γίνεται throttle (default 1024).", + "thermal_file — αρχείο κειμένου με θερμοκρ. GPU σε °C (προαιρετικό).", + "thermal_gpu_index — δείκτης GPU για nvidia-smi / amd-smi (default 0).", + "idle_start_hour / idle_end_hour — παράθυρο mining τοπικής ώρας (255 = πάντα on).", + "pause_if_unprofitable — παύση όταν κόστος > έσοδα.", + "benchmark_seconds — >0 τρέχει autotune και τερματίζει.", + "benchmark_fine_sweep — ρύθμιση work_groups + unit_size (default αν benchmark ≥ 60s).", + "stats_file — JSON για dashboard (π.χ. miner-stats.json).", + ], + }, + HelpSection { + title: "hacash.config.ini — [miner] (HAC)", + lines: &[ + "enable — true για block mining reward.", + "reward — διεύθυνση wallet για block rewards.", + ], + }, + HelpSection { + title: "hacash.config.ini — [diamondminer] (HACD)", + lines: &[ + "enable — true για αυτόματα diamond bids.", + "reward — PRIVAKEY (3x...) για diamond rewards.", + "bid_password — κωδικός wallet για bids.", + "bid_min / bid_max / bid_step — εύρος bid σε mei (1:0 = 1 HAC).", + "Το fullnode χρειάζεται επίσης diamond_form = true.", + ], + }, + HelpSection { + title: "Executables στον φάκελο release", + lines: &[ + "miner-panel.exe — GUI: ρυθμίσεις, dashboard, help.", + "poworker.exe — HAC block miner (διαβάζει poworker.config.ini).", + "diaworker.exe — HACD diamond miner (διαβάζει diaworker.config.ini).", + "list_opencl.exe — λίστα OpenCL platforms/devices.", + "hacash.exe / fullnode.exe — Hacash full node (miner RPC + diamond bids).", + ], + }, +]; \ No newline at end of file diff --git a/miner-panel/src/i18n.rs b/miner-panel/src/i18n.rs new file mode 100644 index 00000000..8ef4d7b6 --- /dev/null +++ b/miner-panel/src/i18n.rs @@ -0,0 +1,1218 @@ +#[derive(Clone, Copy, PartialEq, Eq)] +pub enum Lang { + En, + El, + Tr, + Zh, + Ja, + Es, + Fr, + Th, + Ru, +} + +impl Lang { + pub const ALL: [Lang; 9] = [ + Lang::En, + Lang::El, + Lang::Tr, + Lang::Zh, + Lang::Ja, + Lang::Es, + Lang::Fr, + Lang::Th, + Lang::Ru, + ]; + + pub fn name(self) -> &'static str { + match self { + Lang::En => "English", + Lang::El => "Ελληνικά", + Lang::Tr => "Türkçe", + Lang::Zh => "中文", + Lang::Ja => "日本語", + Lang::Es => "Español", + Lang::Fr => "Français", + Lang::Th => "ไทย", + Lang::Ru => "Русский", + } + } + + pub fn from_code(s: &str) -> Lang { + match s.trim().to_lowercase().as_str() { + "el" | "gr" => Lang::El, + "tr" => Lang::Tr, + "zh" | "cn" => Lang::Zh, + "ja" | "jp" => Lang::Ja, + "es" => Lang::Es, + "fr" => Lang::Fr, + "th" => Lang::Th, + "ru" => Lang::Ru, + _ => Lang::En, + } + } + + pub fn code(self) -> &'static str { + match self { + Lang::En => "en", + Lang::El => "el", + Lang::Tr => "tr", + Lang::Zh => "zh", + Lang::Ja => "ja", + Lang::Es => "es", + Lang::Fr => "fr", + Lang::Th => "th", + Lang::Ru => "ru", + } + } +} + +#[derive(Clone, Copy)] +pub struct Strings { + pub window_title: &'static str, + pub header_subtitle: &'static str, + pub tab_settings: &'static str, + pub tab_dashboard: &'static str, + pub tab_help: &'static str, + pub ready_status: &'static str, + pub saved_prefix: &'static str, + pub save_error_prefix: &'static str, + pub poworker_not_found: &'static str, + pub mining_active: &'static str, + pub start_failed_prefix: &'static str, + pub mining_stopped: &'static str, + pub block_found: &'static str, + pub miner_exited: &'static str, + pub fullnode_starting: &'static str, + pub fullnode_not_ready: &'static str, + pub fullnode_exe_not_found: &'static str, + pub worker_error_prefix: &'static str, + pub settings_intro: &'static str, + pub label_cpu: &'static str, + pub label_gpu: &'static str, + pub label_mode: &'static str, + pub mode_eco: &'static str, + pub mode_profit: &'static str, + pub mode_max: &'static str, + pub label_power_cost: &'static str, + pub label_hac_price: &'static str, + pub label_mining_type: &'static str, + pub mining_hac: &'static str, + pub mining_hacd: &'static str, + pub label_bid_password: &'static str, + pub label_bid_min: &'static str, + pub label_bid_max: &'static str, + pub label_bid_step: &'static str, + pub bid_hint: &'static str, + pub hacd_wallet_hint: &'static str, + pub diaworker_not_found: &'static str, + pub bid_password_required: &'static str, + pub label_connect_mode: &'static str, + pub connect_solo: &'static str, + pub connect_pool: &'static str, + pub connect_pool_hint: &'static str, + pub label_fullnode: &'static str, + pub label_wallet: &'static str, + pub wallet_hint: &'static str, + pub wallet_required: &'static str, + pub wallet_invalid_prefix: &'static str, + pub wallet_restart_hint: &'static str, + pub label_opencl: &'static str, + pub platform: &'static str, + pub device_hint: &'static str, + pub btn_save: &'static str, + pub btn_start_mining: &'static str, + pub btn_stop: &'static str, + pub mining_status: &'static str, + pub stopped_status: &'static str, + pub paused_unprofitable: &'static str, + pub stat_hashrate: &'static str, + pub stat_hac_day: &'static str, + pub stat_power: &'static str, + pub stat_cost_day: &'static str, + pub stat_efficiency: &'static str, + pub stat_network: &'static str, + pub stat_block_height: &'static str, + pub stat_net_day: &'static str, + pub stat_gpu_profile: &'static str, + pub stat_diamond_best: &'static str, + pub stat_revenue_day: &'static str, + pub stat_cpu_threads: &'static str, + pub stat_mining_type_short: &'static str, + pub stat_mode_short: &'static str, + pub dash_details_title: &'static str, + pub dash_detail_cpu: &'static str, + pub dash_detail_gpu: &'static str, + pub dash_detail_connect: &'static str, + pub dash_detail_wallet: &'static str, + pub dash_detail_opencl: &'static str, + pub dash_detail_tuning: &'static str, + pub dash_detail_power_cost: &'static str, + pub dash_detail_max_temp: &'static str, + pub dash_detail_last_update: &'static str, + pub dash_detail_stats_status: &'static str, + pub dash_detail_diamond: &'static str, + pub dash_no_data: &'static str, + pub btn_benchmark: &'static str, + pub label_max_temp: &'static str, + pub label_pause_unprofitable: &'static str, + pub benchmark_running: &'static str, + pub benchmark_done: &'static str, + pub btn_start: &'static str, + pub btn_stop_icon: &'static str, + pub help_title: &'static str, + pub help_step1: &'static str, + pub help_step2: &'static str, + pub help_step3: &'static str, + pub help_work_dir_prefix: &'static str, + pub help_miner_prefix: &'static str, + pub help_opencl_tip: &'static str, + pub help_hac_title: &'static str, + pub help_hacd_title: &'static str, + pub help_hacd_step1: &'static str, + pub help_hacd_step2: &'static str, + pub help_hacd_step3: &'static str, + pub help_hacd_step4: &'static str, + pub help_hacd_step5: &'static str, + pub help_hardware_note: &'static str, + pub help_options_title: &'static str, + pub cpu_only: &'static str, + pub no_gpu: &'static str, + pub label_language: &'static str, + pub label_currency: &'static str, +} + +pub fn strings(lang: Lang) -> Strings { + match lang { + Lang::En => Strings { + window_title: "HAC Miner Panel", + header_subtitle: "By Mosky", + tab_settings: "Settings", + tab_dashboard: "Dashboard", + tab_help: "Help", + ready_status: "Ready — pick CPU/GPU and press Start.", + saved_prefix: "Saved:", + save_error_prefix: "Save error:", + poworker_not_found: "poworker.exe not found — build first.\nSearched:", + mining_active: "Mining active.", + start_failed_prefix: "Failed to start:", + mining_stopped: "Mining stopped.", + block_found: "Block found!", + miner_exited: "Miner exited (check that fullnode is running).", + fullnode_starting: "Starting fullnode (hacash.exe) — wait up to 45s...", + fullnode_not_ready: "Fullnode not ready — start hacash.exe first, then press Start again:", + fullnode_exe_not_found: "hacash.exe not found — build or copy next to miner-panel.exe:", + worker_error_prefix: "Miner warning:", + settings_intro: "Pick your CPU and GPU — the app configures everything automatically.", + label_cpu: "Processor (CPU):", + label_gpu: "Graphics card (GPU):", + label_mode: "Mode:", + mode_eco: "Eco (less power)", + mode_profit: "Profit balance (recommended)", + mode_max: "Maximum hashrate", + label_power_cost: "Power cost (/kWh):", + label_hac_price: "HAC price USD (optional):", + label_mining_type: "Mining type:", + mining_hac: "HAC blocks (poworker)", + mining_hacd: "HACD diamonds + bids (diaworker)", + label_bid_password: "Bid account password:", + label_bid_min: "Min bid (HAC):", + label_bid_max: "Max bid (HAC):", + label_bid_step: "Bid step (HAC):", + bid_hint: "Format: mei e.g. 1:0 = 1 HAC. Fullnode must run with [diamondminer].", + hacd_wallet_hint: "PRIVAKEY address (3x...) for diamond rewards.", + diaworker_not_found: "diaworker.exe not found — build first.\nSearched:", + bid_password_required: "Enter bid account password for HACD mining.", + label_connect_mode: "Connection:", + connect_solo: "Solo (fullnode)", + connect_pool: "Pool (RPC)", + connect_pool_hint: "Host:port with miner RPC API (not CUDA-only pools like hacashdot).", + label_fullnode: "Fullnode (RPC):", + label_wallet: "Reward wallet (HAC address):", + wallet_hint: "Block rewards go to this address (saved in hacash.config.ini).", + wallet_required: "Enter a valid HAC wallet address for rewards.", + wallet_invalid_prefix: "Invalid wallet address:", + wallet_restart_hint: "Restart fullnode (hacash.exe) after changing the wallet.", + label_opencl: "OpenCL platform / device:", + platform: "platform", + device_hint: "device (usually 0)", + btn_save: "Save settings", + btn_start_mining: "Start mining", + btn_stop: "Stop", + mining_status: "MINING", + stopped_status: "STOPPED", + paused_unprofitable: "Paused — not profitable", + stat_hashrate: "Hashrate", + stat_hac_day: "HAC / day", + stat_power: "Power (estimate)", + stat_cost_day: "Cost / day", + stat_efficiency: "Efficiency", + stat_network: "Network %", + stat_block_height: "Block height", + stat_net_day: "Net / day", + stat_gpu_profile: "GPU profile", + stat_diamond_best: "Best diamond", + stat_revenue_day: "Revenue / day", + stat_cpu_threads: "CPU threads", + stat_mining_type_short: "Mining type", + stat_mode_short: "Mode", + dash_details_title: "Configuration & connection", + dash_detail_cpu: "CPU preset", + dash_detail_gpu: "GPU preset", + dash_detail_connect: "RPC / pool", + dash_detail_wallet: "Reward wallet", + dash_detail_opencl: "OpenCL", + dash_detail_tuning: "GPU tuning", + dash_detail_power_cost: "Power cost", + dash_detail_max_temp: "Max GPU temp", + dash_detail_last_update: "Stats updated", + dash_detail_stats_status: "Miner report", + dash_detail_diamond: "Diamond #", + dash_no_data: "—", + btn_benchmark: "Auto-tune GPU", + label_max_temp: "Max GPU temp:", + label_pause_unprofitable: "Pause if unprofitable", + benchmark_running: "Benchmarking GPU profiles (~90s, fine sweep)...", + benchmark_done: "Benchmark done — best profile applied.", + btn_start: "▶ Start", + btn_stop_icon: "■ Stop", + help_title: "3 steps for beginners:", + help_step1: "1. Run the fullnode (hacash.exe) with miner RPC enabled.", + help_step2: "2. Open this app → Settings → pick CPU + GPU → Start.", + help_step3: "3. Check the Dashboard for hashrate, HAC/day and power cost.", + help_work_dir_prefix: "Working folder:", + help_miner_prefix: "Miner:", + help_opencl_tip: "GPU not detected? Run list_opencl.exe — NVIDIA/AMD platform_id may be 0 or 1.", + help_hac_title: "HAC — block mining", + help_hacd_title: "HACD — diamonds + automatic bids", + help_hacd_step1: "1. Run hacash.exe with diamond_form = true; bid wallet needs HAC balance.", + help_hacd_step2: "2. Reward wallet must be PRIVAKEY (3x...) — not a legacy 1x address.", + help_hacd_step3: "3. Settings → HACD → wallet, bid password, min/max/step (format 1:0 = 1 HAC).", + help_hacd_step4: "4. Save & Start — diaworker mines; fullnode auto-bids via [diamondminer].", + help_hacd_step5: "5. Restart fullnode (hacash.exe) after wallet or bid changes.", + help_hardware_note: "AMD & NVIDIA (RX / RTX 40·50) use OpenCL only — no CUDA in poworker/diaworker. Pick closest preset.", + help_options_title: "Options reference (panel + .ini + executables)", + cpu_only: "CPU only (no GPU)", + no_gpu: "No GPU", + label_language: "Language:", + label_currency: "Currency:", + }, + Lang::El => Strings { + window_title: "HAC Miner Panel", + header_subtitle: "By Mosky", + tab_settings: "Ρυθμίσεις", + tab_dashboard: "Dashboard", + tab_help: "Βοήθεια", + ready_status: "Έτοιμο — διάλεξε CPU/GPU και πάτα Έναρξη.", + saved_prefix: "Αποθηκεύτηκε:", + save_error_prefix: "Σφάλμα αποθήκευσης:", + poworker_not_found: "Δεν βρέθηκε poworker.exe — κάνε build πρώτα.\nΑναζήτηση:", + mining_active: "Mining ενεργό.", + start_failed_prefix: "Αποτυχία εκκίνησης:", + mining_stopped: "Mining σταμάτησε.", + block_found: "Βρέθηκε block!", + miner_exited: "Ο miner τερμάτισε (έλεγξε ότι τρέχει το fullnode).", + fullnode_starting: "Εκκίνηση fullnode (hacash.exe) — περίμενε έως 45 δευτ....", + fullnode_not_ready: "Το fullnode δεν είναι έτοιμο — τρέξε πρώτα hacash.exe και πάτα Ξανά Έναρξη:", + fullnode_exe_not_found: "Δεν βρέθηκε hacash.exe — build ή αντιγραφή δίπλα στο miner-panel.exe:", + worker_error_prefix: "Προειδοποίηση miner:", + settings_intro: "Διάλεξε τον επεξεργαστή και την κάρτα σου — το πρόγραμμα φτιάχνει τα πάντα αυτόματα.", + label_cpu: "Επεξεργαστής (CPU):", + label_gpu: "Κάρτα γραφικών (GPU):", + label_mode: "Λειτουργία:", + mode_eco: "Οικονομικό (λιγότερο ρεύμα)", + mode_profit: "Ισορροπία κέρδους (προτείνεται)", + mode_max: "Μέγιστο hashrate", + label_power_cost: "Κόστος ρεύματος (/kWh):", + label_hac_price: "Τιμή HAC USD (προαιρετικό):", + label_mining_type: "Τύπος mining:", + mining_hac: "HAC blocks (poworker)", + mining_hacd: "HACD diamonds + bids (diaworker)", + label_bid_password: "Κωδικός bid account:", + label_bid_min: "Ελάχ. bid (HAC):", + label_bid_max: "Μέγ. bid (HAC):", + label_bid_step: "Βήμα bid (HAC):", + bid_hint: "Μορφή: mei π.χ. 1:0 = 1 HAC. Το fullnode χρειάζεται [diamondminer].", + hacd_wallet_hint: "Διεύθυνση PRIVAKEY (3x...) για ανταμοιβές diamond.", + diaworker_not_found: "Δεν βρέθηκε diaworker.exe — κάνε build πρώτα.\nΑναζήτηση:", + bid_password_required: "Βάλε κωδικό bid account για HACD mining.", + label_connect_mode: "Σύνδεση:", + connect_solo: "Solo (fullnode)", + connect_pool: "Pool (RPC)", + connect_pool_hint: "Host:port με miner RPC API (όχι CUDA pools όπως hacashdot).", + label_fullnode: "Fullnode (RPC):", + label_wallet: "Wallet ανταμοιβών (διεύθυνση HAC):", + wallet_hint: "Οι ανταμοιβές μπλοκ πηγαίνουν εδώ (αποθηκεύεται στο hacash.config.ini).", + wallet_required: "Βάλε έγκυρη διεύθυνση HAC για τις ανταμοιβές.", + wallet_invalid_prefix: "Μη έγκυρη διεύθυνση wallet:", + wallet_restart_hint: "Κάνε restart το fullnode (hacash.exe) μετά από αλλαγή wallet.", + label_opencl: "OpenCL platform / device:", + platform: "platform", + device_hint: "device (συνήθως 0)", + btn_save: "Αποθήκευση ρυθμίσεων", + btn_start_mining: "Έναρξη mining", + btn_stop: "Διακοπή", + mining_status: "MINING", + stopped_status: "ΣΤΑΜΑΤΗΜΕΝΟ", + paused_unprofitable: "Παύση — δεν αξίζει οικονομικά", + stat_hashrate: "Hashrate", + stat_hac_day: "HAC / μέρα", + stat_power: "Ρεύμα (εκτίμηση)", + stat_cost_day: "Κόστος / μέρα", + stat_efficiency: "Απόδοση", + stat_network: "Δίκτυο %", + stat_block_height: "Ύψος block", + stat_net_day: "Καθαρό / μέρα", + stat_gpu_profile: "Προφίλ GPU", + stat_diamond_best: "Καλύτερο diamond", + stat_revenue_day: "Έσοδα / μέρα", + stat_cpu_threads: "CPU threads", + stat_mining_type_short: "Τύπος mining", + stat_mode_short: "Λειτουργία", + dash_details_title: "Ρυθμίσεις & σύνδεση", + dash_detail_cpu: "CPU preset", + dash_detail_gpu: "GPU preset", + dash_detail_connect: "RPC / pool", + dash_detail_wallet: "Wallet ανταμοιβών", + dash_detail_opencl: "OpenCL", + dash_detail_tuning: "GPU tuning", + dash_detail_power_cost: "Κόστος ρεύματος", + dash_detail_max_temp: "Μέγ. θερμ. GPU", + dash_detail_last_update: "Ενημέρωση stats", + dash_detail_stats_status: "Αναφορά miner", + dash_detail_diamond: "Diamond #", + dash_no_data: "—", + btn_benchmark: "Auto-tune GPU", + label_max_temp: "Μέγ. θερμ. GPU:", + label_pause_unprofitable: "Παύση αν δεν αξίζει", + benchmark_running: "Benchmark GPU profiles (~90s, fine sweep)...", + benchmark_done: "Benchmark έτοιμο — εφαρμόστηκε το καλύτερο profile.", + btn_start: "▶ Έναρξη", + btn_stop_icon: "■ Διακοπή", + help_title: "3 βήματα για αρχάριους:", + help_step1: "1. Τρέξε πρώτα το fullnode (hacash.exe) με ενεργό miner RPC.", + help_step2: "2. Άνοιξε αυτό το πρόγραμμα → Ρυθμίσεις → διάλεξε CPU + GPU → Έναρξη.", + help_step3: "3. Δες το Dashboard για hashrate, HAC/μέρα και κόστος ρεύματος.", + help_work_dir_prefix: "Φάκελος εργασίας:", + help_miner_prefix: "Miner:", + help_opencl_tip: "Δεν φαίνεται GPU; Τρέξε list_opencl.exe — NVIDIA/AMD platform_id μπορεί 0 ή 1.", + help_hac_title: "HAC — block mining", + help_hacd_title: "HACD — diamonds + αυτόματα bids", + help_hacd_step1: "1. Τρέξε hacash.exe με diamond_form = true· το bid wallet χρειάζεται HAC.", + help_hacd_step2: "2. Το reward wallet πρέπει να είναι PRIVAKEY (3x...) — όχι legacy 1x.", + help_hacd_step3: "3. Ρυθμίσεις → HACD → wallet, κωδικός bid, min/max/step (1:0 = 1 HAC).", + help_hacd_step4: "4. Αποθήκευση & Έναρξη — diaworker κάνει mine· fullnode κάνει bid στο [diamondminer].", + help_hacd_step5: "5. Κάνε restart το fullnode μετά από αλλαγή wallet ή bid.", + help_hardware_note: "AMD & NVIDIA (RX / RTX 40·50) μόνο OpenCL — όχι CUDA στο poworker/diaworker. Διάλεξε το πιο κοντινό preset.", + help_options_title: "Αναφορά επιλογών (panel + .ini + executables)", + cpu_only: "Μόνο CPU (χωρίς GPU)", + no_gpu: "Χωρίς GPU", + label_language: "Γλώσσα:", + label_currency: "Νόμισμα:", + }, + Lang::Tr => Strings { + window_title: "HAC Miner Panel", + header_subtitle: "By Mosky", + tab_settings: "Ayarlar", + tab_dashboard: "Panel", + tab_help: "Yardım", + ready_status: "Hazır — CPU/GPU seçin ve Başlat'a basın.", + saved_prefix: "Kaydedildi:", + save_error_prefix: "Kayıt hatası:", + poworker_not_found: "poworker.exe bulunamadı — önce derleyin.\nAranan:", + mining_active: "Madencilik aktif.", + start_failed_prefix: "Başlatma başarısız:", + mining_stopped: "Madencilik durdu.", + block_found: "Blok bulundu!", + miner_exited: "Miner kapandı (fullnode çalışıyor mu kontrol edin).", + fullnode_starting: "Fullnode başlatılıyor (hacash.exe) — 45 sn bekleyin...", + fullnode_not_ready: "Fullnode hazır değil — önce hacash.exe çalıştırın, sonra Başlat:", + fullnode_exe_not_found: "hacash.exe bulunamadı — miner-panel.exe yanına kopyalayın:", + worker_error_prefix: "Miner uyarısı:", + settings_intro: "CPU ve GPU'nuzu seçin — uygulama her şeyi otomatik yapılandırır.", + label_cpu: "İşlemci (CPU):", + label_gpu: "Ekran kartı (GPU):", + label_mode: "Mod:", + mode_eco: "Ekonomik (daha az güç)", + mode_profit: "Kâr dengesi (önerilen)", + mode_max: "Maksimum hashrate", + label_power_cost: "Elektrik maliyeti (/kWh):", + label_hac_price: "HAC fiyatı USD (isteğe bağlı):", + label_mining_type: "Madencilik türü:", + mining_hac: "HAC blokları (poworker)", + mining_hacd: "HACD elmas + teklifler (diaworker)", + label_bid_password: "Teklif hesap şifresi:", + label_bid_min: "Min. teklif (HAC):", + label_bid_max: "Maks. teklif (HAC):", + label_bid_step: "Teklif adımı (HAC):", + bid_hint: "Format: mei örn. 1:0 = 1 HAC. Fullnode [diamondminer] gerekir.", + hacd_wallet_hint: "Elmas ödülleri için PRIVAKEY adresi (3x...).", + diaworker_not_found: "diaworker.exe bulunamadı — önce derleyin.\nAranan:", + bid_password_required: "HACD için teklif hesap şifresi girin.", + label_connect_mode: "Bağlantı:", + connect_solo: "Solo (fullnode)", + connect_pool: "Pool (RPC)", + connect_pool_hint: "Miner RPC API host:port (hacashdot CUDA pool değil).", + label_fullnode: "Fullnode (RPC):", + label_wallet: "Ödül cüzdanı (HAC adresi):", + wallet_hint: "Blok ödülleri bu adrese gider (hacash.config.ini'ye kaydedilir).", + wallet_required: "Ödüller için geçerli bir HAC cüzdan adresi girin.", + wallet_invalid_prefix: "Geçersiz cüzdan adresi:", + wallet_restart_hint: "Cüzdanı değiştirdikten sonra fullnode'u (hacash.exe) yeniden başlatın.", + label_opencl: "OpenCL platform / device:", + platform: "platform", + device_hint: "device (genelde 0)", + btn_save: "Ayarları kaydet", + btn_start_mining: "Madenciliği başlat", + btn_stop: "Durdur", + mining_status: "MADENCİLİK", + stopped_status: "DURDU", + paused_unprofitable: "Duraklatıldı — kârlı değil", + stat_hashrate: "Hashrate", + stat_hac_day: "HAC / gün", + stat_power: "Güç (tahmini)", + stat_cost_day: "Maliyet / gün", + stat_efficiency: "Verimlilik", + stat_network: "Ağ %", + stat_block_height: "Blok yüksekliği", + stat_net_day: "Net / gün", + stat_gpu_profile: "GPU profili", + stat_diamond_best: "En iyi elmas", + stat_revenue_day: "Gelir / gün", + stat_cpu_threads: "CPU iş parçacığı", + stat_mining_type_short: "Madencilik türü", + stat_mode_short: "Mod", + dash_details_title: "Yapılandırma ve bağlantı", + dash_detail_cpu: "CPU ön ayarı", + dash_detail_gpu: "GPU ön ayarı", + dash_detail_connect: "RPC / havuz", + dash_detail_wallet: "Ödül cüzdanı", + dash_detail_opencl: "OpenCL", + dash_detail_tuning: "GPU ayarı", + dash_detail_power_cost: "Elektrik maliyeti", + dash_detail_max_temp: "Maks. GPU sıcaklığı", + dash_detail_last_update: "İstatistik güncellemesi", + dash_detail_stats_status: "Miner raporu", + dash_detail_diamond: "Elmas #", + dash_no_data: "—", + btn_benchmark: "GPU otomatik ayar", + label_max_temp: "Maks. GPU sıcaklığı:", + label_pause_unprofitable: "Kârsızsa duraklat", + benchmark_running: "GPU profilleri test ediliyor (~45s)...", + benchmark_done: "Benchmark tamam — en iyi profil uygulandı.", + btn_start: "▶ Başlat", + btn_stop_icon: "■ Durdur", + help_title: "Yeni başlayanlar için 3 adım:", + help_step1: "1. Önce fullnode'u (hacash.exe) miner RPC ile çalıştırın.", + help_step2: "2. Bu uygulamayı açın → Ayarlar → CPU + GPU seçin → Başlat.", + help_step3: "3. Panelde hashrate, HAC/gün ve elektrik maliyetini görün.", + help_work_dir_prefix: "Çalışma klasörü:", + help_miner_prefix: "Miner:", + help_opencl_tip: "GPU yok mu? list_opencl.exe çalıştırın — NVIDIA/AMD platform_id 0 veya 1 olabilir.", + help_hac_title: "HAC — blok madenciliği", + help_hacd_title: "HACD — elmaslar + otomatik teklifler", + help_hacd_step1: "1. diamond_form = true ile hacash.exe çalıştırın; teklif cüzdanında HAC gerekir.", + help_hacd_step2: "2. Ödül cüzdanı PRIVAKEY (3x...) olmalı — legacy 1x değil.", + help_hacd_step3: "3. Ayarlar → HACD → cüzdan, teklif şifresi, min/max/step (1:0 = 1 HAC).", + help_hacd_step4: "4. Kaydet & Başlat — diaworker madencilik; fullnode [diamondminer] ile teklif verir.", + help_hacd_step5: "5. Cüzdan veya teklif değişikliğinden sonra fullnode'u yeniden başlatın.", + help_hardware_note: "AMD ve NVIDIA (RX / RTX 40·50) yalnızca OpenCL — poworker/diaworker'da CUDA yok. En yakın preset'i seçin.", + help_options_title: "Seçenekler referansı (panel + .ini + exe)", + cpu_only: "Sadece CPU (GPU yok)", + no_gpu: "GPU yok", + label_language: "Dil:", + label_currency: "Para birimi:", + }, + Lang::Zh => Strings { + window_title: "HAC 挖矿面板", + header_subtitle: "By Mosky", + tab_settings: "设置", + tab_dashboard: "仪表盘", + tab_help: "帮助", + ready_status: "就绪 — 选择 CPU/GPU 后点击开始。", + saved_prefix: "已保存:", + save_error_prefix: "保存错误:", + poworker_not_found: "未找到 poworker.exe — 请先编译。\n搜索路径:", + mining_active: "挖矿进行中。", + start_failed_prefix: "启动失败:", + mining_stopped: "挖矿已停止。", + block_found: "发现区块!", + miner_exited: "矿工已退出(请检查全节点是否在运行)。", + fullnode_starting: "正在启动全节点 (hacash.exe) — 请等待最多 45 秒...", + fullnode_not_ready: "全节点未就绪 — 请先运行 hacash.exe,再按开始:", + fullnode_exe_not_found: "未找到 hacash.exe — 请放在 miner-panel.exe 旁边:", + worker_error_prefix: "矿工警告:", + settings_intro: "选择您的 CPU 和 GPU — 程序将自动完成所有配置。", + label_cpu: "处理器 (CPU):", + label_gpu: "显卡 (GPU):", + label_mode: "模式:", + mode_eco: "节能(低功耗)", + mode_profit: "利润平衡(推荐)", + mode_max: "最大算力", + label_power_cost: "电费 (/kWh):", + label_hac_price: "HAC 价格 USD (可选):", + label_mining_type: "挖矿类型:", + mining_hac: "HAC 区块 (poworker)", + mining_hacd: "HACD 钻石 + 竞价 (diaworker)", + label_bid_password: "竞价账户密码:", + label_bid_min: "最低竞价 (HAC):", + label_bid_max: "最高竞价 (HAC):", + label_bid_step: "竞价步长 (HAC):", + bid_hint: "格式: mei 如 1:0 = 1 HAC。全节点需启用 [diamondminer]。", + hacd_wallet_hint: "钻石奖励用 PRIVAKEY 地址 (3x...)。", + diaworker_not_found: "未找到 diaworker.exe — 请先编译。\n搜索路径:", + bid_password_required: "请输入 HACD 竞价账户密码。", + label_connect_mode: "连接:", + connect_solo: "Solo (全节点)", + connect_pool: "矿池 (RPC)", + connect_pool_hint: "支持 miner RPC 的 host:port(非 hacashdot CUDA 矿池)。", + label_fullnode: "全节点 (RPC):", + label_wallet: "奖励钱包 (HAC 地址):", + wallet_hint: "区块奖励将发送到此地址(保存在 hacash.config.ini)。", + wallet_required: "请输入有效的 HAC 钱包地址以接收奖励。", + wallet_invalid_prefix: "无效的钱包地址:", + wallet_restart_hint: "更改钱包后请重启全节点 (hacash.exe)。", + label_opencl: "OpenCL 平台 / 设备:", + platform: "平台", + device_hint: "设备(通常为 0)", + btn_save: "保存设置", + btn_start_mining: "开始挖矿", + btn_stop: "停止", + mining_status: "挖矿中", + stopped_status: "已停止", + paused_unprofitable: "已暂停 — 无利可图", + stat_hashrate: "算力", + stat_hac_day: "HAC / 天", + stat_power: "功耗(估算)", + stat_cost_day: "费用 / 天", + stat_efficiency: "效率", + stat_network: "网络 %", + stat_block_height: "区块高度", + stat_net_day: "净收益 / 天", + stat_gpu_profile: "GPU 配置", + stat_diamond_best: "最佳钻石", + stat_revenue_day: "收入 / 天", + stat_cpu_threads: "CPU 线程", + stat_mining_type_short: "挖矿类型", + stat_mode_short: "模式", + dash_details_title: "配置与连接", + dash_detail_cpu: "CPU 预设", + dash_detail_gpu: "GPU 预设", + dash_detail_connect: "RPC / 矿池", + dash_detail_wallet: "奖励钱包", + dash_detail_opencl: "OpenCL", + dash_detail_tuning: "GPU 调优", + dash_detail_power_cost: "电费", + dash_detail_max_temp: "GPU 最高温度", + dash_detail_last_update: "统计更新", + dash_detail_stats_status: "矿工报告", + dash_detail_diamond: "钻石 #", + dash_no_data: "—", + btn_benchmark: "GPU 自动调优", + label_max_temp: "GPU 最高温度:", + label_pause_unprofitable: "无利润时暂停", + benchmark_running: "正在测试 GPU 配置 (~45秒)...", + benchmark_done: "调优完成 — 已应用最佳配置。", + btn_start: "▶ 开始", + btn_stop_icon: "■ 停止", + help_title: "新手三步:", + help_step1: "1. 先运行全节点 (hacash.exe) 并启用矿工 RPC。", + help_step2: "2. 打开本程序 → 设置 → 选择 CPU + GPU → 开始。", + help_step3: "3. 在仪表盘查看算力、HAC/天和电费。", + help_work_dir_prefix: "工作目录:", + help_miner_prefix: "矿工:", + help_opencl_tip: "未检测到 GPU?运行 list_opencl.exe — NVIDIA/AMD 的 platform_id 可能是 0 或 1。", + help_hac_title: "HAC — 区块挖矿", + help_hacd_title: "HACD — 钻石 + 自动竞价", + help_hacd_step1: "1. 运行 hacash.exe 并设置 diamond_form = true;竞价钱包需有 HAC。", + help_hacd_step2: "2. 奖励钱包必须是 PRIVAKEY (3x...) — 不是 legacy 1x。", + help_hacd_step3: "3. 设置 → HACD → 钱包、竞价密码、min/max/step(1:0 = 1 HAC)。", + help_hacd_step4: "4. 保存并启动 — diaworker 挖矿;全节点通过 [diamondminer] 自动竞价。", + help_hacd_step5: "5. 更改钱包或竞价后请重启全节点。", + help_hardware_note: "AMD 和 NVIDIA(RX / RTX 40·50)仅 OpenCL — poworker/diaworker 不支持 CUDA。选最接近的预设。", + help_options_title: "选项参考(面板 + .ini + 可执行文件)", + cpu_only: "仅 CPU(无 GPU)", + no_gpu: "无 GPU", + label_language: "语言:", + label_currency: "货币:", + }, + Lang::Ja => Strings { + window_title: "HAC マイナーパネル", + header_subtitle: "By Mosky", + tab_settings: "設定", + tab_dashboard: "ダッシュボード", + tab_help: "ヘルプ", + ready_status: "準備完了 — CPU/GPU を選んで開始を押してください。", + saved_prefix: "保存しました:", + save_error_prefix: "保存エラー:", + poworker_not_found: "poworker.exe が見つかりません — 先にビルドしてください。\n検索:", + mining_active: "マイニング中。", + start_failed_prefix: "起動に失敗:", + mining_stopped: "マイニング停止。", + block_found: "ブロック発見!", + miner_exited: "マイナーが終了しました(フルノードが動いているか確認してください)。", + fullnode_starting: "フルノード起動中 (hacash.exe) — 最大45秒お待ちください...", + fullnode_not_ready: "フルノード未準備 — 先に hacash.exe を実行してから開始:", + fullnode_exe_not_found: "hacash.exe が見つかりません — miner-panel.exe の横に配置:", + worker_error_prefix: "マイナー警告:", + settings_intro: "CPU と GPU を選ぶだけ — 自動ですべて設定します。", + label_cpu: "プロセッサ (CPU):", + label_gpu: "グラフィックカード (GPU):", + label_mode: "モード:", + mode_eco: "エコ(低消費電力)", + mode_profit: "利益バランス(推奨)", + mode_max: "最大ハッシュレート", + label_power_cost: "電気代 (/kWh):", + label_hac_price: "HAC 価格 USD (任意):", + label_mining_type: "マイニング種類:", + mining_hac: "HAC ブロック (poworker)", + mining_hacd: "HACD ダイヤ + 入札 (diaworker)", + label_bid_password: "入札アカウントパスワード:", + label_bid_min: "最小入札 (HAC):", + label_bid_max: "最大入札 (HAC):", + label_bid_step: "入札ステップ (HAC):", + bid_hint: "形式: mei 例 1:0 = 1 HAC。フルノードに [diamondminer] が必要。", + hacd_wallet_hint: "ダイヤ報酬用 PRIVAKEY アドレス (3x...)。", + diaworker_not_found: "diaworker.exe が見つかりません — 先にビルド。\n検索:", + bid_password_required: "HACD 用の入札パスワードを入力してください。", + label_connect_mode: "接続:", + connect_solo: "Solo (フルノード)", + connect_pool: "プール (RPC)", + connect_pool_hint: "miner RPC API の host:port(hacashdot CUDA プール不可)。", + label_fullnode: "フルノード (RPC):", + label_wallet: "報酬ウォレット (HAC アドレス):", + wallet_hint: "ブロック報酬はこのアドレスに送られます(hacash.config.ini に保存)。", + wallet_required: "報酬受取用の有効な HAC ウォレットアドレスを入力してください。", + wallet_invalid_prefix: "無効なウォレットアドレス:", + wallet_restart_hint: "ウォレット変更後はフルノード (hacash.exe) を再起動してください。", + label_opencl: "OpenCL プラットフォーム / デバイス:", + platform: "プラットフォーム", + device_hint: "デバイス(通常 0)", + btn_save: "設定を保存", + btn_start_mining: "マイニング開始", + btn_stop: "停止", + mining_status: "マイニング中", + stopped_status: "停止中", + paused_unprofitable: "一時停止 — 採算が合いません", + stat_hashrate: "ハッシュレート", + stat_hac_day: "HAC / 日", + stat_power: "消費電力(推定)", + stat_cost_day: "コスト / 日", + stat_efficiency: "効率", + stat_network: "ネットワーク %", + stat_block_height: "ブロック高", + stat_net_day: "純利益 / 日", + stat_gpu_profile: "GPU プロファイル", + stat_diamond_best: "最高ダイヤ", + stat_revenue_day: "収益 / 日", + stat_cpu_threads: "CPU スレッド", + stat_mining_type_short: "マイニング種別", + stat_mode_short: "モード", + dash_details_title: "設定と接続", + dash_detail_cpu: "CPU プリセット", + dash_detail_gpu: "GPU プリセット", + dash_detail_connect: "RPC / プール", + dash_detail_wallet: "報酬ウォレット", + dash_detail_opencl: "OpenCL", + dash_detail_tuning: "GPU チューニング", + dash_detail_power_cost: "電力コスト", + dash_detail_max_temp: "GPU 最高温度", + dash_detail_last_update: "統計更新", + dash_detail_stats_status: "マイナー報告", + dash_detail_diamond: "ダイヤ #", + dash_no_data: "—", + btn_benchmark: "GPU 自動調整", + label_max_temp: "GPU 最高温度:", + label_pause_unprofitable: "非採算時は一時停止", + benchmark_running: "GPU プロファイルをテスト中 (~45秒)...", + benchmark_done: "調整完了 — 最適プロファイルを適用。", + btn_start: "▶ 開始", + btn_stop_icon: "■ 停止", + help_title: "初心者向け 3 ステップ:", + help_step1: "1. まずフルノード (hacash.exe) をマイナー RPC 有効で起動。", + help_step2: "2. このアプリを開く → 設定 → CPU + GPU を選択 → 開始。", + help_step3: "3. ダッシュボードでハッシュレート、HAC/日、電気代を確認。", + help_work_dir_prefix: "作業フォルダ:", + help_miner_prefix: "マイナー:", + help_opencl_tip: "GPU 未検出?list_opencl.exe を実行 — NVIDIA/AMD の platform_id は 0 または 1。", + help_hac_title: "HAC — ブロックマイニング", + help_hacd_title: "HACD — ダイヤ + 自動入札", + help_hacd_step1: "1. diamond_form = true で hacash.exe を実行。入札ウォレットに HAC が必要。", + help_hacd_step2: "2. 報酬ウォレットは PRIVAKEY (3x...) 必須 — legacy 1x は不可。", + help_hacd_step3: "3. 設定 → HACD → ウォレット、入札パスワード、min/max/step(1:0 = 1 HAC)。", + help_hacd_step4: "4. 保存して開始 — diaworker がマイニング、fullnode が [diamondminer] で入札。", + help_hacd_step5: "5. ウォレットまたは入札変更後は fullnode を再起動。", + help_hardware_note: "AMD・NVIDIA(RX / RTX 40·50)は OpenCL のみ — poworker/diaworker に CUDA なし。最も近いプリセットを選択。", + help_options_title: "オプション一覧(パネル + .ini + 実行ファイル)", + cpu_only: "CPU のみ(GPU なし)", + no_gpu: "GPU なし", + label_language: "言語:", + label_currency: "通貨:", + }, + Lang::Es => Strings { + window_title: "Panel HAC Miner", + header_subtitle: "By Mosky", + tab_settings: "Ajustes", + tab_dashboard: "Panel", + tab_help: "Ayuda", + ready_status: "Listo — elige CPU/GPU y pulsa Iniciar.", + saved_prefix: "Guardado:", + save_error_prefix: "Error al guardar:", + poworker_not_found: "No se encontró poworker.exe — compila primero.\nBuscado:", + mining_active: "Minería activa.", + start_failed_prefix: "Error al iniciar:", + mining_stopped: "Minería detenida.", + block_found: "¡Bloque encontrado!", + miner_exited: "El minero terminó (comprueba que el fullnode esté en ejecución).", + fullnode_starting: "Iniciando fullnode (hacash.exe) — espere hasta 45 s...", + fullnode_not_ready: "Fullnode no listo — ejecute hacash.exe primero, luego Iniciar:", + fullnode_exe_not_found: "hacash.exe no encontrado — colóquelo junto a miner-panel.exe:", + worker_error_prefix: "Aviso del minero:", + settings_intro: "Elige tu CPU y GPU — la app configura todo automáticamente.", + label_cpu: "Procesador (CPU):", + label_gpu: "Tarjeta gráfica (GPU):", + label_mode: "Modo:", + mode_eco: "Eco (menos consumo)", + mode_profit: "Equilibrio de beneficio (recomendado)", + mode_max: "Hashrate máximo", + label_power_cost: "Coste electricidad (/kWh):", + label_hac_price: "Precio HAC USD (opcional):", + label_mining_type: "Tipo de minería:", + mining_hac: "Bloques HAC (poworker)", + mining_hacd: "Diamantes HACD + pujas (diaworker)", + label_bid_password: "Contraseña cuenta de puja:", + label_bid_min: "Puja mín. (HAC):", + label_bid_max: "Puja máx. (HAC):", + label_bid_step: "Paso de puja (HAC):", + bid_hint: "Formato: mei ej. 1:0 = 1 HAC. Fullnode con [diamondminer].", + hacd_wallet_hint: "Dirección PRIVAKEY (3x...) para recompensas diamond.", + diaworker_not_found: "No se encontró diaworker.exe — compila primero.\nBuscado:", + bid_password_required: "Introduce la contraseña de puja para HACD.", + label_connect_mode: "Conexión:", + connect_solo: "Solo (fullnode)", + connect_pool: "Pool (RPC)", + connect_pool_hint: "host:port con API miner RPC (no pools CUDA como hacashdot).", + label_fullnode: "Fullnode (RPC):", + label_wallet: "Monedero de recompensas (dirección HAC):", + wallet_hint: "Las recompensas de bloque van a esta dirección (se guarda en hacash.config.ini).", + wallet_required: "Introduce una dirección HAC válida para las recompensas.", + wallet_invalid_prefix: "Dirección de monedero no válida:", + wallet_restart_hint: "Reinicia el fullnode (hacash.exe) tras cambiar el monedero.", + label_opencl: "OpenCL platform / device:", + platform: "platform", + device_hint: "device (normalmente 0)", + btn_save: "Guardar ajustes", + btn_start_mining: "Iniciar minería", + btn_stop: "Detener", + mining_status: "MINANDO", + stopped_status: "DETENIDO", + paused_unprofitable: "Pausado — no rentable", + stat_hashrate: "Hashrate", + stat_hac_day: "HAC / día", + stat_power: "Consumo (estimado)", + stat_cost_day: "Coste / día", + stat_efficiency: "Eficiencia", + stat_network: "Red %", + stat_block_height: "Altura de bloque", + stat_net_day: "Neto / día", + stat_gpu_profile: "Perfil GPU", + stat_diamond_best: "Mejor diamante", + stat_revenue_day: "Ingresos / día", + stat_cpu_threads: "Hilos CPU", + stat_mining_type_short: "Tipo de minería", + stat_mode_short: "Modo", + dash_details_title: "Configuración y conexión", + dash_detail_cpu: "Preset CPU", + dash_detail_gpu: "Preset GPU", + dash_detail_connect: "RPC / pool", + dash_detail_wallet: "Wallet de recompensas", + dash_detail_opencl: "OpenCL", + dash_detail_tuning: "Ajuste GPU", + dash_detail_power_cost: "Coste eléctrico", + dash_detail_max_temp: "Temp. máx. GPU", + dash_detail_last_update: "Stats actualizados", + dash_detail_stats_status: "Informe del miner", + dash_detail_diamond: "Diamante #", + dash_no_data: "—", + btn_benchmark: "Auto-ajuste GPU", + label_max_temp: "Temp. máx. GPU:", + label_pause_unprofitable: "Pausar si no es rentable", + benchmark_running: "Probando perfiles GPU (~45s)...", + benchmark_done: "Listo — mejor perfil aplicado.", + btn_start: "▶ Iniciar", + btn_stop_icon: "■ Detener", + help_title: "3 pasos para principiantes:", + help_step1: "1. Ejecuta el fullnode (hacash.exe) con RPC de miner activo.", + help_step2: "2. Abre esta app → Ajustes → elige CPU + GPU → Iniciar.", + help_step3: "3. Mira el Panel para hashrate, HAC/día y coste eléctrico.", + help_work_dir_prefix: "Carpeta de trabajo:", + help_miner_prefix: "Minero:", + help_opencl_tip: "¿Sin GPU? Ejecuta list_opencl.exe — platform_id NVIDIA/AMD puede ser 0 o 1.", + help_hac_title: "HAC — minería de bloques", + help_hacd_title: "HACD — diamantes + pujas automáticas", + help_hacd_step1: "1. Ejecuta hacash.exe con diamond_form = true; la wallet de puja necesita HAC.", + help_hacd_step2: "2. La wallet de recompensa debe ser PRIVAKEY (3x...) — no legacy 1x.", + help_hacd_step3: "3. Ajustes → HACD → wallet, contraseña puja, min/max/step (1:0 = 1 HAC).", + help_hacd_step4: "4. Guardar e Iniciar — diaworker mina; fullnode puja vía [diamondminer].", + help_hacd_step5: "5. Reinicia el fullnode tras cambiar wallet o pujas.", + help_hardware_note: "AMD y NVIDIA (RX / RTX 40·50) solo OpenCL — sin CUDA en poworker/diaworker. Elige el preset más cercano.", + help_options_title: "Referencia de opciones (panel + .ini + ejecutables)", + cpu_only: "Solo CPU (sin GPU)", + no_gpu: "Sin GPU", + label_language: "Idioma:", + label_currency: "Moneda:", + }, + Lang::Fr => Strings { + window_title: "Panneau HAC Miner", + header_subtitle: "By Mosky", + tab_settings: "Réglages", + tab_dashboard: "Tableau de bord", + tab_help: "Aide", + ready_status: "Prêt — choisissez CPU/GPU et appuyez sur Démarrer.", + saved_prefix: "Enregistré :", + save_error_prefix: "Erreur d'enregistrement :", + poworker_not_found: "poworker.exe introuvable — compilez d'abord.\nRecherché :", + mining_active: "Minage actif.", + start_failed_prefix: "Échec du démarrage :", + mining_stopped: "Minage arrêté.", + block_found: "Bloc trouvé !", + miner_exited: "Le mineur s'est arrêté (vérifiez que le fullnode tourne).", + fullnode_starting: "Démarrage du fullnode (hacash.exe) — attendez jusqu'à 45 s...", + fullnode_not_ready: "Fullnode pas prêt — lancez hacash.exe d'abord, puis Démarrer:", + fullnode_exe_not_found: "hacash.exe introuvable — placez-le à côté de miner-panel.exe:", + worker_error_prefix: "Avertissement mineur:", + settings_intro: "Choisissez votre CPU et GPU — l'app configure tout automatiquement.", + label_cpu: "Processeur (CPU) :", + label_gpu: "Carte graphique (GPU) :", + label_mode: "Mode :", + mode_eco: "Éco (moins de consommation)", + mode_profit: "Équilibre profit (recommandé)", + mode_max: "Hashrate maximum", + label_power_cost: "Coût électricité (/kWh) :", + label_hac_price: "Prix HAC USD (optionnel) :", + label_mining_type: "Type de minage :", + mining_hac: "Blocs HAC (poworker)", + mining_hacd: "Diamants HACD + enchères (diaworker)", + label_bid_password: "Mot de passe compte enchère :", + label_bid_min: "Enchère min. (HAC) :", + label_bid_max: "Enchère max. (HAC) :", + label_bid_step: "Pas d'enchère (HAC) :", + bid_hint: "Format : mei ex. 1:0 = 1 HAC. Fullnode avec [diamondminer].", + hacd_wallet_hint: "Adresse PRIVAKEY (3x...) pour récompenses diamond.", + diaworker_not_found: "diaworker.exe introuvable — compilez d'abord.\nRecherché :", + bid_password_required: "Entrez le mot de passe d'enchère pour HACD.", + label_connect_mode: "Connexion :", + connect_solo: "Solo (fullnode)", + connect_pool: "Pool (RPC)", + connect_pool_hint: "host:port avec API miner RPC (pas les pools CUDA hacashdot).", + label_fullnode: "Fullnode (RPC) :", + label_wallet: "Portefeuille de récompenses (adresse HAC) :", + wallet_hint: "Les récompenses de blocs vont à cette adresse (enregistrée dans hacash.config.ini).", + wallet_required: "Entrez une adresse HAC valide pour les récompenses.", + wallet_invalid_prefix: "Adresse de portefeuille invalide :", + wallet_restart_hint: "Redémarrez le fullnode (hacash.exe) après avoir changé le portefeuille.", + label_opencl: "OpenCL platform / device :", + platform: "platform", + device_hint: "device (souvent 0)", + btn_save: "Enregistrer les réglages", + btn_start_mining: "Démarrer le minage", + btn_stop: "Arrêter", + mining_status: "MINAGE", + stopped_status: "ARRÊTÉ", + paused_unprofitable: "Pause — non rentable", + stat_hashrate: "Hashrate", + stat_hac_day: "HAC / jour", + stat_power: "Puissance (estimation)", + stat_cost_day: "Coût / jour", + stat_efficiency: "Efficacité", + stat_network: "Réseau %", + stat_block_height: "Hauteur de bloc", + stat_net_day: "Net / jour", + stat_gpu_profile: "Profil GPU", + stat_diamond_best: "Meilleur diamant", + stat_revenue_day: "Revenu / jour", + stat_cpu_threads: "Threads CPU", + stat_mining_type_short: "Type de minage", + stat_mode_short: "Mode", + dash_details_title: "Configuration et connexion", + dash_detail_cpu: "Preset CPU", + dash_detail_gpu: "Preset GPU", + dash_detail_connect: "RPC / pool", + dash_detail_wallet: "Wallet de récompense", + dash_detail_opencl: "OpenCL", + dash_detail_tuning: "Réglage GPU", + dash_detail_power_cost: "Coût électricité", + dash_detail_max_temp: "Temp. max GPU", + dash_detail_last_update: "Stats mises à jour", + dash_detail_stats_status: "Rapport miner", + dash_detail_diamond: "Diamant #", + dash_no_data: "—", + btn_benchmark: "Auto-réglage GPU", + label_max_temp: "Temp. max GPU :", + label_pause_unprofitable: "Pause si non rentable", + benchmark_running: "Test des profils GPU (~45s)...", + benchmark_done: "Terminé — meilleur profil appliqué.", + btn_start: "▶ Démarrer", + btn_stop_icon: "■ Arrêter", + help_title: "3 étapes pour débutants :", + help_step1: "1. Lancez le fullnode (hacash.exe) avec le RPC mineur activé.", + help_step2: "2. Ouvrez cette app → Réglages → choisissez CPU + GPU → Démarrer.", + help_step3: "3. Consultez le tableau de bord pour hashrate, HAC/jour et coût électrique.", + help_work_dir_prefix: "Dossier de travail :", + help_miner_prefix: "Mineur :", + help_opencl_tip: "Pas de GPU ? Lancez list_opencl.exe — platform_id NVIDIA/AMD peut être 0 ou 1.", + help_hac_title: "HAC — minage de blocs", + help_hacd_title: "HACD — diamants + enchères auto", + help_hacd_step1: "1. Lancez hacash.exe avec diamond_form = true ; le portefeuille d'enchère doit avoir des HAC.", + help_hacd_step2: "2. Le portefeuille de récompense doit être PRIVAKEY (3x...) — pas legacy 1x.", + help_hacd_step3: "3. Réglages → HACD → wallet, mot de passe enchère, min/max/step (1:0 = 1 HAC).", + help_hacd_step4: "4. Enregistrer & Démarrer — diaworker mine ; fullnode enchérit via [diamondminer].", + help_hacd_step5: "5. Redémarrez le fullnode après changement de wallet ou enchères.", + help_hardware_note: "AMD et NVIDIA (RX / RTX 40·50) OpenCL uniquement — pas de CUDA dans poworker/diaworker. Preset le plus proche.", + help_options_title: "Référence des options (panel + .ini + exécutables)", + cpu_only: "CPU seul (sans GPU)", + no_gpu: "Sans GPU", + label_language: "Langue :", + label_currency: "Devise :", + }, + Lang::Th => Strings { + window_title: "HAC Miner Panel", + header_subtitle: "By Mosky", + tab_settings: "ตั้งค่า", + tab_dashboard: "แดชบอร์ด", + tab_help: "ช่วยเหลือ", + ready_status: "พร้อม — เลือก CPU/GPU แล้วกดเริ่ม", + saved_prefix: "บันทึกแล้ว:", + save_error_prefix: "ข้อผิดพลาดการบันทึก:", + poworker_not_found: "ไม่พบ poworker.exe — กรุณา build ก่อน\nค้นหา:", + mining_active: "กำลังขุด", + start_failed_prefix: "เริ่มไม่สำเร็จ:", + mining_stopped: "หยุดขุดแล้ว", + block_found: "พบบล็อก!", + miner_exited: "ไมเนอร์ปิดแล้ว (ตรวจสอบว่า fullnode ทำงานอยู่)", + fullnode_starting: "กำลังเริ่ม fullnode (hacash.exe) — รอสูงสุด 45 วินาที...", + fullnode_not_ready: "fullnode ยังไม่พร้อม — รัน hacash.exe ก่อน แล้วกดเริ่ม:", + fullnode_exe_not_found: "ไม่พบ hacash.exe — วางไว้ข้าง miner-panel.exe:", + worker_error_prefix: "คำเตือนไมเนอร์:", + settings_intro: "เลือก CPU และ GPU ของคุณ — โปรแกรมตั้งค่าทุกอย่างให้อัตโนมัติ", + label_cpu: "ซีพียู (CPU):", + label_gpu: "การ์ดจอ (GPU):", + label_mode: "โหมด:", + mode_eco: "ประหยัด (ใช้ไฟน้อย)", + mode_profit: "สมดุลกำไร (แนะนำ)", + mode_max: "แฮชเรทสูงสุด", + label_power_cost: "ค่าไฟ (/kWh):", + label_hac_price: "ราคา HAC USD (ไม่บังคับ):", + label_mining_type: "ประเภทการขุด:", + mining_hac: "บล็อก HAC (poworker)", + mining_hacd: "HACD เพชร + ประมูล (diaworker)", + label_bid_password: "รหัสบัญชีประมูล:", + label_bid_min: "ประมูลขั้นต่ำ (HAC):", + label_bid_max: "ประมูลสูงสุด (HAC):", + label_bid_step: "ขั้นประมูล (HAC):", + bid_hint: "รูปแบบ: mei เช่น 1:0 = 1 HAC ต้องมี [diamondminer] ใน fullnode", + hacd_wallet_hint: "ที่อยู่ PRIVAKEY (3x...) สำหรับรางวัลเพชร", + diaworker_not_found: "ไม่พบ diaworker.exe — กรุณา build ก่อน\nค้นหา:", + bid_password_required: "กรอกรหัสบัญชีประมูลสำหรับ HACD", + label_connect_mode: "การเชื่อมต่อ:", + connect_solo: "Solo (fullnode)", + connect_pool: "Pool (RPC)", + connect_pool_hint: "host:port ที่มี miner RPC API (ไม่ใช่ CUDA pool เช่น hacashdot)", + label_fullnode: "Fullnode (RPC):", + label_wallet: "กระเป๋าเงินรางวัล (ที่อยู่ HAC):", + wallet_hint: "รางวัลบล็อกจะส่งไปที่อยู่นี้ (บันทึกใน hacash.config.ini)", + wallet_required: "กรอกที่อยู่กระเป๋า HAC ที่ถูกต้องเพื่อรับรางวัล", + wallet_invalid_prefix: "ที่อยู่กระเป๋าไม่ถูกต้อง:", + wallet_restart_hint: "เปลี่ยนกระเป๋าแล้วให้รีสตาร์ท fullnode (hacash.exe)", + label_opencl: "OpenCL platform / device:", + platform: "platform", + device_hint: "device (มักเป็น 0)", + btn_save: "บันทึกการตั้งค่า", + btn_start_mining: "เริ่มขุด", + btn_stop: "หยุด", + mining_status: "กำลังขุด", + stopped_status: "หยุดแล้ว", + paused_unprofitable: "หยุดชั่วคราว — ไม่คุ้มค่า", + stat_hashrate: "แฮชเรท", + stat_hac_day: "HAC / วัน", + stat_power: "กำลังไฟ (ประมาณ)", + stat_cost_day: "ค่าใช้จ่าย / วัน", + stat_efficiency: "ประสิทธิภาพ", + stat_network: "เครือข่าย %", + stat_block_height: "ความสูงบล็อก", + stat_net_day: "สุทธิ / วัน", + stat_gpu_profile: "โปรไฟล์ GPU", + stat_diamond_best: "ไดมอนด์ที่ดีที่สุด", + stat_revenue_day: "รายได้ / วัน", + stat_cpu_threads: "เธรด CPU", + stat_mining_type_short: "ประเภทการขุด", + stat_mode_short: "โหมด", + dash_details_title: "การตั้งค่าและการเชื่อมต่อ", + dash_detail_cpu: "พรีเซ็ต CPU", + dash_detail_gpu: "พรีเซ็ต GPU", + dash_detail_connect: "RPC / พูล", + dash_detail_wallet: "กระเป๋ารางวัล", + dash_detail_opencl: "OpenCL", + dash_detail_tuning: "ปรับแต่ง GPU", + dash_detail_power_cost: "ค่าไฟ", + dash_detail_max_temp: "อุณหภูมิ GPU สูงสุด", + dash_detail_last_update: "อัปเดตสถิติ", + dash_detail_stats_status: "รายงาน miner", + dash_detail_diamond: "ไดมอนด์ #", + dash_no_data: "—", + btn_benchmark: "ปรับ GPU อัตโนมัติ", + label_max_temp: "อุณหภูมิ GPU สูงสุด:", + label_pause_unprofitable: "หยุดถ้าไม่คุ้ม", + benchmark_running: "กำลังทดสอบโปรไฟล์ GPU (~45 วิ)...", + benchmark_done: "เสร็จแล้ว — ใช้โปรไฟล์ที่ดีที่สุด", + btn_start: "▶ เริ่ม", + btn_stop_icon: "■ หยุด", + help_title: "3 ขั้นตอนสำหรับมือใหม่:", + help_step1: "1. รัน fullnode (hacash.exe) พร้อมเปิด miner RPC", + help_step2: "2. เปิดโปรแกรมนี้ → ตั้งค่า → เลือก CPU + GPU → เริ่ม", + help_step3: "3. ดูแดชบอร์ดสำหรับแฮชเรท HAC/วัน และค่าไฟ", + help_work_dir_prefix: "โฟลเดอร์ทำงาน:", + help_miner_prefix: "ไมเนอร์:", + help_opencl_tip: "ไม่เจอ GPU? รัน list_opencl.exe — platform_id NVIDIA/AMD อาจเป็น 0 หรือ 1", + help_hac_title: "HAC — ขุดบล็อก", + help_hacd_title: "HACD — เพชร + ประมูลอัตโนมัติ", + help_hacd_step1: "1. รัน hacash.exe พร้อม diamond_form = true กระเป๋าประมูลต้องมี HAC", + help_hacd_step2: "2. กระเป๋ารางวัลต้องเป็น PRIVAKEY (3x...) ไม่ใช่ legacy 1x", + help_hacd_step3: "3. ตั้งค่า → HACD → กระเป๋า รหัสประมูล min/max/step (1:0 = 1 HAC)", + help_hacd_step4: "4. บันทึกและเริ่ม — diaworker ขุด fullnode ประมูลผ่าน [diamondminer]", + help_hacd_step5: "5. รีสตาร์ท fullnode หลังเปลี่ยนกระเป๋าหรือการประมูล", + help_hardware_note: "AMD และ NVIDIA (RX / RTX 40·50) ใช้ OpenCL เท่านั้น — ไม่มี CUDA ใน poworker/diaworker เลือก preset ที่ใกล้เคียง", + help_options_title: "คู่มือตัวเลือก (แผง + .ini + โปรแกรม)", + cpu_only: "CPU เท่านั้น (ไม่มี GPU)", + no_gpu: "ไม่มี GPU", + label_language: "ภาษา:", + label_currency: "สกุลเงิน:", + }, + Lang::Ru => Strings { + window_title: "Панель HAC Miner", + header_subtitle: "By Mosky", + tab_settings: "Настройки", + tab_dashboard: "Панель", + tab_help: "Справка", + ready_status: "Готово — выберите CPU/GPU и нажмите Старт.", + saved_prefix: "Сохранено:", + save_error_prefix: "Ошибка сохранения:", + poworker_not_found: "poworker.exe не найден — сначала соберите проект.\nПоиск:", + mining_active: "Майнинг активен.", + start_failed_prefix: "Не удалось запустить:", + mining_stopped: "Майнинг остановлен.", + block_found: "Блок найден!", + miner_exited: "Майнер завершился (проверьте, что fullnode запущен).", + fullnode_starting: "Запуск fullnode (hacash.exe) — подождите до 45 с...", + fullnode_not_ready: "Fullnode не готов — сначала запустите hacash.exe, затем Старт:", + fullnode_exe_not_found: "hacash.exe не найден — положите рядом с miner-panel.exe:", + worker_error_prefix: "Предупреждение майнера:", + settings_intro: "Выберите CPU и GPU — программа настроит всё автоматически.", + label_cpu: "Процессор (CPU):", + label_gpu: "Видеокарта (GPU):", + label_mode: "Режим:", + mode_eco: "Эко (меньше энергии)", + mode_profit: "Баланс прибыли (рекомендуется)", + mode_max: "Максимальный hashrate", + label_power_cost: "Стоимость электричества (/kWh):", + label_hac_price: "Цена HAC USD (необязательно):", + label_mining_type: "Тип майнинга:", + mining_hac: "Блоки HAC (poworker)", + mining_hacd: "HACD алмазы + ставки (diaworker)", + label_bid_password: "Пароль bid-аккаунта:", + label_bid_min: "Мин. ставка (HAC):", + label_bid_max: "Макс. ставка (HAC):", + label_bid_step: "Шаг ставки (HAC):", + bid_hint: "Формат: mei напр. 1:0 = 1 HAC. Нужен [diamondminer] в fullnode.", + hacd_wallet_hint: "PRIVAKEY адрес (3x...) для наград за алмазы.", + diaworker_not_found: "diaworker.exe не найден — сначала соберите.\nПоиск:", + bid_password_required: "Введите пароль bid-аккаунта для HACD.", + label_connect_mode: "Подключение:", + connect_solo: "Solo (fullnode)", + connect_pool: "Пул (RPC)", + connect_pool_hint: "host:port с miner RPC API (не CUDA-пулы вроде hacashdot).", + label_fullnode: "Fullnode (RPC):", + label_wallet: "Кошелёк для наград (адрес HAC):", + wallet_hint: "Награды за блоки идут на этот адрес (сохраняется в hacash.config.ini).", + wallet_required: "Введите действительный адрес HAC для получения наград.", + wallet_invalid_prefix: "Неверный адрес кошелька:", + wallet_restart_hint: "После смены кошелька перезапустите fullnode (hacash.exe).", + label_opencl: "OpenCL platform / device:", + platform: "platform", + device_hint: "device (обычно 0)", + btn_save: "Сохранить настройки", + btn_start_mining: "Начать майнинг", + btn_stop: "Остановить", + mining_status: "МАЙНИНГ", + stopped_status: "ОСТАНОВЛЕН", + paused_unprofitable: "Пауза — невыгодно", + stat_hashrate: "Хешрейт", + stat_hac_day: "HAC / день", + stat_power: "Мощность (оценка)", + stat_cost_day: "Расход / день", + stat_efficiency: "Эффективность", + stat_network: "Сеть %", + stat_block_height: "Высота блока", + stat_net_day: "Чистая / день", + stat_gpu_profile: "Профиль GPU", + stat_diamond_best: "Лучший алмаз", + stat_revenue_day: "Доход / день", + stat_cpu_threads: "Потоки CPU", + stat_mining_type_short: "Тип майнинга", + stat_mode_short: "Режим", + dash_details_title: "Настройки и подключение", + dash_detail_cpu: "Пресет CPU", + dash_detail_gpu: "Пресет GPU", + dash_detail_connect: "RPC / пул", + dash_detail_wallet: "Кошелёк наград", + dash_detail_opencl: "OpenCL", + dash_detail_tuning: "Настройка GPU", + dash_detail_power_cost: "Стоимость электричества", + dash_detail_max_temp: "Макс. темп. GPU", + dash_detail_last_update: "Обновление статистики", + dash_detail_stats_status: "Отчёт майнера", + dash_detail_diamond: "Алмаз #", + dash_no_data: "—", + btn_benchmark: "Авто-настройка GPU", + label_max_temp: "Макс. темп. GPU:", + label_pause_unprofitable: "Пауза если невыгодно", + benchmark_running: "Тест профилей GPU (~45с)...", + benchmark_done: "Готово — применён лучший профиль.", + btn_start: "▶ Старт", + btn_stop_icon: "■ Стоп", + help_title: "3 шага для новичков:", + help_step1: "1. Запустите fullnode (hacash.exe) с включённым miner RPC.", + help_step2: "2. Откройте это приложение → Настройки → выберите CPU + GPU → Старт.", + help_step3: "3. Смотрите панель: hashrate, HAC/день и стоимость электричества.", + help_work_dir_prefix: "Рабочая папка:", + help_miner_prefix: "Майнер:", + help_opencl_tip: "Нет GPU? Запустите list_opencl.exe — platform_id NVIDIA/AMD может быть 0 или 1.", + help_hac_title: "HAC — майнинг блоков", + help_hacd_title: "HACD — алмазы + авто-ставки", + help_hacd_step1: "1. Запустите hacash.exe с diamond_form = true; на кошельке для ставок нужен HAC.", + help_hacd_step2: "2. Кошелёк наград — PRIVAKEY (3x...), не legacy 1x.", + help_hacd_step3: "3. Настройки → HACD → кошелёк, пароль ставок, min/max/step (1:0 = 1 HAC).", + help_hacd_step4: "4. Сохранить и Старт — diaworker майнит; fullnode ставит через [diamondminer].", + help_hacd_step5: "5. Перезапустите fullnode после смены кошелька или ставок.", + help_hardware_note: "AMD и NVIDIA (RX / RTX 40·50) только OpenCL — CUDA нет в poworker/diaworker. Выберите ближайший пресет.", + help_options_title: "Справочник опций (панель + .ini + exe)", + cpu_only: "Только CPU (без GPU)", + no_gpu: "Без GPU", + label_language: "Язык:", + label_currency: "Валюта:", + }, + } +} + +pub fn load_lang(work_dir: &std::path::Path) -> Lang { + let path = work_dir.join("miner-panel.lang"); + std::fs::read_to_string(path) + .ok() + .map(|s| Lang::from_code(&s)) + .unwrap_or(Lang::El) +} + +pub fn save_lang(work_dir: &std::path::Path, lang: Lang) { + let path = work_dir.join("miner-panel.lang"); + let _ = std::fs::write(path, lang.code()); +} \ No newline at end of file diff --git a/miner-panel/src/main.rs b/miner-panel/src/main.rs new file mode 100644 index 00000000..f9c13d02 --- /dev/null +++ b/miner-panel/src/main.rs @@ -0,0 +1,1545 @@ +mod assets; +mod config; +mod connect; +mod currency; +mod fonts; +mod hacash_config; +mod help_options; +mod i18n; +mod mining_kind; +mod presets; +mod theme; + +use std::io::{BufRead, BufReader, Read}; +use std::net::{SocketAddr, TcpStream}; +use std::path::{Path, PathBuf}; +use std::process::{Child, Command, Stdio}; +use std::sync::mpsc::{self, Receiver, Sender}; +use std::thread; +use std::time::{Duration, Instant}; + +use app::efficiency::{EfficiencyMode, MiningStatsSnapshot}; +use config::{ + apply_loaded_ini, load_panel_ini, write_diaworker_config, write_poworker_benchmark_config, + write_poworker_config, PanelSettings, +}; +use presets::tuning_for_profile; +use connect::{pool_presets, ConnectMode, SOLO_DEFAULT}; +use eframe::egui; +use hacash_config::{ + find_hacash_config, read_diamond_miner, read_reward_wallet, validate_wallet, + write_diamond_miner, write_hac_miner_only, DiamondMinerSettings, +}; +use currency::{load_currency, save_currency, Currency}; +use i18n::{load_lang, save_lang, strings, Lang, Strings}; +use mining_kind::{load_mining_kind, save_mining_kind, MiningKind}; +use presets::{cpu_presets, gpu_presets, CpuPreset, GpuPreset}; + +fn main() -> eframe::Result<()> { + let options = eframe::NativeOptions { + viewport: egui::ViewportBuilder::default() + .with_inner_size([980.0, 700.0]) + .with_min_inner_size([760.0, 560.0]), + ..Default::default() + }; + eframe::run_native( + "HAC Miner Panel", + options, + Box::new(|cc| Ok(Box::new(MinerApp::new(cc)))), + ) +} + +#[derive(Clone)] +struct PendingStart { + worker_path: PathBuf, + deadline: Instant, +} + +struct MinerApp { + work_dir: PathBuf, + config_path: PathBuf, + dia_config_path: PathBuf, + hacash_config_path: PathBuf, + stats_path: PathBuf, + poworker_path: PathBuf, + diaworker_path: PathBuf, + wallet: String, + mining_kind: MiningKind, + bid_password: String, + bid_min: String, + bid_max: String, + bid_step: String, + cpu_presets: Vec, + gpu_presets: Vec, + lang: Lang, + currency: Currency, + cpu_idx: usize, + gpu_idx: usize, + mode_idx: usize, + power_cost: f32, + hac_price: f32, + platform_id: u32, + device_id: u32, + connect: String, + connect_mode: ConnectMode, + pool_preset_idx: usize, + max_temp_c: u32, + pause_unprofitable: bool, + work_groups: u32, + unit_size: u32, + benchmarking: bool, + benchmark_child: Option, + pending_start: Option, + mining: bool, + child: Option, + stats: MiningStatsSnapshot, + status_msg: String, + log_rx: Option>, + tab: usize, + logo_texture: Option, +} + +impl MinerApp { + fn new(cc: &eframe::CreationContext<'_>) -> Self { + fonts::setup_fonts(&cc.egui_ctx); + theme::setup_theme(&cc.egui_ctx); + let work_dir = exe_dir(); + let logo_texture = assets::load_logo(&cc.egui_ctx, &work_dir); + let config_path = work_dir.join("poworker.config.ini"); + let dia_config_path = work_dir.join("diaworker.config.ini"); + let hacash_config_path = find_hacash_config(&work_dir); + let mining_kind = load_mining_kind(&work_dir); + let diamond = read_diamond_miner(&hacash_config_path); + let wallet = match mining_kind { + MiningKind::Hacd if !diamond.reward.is_empty() => diamond.reward, + _ => read_reward_wallet(&hacash_config_path), + }; + let stats_path = work_dir.join("miner-stats.json"); + let poworker_path = find_worker_exe(&work_dir, "poworker.exe"); + let diaworker_path = find_worker_exe(&work_dir, "diaworker.exe"); + let cpus = cpu_presets(); + let gpus = gpu_presets(); + let lang = load_lang(&work_dir); + let currency = load_currency(&work_dir, lang); + let t = strings(lang); + let mut cpu_idx = 2usize; + let mut gpu_idx = 5usize; + let mut mode_idx = 1usize; + let mut power_cost = currency.default_power_cost(); + let mut hac_price = 0.0f32; + let mut platform_id = 0u32; + let mut device_id = 0u32; + let mut connect = SOLO_DEFAULT.to_string(); + let mut max_temp_c = 83u32; + let mut pause_unprofitable = false; + let (mut work_groups, mut unit_size) = + tuning_for_profile(&gpus[gpu_idx].profile); + let ini_path = work_dir.join("poworker.config.ini"); + apply_loaded_ini( + &load_panel_ini(&ini_path), + &cpus, + &gpus, + &mut cpu_idx, + &mut gpu_idx, + &mut mode_idx, + &mut work_groups, + &mut unit_size, + &mut platform_id, + &mut device_id, + &mut connect, + &mut power_cost, + &mut hac_price, + &mut max_temp_c, + &mut pause_unprofitable, + ); + Self { + work_dir, + config_path, + dia_config_path, + hacash_config_path, + stats_path, + poworker_path, + diaworker_path, + wallet, + mining_kind, + bid_password: diamond.bid_password, + bid_min: diamond.bid_min, + bid_max: diamond.bid_max, + bid_step: diamond.bid_step, + cpu_presets: cpus, + gpu_presets: gpus, + lang, + currency, + cpu_idx, + gpu_idx, + mode_idx, + power_cost, + hac_price, + platform_id, + device_id, + connect, + connect_mode: ConnectMode::Solo, + pool_preset_idx: 0, + max_temp_c, + pause_unprofitable, + work_groups, + unit_size, + benchmarking: false, + benchmark_child: None, + pending_start: None, + mining: false, + child: None, + stats: MiningStatsSnapshot::default(), + status_msg: t.ready_status.to_string(), + log_rx: None, + tab: 0, + logo_texture, + } + } + + fn miner_badge_state(&self) -> theme::MinerBadgeState { + theme::miner_badge_state(self.mining, self.stats.paused_unprofitable) + } + + fn miner_status_label(&self) -> &str { + let t = self.t(); + match self.miner_badge_state() { + theme::MinerBadgeState::Mining => t.mining_status, + theme::MinerBadgeState::Paused => t.paused_unprofitable, + theme::MinerBadgeState::Stopped => t.stopped_status, + } + } + + fn apply_gpu_preset_tuning(&mut self) { + if self.gpu_presets[self.gpu_idx].slug == "none" { + return; + } + let (wg, us) = tuning_for_profile(&self.gpu_presets[self.gpu_idx].profile); + self.work_groups = wg; + self.unit_size = us; + } + + fn t(&self) -> Strings { + strings(self.lang) + } + + fn cpu_label(&self, idx: usize) -> &str { + if idx + 1 == self.cpu_presets.len() { + self.t().cpu_only + } else { + self.cpu_presets[idx].label + } + } + + fn gpu_label(&self, idx: usize) -> &str { + if idx + 1 == self.gpu_presets.len() { + self.t().no_gpu + } else { + self.gpu_presets[idx].label + } + } + + fn mode_label(&self, idx: usize) -> &str { + let t = self.t(); + match idx { + 0 => t.mode_eco, + 2 => t.mode_max, + _ => t.mode_profit, + } + } + + fn convert_money(&mut self, from: Currency, to: Currency) { + if from == to { + return; + } + self.power_cost = + Currency::convert(self.power_cost as f64, from, to) as f32; + let (min, max) = to.power_cost_range(); + self.power_cost = self.power_cost.clamp(min, max); + } + + fn set_currency(&mut self, currency: Currency) { + if self.currency == currency { + return; + } + let from = self.currency; + self.convert_money(from, currency); + self.currency = currency; + save_currency(&self.work_dir, currency); + } + + fn set_lang(&mut self, lang: Lang) { + if self.lang == lang { + return; + } + let new_currency = Currency::default_for_lang(lang); + self.convert_money(self.currency, new_currency); + self.lang = lang; + self.currency = new_currency; + save_lang(&self.work_dir, lang); + save_currency(&self.work_dir, new_currency); + let t = strings(lang); + if self.mining { + self.status_msg = t.mining_active.to_string(); + } else { + self.status_msg = t.ready_status.to_string(); + } + } + + fn panel_settings(&self) -> PanelSettings { + PanelSettings { + cpu: self.cpu_presets[self.cpu_idx].clone(), + gpu: self.gpu_presets[self.gpu_idx].clone(), + mode: match self.mode_idx { + 0 => EfficiencyMode::Eco, + 2 => EfficiencyMode::Max, + _ => EfficiencyMode::Profit, + }, + power_cost_kwh: self.power_cost as f64, + hac_price: self.hac_price as f64, + platform_id: self.platform_id, + device_id: self.device_id, + connect: self.connect.clone(), + stats_file: self.stats_path.to_string_lossy().to_string(), + opencl_dir: opencl_dir_for(&self.work_dir), + max_temp_c: self.max_temp_c, + pause_if_unprofitable: self.pause_unprofitable, + benchmark_seconds: 0, + idle_start_hour: 255, + idle_end_hour: 255, + benchmark_fine_sweep: true, + thermal_gpu_index: self.device_id, + work_groups: self.work_groups, + unit_size: self.unit_size, + } + } + + fn set_connect_mode(&mut self, mode: ConnectMode) { + self.connect_mode = mode; + if mode == ConnectMode::Solo { + self.connect = SOLO_DEFAULT.to_string(); + } + } + + fn apply_pool_preset(&mut self, idx: usize) { + self.pool_preset_idx = idx; + let pools = pool_presets(); + if let Some(p) = pools.get(idx) { + if !p.host.is_empty() { + self.connect = p.host.to_string(); + } + } + } + + fn run_benchmark(&mut self) { + let t = self.t(); + if self.mining || self.benchmarking { + return; + } + if self.gpu_presets[self.gpu_idx].slug == "none" { + self.status_msg = t.no_gpu.to_string(); + return; + } + if !self.poworker_path.exists() { + self.status_msg = format!("{}\n{}", t.poworker_not_found, self.poworker_path.display()); + return; + } + let s = self.panel_settings(); + if write_poworker_benchmark_config(&self.config_path, &s, 90).is_err() { + self.status_msg = t.save_error_prefix.to_string(); + return; + } + let mut cmd = Command::new(&self.poworker_path); + cmd.current_dir(&self.work_dir); + match Self::spawn_worker_with_logs(&mut cmd) { + Ok((child, _rx)) => { + self.benchmark_child = Some(child); + self.benchmarking = true; + self.status_msg = t.benchmark_running.to_string(); + } + Err(e) => self.status_msg = format!("{} {e}", t.start_failed_prefix), + } + } + + fn poll_benchmark(&mut self) { + if !self.benchmarking { + return; + } + let t = self.t(); + if let Some(child) = &mut self.benchmark_child { + if let Ok(Some(_)) = child.try_wait() { + self.benchmark_child = None; + self.benchmarking = false; + apply_loaded_ini( + &load_panel_ini(&self.config_path), + &self.cpu_presets, + &self.gpu_presets, + &mut self.cpu_idx, + &mut self.gpu_idx, + &mut self.mode_idx, + &mut self.work_groups, + &mut self.unit_size, + &mut self.platform_id, + &mut self.device_id, + &mut self.connect, + &mut self.power_cost, + &mut self.hac_price, + &mut self.max_temp_c, + &mut self.pause_unprofitable, + ); + self.status_msg = t.benchmark_done.to_string(); + } + } + } + + fn diamond_settings(&self) -> DiamondMinerSettings { + DiamondMinerSettings { + reward: self.wallet.clone(), + bid_password: self.bid_password.clone(), + bid_min: self.bid_min.clone(), + bid_max: self.bid_max.clone(), + bid_step: self.bid_step.clone(), + } + } + + fn save_config(&mut self) -> bool { + let t = self.t(); + if validate_wallet(&self.wallet).is_err() { + self.status_msg = t.wallet_required.to_string(); + return false; + } + if self.mining_kind == MiningKind::Hacd && self.bid_password.trim().is_empty() { + self.status_msg = t.bid_password_required.to_string(); + return false; + } + let s = self.panel_settings(); + save_mining_kind(&self.work_dir, self.mining_kind); + match self.mining_kind { + MiningKind::Hac => { + if let Err(e) = write_poworker_config(&self.config_path, &s) { + self.status_msg = format!("{} {e}", t.save_error_prefix); + return false; + } + if let Err(e) = write_hac_miner_only(&self.hacash_config_path, &self.wallet) { + self.status_msg = format!("{} {e}", t.save_error_prefix); + return false; + } + self.status_msg = format!( + "{} {} + {}", + t.saved_prefix, + self.config_path.display(), + self.hacash_config_path.display() + ); + } + MiningKind::Hacd => { + if let Err(e) = write_diaworker_config(&self.dia_config_path, &s) { + self.status_msg = format!("{} {e}", t.save_error_prefix); + return false; + } + if let Err(e) = + write_diamond_miner(&self.hacash_config_path, &self.wallet, &self.diamond_settings()) + { + self.status_msg = format!("{} {e}", t.save_error_prefix); + return false; + } + self.status_msg = format!( + "{} {} + {}", + t.saved_prefix, + self.dia_config_path.display(), + self.hacash_config_path.display() + ); + } + } + true + } + + fn spawn_worker_with_logs(cmd: &mut Command) -> Result<(Child, Receiver), String> { + let mut child = cmd + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .spawn() + .map_err(|e| e.to_string())?; + let (tx, rx) = mpsc::channel(); + if let Some(out) = child.stdout.take() { + spawn_log_drainer(out, tx.clone()); + } + if let Some(err) = child.stderr.take() { + spawn_log_drainer(err, tx); + } + Ok((child, rx)) + } + + fn start_mining(&mut self) { + let t = self.t(); + if self.mining { + return; + } + if let Err(e) = validate_wallet(&self.wallet) { + self.status_msg = if e == "empty" { + t.wallet_required.to_string() + } else { + format!("{} {e}", t.wallet_invalid_prefix) + }; + return; + } + if self.mining_kind == MiningKind::Hacd && self.bid_password.trim().is_empty() { + self.status_msg = t.bid_password_required.to_string(); + return; + } + let worker_path = match self.mining_kind { + MiningKind::Hac => self.poworker_path.clone(), + MiningKind::Hacd => self.diaworker_path.clone(), + }; + let not_found = match self.mining_kind { + MiningKind::Hac => t.poworker_not_found, + MiningKind::Hacd => t.diaworker_not_found, + }; + if !worker_path.exists() { + self.status_msg = format!("{}\n{}", not_found, worker_path.display()); + return; + } + if !self.save_config() { + return; + } + if self.connect_mode == ConnectMode::Solo && !rpc_reachable(&self.connect) { + let hacash = find_hacash_exe(&self.work_dir); + if !hacash.exists() { + self.status_msg = + format!("{}\n{}", t.fullnode_exe_not_found, hacash.display()); + return; + } + self.status_msg = t.fullnode_starting.to_string(); + let _ = Command::new(&hacash) + .current_dir(&self.work_dir) + .stdout(Stdio::null()) + .stderr(Stdio::null()) + .spawn(); + self.pending_start = Some(PendingStart { + worker_path, + deadline: Instant::now() + Duration::from_secs(45), + }); + return; + } + self.launch_worker(worker_path); + } + + fn launch_worker(&mut self, worker_path: PathBuf) { + let t = self.t(); + let mut cmd = Command::new(&worker_path); + cmd.current_dir(&self.work_dir); + match Self::spawn_worker_with_logs(&mut cmd) { + Ok((child, rx)) => { + self.log_rx = Some(rx); + self.child = Some(child); + self.mining = true; + self.pending_start = None; + self.status_msg = t.mining_active.to_string(); + } + Err(e) => self.status_msg = format!("{} {e}", t.start_failed_prefix), + } + } + + fn poll_pending_start(&mut self) { + let Some(pending) = self.pending_start.clone() else { + return; + }; + let t = self.t(); + if rpc_reachable(&self.connect) { + self.launch_worker(pending.worker_path); + return; + } + if Instant::now() >= pending.deadline { + self.pending_start = None; + self.status_msg = format!("{} {}", t.fullnode_not_ready, self.connect); + } + } + + fn stop_mining(&mut self) { + if let Some(mut child) = self.child.take() { + let _ = child.kill(); + let _ = child.wait(); + } + self.mining = false; + self.log_rx = None; + self.status_msg = self.t().mining_stopped.to_string(); + } + + fn poll_stats(&mut self) { + self.poll_pending_start(); + self.poll_benchmark(); + let t = self.t(); + if let Ok(data) = std::fs::read_to_string(&self.stats_path) { + if let Ok(s) = serde_json::from_str::(&data) { + self.stats = s; + } + } + if !self.mining { + return; + } + if let Some(rx) = &self.log_rx { + while let Ok(line) = rx.try_recv() { + if line.contains("MINING SUCCESS") { + self.status_msg = t.block_found.to_string(); + } else if line.contains("cannot get block data") { + self.status_msg = t.fullnode_not_ready.to_string(); + } else if line.contains("OpenCL error") + || line.contains("GPU batch failed") + || line.contains("CL_OUT_OF") + { + self.status_msg = format!("{} {line}", t.worker_error_prefix); + } + } + } + if let Some(child) = &mut self.child { + if let Ok(Some(_)) = child.try_wait() { + self.mining = false; + self.child = None; + self.status_msg = t.miner_exited.to_string(); + } + } + } +} + +impl eframe::App for MinerApp { + fn update(&mut self, ctx: &egui::Context, _frame: &mut eframe::Frame) { + self.poll_stats(); + ctx.request_repaint_after(Duration::from_millis(500)); + + let t = self.t(); + ctx.send_viewport_cmd(egui::ViewportCommand::Title(t.window_title.to_string())); + + egui::TopBottomPanel::top("header") + .frame(theme::header_frame()) + .show(ctx, |ui| { + ui.horizontal(|ui| { + if let Some(tex) = self.logo_texture.as_ref() { + theme::show_logo(ui, tex, 44.0); + } else { + theme::logo_fallback(ui); + } + ui.add_space(12.0); + ui.vertical(|ui| { + ui.label( + egui::RichText::new("HAC Miner Panel") + .size(20.0) + .strong() + .color(theme::colors::TEXT), + ); + ui.label( + egui::RichText::new(t.header_subtitle) + .size(12.5) + .color(theme::colors::GOLD_DIM), + ); + }); + ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { + egui::ComboBox::from_id_salt("lang") + .selected_text(self.lang.name()) + .width(140.0) + .show_ui(ui, |ui| { + for lang in Lang::ALL { + if ui + .selectable_label(self.lang == lang, lang.name()) + .clicked() + { + self.set_lang(lang); + } + } + }); + ui.add_space(6.0); + ui.label( + egui::RichText::new(t.label_language) + .color(theme::colors::TEXT_MUTED) + .size(13.0), + ); + ui.add_space(14.0); + egui::ComboBox::from_id_salt("currency") + .selected_text(self.currency.name()) + .width(120.0) + .show_ui(ui, |ui| { + for c in Currency::ALL { + if ui + .selectable_label(self.currency == c, c.name()) + .clicked() + { + self.set_currency(c); + } + } + }); + ui.add_space(6.0); + ui.label( + egui::RichText::new(t.label_currency) + .color(theme::colors::TEXT_MUTED) + .size(13.0), + ); + }); + }); + }); + + egui::TopBottomPanel::bottom("footer") + .frame(theme::footer_frame()) + .show(ctx, |ui| { + ui.horizontal(|ui| { + let badge = self.miner_badge_state(); + let badge_label = self.miner_status_label(); + theme::footer_status_chip(ui, badge, badge_label); + ui.add_space(10.0); + ui.label( + egui::RichText::new(&self.status_msg) + .color(theme::colors::TEXT_MUTED) + .size(13.0), + ); + }); + }); + + egui::CentralPanel::default() + .frame(theme::content_frame()) + .show(ctx, |ui| { + theme::tab_bar(ui, |ui| { + if theme::tab_pill(ui, self.tab == 0, theme::TabIcon::Settings, t.tab_settings) { + self.tab = 0; + } + if theme::tab_pill(ui, self.tab == 1, theme::TabIcon::Dashboard, t.tab_dashboard) { + self.tab = 1; + } + if theme::tab_pill(ui, self.tab == 2, theme::TabIcon::Help, t.tab_help) { + self.tab = 2; + } + }); + ui.add_space(16.0); + + match self.tab { + 0 | 2 => { + egui::ScrollArea::vertical() + .auto_shrink([false, false]) + .show(ui, |ui| { + if self.tab == 0 { + self.ui_settings(ui); + } else { + self.ui_help(ui); + } + }); + } + _ => self.ui_dashboard(ui), + } + }); + } + + fn on_exit(&mut self, _gl: Option<&eframe::glow::Context>) { + self.stop_mining(); + } +} + +impl MinerApp { + fn ui_settings(&mut self, ui: &mut egui::Ui) { + let t = self.t(); + ui.label( + egui::RichText::new(t.settings_intro) + .color(theme::colors::TEXT_MUTED) + .size(14.0), + ); + ui.add_space(14.0); + + theme::section_card().show(ui, |ui| { + ui.label( + egui::RichText::new(t.label_mining_type) + .strong() + .color(theme::colors::TEXT), + ); + ui.add_space(8.0); + ui.horizontal(|ui| { + if ui + .selectable_label(self.mining_kind == MiningKind::Hac, t.mining_hac) + .clicked() + { + self.mining_kind = MiningKind::Hac; + save_mining_kind(&self.work_dir, MiningKind::Hac); + } + ui.add_space(12.0); + if ui + .selectable_label(self.mining_kind == MiningKind::Hacd, t.mining_hacd) + .clicked() + { + self.mining_kind = MiningKind::Hacd; + save_mining_kind(&self.work_dir, MiningKind::Hacd); + } + }); + }); + + ui.add_space(12.0); + + let cpu_idx = self.cpu_idx; + let gpu_idx = self.gpu_idx; + let mode_idx = self.mode_idx; + + theme::section_card().show(ui, |ui| { + egui::Grid::new("hw_grid") + .num_columns(2) + .spacing([20.0, 12.0]) + .show(ui, |ui| { + theme::field_label(ui, t.label_cpu); + egui::ComboBox::from_id_salt("cpu") + .selected_text(self.cpu_label(cpu_idx)) + .width(400.0) + .show_ui(ui, |ui| { + for (i, _) in self.cpu_presets.iter().enumerate() { + let label = if i + 1 == self.cpu_presets.len() { + t.cpu_only + } else { + self.cpu_presets[i].label + }; + ui.selectable_value(&mut self.cpu_idx, i, label); + } + }); + ui.end_row(); + + theme::field_label(ui, t.label_gpu); + let gpu_before = gpu_idx; + egui::ComboBox::from_id_salt("gpu") + .selected_text(self.gpu_label(gpu_idx)) + .width(400.0) + .show_ui(ui, |ui| { + for (i, _) in self.gpu_presets.iter().enumerate() { + let label = if i + 1 == self.gpu_presets.len() { + t.no_gpu + } else { + self.gpu_presets[i].label + }; + ui.selectable_value(&mut self.gpu_idx, i, label); + } + }); + if self.gpu_idx != gpu_before { + self.apply_gpu_preset_tuning(); + } + ui.end_row(); + + theme::field_label(ui, t.label_mode); + egui::ComboBox::from_id_salt("mode") + .selected_text(self.mode_label(mode_idx)) + .width(400.0) + .show_ui(ui, |ui| { + ui.selectable_value(&mut self.mode_idx, 0, t.mode_eco); + ui.selectable_value(&mut self.mode_idx, 1, t.mode_profit); + ui.selectable_value(&mut self.mode_idx, 2, t.mode_max); + }); + ui.end_row(); + }); + }); + + ui.add_space(12.0); + + theme::section_card().show(ui, |ui| { + egui::Grid::new("eco_grid") + .num_columns(2) + .spacing([20.0, 12.0]) + .show(ui, |ui| { + theme::field_label(ui, t.label_power_cost); + theme::power_cost_slider(ui, &mut self.power_cost, self.currency); + ui.end_row(); + + theme::field_label(ui, t.label_hac_price); + ui.add( + egui::DragValue::new(&mut self.hac_price) + .speed(0.01) + .range(0.0..=1_000_000.0) + .suffix(" $"), + ); + ui.end_row(); + + theme::field_label(ui, t.label_max_temp); + ui.add( + egui::DragValue::new(&mut self.max_temp_c) + .range(0..=100) + .suffix(" °C"), + ); + ui.end_row(); + + ui.label(""); + ui.checkbox(&mut self.pause_unprofitable, t.label_pause_unprofitable); + ui.end_row(); + }); + }); + + ui.add_space(10.0); + ui.horizontal(|ui| { + if theme::btn_secondary(ui, t.btn_benchmark).clicked() { + self.run_benchmark(); + } + if self.benchmarking { + ui.spinner(); + ui.label( + egui::RichText::new(t.benchmark_running) + .color(theme::colors::GOLD) + .size(12.5), + ); + } + }); + + if self.mining_kind == MiningKind::Hacd { + ui.add_space(12.0); + theme::section_card().show(ui, |ui| { + ui.label( + egui::RichText::new(t.bid_hint) + .size(12.0) + .color(theme::colors::TEXT_MUTED), + ); + ui.add_space(10.0); + egui::Grid::new("bid_grid") + .num_columns(2) + .spacing([20.0, 12.0]) + .show(ui, |ui| { + theme::field_label(ui, t.label_bid_password); + ui.add( + egui::TextEdit::singleline(&mut self.bid_password) + .password(true) + .desired_width(400.0), + ); + ui.end_row(); + + theme::field_label(ui, t.label_bid_min); + ui.add( + egui::TextEdit::singleline(&mut self.bid_min) + .desired_width(160.0) + .hint_text("1:0"), + ); + ui.end_row(); + + theme::field_label(ui, t.label_bid_max); + ui.add( + egui::TextEdit::singleline(&mut self.bid_max) + .desired_width(160.0) + .hint_text("31:0"), + ); + ui.end_row(); + + theme::field_label(ui, t.label_bid_step); + ui.add( + egui::TextEdit::singleline(&mut self.bid_step) + .desired_width(160.0) + .hint_text("0:5"), + ); + ui.end_row(); + }); + }); + } + + ui.add_space(12.0); + + theme::section_card().show(ui, |ui| { + egui::Grid::new("net_grid") + .num_columns(2) + .spacing([20.0, 12.0]) + .show(ui, |ui| { + theme::field_label(ui, t.label_connect_mode); + ui.horizontal(|ui| { + let solo = self.connect_mode == ConnectMode::Solo; + if ui.selectable_label(solo, t.connect_solo).clicked() { + self.set_connect_mode(ConnectMode::Solo); + } + if ui + .selectable_label(!solo, t.connect_pool) + .clicked() + { + self.set_connect_mode(ConnectMode::Pool); + } + }); + ui.end_row(); + + theme::field_label( + ui, + if self.connect_mode == ConnectMode::Solo { + t.label_fullnode + } else { + t.connect_pool + }, + ); + ui.vertical(|ui| { + if self.connect_mode == ConnectMode::Pool { + let pools = pool_presets(); + let preset_label = pools + .get(self.pool_preset_idx) + .map(|p| p.label) + .unwrap_or("Pool"); + egui::ComboBox::from_id_salt("pool_preset") + .selected_text(preset_label) + .show_ui(ui, |ui| { + for (i, p) in pools.iter().enumerate() { + if ui + .selectable_value( + &mut self.pool_preset_idx, + i, + p.label, + ) + .clicked() + { + self.apply_pool_preset(i); + } + } + }); + } + ui.add( + egui::TextEdit::singleline(&mut self.connect) + .desired_width(400.0) + .margin(egui::Margin::symmetric(8.0, 6.0)), + ); + if self.connect_mode == ConnectMode::Pool { + ui.label( + egui::RichText::new(t.connect_pool_hint) + .size(11.5) + .color(theme::colors::TEXT_MUTED), + ); + } + }); + ui.end_row(); + + theme::field_label(ui, t.label_wallet); + ui.vertical(|ui| { + ui.add( + egui::TextEdit::singleline(&mut self.wallet) + .desired_width(400.0) + .hint_text("1LCY6uQS3iNGy2mKSmhFVU2dHgBQLf74Fx") + .margin(egui::Margin::symmetric(8.0, 6.0)), + ); + ui.label( + egui::RichText::new(if self.mining_kind == MiningKind::Hacd { + t.hacd_wallet_hint + } else { + t.wallet_hint + }) + .size(11.5) + .color(theme::colors::TEXT_MUTED), + ); + }); + ui.end_row(); + + theme::field_label(ui, t.label_opencl); + ui.horizontal(|ui| { + ui.add(egui::DragValue::new(&mut self.platform_id).range(0..=8)); + ui.label( + egui::RichText::new(t.platform) + .color(theme::colors::TEXT_MUTED) + .size(12.0), + ); + ui.add_space(8.0); + ui.add(egui::DragValue::new(&mut self.device_id).range(0..=8)); + ui.label( + egui::RichText::new(t.device_hint) + .color(theme::colors::TEXT_MUTED) + .size(12.0), + ); + }); + ui.end_row(); + }); + }); + + ui.add_space(18.0); + ui.horizontal(|ui| { + if theme::btn_secondary(ui, t.btn_save).clicked() { + self.save_config(); + } + ui.add_space(8.0); + if theme::btn_primary(ui, t.btn_start_mining).clicked() { + self.start_mining(); + self.tab = 1; + } + if self.mining { + ui.add_space(8.0); + if theme::btn_danger(ui, t.btn_stop).clicked() { + self.stop_mining(); + } + } + }); + ui.add_space(8.0); + } + + fn truncate_wallet(wallet: &str) -> String { + let w = wallet.trim(); + if w.is_empty() { + return String::new(); + } + if w.len() <= 20 { + return w.to_string(); + } + format!("{}…{}", &w[..8], &w[w.len() - 6..]) + } + + fn format_stats_age(ms: u64) -> String { + if ms == 0 { + return "—".to_string(); + } + let now_ms = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap_or_default() + .as_millis() as u64; + let sec = now_ms.saturating_sub(ms) / 1000; + if sec < 60 { + format!("{sec}s") + } else if sec < 3600 { + format!("{}m", sec / 60) + } else { + format!("{}h", sec / 3600) + } + } + + fn ui_dashboard(&mut self, ui: &mut egui::Ui) { + let t = self.t(); + let s = &self.stats; + let cpu_threads = if s.active_cpu_threads > 0 { + format!( + "{} / {}", + s.active_cpu_threads, + self.cpu_presets[self.cpu_idx].supervene + ) + } else { + format!("{}", self.cpu_presets[self.cpu_idx].supervene) + }; + let revenue_day = if s.daily_revenue_eur > 0.0 { + self.currency.format_amount(s.daily_revenue_eur) + } else { + t.dash_no_data.to_string() + }; + let mining_type = if self.mining_kind == MiningKind::Hacd { + t.mining_hacd + } else { + t.mining_hac + }; + let connect_mode = match self.connect_mode { + ConnectMode::Solo => t.connect_solo, + ConnectMode::Pool => t.connect_pool, + }; + let connect_display = format!("{} · {}", self.connect, connect_mode); + let wallet_display = if self.wallet.trim().is_empty() { + t.dash_no_data.to_string() + } else { + Self::truncate_wallet(&self.wallet) + }; + let opencl_display = if self.gpu_presets[self.gpu_idx].slug == "none" { + t.dash_no_data.to_string() + } else { + format!("P{} / D{}", self.platform_id, self.device_id) + }; + let tuning_display = if self.gpu_presets[self.gpu_idx].slug == "none" { + t.dash_no_data.to_string() + } else { + format!("WG {} × US {}", self.work_groups, self.unit_size) + }; + let stats_status = if !s.status.is_empty() { + s.status.clone() + } else if self.mining { + "mining".to_string() + } else { + "stopped".to_string() + }; + let last_update = Self::format_stats_age(s.updated_unix_ms); + let power_cost_display = format!( + "{}/kWh", + self.currency.format_amount(self.power_cost as f64) + ); + + let badge = self.miner_badge_state(); + let badge_label = self.miner_status_label(); + theme::section_card().show(ui, |ui| { + theme::status_badge(ui, badge, badge_label); + }); + + ui.add_space(14.0); + + theme::section_card().show(ui, |ui| { + egui::Grid::new("dash_row1") + .num_columns(4) + .spacing([12.0, 12.0]) + .min_col_width(200.0) + .show(ui, |ui| { + theme::show_stat_card( + ui, + theme::colors::ACCENT, + t.stat_hashrate, + &s.hashrate_display, + ); + theme::show_stat_card( + ui, + theme::colors::GREEN, + t.stat_hac_day, + &format!("{:.4}", s.hac_per_day), + ); + theme::show_stat_card( + ui, + theme::colors::BLUE, + t.stat_power, + &format!("{:.0} W", s.watts), + ); + theme::show_stat_card( + ui, + theme::colors::RED, + t.stat_cost_day, + &self.currency.format_amount(s.daily_cost_eur), + ); + ui.end_row(); + }); + }); + + ui.add_space(12.0); + + theme::section_card().show(ui, |ui| { + egui::Grid::new("dash_row2") + .num_columns(4) + .spacing([12.0, 12.0]) + .min_col_width(200.0) + .show(ui, |ui| { + theme::show_stat_card( + ui, + theme::colors::BLUE, + t.stat_efficiency, + &format!("{:.1} kH/J", s.kh_per_j), + ); + theme::show_stat_card( + ui, + theme::colors::ACCENT, + t.stat_network, + &format!("{:.4}%", s.network_pct), + ); + theme::show_stat_card( + ui, + theme::colors::GREEN, + t.stat_block_height, + &format!("{}", s.height), + ); + if s.daily_revenue_eur > 0.0 { + theme::show_stat_card( + ui, + theme::colors::GREEN, + t.stat_net_day, + &self.currency.format_amount(s.daily_net_eur), + ); + } else if s.mining_kind == "hacd" && !s.diamond_best.is_empty() { + theme::show_stat_card( + ui, + theme::colors::GOLD, + t.stat_diamond_best, + &s.diamond_best, + ); + } else { + theme::show_stat_card( + ui, + theme::colors::TEXT_MUTED, + t.stat_gpu_profile, + &s.gpu_profile, + ); + } + ui.end_row(); + }); + }); + + ui.add_space(12.0); + + theme::section_card().show(ui, |ui| { + egui::Grid::new("dash_row3") + .num_columns(4) + .spacing([12.0, 12.0]) + .min_col_width(200.0) + .show(ui, |ui| { + theme::show_stat_card( + ui, + theme::colors::GREEN, + t.stat_revenue_day, + &revenue_day, + ); + theme::show_stat_card( + ui, + theme::colors::ACCENT, + t.stat_cpu_threads, + &cpu_threads, + ); + theme::show_stat_card( + ui, + theme::colors::BLUE, + t.stat_mining_type_short, + mining_type, + ); + theme::show_stat_card( + ui, + theme::colors::GOLD, + t.stat_mode_short, + self.mode_label(self.mode_idx), + ); + ui.end_row(); + }); + }); + + ui.add_space(12.0); + + theme::section_card().show(ui, |ui| { + ui.label( + egui::RichText::new(t.dash_details_title) + .size(14.0) + .strong() + .color(theme::colors::TEXT), + ); + ui.add_space(10.0); + egui::Grid::new("dash_details") + .num_columns(2) + .spacing([24.0, 6.0]) + .min_col_width(320.0) + .show(ui, |ui| { + theme::show_detail_row(ui, t.dash_detail_cpu, self.cpu_label(self.cpu_idx)); + theme::show_detail_row(ui, t.dash_detail_gpu, self.gpu_label(self.gpu_idx)); + ui.end_row(); + theme::show_detail_row(ui, t.dash_detail_connect, &connect_display); + theme::show_detail_row(ui, t.dash_detail_wallet, &wallet_display); + ui.end_row(); + theme::show_detail_row(ui, t.dash_detail_opencl, &opencl_display); + theme::show_detail_row(ui, t.dash_detail_tuning, &tuning_display); + ui.end_row(); + theme::show_detail_row(ui, t.dash_detail_power_cost, &power_cost_display); + theme::show_detail_row( + ui, + t.dash_detail_max_temp, + &format!("{}°C", self.max_temp_c), + ); + ui.end_row(); + theme::show_detail_row(ui, t.dash_detail_last_update, &last_update); + theme::show_detail_row(ui, t.dash_detail_stats_status, &stats_status); + ui.end_row(); + let diamond_label = (self.mining_kind == MiningKind::Hacd && s.diamond_number > 0) + .then(|| (t.dash_detail_diamond, format!("#{}", s.diamond_number))); + let profile_label = (!s.gpu_profile.is_empty()) + .then(|| (t.stat_gpu_profile, s.gpu_profile.clone())); + match (profile_label, diamond_label) { + (Some((pl, pv)), Some((dl, dv))) => { + theme::show_detail_row(ui, pl, &pv); + theme::show_detail_row(ui, dl, &dv); + ui.end_row(); + } + (Some((pl, pv)), None) => { + theme::show_detail_row(ui, pl, &pv); + ui.end_row(); + } + (None, Some((dl, dv))) => { + theme::show_detail_row(ui, dl, &dv); + ui.end_row(); + } + (None, None) => {} + } + }); + }); + + ui.add_space(20.0); + ui.horizontal(|ui| { + if !self.mining && theme::btn_primary(ui, t.btn_start).clicked() { + self.start_mining(); + } + if self.mining && theme::btn_danger(ui, t.btn_stop_icon).clicked() { + self.stop_mining(); + } + }); + } + + fn ui_help(&mut self, ui: &mut egui::Ui) { + let t = self.t(); + ui.label( + egui::RichText::new(t.help_title) + .size(16.0) + .strong() + .color(theme::colors::TEXT), + ); + ui.add_space(12.0); + + ui.label( + egui::RichText::new(t.help_hac_title) + .size(14.5) + .strong() + .color(theme::colors::ACCENT), + ); + ui.add_space(8.0); + theme::help_step(ui, 1, t.help_step1); + theme::help_step(ui, 2, t.help_step2); + theme::help_step(ui, 3, t.help_step3); + + ui.add_space(14.0); + ui.label( + egui::RichText::new(t.help_hacd_title) + .size(14.5) + .strong() + .color(theme::colors::ACCENT), + ); + ui.add_space(8.0); + theme::help_step(ui, 1, t.help_hacd_step1); + theme::help_step(ui, 2, t.help_hacd_step2); + theme::help_step(ui, 3, t.help_hacd_step3); + theme::help_step(ui, 4, t.help_hacd_step4); + theme::help_step(ui, 5, t.help_hacd_step5); + + ui.add_space(10.0); + theme::section_card().show(ui, |ui| { + ui.label( + egui::RichText::new(t.wallet_restart_hint) + .color(theme::colors::GOLD) + .size(13.0), + ); + }); + + ui.add_space(10.0); + theme::section_card().show(ui, |ui| { + ui.label( + egui::RichText::new(t.help_hardware_note) + .color(theme::colors::TEXT) + .size(13.0), + ); + }); + + ui.add_space(12.0); + theme::section_card().show(ui, |ui| { + ui.label( + egui::RichText::new(format!( + "{} {}", + t.help_work_dir_prefix, + self.work_dir.display() + )) + .color(theme::colors::TEXT_MUTED) + .size(12.5), + ); + ui.add_space(4.0); + ui.label( + egui::RichText::new(format!( + "{} {}", + t.help_miner_prefix, + if self.mining_kind == MiningKind::Hacd { + self.diaworker_path.display().to_string() + } else { + self.poworker_path.display().to_string() + } + )) + .color(theme::colors::TEXT_MUTED) + .size(12.5), + ); + ui.add_space(8.0); + ui.label( + egui::RichText::new(t.help_opencl_tip) + .color(theme::colors::TEXT_MUTED) + .size(12.5), + ); + }); + + ui.add_space(12.0); + ui.label( + egui::RichText::new(t.help_options_title) + .size(14.5) + .strong() + .color(theme::colors::ACCENT), + ); + ui.add_space(6.0); + theme::section_card().show(ui, |ui| { + egui::ScrollArea::vertical() + .max_height(320.0) + .auto_shrink([false, false]) + .show(ui, |ui| { + for section in help_options::option_reference(self.lang) { + ui.label( + egui::RichText::new(section.title) + .strong() + .color(theme::colors::TEXT) + .size(13.0), + ); + ui.add_space(4.0); + for line in section.lines { + ui.horizontal(|ui| { + ui.label( + egui::RichText::new("•") + .color(theme::colors::ACCENT) + .size(12.0), + ); + ui.label( + egui::RichText::new(*line) + .color(theme::colors::TEXT_MUTED) + .size(12.0), + ); + }); + } + ui.add_space(10.0); + } + }); + }); + ui.add_space(8.0); + } +} + +fn exe_dir() -> PathBuf { + std::env::current_exe() + .ok() + .and_then(|p| p.parent().map(|d| d.to_path_buf())) + .unwrap_or_else(|| std::env::current_dir().unwrap_or_default()) +} + +fn spawn_log_drainer(stream: R, tx: Sender) { + thread::spawn(move || { + let reader = BufReader::new(stream); + for line in reader.lines().map_while(Result::ok) { + let _ = tx.send(line); + } + }); +} + +fn rpc_reachable(connect: &str) -> bool { + let Some(addr) = parse_rpc_addr(connect) else { + return false; + }; + TcpStream::connect_timeout(&addr, Duration::from_millis(800)).is_ok() +} + +fn parse_rpc_addr(connect: &str) -> Option { + let trimmed = connect.trim(); + if trimmed.is_empty() { + return None; + } + if let Ok(addr) = trimmed.parse::() { + return Some(addr); + } + let (host, port) = trimmed.rsplit_once(':')?; + let port: u16 = port.parse().ok()?; + format!("{host}:{port}").parse().ok() +} + +fn find_hacash_exe(work_dir: &Path) -> PathBuf { + for name in ["hacash.exe", "fullnode.exe"] { + let p = work_dir.join(name); + if p.is_file() { + return p; + } + } + work_dir.join("hacash.exe") +} + +fn find_worker_exe(work_dir: &PathBuf, name: &str) -> PathBuf { + let candidates = [ + work_dir.join(name), + work_dir.join("..").join(name), + work_dir.join("..") + .join("..") + .join("target") + .join("release") + .join(name), + work_dir.join("..") + .join("..") + .join("target") + .join("debug") + .join(name), + ]; + for c in candidates { + if c.exists() { + return c.canonicalize().unwrap_or(c); + } + } + work_dir.join(name) +} + +fn opencl_dir_for(work_dir: &PathBuf) -> String { + let candidates = [ + work_dir.join("x16rs").join("opencl"), + work_dir.join("..").join("..").join("x16rs").join("opencl"), + ]; + for c in candidates { + if c.is_dir() { + return format!("{}/", c.to_string_lossy().replace('\\', "/")); + } + } + "../../x16rs/opencl/".to_string() +} \ No newline at end of file diff --git a/miner-panel/src/mining_kind.rs b/miner-panel/src/mining_kind.rs new file mode 100644 index 00000000..7a87ccef --- /dev/null +++ b/miner-panel/src/mining_kind.rs @@ -0,0 +1,34 @@ +#[derive(Clone, Copy, PartialEq, Eq)] +pub enum MiningKind { + Hac, + Hacd, +} + +impl MiningKind { + pub fn from_code(s: &str) -> Self { + match s.trim().to_lowercase().as_str() { + "hacd" | "diamond" | "dia" => MiningKind::Hacd, + _ => MiningKind::Hac, + } + } + + pub fn code(self) -> &'static str { + match self { + MiningKind::Hac => "hac", + MiningKind::Hacd => "hacd", + } + } +} + +pub fn load_mining_kind(work_dir: &std::path::Path) -> MiningKind { + let path = work_dir.join("miner-panel.mode"); + std::fs::read_to_string(path) + .ok() + .map(|s| MiningKind::from_code(&s)) + .unwrap_or(MiningKind::Hac) +} + +pub fn save_mining_kind(work_dir: &std::path::Path, kind: MiningKind) { + let path = work_dir.join("miner-panel.mode"); + let _ = std::fs::write(path, kind.code()); +} \ No newline at end of file diff --git a/miner-panel/src/presets.rs b/miner-panel/src/presets.rs new file mode 100644 index 00000000..435803a5 --- /dev/null +++ b/miner-panel/src/presets.rs @@ -0,0 +1,87 @@ +use app::efficiency::{EfficiencyConf, EfficiencyMode, resolve_gpu_tuning}; + +#[derive(Clone)] +pub struct CpuPreset { + pub label: &'static str, + pub supervene: u32, +} + +#[derive(Clone)] +pub struct GpuPreset { + pub label: &'static str, + pub slug: &'static str, + pub profile: &'static str, + /// Typical board power (W) for kH/J and profit estimates. + pub watts: f64, +} + +pub fn cpu_presets() -> Vec { + vec![ + CpuPreset { label: "Ryzen 5 (5600X, 7600X)", supervene: 4 }, + CpuPreset { label: "Ryzen 7 (5800X, 7700X)", supervene: 6 }, + CpuPreset { label: "Ryzen 9 (5900X, 7900X)", supervene: 8 }, + CpuPreset { label: "Ryzen 9 9950X", supervene: 10 }, + CpuPreset { label: "Threadripper 7960X", supervene: 14 }, + CpuPreset { label: "Threadripper 7970X", supervene: 18 }, + CpuPreset { label: "Threadripper 7980X", supervene: 22 }, + CpuPreset { label: "Intel Core i5 (12400, 13400)", supervene: 4 }, + CpuPreset { label: "Intel Core i7 (12700, 13700)", supervene: 6 }, + CpuPreset { label: "Intel Core i9 (12900, 13900)", supervene: 8 }, + CpuPreset { label: "Intel Core i9 (14900, 24-core)", supervene: 10 }, + CpuPreset { label: "CPU only (no GPU)", supervene: 10 }, + ] +} + +pub fn gpu_presets() -> Vec { + vec![ + GpuPreset { label: "RX 6600 / 6600 XT (8GB)", slug: "rx6600", profile: "amd_balanced", watts: 130.0 }, + GpuPreset { label: "RX 7600 (8GB)", slug: "rx7600", profile: "amd_balanced", watts: 165.0 }, + GpuPreset { label: "RX 6700 XT (12GB)", slug: "rx6700xt", profile: "amd_performance", watts: 220.0 }, + GpuPreset { label: "RX 6800 / 6800 XT (16GB)", slug: "rx6800xt", profile: "amd_performance", watts: 260.0 }, + GpuPreset { label: "RX 7900 XT (20GB)", slug: "rx7900xt", profile: "amd_performance", watts: 300.0 }, + GpuPreset { label: "RX 7900 XTX (24GB)", slug: "rx7900xtx", profile: "amd_max", watts: 355.0 }, + GpuPreset { label: "RX 9070 XT (16GB)", slug: "rx9070xt", profile: "amd_performance", watts: 280.0 }, + GpuPreset { label: "GTX 1660 / RTX 3060 (8GB)", slug: "rtx3060", profile: "nvidia_balanced", watts: 170.0 }, + GpuPreset { label: "RTX 3060 Ti / 4060 (8GB)", slug: "rtx4060", profile: "nvidia_balanced", watts: 190.0 }, + GpuPreset { label: "RTX 3070 / 4060 Ti (8-12GB)", slug: "rtx3070", profile: "nvidia_profit", watts: 220.0 }, + GpuPreset { label: "RTX 3080 / 4070 (10-12GB)", slug: "rtx4070", profile: "nvidia_performance", watts: 250.0 }, + GpuPreset { label: "RTX 4080 / 4090 (16GB+)", slug: "rtx4090", profile: "nvidia_max", watts: 320.0 }, + GpuPreset { label: "RTX 5060 (8GB)", slug: "rtx5060", profile: "nvidia_balanced", watts: 150.0 }, + GpuPreset { label: "RTX 5070 / 5070 Ti (12GB)", slug: "rtx5070", profile: "nvidia_performance", watts: 250.0 }, + GpuPreset { label: "RTX 5080 (16GB)", slug: "rtx5080", profile: "nvidia_performance", watts: 320.0 }, + GpuPreset { label: "RTX 5090 (32GB)", slug: "rtx5090", profile: "nvidia_max", watts: 450.0 }, + GpuPreset { label: "No GPU", slug: "none", profile: "", watts: 0.0 }, + ] +} + +pub fn gpu_idx_for_profile(gpus: &[GpuPreset], profile: &str) -> Option { + gpus.iter().position(|g| g.profile == profile) +} + +pub fn tuning_for_profile(profile: &str) -> (u32, u32) { + let eff = EfficiencyConf { + mode: EfficiencyMode::Profit, + power_cost_kwh: 0.15, + gpu_watts: 0.0, + cpu_watts_per_thread: 8.0, + hac_price: 0.0, + dynamic_supervene: true, + supervene_min: 2, + supervene_max: 0, + oom_fallback: true, + max_temp_c: 0, + throttle_workgroups: 1024, + thermal_file: String::new(), + idle_start_hour: 255, + idle_end_hour: 255, + pause_if_unprofitable: false, + benchmark_seconds: 0, + benchmark_fine_sweep: true, + thermal_gpu_index: 0, + stats_file: String::new(), + }; + let mut sec = std::collections::HashMap::new(); + sec.insert("gpu_profile".to_string(), Some(profile.to_string())); + let t = resolve_gpu_tuning(&sec, &eff); + (t.workgroups, t.unitsize) +} \ No newline at end of file diff --git a/miner-panel/src/theme.rs b/miner-panel/src/theme.rs new file mode 100644 index 00000000..35e8c7ec --- /dev/null +++ b/miner-panel/src/theme.rs @@ -0,0 +1,510 @@ +use eframe::egui::{ + self, Color32, FontFamily, FontId, Frame, Margin, Rect, Rounding, Sense, Stroke, TextStyle, Ui, + Vec2, +}; + +pub mod colors { + use super::Color32; + + pub const BG_DEEP: Color32 = Color32::from_rgb(10, 14, 20); + pub const BG_PANEL: Color32 = Color32::from_rgb(16, 22, 31); + pub const BG_HEADER: Color32 = Color32::from_rgb(20, 28, 40); + pub const BG_CARD: Color32 = Color32::from_rgb(26, 34, 48); + pub const BG_INPUT: Color32 = Color32::from_rgb(14, 20, 28); + pub const BORDER: Color32 = Color32::from_rgb(44, 58, 76); + pub const BORDER_SOFT: Color32 = Color32::from_rgb(34, 46, 62); + pub const TEXT: Color32 = Color32::from_rgb(232, 238, 245); + pub const TEXT_MUTED: Color32 = Color32::from_rgb(130, 145, 165); + pub const GOLD: Color32 = Color32::from_rgb(175, 148, 90); + pub const GOLD_DIM: Color32 = Color32::from_rgb(130, 110, 68); + pub const ACCENT: Color32 = Color32::from_rgb(96, 165, 220); + pub const ACCENT_DIM: Color32 = Color32::from_rgb(55, 95, 140); + pub const SLIDER_TRACK: Color32 = Color32::from_rgb(12, 18, 28); + pub const GREEN: Color32 = Color32::from_rgb(58, 210, 145); + pub const GREEN_DIM: Color32 = Color32::from_rgb(32, 120, 82); + pub const RED: Color32 = Color32::from_rgb(248, 108, 108); + pub const RED_DIM: Color32 = Color32::from_rgb(140, 48, 48); + pub const BLUE: Color32 = Color32::from_rgb(88, 166, 255); + +} + +use colors::*; + +pub fn setup_theme(ctx: &egui::Context) { + let mut style = (*ctx.style()).clone(); + let v = &mut style.visuals; + + *v = egui::Visuals::dark(); + v.panel_fill = BG_PANEL; + v.window_fill = BG_DEEP; + v.extreme_bg_color = BG_INPUT; + v.faint_bg_color = Color32::from_rgba_premultiplied(255, 255, 255, 10); + v.hyperlink_color = BLUE; + v.warn_fg_color = GOLD; + v.selection.bg_fill = Color32::from_rgb(32, 68, 98); + v.selection.stroke = Stroke::new(1.0, BLUE); + + let round = Rounding::same(8.0); + v.widgets.noninteractive.rounding = round; + v.widgets.inactive.rounding = round; + v.widgets.hovered.rounding = round; + v.widgets.active.rounding = round; + v.widgets.open.rounding = round; + + v.widgets.inactive.bg_fill = SLIDER_TRACK; // visible slider rail on cards + v.widgets.noninteractive.bg_fill = BG_CARD; + v.widgets.hovered.bg_fill = Color32::from_rgb(34, 44, 60); + v.widgets.active.bg_fill = Color32::from_rgb(40, 54, 74); + v.widgets.inactive.bg_stroke = Stroke::new(1.0, BORDER_SOFT); + v.widgets.hovered.bg_stroke = Stroke::new(1.0, BORDER); + v.widgets.active.bg_stroke = Stroke::new(1.5, ACCENT_DIM); + v.widgets.inactive.fg_stroke = Stroke::new(1.0, TEXT); + v.widgets.hovered.fg_stroke = Stroke::new(1.5, ACCENT); + v.widgets.active.fg_stroke = Stroke::new(1.5, ACCENT); + + v.slider_trailing_fill = true; + v.selection.bg_fill = Color32::from_rgb(45, 110, 85); + + v.window_rounding = Rounding::same(10.0); + v.window_stroke = Stroke::new(1.0, BORDER); + + style.spacing.item_spacing = Vec2::new(12.0, 10.0); + style.spacing.button_padding = Vec2::new(16.0, 9.0); + style.spacing.slider_width = 280.0; + style.spacing.slider_rail_height = 10.0; + style.spacing.indent = 18.0; + style.spacing.window_margin = Margin::same(14.0); + + style.text_styles.insert( + TextStyle::Heading, + FontId::new(22.0, FontFamily::Proportional), + ); + style.text_styles.insert( + TextStyle::Body, + FontId::new(14.0, FontFamily::Proportional), + ); + style.text_styles.insert( + TextStyle::Button, + FontId::new(14.0, FontFamily::Proportional), + ); + style.text_styles.insert( + TextStyle::Small, + FontId::new(12.0, FontFamily::Proportional), + ); + + ctx.set_style(style); +} + +pub fn header_frame() -> Frame { + Frame::none() + .fill(BG_HEADER) + .stroke(Stroke::new(1.0, BORDER_SOFT)) + .inner_margin(Margin::symmetric(22.0, 14.0)) +} + +pub fn footer_frame() -> Frame { + Frame::none() + .fill(BG_HEADER) + .stroke(Stroke::new(1.0, BORDER_SOFT)) + .inner_margin(Margin::symmetric(22.0, 10.0)) +} + +pub fn content_frame() -> Frame { + Frame::none() + .fill(BG_DEEP) + .inner_margin(Margin::symmetric(22.0, 16.0)) +} + +pub fn section_card() -> Frame { + Frame::none() + .fill(BG_CARD) + .stroke(Stroke::new(1.0, BORDER_SOFT)) + .rounding(Rounding::same(12.0)) + .inner_margin(Margin::symmetric(18.0, 14.0)) +} + +pub fn show_detail_row(ui: &mut Ui, label: &str, value: &str) { + ui.horizontal(|ui| { + ui.label( + egui::RichText::new(label) + .size(12.5) + .color(TEXT_MUTED), + ); + ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { + ui.label( + egui::RichText::new(value) + .size(13.0) + .color(TEXT), + ); + }); + }); + ui.add_space(4.0); +} + +pub fn show_stat_card(ui: &mut Ui, accent: Color32, title: &str, value: &str) { + Frame::none() + .fill(BG_CARD) + .stroke(Stroke::new(1.0, BORDER_SOFT)) + .rounding(Rounding::same(12.0)) + .inner_margin(Margin::symmetric(12.0, 14.0)) + .show(ui, |ui| { + ui.set_min_width(160.0); + ui.set_min_height(72.0); + ui.horizontal(|ui| { + let (stripe, _) = + ui.allocate_exact_size(Vec2::new(4.0, 44.0), egui::Sense::hover()); + ui.painter() + .rect_filled(stripe, Rounding::same(2.0), accent); + ui.add_space(10.0); + ui.vertical(|ui| { + ui.label( + egui::RichText::new(title) + .size(12.0) + .color(TEXT_MUTED), + ); + ui.add_space(4.0); + ui.label( + egui::RichText::new(value) + .size(20.0) + .strong() + .color(TEXT), + ); + }); + }); + }); +} + +pub fn field_label(ui: &mut Ui, text: &str) { + ui.label(egui::RichText::new(text).color(TEXT).size(13.5)); +} + +#[derive(Clone, Copy, PartialEq, Eq)] +pub enum TabIcon { + Settings, + Dashboard, + Help, +} + +#[derive(Clone, Copy, PartialEq, Eq)] +pub enum MinerBadgeState { + Mining, + Stopped, + Paused, +} + +fn paint_tab_icon(painter: &egui::Painter, rect: Rect, icon: TabIcon, color: Color32) { + let c = rect.center(); + let s = rect.width().min(rect.height()) * 0.5; + match icon { + TabIcon::Settings => { + painter.circle_stroke(c, s * 0.55, Stroke::new(1.6, color)); + for i in 0..6 { + let a = std::f32::consts::TAU * i as f32 / 6.0; + let inner = c + Vec2::angled(a) * s * 0.28; + let outer = c + Vec2::angled(a) * s * 0.82; + painter.line_segment([inner, outer], Stroke::new(1.8, color)); + } + painter.circle_filled(c, s * 0.16, color); + } + TabIcon::Dashboard => { + let gap = s * 0.14; + let half = (s - gap) * 0.5; + let tl = c + Vec2::new(-half - gap * 0.5, -half - gap * 0.5); + for row in 0..2 { + for col in 0..2 { + let min = tl + Vec2::new(col as f32 * (half + gap), row as f32 * (half + gap)); + let tile = Rect::from_min_size(min, Vec2::splat(half)); + painter.rect_filled(tile, Rounding::same(2.0), color); + } + } + } + TabIcon::Help => { + painter.circle_stroke(c, s * 0.72, Stroke::new(1.8, color)); + painter.text( + c + Vec2::new(0.0, s * 0.06), + egui::Align2::CENTER_CENTER, + "?", + FontId::new(s * 1.15, FontFamily::Proportional), + color, + ); + } + } +} + +pub fn tab_bar(ui: &mut Ui, content: impl FnOnce(&mut Ui)) { + Frame::none() + .fill(Color32::from_rgba_premultiplied(18, 26, 38, 220)) + .stroke(Stroke::new(1.0, BORDER_SOFT)) + .rounding(Rounding::same(14.0)) + .inner_margin(Margin::symmetric(8.0, 6.0)) + .show(ui, |ui| { + ui.horizontal(|ui| { + ui.spacing_mut().item_spacing.x = 6.0; + content(ui); + }); + }); +} + +pub fn tab_pill(ui: &mut Ui, selected: bool, icon: TabIcon, label: &str) -> bool { + let desired = Vec2::new(148.0, 42.0); + let (rect, response) = ui.allocate_exact_size(desired, Sense::click()); + if ui.is_rect_visible(rect) { + let hover = response.hovered(); + let fill = if selected { + Color32::from_rgba_premultiplied(58, 118, 188, 72) + } else if hover { + Color32::from_rgba_premultiplied(42, 58, 82, 90) + } else { + Color32::from_rgba_premultiplied(24, 34, 48, 60) + }; + let stroke = if selected { + Stroke::new(1.5, ACCENT) + } else if hover { + Stroke::new(1.0, BORDER) + } else { + Stroke::new(1.0, BORDER_SOFT) + }; + let text_color = if selected { + TEXT + } else if hover { + ACCENT + } else { + TEXT_MUTED + }; + let icon_color = if selected { ACCENT } else { text_color }; + + ui.painter().rect_filled(rect, Rounding::same(11.0), fill); + ui.painter().rect_stroke(rect, Rounding::same(11.0), stroke); + if selected { + let bar = Rect::from_min_size( + egui::pos2(rect.left() + 10.0, rect.bottom() - 4.0), + Vec2::new(rect.width() - 20.0, 3.0), + ); + ui.painter() + .rect_filled(bar, Rounding::same(2.0), GREEN); + } + + let icon_rect = Rect::from_center_size( + egui::pos2(rect.left() + 22.0, rect.center().y), + Vec2::splat(18.0), + ); + paint_tab_icon(ui.painter(), icon_rect, icon, icon_color); + + ui.painter().text( + egui::pos2(rect.left() + 40.0, rect.center().y), + egui::Align2::LEFT_CENTER, + label, + FontId::new(14.0, FontFamily::Proportional), + text_color, + ); + } + if response.clicked() { + ui.ctx().request_repaint(); + } + response.clicked() +} + +pub fn btn_primary(ui: &mut Ui, label: &str) -> egui::Response { + ui.add( + egui::Button::new(egui::RichText::new(label).color(BG_DEEP).strong()) + .fill(GREEN) + .stroke(Stroke::new(1.0, GREEN_DIM)) + .rounding(Rounding::same(10.0)), + ) +} + +pub fn btn_secondary(ui: &mut Ui, label: &str) -> egui::Response { + ui.add( + egui::Button::new(egui::RichText::new(label).color(TEXT)) + .fill(BG_CARD) + .stroke(Stroke::new(1.0, BORDER)) + .rounding(Rounding::same(10.0)), + ) +} + +pub fn power_cost_slider(ui: &mut Ui, value: &mut f32, currency: crate::currency::Currency) { + let (min, max) = currency.power_cost_range(); + let step = currency.slider_step(); + let decimals = if currency == crate::currency::Currency::Jpy { + 0 + } else { + 2 + }; + ui.vertical(|ui| { + ui.label( + egui::RichText::new(format!( + "{value:.decimals$} {}/kWh", + currency.symbol() + )) + .color(ACCENT) + .size(13.0), + ); + ui.add_space(4.0); + ui.set_min_width(280.0); + ui.add( + egui::Slider::new(value, min..=max) + .step_by(step as f64) + .trailing_fill(true) + .show_value(false), + ); + }); +} + +pub fn btn_danger(ui: &mut Ui, label: &str) -> egui::Response { + ui.add( + egui::Button::new(egui::RichText::new(label).color(Color32::WHITE).strong()) + .fill(RED_DIM) + .stroke(Stroke::new(1.0, RED)) + .rounding(Rounding::same(10.0)), + ) +} + +pub fn miner_badge_state(mining: bool, paused: bool) -> MinerBadgeState { + if mining && paused { + MinerBadgeState::Paused + } else if mining { + MinerBadgeState::Mining + } else { + MinerBadgeState::Stopped + } +} + +pub fn status_badge(ui: &mut Ui, state: MinerBadgeState, label: &str) { + let time = ui.input(|i| i.time); + let (fill, stroke, dot, glow, text) = match state { + MinerBadgeState::Mining => { + let pulse = ((time * 3.2).sin() * 0.5 + 0.5) as f32; + ( + Color32::from_rgba_premultiplied(34, 120, 86, (40.0 + pulse * 35.0) as u8), + Stroke::new(1.8, GREEN), + GREEN, + Some((8.0 + pulse * 5.0, GREEN.linear_multiply(0.25 + pulse * 0.35))), + GREEN, + ) + } + MinerBadgeState::Paused => ( + Color32::from_rgba_premultiplied(150, 110, 40, 48), + Stroke::new(1.5, GOLD), + GOLD, + None, + GOLD, + ), + MinerBadgeState::Stopped => ( + Color32::from_rgba_premultiplied(70, 80, 96, 50), + Stroke::new(1.0, BORDER), + Color32::from_rgb(150, 160, 175), + None, + TEXT_MUTED, + ), + }; + + Frame::none() + .fill(fill) + .stroke(stroke) + .rounding(Rounding::same(22.0)) + .inner_margin(Margin::symmetric(18.0, 10.0)) + .show(ui, |ui| { + ui.horizontal(|ui| { + let (rect, _) = ui.allocate_exact_size(Vec2::new(14.0, 14.0), egui::Sense::hover()); + let center = rect.center(); + if let Some((radius, color)) = glow { + ui.painter() + .circle_filled(center, radius, color); + } + ui.painter().circle_filled(center, 5.5, dot); + if state == MinerBadgeState::Mining { + ui.painter() + .circle_stroke(center, 7.5, Stroke::new(1.5, GREEN.linear_multiply(0.65))); + } + ui.add_space(4.0); + ui.label(egui::RichText::new(label).strong().color(text).size(15.5)); + }); + }); + if state == MinerBadgeState::Mining { + ui.ctx().request_repaint_after(std::time::Duration::from_millis(80)); + } +} + +pub fn footer_status_chip(ui: &mut Ui, state: MinerBadgeState, label: &str) { + let (fill, stroke, dot, text) = match state { + MinerBadgeState::Mining => ( + Color32::from_rgba_premultiplied(34, 120, 86, 36), + Stroke::new(1.0, GREEN_DIM), + GREEN, + GREEN, + ), + MinerBadgeState::Paused => ( + Color32::from_rgba_premultiplied(150, 110, 40, 36), + Stroke::new(1.0, GOLD_DIM), + GOLD, + GOLD, + ), + MinerBadgeState::Stopped => ( + Color32::TRANSPARENT, + Stroke::new(1.0, BORDER_SOFT), + TEXT_MUTED, + TEXT_MUTED, + ), + }; + + Frame::none() + .fill(fill) + .stroke(stroke) + .rounding(Rounding::same(16.0)) + .inner_margin(Margin::symmetric(10.0, 5.0)) + .show(ui, |ui| { + ui.horizontal(|ui| { + let (rect, _) = ui.allocate_exact_size(Vec2::new(10.0, 10.0), egui::Sense::hover()); + ui.painter().circle_filled(rect.center(), 4.0, dot); + ui.label(egui::RichText::new(label).color(text).size(13.0)); + }); + }); +} + +pub fn help_step(ui: &mut Ui, num: u8, text: &str) { + Frame::none() + .fill(BG_CARD) + .stroke(Stroke::new(1.0, BORDER_SOFT)) + .rounding(Rounding::same(10.0)) + .inner_margin(Margin::symmetric(14.0, 12.0)) + .show(ui, |ui| { + ui.horizontal(|ui| { + Frame::none() + .fill(Color32::from_rgba_premultiplied(175, 148, 90, 28)) + .rounding(Rounding::same(8.0)) + .inner_margin(Margin::same(8.0)) + .show(ui, |ui| { + ui.label( + egui::RichText::new(num.to_string()) + .strong() + .color(GOLD) + .size(14.0), + ); + }); + ui.add_space(6.0); + ui.label(egui::RichText::new(text).color(TEXT).size(14.0)); + }); + }); + ui.add_space(6.0); +} + +pub fn show_logo(ui: &mut Ui, texture: &egui::TextureHandle, height: f32) { + let size = crate::assets::logo_size_for_height(texture, height); + ui.add(egui::Image::new(texture).fit_to_exact_size(size)); +} + +pub fn logo_fallback(ui: &mut Ui) { + Frame::none() + .fill(Color32::from_rgba_premultiplied(175, 148, 90, 30)) + .stroke(Stroke::new(1.0, GOLD_DIM)) + .rounding(Rounding::same(10.0)) + .inner_margin(Margin::symmetric(10.0, 8.0)) + .show(ui, |ui| { + ui.label(egui::RichText::new("HAC").strong().color(GOLD).size(17.0)); + }); +} + +pub fn status_dot(ui: &mut Ui, color: Color32) { + let (rect, _) = ui.allocate_exact_size(Vec2::new(12.0, 12.0), egui::Sense::hover()); + ui.painter().circle_filled(rect.center(), 4.5, color); +} \ No newline at end of file diff --git a/mint/Cargo.toml b/mint/Cargo.toml index 4a5082fc..c08b8695 100644 --- a/mint/Cargo.toml +++ b/mint/Cargo.toml @@ -14,5 +14,7 @@ num-bigint = "0.4.6" num-traits = "0.2.19" concat-idents = "1.1.5" serde_json = "1.0" -getrandom = "0.3.2" +getrandom = { version = "0.3", features = ["wasm_js"] } +argon2 = "0.5.3" +aes-gcm = "0.10.3" tokio = { version = "1.49.0", features = ["time"] } diff --git a/mint/src/api/common.rs b/mint/src/api/common.rs index f90eaaf9..17548808 100644 --- a/mint/src/api/common.rs +++ b/mint/src/api/common.rs @@ -59,6 +59,18 @@ fn q_string(req: &ApiRequest, key: &str, dv: &str) -> String { .map_or_else(|| dv.to_owned(), |s| s.to_owned()) } +/// Type 4 keystore: query `hybrid_keystore` or raw JSON POST body (browser/wallet friendly). +fn hybrid_keystore_from_req(req: &ApiRequest) -> String { + let from_query = q_string(req, "hybrid_keystore", ""); + if !from_query.is_empty() { + return from_query; + } + if req.body.is_empty() { + return String::new(); + } + String::from_utf8_lossy(&req.body).trim().to_string() +} + fn q_u32(req: &ApiRequest, key: &str, dv: u32) -> u32 { req.query(key) .and_then(|v| v.parse::().ok()) diff --git a/mint/src/api/console.rs b/mint/src/api/console.rs index 00efff4b..48ec5163 100644 --- a/mint/src/api/console.rs +++ b/mint/src/api/console.rs @@ -33,6 +33,13 @@ fn console(ctx: &ApiExecCtx, _req: ApiRequest) -> ApiResponse { *n }; + let pqc_lines = ctx.hnoder.pqc_metrics_prometheus(); + let pqc_summary = if pqc_lines.is_empty() { + "PQC metrics: n/a".to_owned() + } else { + pqc_lines.join(" | ") + }; + api_html(format!( r#"Hacash node console

Hacash console

@@ -41,6 +48,8 @@ fn console(ctx: &ApiExecCtx, _req: ApiRequest) -> ApiResponse {

P2P peers: {}

{}

Miner worker notice connected: {}

+

{}

+

/query/metrics | prometheus

"#, latest.height().uint(), timeshow(latest.timestamp().uint()), @@ -48,5 +57,6 @@ fn console(ctx: &ApiExecCtx, _req: ApiRequest) -> ApiResponse { ctx.hnoder.all_peer_prints().join(", "), ctx.hnoder.txpool().print(), poworkers, + pqc_summary, )) } diff --git a/mint/src/api/create_transfer.rs b/mint/src/api/create_transfer.rs index 7c956f4f..879f0b7b 100644 --- a/mint/src/api/create_transfer.rs +++ b/mint/src/api/create_transfer.rs @@ -1,12 +1,12 @@ fn create_coin_transfer(_ctx: &ApiExecCtx, req: ApiRequest) -> ApiResponse { + use basis::interface::Transaction; + use protocol::action::*; let fee = q_string(&req, "fee", ""); - let main_prikey = q_string(&req, "main_prikey", ""); let to_address = q_string(&req, "to_address", ""); let timestamp = req.query_u64("timestamp", 0); - let from_prikey = q_string(&req, "from_prikey", ""); let hacash = q_string(&req, "hacash", ""); - let satoshi = req.query_u64("satoshi", 0); - let diamonds = q_string(&req, "diamonds", ""); + let tx_type = req.query_u64("tx_type", 2); + let gas_max = req.query_u64("gas_max", 0) as u8; let Ok(toaddr) = Address::from_readable(&to_address) else { return api_error("to_address format invalid"); @@ -14,6 +14,22 @@ fn create_coin_transfer(_ctx: &ApiExecCtx, req: ApiRequest) -> ApiResponse { let Ok(fee) = Amount::from(&fee) else { return api_error("fee format invalid"); }; + if hacash.is_empty() { + return api_error("hacash amount required"); + } + let Ok(hac) = Amount::from(&hacash) else { + return api_error("hacash amount format invalid"); + }; + + if tx_type == TransactionType4::TYPE as u64 { + return create_coin_transfer_type4(&req, toaddr, fee, hac, timestamp, gas_max); + } + + let main_prikey = q_string(&req, "main_prikey", ""); + let from_prikey = q_string(&req, "from_prikey", ""); + let satoshi = req.query_u64("satoshi", 0); + let diamonds = q_string(&req, "diamonds", ""); + let Ok(main_acc) = Account::create_by(&main_prikey) else { return api_error("main_prikey format invalid"); }; @@ -79,25 +95,20 @@ fn create_coin_transfer(_ctx: &ApiExecCtx, req: ApiRequest) -> ApiResponse { } } - if !hacash.is_empty() { - let Ok(hac) = Amount::from(&hacash) else { - return api_error("hacash amount format invalid"); - }; - let act: Box = if is_from { - let mut obj = HacFromToTrs::new(); - obj.from = AddrOrPtr::from_addr(fromaddr.clone()); - obj.to = AddrOrPtr::from_addr(toaddr.clone()); - obj.hacash = hac; - Box::new(obj) - } else { - let mut obj = HacToTrs::new(); - obj.to = AddrOrPtr::from_addr(toaddr.clone()); - obj.hacash = hac; - Box::new(obj) - }; - if tx.push_action(act).is_err() { - return api_error("push hac action failed"); - } + let act: Box = if is_from { + let mut obj = HacFromToTrs::new(); + obj.from = AddrOrPtr::from_addr(fromaddr.clone()); + obj.to = AddrOrPtr::from_addr(toaddr.clone()); + obj.hacash = hac; + Box::new(obj) + } else { + let mut obj = HacToTrs::new(); + obj.to = AddrOrPtr::from_addr(toaddr.clone()); + obj.hacash = hac; + Box::new(obj) + }; + if tx.push_action(act).is_err() { + return api_error("push hac action failed"); } if let Err(e) = tx.fill_sign(&main_acc) { @@ -113,6 +124,76 @@ fn create_coin_transfer(_ctx: &ApiExecCtx, req: ApiRequest) -> ApiResponse { ("hash".to_owned(), json!(tx.hash().to_hex())), ("hash_with_fee".to_owned(), json!(tx.hash_with_fee().to_hex())), ("timestamp".to_owned(), json!(tx.timestamp().uint())), + ("type".to_owned(), json!(tx.ty())), ("body".to_owned(), json!(tx.serialize().to_hex())), ])) } + +fn create_coin_transfer_type4( + req: &ApiRequest, + toaddr: Address, + fee: Amount, + hac: Amount, + timestamp: u64, + gas_max: u8, +) -> ApiResponse { + use protocol::action::*; + let keystore = hybrid_keystore_from_req(req); + let pass = q_string(req, "keystore_pass", ""); + if keystore.is_empty() { + return api_error("type 4 transfer requires hybrid_keystore (query param or JSON body)"); + } + let Ok(blob) = sdk_unlock_hybrid_keystore_blob(&keystore, &pass) else { + return api_error("hybrid keystore unlock failed"); + }; + let Ok(hybrid) = HybridAccount::from_key_blob(&blob) else { + return api_error("hybrid key material invalid"); + }; + let mainaddr = Address::from(*hybrid.address()); + if !mainaddr.is_pqckey() && !mainaddr.is_hybrid() { + return api_error("main keystore address must be pqckey or hybrid"); + } + + let ts = if timestamp > 0 { timestamp } else { curtimes() }; + let mut tx = TransactionType4::new_by(mainaddr, fee, ts); + tx.gas_max = Uint1::from(gas_max); + if let Err(e) = tx.push_action(Box::new(HacToTrs::create_by(toaddr, hac))) { + return api_error(&format!("push hac action failed: {}", e)); + } + if let Err(e) = tx.fill_hybrid_sign(&hybrid) { + return api_error(&format!("fill hybrid sign failed: {}", e)); + } + + api_data(serde_json::Map::from_iter([ + ("hash".to_owned(), json!(tx.hash().to_hex())), + ("hash_with_fee".to_owned(), json!(tx.hash_with_fee().to_hex())), + ("timestamp".to_owned(), json!(tx.timestamp().uint())), + ("type".to_owned(), json!(tx.ty())), + ("main_address".to_owned(), json!(mainaddr.to_readable())), + ("address_version".to_owned(), json!(mainaddr.version())), + ("body".to_owned(), json!(tx.serialize().to_hex())), + ])) +} + +fn create_coin_transfer_v4(_ctx: &ApiExecCtx, req: ApiRequest) -> ApiResponse { + let fee = q_string(&req, "fee", ""); + let to_address = q_string(&req, "to_address", ""); + let timestamp = req.query_u64("timestamp", 0); + let hacash = q_string(&req, "hacash", ""); + let gas_max = req.query_u64("gas_max", 0) as u8; + + let Ok(toaddr) = Address::from_readable(&to_address) else { + return api_error("to_address format invalid"); + }; + let Ok(fee) = Amount::from(&fee) else { + return api_error("fee format invalid"); + }; + if hacash.is_empty() { + return api_error("hacash amount required"); + } + let Ok(hac) = Amount::from(&hacash) else { + return api_error("hacash amount format invalid"); + }; + + create_coin_transfer_type4(&req, toaddr, fee, hac, timestamp, gas_max) +} \ No newline at end of file diff --git a/mint/src/api/metrics.rs b/mint/src/api/metrics.rs new file mode 100644 index 00000000..17c10f9f --- /dev/null +++ b/mint/src/api/metrics.rs @@ -0,0 +1,42 @@ +fn query_metrics(_ctx: &ApiExecCtx, req: ApiRequest) -> ApiResponse { + let format = q_string(&req, "format", "json"); + let lines = _ctx.hnoder.pqc_metrics_prometheus(); + if format == "prometheus" { + let body = if lines.is_empty() { + "# hacash pqc metrics unavailable\n".to_owned() + } else { + let mut out = String::new(); + for line in lines { + out.push_str(&line); + out.push('\n'); + } + out + }; + return ApiResponse { + status: 200, + headers: vec![( + "content-type".to_owned(), + "text/plain; version=0.0.4; charset=utf-8".to_owned(), + )], + body: body.into_bytes(), + }; + } + + let mut data = serde_json::Map::new(); + data.insert("ret".to_owned(), json!(0)); + if lines.is_empty() { + data.insert("available".to_owned(), json!(false)); + return ApiResponse::json(Value::Object(data).to_string()); + } + data.insert("available".to_owned(), json!(true)); + for line in lines { + if let Some((name, value)) = line.split_once(' ') { + if let Ok(v) = value.parse::() { + data.insert(name.to_owned(), json!(v)); + } else { + data.insert(name.to_owned(), json!(value)); + } + } + } + ApiResponse::json(Value::Object(data).to_string()) +} \ No newline at end of file diff --git a/mint/src/api/mod.rs b/mint/src/api/mod.rs index 70dc35a5..da9f466c 100644 --- a/mint/src/api/mod.rs +++ b/mint/src/api/mod.rs @@ -42,3 +42,5 @@ include!("miner_pending.rs"); include!("miner_success.rs"); include!("diamondminer_init.rs"); include!("diamondminer_success.rs"); +include!("create_transfer.rs"); +include!("metrics.rs"); diff --git a/mint/src/api/routes.rs b/mint/src/api/routes.rs index dc8ea63f..bec73294 100644 --- a/mint/src/api/routes.rs +++ b/mint/src/api/routes.rs @@ -8,7 +8,13 @@ fn routes() -> Vec { R::get("/query/block/datas", block_datas), R::get("/query/fee/average", fee_average), R::get("/query/transaction", transaction_exist), + R::get("/query/metrics", query_metrics), + // Type 2/3/4 transaction builder (tx_type=4 requires v6/v7 main_address) R::post("/create/transaction", transaction_build), + // Legacy Type 2 transfer (main_prikey); Type 4 via tx_type=4 query param + R::post("/create/coin/transfer", create_coin_transfer), + // Dedicated Type 4 PQC/hybrid transfer (hybrid_keystore + keystore_pass required) + R::post("/create/coin/transfer/v4", create_coin_transfer_v4), R::post("/submit/transaction", submit_transaction), R::post("/submit/block", submit_block), R::debug_get("block/txs", debug_block_txs), @@ -17,7 +23,10 @@ fn routes() -> Vec { R::debug_post("submit/transaction", debug_submit_transaction), R::post("/operate/fee/raise", fee_raise), R::post("/util/transaction/check", transaction_check), + // Legacy sign (prikey) + auto Type 4 branch when body is ty=4 and hybrid_keystore set R::post("/util/transaction/sign", transaction_sign), + // Type 4 only: requires hybrid_keystore + keystore_pass (no prikey path) + R::post("/util/transaction/sign/v4", transaction_sign_v4), R::get("/query/hashrate", hashrate), R::get("/query/hashrate/logs", hashrate_logs), R::get("/query/balance", balance), @@ -54,4 +63,4 @@ fn routes() -> Vec { R::get("/query/diamondminer/init", diamondminer_init), R::post("/submit/diamondminer/success", diamondminer_success), ] -} +} \ No newline at end of file diff --git a/mint/src/api/submit_transaction.rs b/mint/src/api/submit_transaction.rs index 9a9abd3e..99ae86b5 100644 --- a/mint/src/api/submit_transaction.rs +++ b/mint/src/api/submit_transaction.rs @@ -17,9 +17,18 @@ fn submit_transaction(ctx: &ApiExecCtx, req: ApiRequest) -> ApiResponse { txpkg.fpur(), engcnf.lowest_fee_purity )); } + let txr = txpkg.tx_read(); + let max_wire = + protocol::transaction::effective_max_tx_wire_size(engcnf.max_tx_size, txr.ty()); let txsz = txpkg.data().len(); - if txsz > engcnf.max_tx_size { - return api_error(&format!("tx size cannot exceed {} bytes", engcnf.max_tx_size)); + if txsz > max_wire { + if txr.ty() == TransactionType4::TYPE { + protocol::metrics::emit(protocol::metrics::PqcMetricEvent::Type4MempoolRejected); + } + return api_error(&format!( + "tx size {} cannot exceed {} bytes", + txsz, max_wire + )); } let is_async = true; @@ -28,10 +37,40 @@ fn submit_transaction(ctx: &ApiExecCtx, req: ApiRequest) -> ApiResponse { .hnoder .submit_transaction(&txpkg, is_async, only_insert_txpool) { + if txr.ty() == TransactionType4::TYPE { + protocol::metrics::emit(protocol::metrics::PqcMetricEvent::Type4MempoolRejected); + } return api_error(&e); } - api_data(serde_json::Map::from_iter([( - "hash".to_owned(), - json!(txpkg.hash().to_hex()), - )])) -} + + let mut data = serde_json::Map::new(); + data.insert("hash".to_owned(), json!(txpkg.hash().to_hex())); + data.insert("tx_type".to_owned(), json!(txr.ty())); + data.insert("wire_size".to_owned(), json!(txsz)); + let main = txr.main(); + data.insert("main_address".to_owned(), json!(main.to_readable())); + data.insert("address_version".to_owned(), json!(main.version())); + if txr.ty() == TransactionType4::TYPE { + if let Some(sign) = txr.hybrid_signs().first() { + data.insert("sign_alg".to_owned(), json!(sign.alg_id())); + } + let mut hybrid_sigs = vec![]; + for sign in txr.hybrid_signs() { + let alg = sign.alg_id(); + hybrid_sigs.push(json!({ + "alg": alg, + "sign_alg": alg, + "body_len": sign.body.length(), + })); + } + data.insert("hybrid_signatures".to_owned(), json!(hybrid_sigs)); + println!( + "[rpc] submit type4 ok hash={} size={} main={} alg={}", + txpkg.hash().to_hex(), + txsz, + main.to_readable(), + txr.hybrid_signs().first().map(|s| s.alg_id()).unwrap_or(0) + ); + } + api_data(data) +} \ No newline at end of file diff --git a/mint/src/api/transaction.rs b/mint/src/api/transaction.rs index 6401f747..497b7d88 100644 --- a/mint/src/api/transaction.rs +++ b/mint/src/api/transaction.rs @@ -17,6 +17,46 @@ fn transaction_sign(ctx: &ApiExecCtx, req: ApiRequest) -> ApiResponse { return resp; } + if tx.ty() == TransactionType4::TYPE { + let keystore = hybrid_keystore_from_req(&req); + let pass = q_string(&req, "keystore_pass", ""); + if keystore.is_empty() { + return api_error("type 4 transaction requires hybrid_keystore (query param or JSON body)"); + } + let Ok(blob) = sdk_unlock_hybrid_keystore_blob(&keystore, &pass) else { + return api_error("hybrid keystore unlock failed"); + }; + let Ok(hybrid) = HybridAccount::from_key_blob(&blob) else { + return api_error("hybrid key material invalid"); + }; + use basis::interface::Transaction; + if let Err(e) = tx.fill_hybrid_sign(&hybrid) { + return api_error(&format!("fill hybrid sign failed: {}", e)); + } + let address = Address::from(*hybrid.address()); + let mut data = render_tx_info( + tx.as_read(), + None, + lasthei, + &unit, + true, + signature, + false, + description, + ); + let signs = tx.as_read().hybrid_signs(); + let signobj = signs.last().cloned().unwrap_or_default(); + data.insert( + "sign_data".to_owned(), + json!({ + "address": address.to_readable(), + "alg": signobj.alg_id(), + "body": signobj.body.to_hex(), + }), + ); + return api_data(data); + } + let (address, signobj) = if prikey.len() == 64 { let Ok(prik) = hex::decode(&prikey) else { return api_error("prikey format invalid"); @@ -72,6 +112,27 @@ fn transaction_sign(ctx: &ApiExecCtx, req: ApiRequest) -> ApiResponse { api_data(data) } +fn transaction_sign_v4(ctx: &ApiExecCtx, req: ApiRequest) -> ApiResponse { + let keystore = hybrid_keystore_from_req(&req); + let pass = q_string(&req, "keystore_pass", ""); + if keystore.is_empty() { + return api_error("type 4 sign requires hybrid_keystore (query param or JSON body)"); + } + if pass.is_empty() { + return api_error("type 4 sign requires keystore_pass"); + } + let Ok(txdts) = body_data_may_hex(&req) else { + return api_error("transaction body invalid"); + }; + let Ok((tx, _)) = protocol::transaction::transaction_create(&txdts) else { + return api_error("transaction body invalid"); + }; + if tx.ty() != TransactionType4::TYPE { + return api_error("transaction_sign_v4 requires transaction type 4"); + } + transaction_sign(ctx, req) +} + fn create_transaction_error_response( code: &str, message: &str, @@ -187,10 +248,35 @@ fn transaction_build_inner(req: &ApiRequest) -> ApiResponse { tx.gas_max = Uint1::from(gas_max as u8); Box::new(tx) } + v if v == TransactionType4::TYPE as u64 => { + if !main_addr.is_pqckey() && !main_addr.is_hybrid() { + return create_transaction_error_response( + "create_transaction_invalid_main_address", + "type 4 main_address must be pqckey (v6) or hybrid (v7)", + "parse_main_address", + vec![("field", json!("main_address"))], + ); + } + let gas_max = jsonv["gas_max"].as_u64().unwrap_or(0); + if gas_max > protocol::context::TX_GAS_BUDGET_CAP_BYTE as u64 { + return create_transaction_error_response( + "create_transaction_invalid_gas_max", + "gas_max exceeds the current Type4 cap", + "parse_gas_max", + vec![ + ("field", json!("gas_max")), + ("max", json!(protocol::context::TX_GAS_BUDGET_CAP_BYTE)), + ], + ); + } + let mut tx = TransactionType4::new_by(main_addr, fee, timestamp); + tx.gas_max = Uint1::from(gas_max as u8); + Box::new(tx) + } _ => { return create_transaction_error_response( "create_transaction_invalid_type", - "transaction type must be 2 or 3", + "transaction type must be 2, 3, or 4", "parse_type", vec![("field", json!("tx_type"))], ); @@ -503,6 +589,48 @@ fn transaction_exist(ctx: &ApiExecCtx, req: ApiRequest) -> ApiResponse { )) } +fn hybrid_sign_alg_name(alg: u8) -> &'static str { + use field::sign_alg; + match alg { + sign_alg::LEGACY_SECP => "LEGACY_SECP", + sign_alg::MLDSA65 => "MLDSA65", + sign_alg::HYBRID_SECP_MLDSA65 => "HYBRID_SECP_MLDSA65", + _ => "UNKNOWN", + } +} + +fn address_kind_label(addr: &Address) -> &'static str { + if addr.is_hybrid() { + "hybrid" + } else if addr.is_pqckey() { + "pqckey" + } else if addr.is_privakey() { + "privakey" + } else if addr.is_contract() { + "contract" + } else { + "other" + } +} + +fn append_type4_rpc_fields(data: &mut serde_json::Map, tx: &dyn TransactionRead) { + if tx.ty() != TransactionType4::TYPE { + return; + } + let main = tx.main(); + data.insert("tx_type".to_owned(), json!(tx.ty())); + data.insert("address_version".to_owned(), json!(main.version())); + data.insert("address_kind".to_owned(), json!(address_kind_label(&main))); + data.insert("wire_size".to_owned(), json!(tx.size())); + if let Some(sign) = tx.hybrid_signs().first() { + data.insert("sign_alg".to_owned(), json!(sign.alg_id())); + data.insert( + "sign_alg_name".to_owned(), + json!(hybrid_sign_alg_name(sign.alg_id())), + ); + } +} + fn render_tx_info( tx: &dyn TransactionRead, blblk: Option<&dyn BlockRead>, @@ -514,11 +642,13 @@ fn render_tx_info( description: bool, ) -> serde_json::Map { let fee_str = tx.fee().to_unit_string(unit); - let main_addr = tx.main().to_readable(); + let main = tx.main(); + let main_addr = main.to_readable(); let mut data = serde_json::Map::new(); data.insert("hash".to_owned(), json!(tx.hash().to_hex())); data.insert("hash_with_fee".to_owned(), json!(tx.hash_with_fee().to_hex())); data.insert("type".to_owned(), json!(tx.ty())); + data.insert("tx_type".to_owned(), json!(tx.ty())); data.insert("timestamp".to_owned(), json!(tx.timestamp().uint())); data.insert("fee".to_owned(), json!(fee_str)); data.insert("fee_got".to_owned(), json!(tx.fee_got().to_unit_string(unit))); @@ -526,7 +656,10 @@ fn render_tx_info( data.insert("gas_max".to_owned(), json!(gas_max)); } data.insert("main_address".to_owned(), json!(main_addr.clone())); + data.insert("address_version".to_owned(), json!(main.version())); + data.insert("address_kind".to_owned(), json!(address_kind_label(&main))); data.insert("action".to_owned(), json!(tx.action_count())); + append_type4_rpc_fields(&mut data, tx); if body { data.insert("body".to_owned(), json!(tx.serialize().to_hex())); @@ -562,15 +695,124 @@ fn render_tx_info( } fn check_signature(data: &mut serde_json::Map, tx: &dyn TransactionRead) { - let Ok(sigstats) = check_tx_signature(tx) else { + let sigstats = if tx.ty() == TransactionType4::TYPE { + protocol::transaction::check_tx_hybrid_signature(tx).ok() + } else { + check_tx_signature(tx).ok() + }; + let Some(sigstats) = sigstats else { return; }; let mut sigchs = vec![]; - for (adr, sg) in sigstats { + for (adr, sg) in &sigstats { sigchs.push(json!({ "address": adr.to_readable(), + "address_version": adr.version(), + "address_kind": address_kind_label(adr), "complete": sg, })); } data.insert("signatures".to_owned(), json!(sigchs)); + if tx.ty() == TransactionType4::TYPE { + let mut hybrid_sigs = vec![]; + for sign in tx.hybrid_signs() { + let alg = sign.alg_id(); + let addr_ok = basis::method::hybrid_sign_address(sign) + .map(|a| a.to_readable()) + .unwrap_or_default(); + let addr_ver = basis::method::hybrid_sign_address(sign) + .map(|a| a.version()) + .unwrap_or(0); + let complete = basis::method::hybrid_sign_address(sign) + .ok() + .and_then(|a| sigstats.get(&a).copied()) + .unwrap_or(false); + hybrid_sigs.push(json!({ + "alg": alg, + "sign_alg": alg, + "sign_alg_name": hybrid_sign_alg_name(alg), + "address": addr_ok, + "address_version": addr_ver, + "body_len": sign.body.length(), + "complete": complete, + })); + } + data.insert("hybrid_signatures".to_owned(), json!(hybrid_sigs)); + if let Some(first) = tx.hybrid_signs().first() { + data.insert("sign_alg".to_owned(), json!(first.alg_id())); + data.insert( + "sign_alg_name".to_owned(), + json!(hybrid_sign_alg_name(first.alg_id())), + ); + } + } +} + +fn sdk_unlock_hybrid_keystore_blob(json: &str, pass: &str) -> Ret { + let v: serde_json::Value = + serde_json::from_str(json).map_err(|e: serde_json::Error| e.to_string())?; + if v["version"].as_u64() != Some(3) { + return errf!("keystore version must be 3"); + } + let salt = hex::decode(v["kdf_salt"].as_str().unwrap_or("")) + .map_err(|e: hex::FromHexError| e.to_string())?; + let nonce = hex::decode(v["cipher_nonce"].as_str().unwrap_or("")) + .map_err(|e: hex::FromHexError| e.to_string())?; + let ciphertext = hex::decode(v["ciphertext"].as_str().unwrap_or("")) + .map_err(|e: hex::FromHexError| e.to_string())?; + let m_cost = v["kdf_m_cost_kb"].as_u64().unwrap_or(19456) as u32; + let t_cost = v["kdf_t_cost"].as_u64().unwrap_or(2) as u32; + let p_cost = v["kdf_p_cost"].as_u64().unwrap_or(1) as u32; + mint_unlock_keystore(pass, &salt, m_cost, t_cost, p_cost, &nonce, &ciphertext, &v) +} + +fn mint_unlock_keystore( + pass: &str, + salt: &[u8], + m_cost: u32, + t_cost: u32, + p_cost: u32, + nonce: &[u8], + ciphertext: &[u8], + v: &serde_json::Value, +) -> Ret { + use argon2::{Algorithm, Argon2, Params, Version}; + let params = Params::new(m_cost, t_cost, p_cost, Some(32)) + .map_err(|e: argon2::Error| e.to_string())?; + let argon = Argon2::new(Algorithm::Argon2id, Version::V0x13, params); + let mut key = [0u8; 32]; + argon + .hash_password_into(pass.as_bytes(), salt, &mut key) + .map_err(|e: argon2::Error| e.to_string())?; + use aes_gcm::aead::{Aead, KeyInit}; + use aes_gcm::{Aes256Gcm, Nonce}; + let cipher = Aes256Gcm::new_from_slice(&key).map_err(|e| e.to_string())?; + let plain = cipher + .decrypt(Nonce::from_slice(nonce), ciphertext) + .map_err(|_| "keystore decrypt failed".to_string())?; + let kind = match v["kind"].as_str() { + Some("pqckey") => 1u8, + Some("hybrid") => 3u8, + _ => return errf!("keystore kind invalid"), + }; + let sk_len = sys::mldsa65_secret_key_size(); + if plain.len() < 1 + sk_len { + return errf!("keystore plaintext too short"); + } + let mldsa_sk = plain[1..1 + sk_len].to_vec(); + let secp_sk = if kind == 3 { + let mut sk = [0u8; 32]; + sk.copy_from_slice(&plain[1 + sk_len..1 + sk_len + 32]); + Some(sk) + } else { + None + }; + let mldsa_pk = hex::decode(v["mldsa_pk"].as_str().unwrap_or("")) + .map_err(|e: hex::FromHexError| e.to_string())?; + Ok(HybridKeyBlob { + kind, + mldsa_sk, + secp_sk, + mldsa_pk, + }) } diff --git a/mint/src/check/block_build.rs b/mint/src/check/block_build.rs index db9c044e..3a679101 100644 --- a/mint/src/check/block_build.rs +++ b/mint/src/check/block_build.rs @@ -14,7 +14,9 @@ fn impl_packing_next_block( newdifn = Uint4::from(LOWEST_DIFFICULTY); } let nexthei = oldblk.height().uint() + 1; - let nextts = curtimes(); + let prev_ts = oldblk.timestamp().uint(); + // Release chain rejects blk_time <= prev_ts; always strictly increase. + let nextts = std::cmp::max(curtimes(), prev_ts.saturating_add(1)); if this.difficulty.is_upgrade_height(nexthei) || nexthei % mtcnf.difficulty_adjust_blocks == 0 { let sto = engine.store(); let src = StoreBlockIntroSource::new(sto.as_ref()); diff --git a/mint/src/check/tx.rs b/mint/src/check/tx.rs index 6858ae80..615d0044 100644 --- a/mint/src/check/tx.rs +++ b/mint/src/check/tx.rs @@ -1,7 +1,17 @@ +/// Minimum sane wire size for a signed Type 4 tx (~5 KB signatures + header). +const TYPE4_MEMPOOL_MIN_BYTES: usize = 512; +/// Typical signed Type 4 wire size is ~5–6 KB; log when larger for observability. +const TYPE4_MEMPOOL_WARN_BYTES: usize = 8 * 1024; + fn impl_tx_submit(this: &HacashMinter, engine: &dyn EngineRead, txp: &TxPkg) -> Rerr { let txr = txp.tx_read(); let curr_hei = engine.latest_block().height().uint(); let next_hei = curr_hei + 1; + + if txr.ty() == TransactionType4::TYPE { + check_type4_mempool_submit(engine, txp, next_hei)?; + } + let Some(diamintact) = action::pickout_diamond_mint_action(txr) else { return Ok(()) // other normal tx }; @@ -14,6 +24,83 @@ fn impl_tx_submit(this: &HacashMinter, engine: &dyn EngineRead, txp: &TxPkg) -> Ok(()) } +fn check_type4_mempool_submit( + engine: &dyn EngineRead, + txp: &TxPkg, + next_hei: u64, +) -> Rerr { + use protocol::metrics::PqcMetricEvent; + use protocol::transaction::effective_max_tx_wire_size; + + let txr = txp.tx_read(); + let engcnf = engine.config(); + let wire_len = txp.data().len(); + let max_allowed = effective_max_tx_wire_size(engcnf.max_tx_size, txr.ty()); + + if wire_len < TYPE4_MEMPOOL_MIN_BYTES { + protocol::metrics::emit(PqcMetricEvent::Type4MempoolRejected); + return errf!( + "type 4 tx wire size {} below mempool minimum {}", + wire_len, + TYPE4_MEMPOOL_MIN_BYTES + ); + } + if wire_len > max_allowed { + protocol::metrics::emit(PqcMetricEvent::Type4MempoolRejected); + return errf!( + "type 4 tx wire size {} exceeds mempool cap {} (engine max {})", + wire_len, + max_allowed, + engcnf.max_tx_size + ); + } + + protocol::upgrade::check_gated_tx(engcnf.chain_id, next_hei, txr.ty())?; + + let main = txr.main(); + if !main.is_pqckey() && !main.is_hybrid() { + protocol::metrics::emit(PqcMetricEvent::Type4MempoolRejected); + return errf!("type 4 mempool: main address must be PQCKEY (v6) or HYBRID (v7)"); + } + if main.is_privakey_unknown() { + protocol::metrics::emit(PqcMetricEvent::Type4MempoolRejected); + return errf!( + "type 4 mempool: main address {} has unknown system private key", + main + ); + } + + for sign in txr.hybrid_signs() { + if let Err(e) = sign.check_wire() { + protocol::metrics::emit(PqcMetricEvent::Type4MempoolRejected); + return errf!("type 4 hybrid sign wire invalid: {}", e); + } + } + + if wire_len > TYPE4_MEMPOOL_WARN_BYTES { + println!( + "[mempool] type4 tx wire size {} bytes (>{} warn threshold)", + wire_len, TYPE4_MEMPOOL_WARN_BYTES + ); + } + + let hybrid = main.is_hybrid(); + let alg = txr + .hybrid_signs() + .first() + .map(|s| s.alg_id()) + .unwrap_or(0); + println!( + "[mempool] type4 accepted size={} main={} version={} sign_alg={}", + wire_len, + main.to_readable(), + main.version(), + alg + ); + protocol::metrics::emit(PqcMetricEvent::Type4MempoolAccepted { hybrid }); + Ok(()) +} + fn impl_tx_pool_group(tx: &TxPkg) -> usize { let mut group_id = TXGID_NORMAL; if let Some(..) = action::pickout_diamond_mint_action(tx.tx_read()) { @@ -50,9 +137,9 @@ fn impl_tx_pool_refresh( fn clean_invalid_normal_txs(eng: &dyn EngineRead, txpool: &dyn TxPool, blkhei: u64) { let pdhei = blkhei + 1; let mut sub_state = eng.fork_sub_state(); - let mut keep_rest_after_uncertain_type3 = false; + let mut keep_rest_after_uncertain = false; let _ = txpool.retain_at(TXGID_NORMAL, &mut |a: &TxPkg| { - if keep_rest_after_uncertain_type3 { + if keep_rest_after_uncertain { return true; } let txr = a.tx_read(); @@ -60,8 +147,9 @@ fn clean_invalid_normal_txs(eng: &dyn EngineRead, txpool: &dyn TxPool, blkhei: u if exec.is_ok() { return true; } + // Keep Type 3+ (incl. Type 4 ~5–6 KB PQC txs) when deterministic precheck is uncertain. if txr.ty() >= TransactionType3::TYPE { - keep_rest_after_uncertain_type3 = true; + keep_rest_after_uncertain = true; return true; } false // delete legacy txs that still fail under deterministic precheck @@ -79,4 +167,4 @@ fn clean_invalid_diamond_mint_txs(eng: &dyn EngineRead, txpool: &dyn TxPool, _bl let _ = txpool.retain_at(TXGID_DIAMINT, &mut |a: &TxPkg| { nextdn == action::get_diamond_mint_number(a.tx_read()) }); -} +} \ No newline at end of file diff --git a/node/src/core/api.rs b/node/src/core/api.rs index 3543591b..f72cefee 100644 --- a/node/src/core/api.rs +++ b/node/src/core/api.rs @@ -46,6 +46,10 @@ impl HNoder for HacashNode { self.runtime.all_peer_prints() } + fn pqc_metrics_prometheus(&self) -> Vec { + self.runtime.pqc_metrics_snapshot().prometheus_lines() + } + fn exit(&self) { self.runtime.exit() } diff --git a/node/src/core/metrics.rs b/node/src/core/metrics.rs index ad173e92..14b3b48a 100644 --- a/node/src/core/metrics.rs +++ b/node/src/core/metrics.rs @@ -1,3 +1,8 @@ +use std::sync::{Arc, Mutex as StdMutex, OnceLock}; + +use protocol::metrics::PqcMetricEvent; + +/// Runtime lifecycle counters (unchanged dashboard contract). #[derive(Default)] pub struct RuntimeMetrics { pub start_count: u64, @@ -13,3 +18,138 @@ impl RuntimeMetrics { self.exit_count += 1; } } + +/// Post-quantum / Type 4 observability counters. +#[derive(Default, Clone)] +pub struct PqcMetrics { + pub pqc_tx_total: u64, + pub hybrid_tx_total: u64, + pub type4_mempool_rejected: u64, + pub type4_sign_verified: u64, + /// Cumulative ML-DSA verify time in microseconds. + pub mldsa_verify_us_total: u64, + pub mldsa_verify_count: u64, +} + +impl PqcMetrics { + pub fn on_event(&mut self, event: PqcMetricEvent) { + match event { + PqcMetricEvent::MldsaVerifyUs(us) => { + self.mldsa_verify_us_total = self.mldsa_verify_us_total.saturating_add(us); + self.mldsa_verify_count = self.mldsa_verify_count.saturating_add(1); + } + PqcMetricEvent::Type4MempoolAccepted { hybrid } => { + self.pqc_tx_total = self.pqc_tx_total.saturating_add(1); + if hybrid { + self.hybrid_tx_total = self.hybrid_tx_total.saturating_add(1); + } + } + PqcMetricEvent::Type4MempoolRejected => { + self.type4_mempool_rejected = self.type4_mempool_rejected.saturating_add(1); + } + PqcMetricEvent::Type4SignVerified { hybrid } => { + self.type4_sign_verified = self.type4_sign_verified.saturating_add(1); + if hybrid { + self.hybrid_tx_total = self.hybrid_tx_total.saturating_add(1); + } + } + } + } + + pub fn mldsa_verify_ms(&self) -> f64 { + self.mldsa_verify_us_total as f64 / 1000.0 + } + + pub fn mldsa_verify_ms_avg(&self) -> f64 { + if self.mldsa_verify_count == 0 { + return 0.0; + } + self.mldsa_verify_ms() / self.mldsa_verify_count as f64 + } + + pub fn prometheus_lines(&self) -> Vec { + vec![ + format!( + "hacash_pqc_tx_total {}", + self.pqc_tx_total + ), + format!( + "hacash_hybrid_tx_total {}", + self.hybrid_tx_total + ), + format!( + "hacash_type4_mempool_rejected_total {}", + self.type4_mempool_rejected + ), + format!( + "hacash_type4_sign_verified_total {}", + self.type4_sign_verified + ), + format!( + "hacash_mldsa_verify_ms {}", + self.mldsa_verify_ms() + ), + format!( + "hacash_mldsa_verify_count {}", + self.mldsa_verify_count + ), + format!( + "hacash_mldsa_verify_ms_avg {}", + self.mldsa_verify_ms_avg() + ), + ] + } + + pub fn to_json_map(&self) -> std::collections::HashMap { + let mut m = std::collections::HashMap::new(); + m.insert("pqc_tx_total".to_owned(), self.pqc_tx_total.to_string()); + m.insert("hybrid_tx_total".to_owned(), self.hybrid_tx_total.to_string()); + m.insert( + "type4_mempool_rejected".to_owned(), + self.type4_mempool_rejected.to_string(), + ); + m.insert( + "type4_sign_verified".to_owned(), + self.type4_sign_verified.to_string(), + ); + m.insert( + "mldsa_verify_ms".to_owned(), + format!("{:.3}", self.mldsa_verify_ms()), + ); + m.insert( + "mldsa_verify_ms_avg".to_owned(), + format!("{:.3}", self.mldsa_verify_ms_avg()), + ); + m.insert( + "mldsa_verify_count".to_owned(), + self.mldsa_verify_count.to_string(), + ); + m + } +} + +static GLOBAL_PQC_METRICS: OnceLock>> = OnceLock::new(); + +/// Register the node-owned metrics Arc for the protocol hook and RPC snapshot. +pub fn install_global_pqc_metrics(metrics: Arc>) { + let _ = GLOBAL_PQC_METRICS.set(metrics); +} + +pub fn global_pqc_metrics() -> Option>> { + GLOBAL_PQC_METRICS.get().cloned() +} + +pub fn pqc_metrics_hook_fn(event: PqcMetricEvent) { + if let Some(metrics) = global_pqc_metrics() { + if let Ok(mut m) = metrics.lock() { + m.on_event(event); + } + } +} + +pub fn install_pqc_metrics_hook() { + protocol::metrics::install_hook(pqc_metrics_hook_fn); + sys::set_mldsa_verify_observer(|us| { + pqc_metrics_hook_fn(PqcMetricEvent::MldsaVerifyUs(us)); + }); +} \ No newline at end of file diff --git a/node/src/core/mod.rs b/node/src/core/mod.rs index 4197d3e1..7e76704a 100644 --- a/node/src/core/mod.rs +++ b/node/src/core/mod.rs @@ -29,7 +29,10 @@ mod transport; pub use api::HacashNode; pub use sync::SyncTracker; -pub(crate) use metrics::RuntimeMetrics; +pub(crate) use metrics::{ + PqcMetrics, RuntimeMetrics, install_global_pqc_metrics, install_pqc_metrics_hook, +}; +pub use metrics::PqcMetrics as NodePqcMetrics; pub(crate) use protocol::{ handle_new_block, handle_new_tx, receive_blocks, receive_hashs, receive_status, send_blocks, send_hashs, send_status, diff --git a/node/src/core/protocol.rs b/node/src/core/protocol.rs index f78cc41f..a5256d96 100644 --- a/node/src/core/protocol.rs +++ b/node/src/core/protocol.rs @@ -334,11 +334,23 @@ pub(crate) async fn handle_new_tx( engcnf.lowest_fee_purity ); } - if txpkg.data().len() > engcnf.max_tx_size { + let txpr = txpkg.tx_read(); + let max_wire = protocol::transaction::effective_max_tx_wire_size( + engcnf.max_tx_size, + txpr.ty(), + ); + if txpkg.data().len() > max_wire { + if txpr.ty() == protocol::transaction::TransactionType4::TYPE { + protocol::metrics::emit(protocol::metrics::PqcMetricEvent::Type4MempoolRejected); + println!( + "[p2p] type4 tx rejected: wire size {} > cap {}", + txpkg.data().len(), + max_wire + ); + } return errf!("tx size exceeds max_tx_size"); } let txdatas = txpkg.data().to_vec(); - let txpr = txpkg.tx_read(); hdl.engine.try_execute_tx(txpr)?; minter.tx_submit(hdl.engine.as_read(), &txpkg)?; hdl.txpool diff --git a/node/src/core/runtime.rs b/node/src/core/runtime.rs index b05bbe79..41716965 100644 --- a/node/src/core/runtime.rs +++ b/node/src/core/runtime.rs @@ -10,6 +10,7 @@ pub struct NodeRuntime { pub(super) transport: TransportAdapter, pub(super) tasks: Arc, pub(super) metrics: Arc>, + pub(super) pqc_metrics: Arc>, pub(super) exited: AtomicBool, } @@ -21,6 +22,9 @@ impl NodeRuntime { msghdl.set_p2p_mng(Box::new(PeerMngInst::new(p2p.clone()))); let protocol = ProtocolAdapter::new(msghdl.clone()); let transport = TransportAdapter::new(&cnf, p2p.clone()); + let pqc_metrics = Arc::new(StdMutex::new(PqcMetrics::default())); + install_global_pqc_metrics(pqc_metrics.clone()); + install_pqc_metrics_hook(); Self { engine, txpool, @@ -29,10 +33,18 @@ impl NodeRuntime { transport, tasks: TaskGroup::new(), metrics: Arc::new(StdMutex::new(RuntimeMetrics::default())), + pqc_metrics, exited: AtomicBool::new(false), } } + pub fn pqc_metrics_snapshot(&self) -> PqcMetrics { + self.pqc_metrics + .lock() + .map(|m| m.clone()) + .unwrap_or_default() + } + pub fn start(&self, worker: Worker) { self.start_network(worker) } diff --git a/protocol/src/context/actcall.rs b/protocol/src/context/actcall.rs index d3c388bf..0824e7f4 100644 --- a/protocol/src/context/actcall.rs +++ b/protocol/src/context/actcall.rs @@ -22,7 +22,7 @@ fn ctx_action_call(this: &mut dyn Context, k: u16, b: Vec) -> XRet<(u32, Vec if !seen.insert(adr) { continue; } - if adr.is_privakey() { + if adr.is_user_signing_address() { this.check_sign(&adr)?; } } diff --git a/protocol/src/context/context.rs b/protocol/src/context/context.rs index 58faa08e..5a1525d0 100644 --- a/protocol/src/context/context.rs +++ b/protocol/src/context/context.rs @@ -148,8 +148,21 @@ impl<'a> ContextInst<'a> { adr ); } - adr.must_privakey()?; - let isok = verify_target_signature(adr, self.txr); + let isok = if self.txr.ty() == crate::transaction::TransactionType4::TYPE { + if !adr.is_user_signing_address() { + return errf!("address {} is not a user signing address", adr); + } + let res = crate::transaction::verify_target_hybrid_signature(adr, self.txr); + if res.as_ref().ok().copied() == Some(true) { + crate::metrics::emit(crate::metrics::PqcMetricEvent::Type4SignVerified { + hybrid: adr.is_hybrid(), + }); + } + res + } else { + adr.must_privakey()?; + verify_target_signature(adr, self.txr) + }; self.check_sign_cache.insert(*adr, isok.clone()); isok.map(|_| ()) } diff --git a/protocol/src/lib.rs b/protocol/src/lib.rs index 219c75f3..8a485b45 100644 --- a/protocol/src/lib.rs +++ b/protocol/src/lib.rs @@ -1,6 +1,7 @@ pub mod action; pub mod block; pub mod context; +pub mod metrics; pub mod operate; pub mod setup; pub mod state; diff --git a/protocol/src/metrics.rs b/protocol/src/metrics.rs new file mode 100644 index 00000000..c2204dcc --- /dev/null +++ b/protocol/src/metrics.rs @@ -0,0 +1,30 @@ +use std::sync::OnceLock; + +/// Observability events for post-quantum / Type 4 traffic. +#[derive(Clone, Copy, Debug)] +pub enum PqcMetricEvent { + /// ML-DSA-65 detached verify duration in microseconds. + MldsaVerifyUs(u64), + /// Type 4 tx accepted into mempool (local submit or P2P). + Type4MempoolAccepted { hybrid: bool }, + /// Type 4 tx rejected before mempool insert. + Type4MempoolRejected, + /// Type 4 hybrid signature verified during execution. + Type4SignVerified { hybrid: bool }, +} + +pub type PqcMetricsHook = fn(PqcMetricEvent); + +static PQC_METRICS_HOOK: OnceLock = OnceLock::new(); + +/// Install a process-wide hook (typically from the node runtime at startup). +pub fn install_hook(hook: PqcMetricsHook) { + let _ = PQC_METRICS_HOOK.set(hook); +} + +#[inline] +pub fn emit(event: PqcMetricEvent) { + if let Some(hook) = PQC_METRICS_HOOK.get() { + hook(event); + } +} \ No newline at end of file diff --git a/protocol/src/operate/hacash.rs b/protocol/src/operate/hacash.rs index af163760..b4443b95 100644 --- a/protocol/src/operate/hacash.rs +++ b/protocol/src/operate/hacash.rs @@ -50,8 +50,8 @@ pub fn hac_transfer(ctx: &mut dyn Context, from: &Address, to: &Address, amt: &A )?; // is to self if from == to { - if !from.is_privakey() { - return xerrf!("non-privakey address cannot transfer HAC to self") + if !from.is_user_signing_address() { + return xerrf!("non-signing address cannot transfer HAC to self") } // historical compatibility: legacy blocks (<200000) allow self-transfer fast path if ctx.env().block.height >= 20_0000 { diff --git a/protocol/src/transaction/hybrid_util.rs b/protocol/src/transaction/hybrid_util.rs new file mode 100644 index 00000000..e92d8e24 --- /dev/null +++ b/protocol/src/transaction/hybrid_util.rs @@ -0,0 +1,88 @@ +use std::collections::HashMap; + +use basis::method::{hybrid_sign_address, verify_hybrid_signature}; +use field::{Address, Hash, HybridSign}; + +pub fn verify_tx_hybrid_signature(tx: &dyn TransactionRead) -> Rerr { + let hx = tx.hash(); + let hxwf = tx.hash_with_fee(); + let signs = tx.hybrid_signs(); + let addrs = tx.req_sign()?; + let main_addr = tx.main(); + for adr in addrs { + let ckhx = if adr == main_addr { + &hxwf + } else { + &hx + }; + verify_one_hybrid_sign(ckhx, &adr, signs)?; + } + Ok(()) +} + +pub fn verify_target_hybrid_signature(adr: &Address, tx: &dyn TransactionRead) -> Ret { + if adr.is_privakey_unknown() { + return errf!( + "address {} is a system address (value < u32::MAX) with unknown private key, cannot sign", + adr + ); + } + let hx = tx.hash(); + let hxwf = tx.hash_with_fee(); + let signs = tx.hybrid_signs(); + let main_addr = tx.main(); + let ckhx = if *adr == main_addr { + &hxwf + } else { + &hx + }; + verify_one_hybrid_sign(ckhx, adr, signs) +} + +pub fn verify_one_hybrid_sign(hash: &Hash, addr: &Address, signs: &Vec) -> Ret { + if addr.is_privakey_unknown() { + return errf!( + "address {} is a system address (value < u32::MAX) with unknown private key, cannot sign", + addr + ); + } + if !addr.is_user_signing_address() { + return errf!("address {} is not a user signing address", addr); + } + for sig in signs { + if verify_hybrid_signature(hash, addr, sig) { + return Ok(true); + } + } + errf!("{} hybrid signature verification failed", addr) +} + +pub fn check_tx_hybrid_signature(tx: &dyn TransactionRead) -> Ret> { + let hx = tx.hash(); + let hxwf = tx.hash_with_fee(); + let signs = tx.hybrid_signs(); + let addrs = tx.req_sign()?; + let main_addr = tx.main(); + let mut ckres = HashMap::new(); + for sig in signs { + if let Ok(adr) = hybrid_sign_address(sig) { + ckres.insert(adr, true); + } + } + for adr in addrs { + let ckhx = if adr == main_addr { + &hxwf + } else { + &hx + }; + let mut sigok = false; + if let Ok(yes) = verify_one_hybrid_sign(ckhx, &adr, signs) { + if yes { + sigok = true; + } + } + ckres.insert(adr, sigok); + } + Ok(ckres) +} + diff --git a/protocol/src/transaction/mod.rs b/protocol/src/transaction/mod.rs index 6b1d0d7b..05230e9d 100644 --- a/protocol/src/transaction/mod.rs +++ b/protocol/src/transaction/mod.rs @@ -14,6 +14,8 @@ use super::*; include! {"util.rs"} include! {"macro.rs"} include! {"type3.rs"} +include! {"hybrid_util.rs"} +include! {"type4.rs"} include! {"prelude.rs"} include! {"create.rs"} include! {"store.rs"} @@ -68,6 +70,17 @@ fn decode_tx_type3(json: &str) -> Ret> { Ok(Box::new(tx)) } +fn create_tx_type4(buf: &[u8]) -> Ret<(Box, usize)> { + let (tx, sk) = TransactionType4::create(buf)?; + Ok((Box::new(tx), sk)) +} + +fn decode_tx_type4(json: &str) -> Ret> { + let mut tx = TransactionType4::default(); + tx.from_json(json)?; + Ok(Box::new(tx)) +} + pub fn register(setup: &mut crate::setup::ProtocolSetup) { setup.tx_codec( DefaultPreludeTx::TYPE, @@ -77,6 +90,7 @@ pub fn register(setup: &mut crate::setup::ProtocolSetup) { setup.tx_codec(TransactionType1::TYPE, create_tx_type1, decode_tx_type1); setup.tx_codec(TransactionType2::TYPE, create_tx_type2, decode_tx_type2); setup.tx_codec(TransactionType3::TYPE, create_tx_type3, decode_tx_type3); + setup.tx_codec(TransactionType4::TYPE, create_tx_type4, decode_tx_type4); } /* diff --git a/protocol/src/transaction/type4.rs b/protocol/src/transaction/type4.rs new file mode 100644 index 00000000..6466444e --- /dev/null +++ b/protocol/src/transaction/type4.rs @@ -0,0 +1,321 @@ +use std::collections::HashSet; + +use field::sign_alg; + +field::combi_struct! { TransactionType4, + ty : Uint1 + timestamp : Timestamp + addrlist : AddrOrList + fee : Amount + actions : DynListActionW2 + signs : HybridSignW2 + gas_max : Uint1 + ano_mark : Fixed1 +} + +impl TransactionRead for TransactionType4 { + fn hash(&self) -> Hash { + self.hash_ex(vec![]) + } + + fn hash_with_fee(&self) -> Hash { + self.hash_ex(self.fee.serialize()) + } + + fn ty(&self) -> u8 { + *self.ty + } + + fn main(&self) -> Address { + self.addrs()[0] + } + + fn addrs(&self) -> Vec
{ + self.addrlist.to_list() + } + + fn timestamp(&self) -> &Timestamp { + &self.timestamp + } + + fn fee(&self) -> &Amount { + &self.fee + } + + fn fee_pay(&self) -> Amount { + self.fee.clone() + } + + fn fee_got(&self) -> Amount { + self.fee.clone() + } + + fn gas_max_byte(&self) -> Option { + Some(*self.gas_max) + } + + fn fee_purity(&self) -> u64 { + let txsz = self.size() as u64; + if txsz == 0 { + return 0; + } + let fee238 = self.fee.to_238_u128().unwrap_or(u128::MAX); + let purity = fee238 / txsz as u128; + purity.min(u64::MAX as u128) as u64 + } + + fn action_count(&self) -> usize { + self.actions.length() + } + + fn actions(&self) -> &Vec> { + self.actions.as_list() + } + + fn hybrid_signs(&self) -> &Vec { + self.signs.as_list() + } + + fn req_sign(&self) -> Ret> { + let addrs = &self.addrs(); + let mut adrsets = HashSet::from([self.main()]); + for act in self.actions() { + for ptr in act.req_sign() { + let adr = ptr.real(addrs)?; + if adr.is_user_signing_address() { + adrsets.insert(adr); + } + } + } + Ok(adrsets) + } + + fn verify_signature(&self) -> Rerr { + verify_tx_hybrid_signature(self) + } +} + +impl Transaction for TransactionType4 { + fn as_read(&self) -> &dyn TransactionRead { + self + } + + fn set_fee(&mut self, fee: Amount) { + self.fee = fee; + } + + fn fill_hybrid_sign(&mut self, acc: &HybridAccount) -> Ret { + let mut fhx = self.hash(); + if acc.address() == self.main().as_bytes() { + fhx = self.hash_with_fee(); + } + let signobj = self.create_hybrid_sign_by(acc, &fhx)?; + self.insert_hybrid_sign(signobj.clone())?; + Ok(signobj) + } + + fn push_hybrid_sign(&mut self, signobj: HybridSign) -> Rerr { + self.insert_hybrid_sign(signobj) + } + + fn push_action(&mut self, act: Box) -> Rerr { + self.actions.push(act) + } +} + +impl TxExec for TransactionType4 { + fn execute(&self, ctx: &mut dyn Context) -> Rerr { + do_tx_execute_type4(self, ctx) + } +} + +impl TransactionType4 { + pub const TYPE: u8 = 4u8; + pub const MAX_WIRE_SIZE: usize = 256 * 1024; + + pub fn new_by(addr: Address, fee: Amount, ts: u64) -> Self { + Self { + ty: Uint1::from(Self::TYPE), + timestamp: Timestamp::from(ts), + addrlist: AddrOrList::from_addr(addr), + fee, + actions: DynListActionW2::default(), + signs: HybridSignW2::default(), + gas_max: Uint1::default(), + ano_mark: Fixed1::default(), + } + } + + fn hash_ex(&self, adfe: Vec) -> Hash { + let mut stuff = Vec::with_capacity( + self.ty.size() + + self.timestamp.size() + + self.addrlist.size() + + adfe.len() + + self.actions.size() + + self.gas_max.size() + + self.ano_mark.size(), + ); + self.ty.serialize_to(&mut stuff); + self.timestamp.serialize_to(&mut stuff); + self.addrlist.serialize_to(&mut stuff); + stuff.extend_from_slice(&adfe); + self.actions.serialize_to(&mut stuff); + self.gas_max.serialize_to(&mut stuff); + self.ano_mark.serialize_to(&mut stuff); + let hx = sys::calculate_hash(stuff); + Hash::must(&hx[..]) + } + + pub fn create_hybrid_sign_by(&self, acc: &HybridAccount, hash: &Hash) -> Ret { + let body = acc.sign_hash(hash.as_array())?; + let alg = acc.sign_alg_id(); + if self.main().is_pqckey() && alg != sign_alg::MLDSA65 { + return errf!("PQCKEY main address requires ML-DSA-65 signature alg"); + } + if self.main().is_hybrid() && alg != sign_alg::HYBRID_SECP_MLDSA65 { + return errf!("HYBRID main address requires hybrid signature alg"); + } + let mut signobj = HybridSign::new(); + signobj.alg = Uint1::from(alg); + signobj.body = BytesW2::from(body)?; + signobj.check_wire()?; + Ok(signobj) + } + + pub fn fill_legacy_secp_hybrid_sign(&mut self, acc: &Account) -> Ret { + let mut fhx = self.hash(); + if acc.address() == self.main().as_bytes() { + fhx = self.hash_with_fee(); + } + let body = sys::legacy_secp_sign_body(acc, fhx.as_array()); + let mut signobj = HybridSign::new(); + signobj.alg = Uint1::from(sign_alg::LEGACY_SECP); + signobj.body = BytesW2::from(body)?; + self.insert_hybrid_sign(signobj.clone())?; + Ok(signobj) + } + + fn insert_hybrid_sign(&mut self, signobj: HybridSign) -> Rerr { + signobj.check_wire()?; + if self.size() > Self::MAX_WIRE_SIZE { + return errf!( + "type 4 transaction wire size {} exceeds cap {}", + self.size(), + Self::MAX_WIRE_SIZE + ); + } + let plen = self.signs.length(); + if plen >= u16::MAX as usize - 1 { + return errf!("too many hybrid sign objects"); + } + let curaddr = hybrid_sign_address(&signobj)?; + let mut istid = usize::MAX; + let sglist = self.signs.as_list(); + for i in 0..plen { + if let Ok(adr) = hybrid_sign_address(&sglist[i]) { + if adr == curaddr { + istid = i; + break; + } + } + } + if istid == usize::MAX { + self.signs.push(signobj)?; + } else { + self.signs.as_mut()[istid] = signobj; + } + if let Ok(yes) = verify_target_hybrid_signature(&curaddr, self) { + if yes { + return Ok(()); + } + } + errf!( + "address {} hybrid signature verification failed", + curaddr + ) + } +} + +fn do_tx_execute_type4(tx: &TransactionType4, ctx: &mut dyn Context) -> Rerr { + let prep = prepare_tx_execute_type4(tx, ctx)?; + if tx.ano_mark[0] != 0 { + return errf!("tx type {} ano_mark must be zero", prep.txty); + } + mark_tx_exist(ctx, &prep.hx, prep.blkhei); + { + let mut state = CoreState::wrap(ctx.state()); + crate::operate::total_add_tx_fee_pay(&mut state, tx)?; + } + let gas_initialized = tx_gas_initialize(ctx)?; + for action in tx.actions() { + ctx.exec_from_set(ExecFrom::Top); + let (ret_gas, _retv) = action.execute(ctx)?; + ctx.gas_charge(extra9_surcharge(action.extra9(), ret_gas) as i64)?; + } + super::tex::do_settlement(ctx)?; + ctx.run_deferred_phase()?; + if gas_initialized { + ctx.gas_refund()?; + } + operate::hac_sub(ctx, &prep.main, &prep.fee)?; + crate::tex::settlement_addr_postsettle_cleanup(ctx); + Ok(()) +} + +struct TxExecutePrep4 { + blkhei: u64, + txty: u8, + hx: Hash, + main: Address, + fee: Amount, +} + +fn prepare_tx_execute_type4(tx: &TransactionType4, ctx: &mut dyn Context) -> Ret { + let env = ctx.env(); + let blkhei = env.block.height; + crate::upgrade::check_gated_tx(env.chain.id, blkhei, tx.ty())?; + let not_fast_sync = !env.chain.fast_sync; + let hx = tx.hash(); + let main = tx.main(); + let fee = tx.fee().clone(); + precheck_tx_actions(tx.ty(), tx.actions())?; + let state = CoreState::wrap(ctx.state()); + if not_fast_sync { + if !main.is_pqckey() && !main.is_hybrid() { + return errf!("tx type 4 main address must be PQCKEY or HYBRID"); + } + if main.is_privakey_unknown() { + return errf!( + "tx main address {} is a system address with unknown private key", + main + ); + } + for adr in tx.addrs() { + adr.check_version()?; + } + if blkhei > 20_0000 { + fee.check_6_long().map_err(|_| { + "tx fee size cannot exceed 6 bytes when block height above 200,000".to_string() + })?; + } + if tx.size() > TransactionType4::MAX_WIRE_SIZE { + return errf!( + "type 4 transaction wire size {} exceeds cap {}", + tx.size(), + TransactionType4::MAX_WIRE_SIZE + ); + } + tx.verify_signature()?; + if let Some(exhei) = state.tx_exist(&hx) { + return errf!("tx {} already exists in height {}", hx, *exhei); + } + } + Ok(TxExecutePrep4 { + blkhei, + txty: tx.ty(), + hx, + main, + fee, + }) +} \ No newline at end of file diff --git a/protocol/src/transaction/util.rs b/protocol/src/transaction/util.rs index 14604163..5f08ffbe 100644 --- a/protocol/src/transaction/util.rs +++ b/protocol/src/transaction/util.rs @@ -1,95 +1,104 @@ - -pub fn create_tx_info(tx: &dyn TransactionRead) -> TxInfo { - TxInfo { - ty: tx.ty(), - main: tx.main(), - addrs: tx.addrs(), - fee: tx.fee_pay(), - } -} - - -/** -* verify tx all needs signature -*/ -pub fn verify_tx_signature(tx: &dyn TransactionRead) -> Rerr { - let hx = tx.hash(); - let hxwf = tx.hash_with_fee(); - let signs = tx.signs(); - let addrs = tx.req_sign()?; - let main_addr = tx.main(); - let txty = tx.ty(); - for adr in addrs { - let mut ckhx = &hx; - if adr == main_addr && txty != TransactionType1::TYPE { - ckhx = &hxwf; - } - verify_one_sign(ckhx, &adr, signs)?; - } - Ok(()) -} - - -pub fn check_tx_signature(tx: &dyn TransactionRead) -> Ret> { - let hx = tx.hash(); - let hxwf = tx.hash_with_fee(); - let signs = tx.signs(); - let addrs = tx.req_sign()?; - let main_addr = tx.main(); - let txty = tx.ty(); - let mut ckres = HashMap::new(); - for sig in signs { - let adr = Address::from(Account::get_address_by_public_key(*sig.publickey)); - ckres.insert(adr, true); - } - for adr in addrs { - let mut ckhx = &hx; - if adr == main_addr && txty != TransactionType1::TYPE { - ckhx = &hxwf; - } - let mut sigok = false; - if let Ok(yes) = verify_one_sign(ckhx, &adr, signs) { - if yes { - sigok = true; - } - } - ckres.insert(adr, sigok); + /// Effective mempool / RPC wire-size cap for a transaction type. +#[inline] +pub fn effective_max_tx_wire_size(engine_max_tx_size: usize, tx_type: u8) -> usize { + if tx_type == TransactionType4::TYPE { + engine_max_tx_size.min(TransactionType4::MAX_WIRE_SIZE) + } else { + engine_max_tx_size } - Ok(ckres) } - -pub fn verify_target_signature(adr: &Address, tx: &dyn TransactionRead) -> Ret { - if adr.is_privakey_unknown() { - return errf!( - "address {} is a system address (value < u32::MAX) with unknown private key, cannot sign", - adr - ); - } - let hx = tx.hash(); - let hxwf = tx.hash_with_fee(); - let signs = tx.signs(); - // let ddrs = tx.req_sign(); - let main_addr = tx.main(); - let mut ckhx = &hx; - if *adr == main_addr && tx.ty() != TransactionType1::TYPE { - ckhx = &hxwf; - } - verify_one_sign(ckhx, adr, signs) -} - - -pub fn verify_one_sign(hash: &Hash, addr: &Address, signs: &Vec) -> Ret { - if addr.is_privakey_unknown() { - return errf!( - "address {} is a system address (value < u32::MAX) with unknown private key, cannot sign", - addr - ); - } - for sig in signs { - if basis::method::verify_signature(hash, addr, sig) { - return Ok(true) - } - } - errf!("{} signature verification failed", addr) -} +pub fn create_tx_info(tx: &dyn TransactionRead) -> TxInfo { + TxInfo { + ty: tx.ty(), + main: tx.main(), + addrs: tx.addrs(), + fee: tx.fee_pay(), + } +} + + +/** +* verify tx all needs signature +*/ +pub fn verify_tx_signature(tx: &dyn TransactionRead) -> Rerr { + let hx = tx.hash(); + let hxwf = tx.hash_with_fee(); + let signs = tx.signs(); + let addrs = tx.req_sign()?; + let main_addr = tx.main(); + let txty = tx.ty(); + for adr in addrs { + let mut ckhx = &hx; + if adr == main_addr && txty != TransactionType1::TYPE { + ckhx = &hxwf; + } + verify_one_sign(ckhx, &adr, signs)?; + } + Ok(()) +} + + +pub fn check_tx_signature(tx: &dyn TransactionRead) -> Ret> { + let hx = tx.hash(); + let hxwf = tx.hash_with_fee(); + let signs = tx.signs(); + let addrs = tx.req_sign()?; + let main_addr = tx.main(); + let txty = tx.ty(); + let mut ckres = HashMap::new(); + for sig in signs { + let adr = Address::from(Account::get_address_by_public_key(*sig.publickey)); + ckres.insert(adr, true); + } + for adr in addrs { + let mut ckhx = &hx; + if adr == main_addr && txty != TransactionType1::TYPE { + ckhx = &hxwf; + } + let mut sigok = false; + if let Ok(yes) = verify_one_sign(ckhx, &adr, signs) { + if yes { + sigok = true; + } + } + ckres.insert(adr, sigok); + } + Ok(ckres) +} + + +pub fn verify_target_signature(adr: &Address, tx: &dyn TransactionRead) -> Ret { + if adr.is_privakey_unknown() { + return errf!( + "address {} is a system address (value < u32::MAX) with unknown private key, cannot sign", + adr + ); + } + let hx = tx.hash(); + let hxwf = tx.hash_with_fee(); + let signs = tx.signs(); + // let ddrs = tx.req_sign(); + let main_addr = tx.main(); + let mut ckhx = &hx; + if *adr == main_addr && tx.ty() != TransactionType1::TYPE { + ckhx = &hxwf; + } + verify_one_sign(ckhx, adr, signs) +} + + +pub fn verify_one_sign(hash: &Hash, addr: &Address, signs: &Vec) -> Ret { + if addr.is_privakey_unknown() { + return errf!( + "address {} is a system address (value < u32::MAX) with unknown private key, cannot sign", + addr + ); + } + for sig in signs { + if basis::method::verify_signature(hash, addr, sig) { + return Ok(true) + } + } + errf!("{} signature verification failed", addr) +} diff --git a/protocol/src/upgrade.rs b/protocol/src/upgrade.rs index 39dece73..168ae6e6 100644 --- a/protocol/src/upgrade.rs +++ b/protocol/src/upgrade.rs @@ -6,6 +6,8 @@ pub const DEV_OPEN_MAX_HEIGHT: u64 = 65_432; // Set the real mainnet activation height before rollout. pub const ONLINE_OPEN_HEIGHT: u64 = 765_432; +// Post-quantum Type4 transaction activation height (soft-fork). +pub const PQC_TYPE4_OPEN_HEIGHT: u64 = 876_543; pub const MAINNET_CHAIN_ID: u32 = 0; // One-time pre-upgrade allowlist. @@ -17,6 +19,16 @@ fn is_pre_upgrade_allowed_tx_type(tx_type: u8) -> bool { matches!(tx_type, 1 | 2) } +#[inline] +fn is_pqc_tx_type(tx_type: u8) -> bool { + tx_type == 4 +} + +#[inline] +pub fn is_pqc_type4_open(height: u64) -> bool { + height >= PQC_TYPE4_OPEN_HEIGHT +} + #[inline] fn is_pre_upgrade_allowed_action(kind: u16) -> bool { matches!( @@ -44,6 +56,17 @@ pub fn check_gated_tx(chain_id: u32, height: u64, tx_type: u8) -> Rerr { if chain_id != MAINNET_CHAIN_ID { return Ok(()); } + if is_pqc_tx_type(tx_type) { + if is_pqc_type4_open(height) || is_dev_upgrade_open(height) { + return Ok(()); + } + return errf!( + "tx type {} not enabled at height {}, allowed when height >= {}", + tx_type, + height, + PQC_TYPE4_OPEN_HEIGHT + ); + } if is_online_upgrade_open(height) || is_dev_upgrade_open(height) || is_pre_upgrade_allowed_tx_type(tx_type) @@ -180,4 +203,21 @@ mod tests { assert!(check_gated_tx(sidechain_id, height, 3).is_ok()); assert!(check_gated_action(sidechain_id, height, 25).is_ok()); } + + #[test] + fn tx_type4_is_gated_in_middle_closed_interval() { + let height = DEV_OPEN_MAX_HEIGHT.saturating_add(1); + assert!(check_gated_tx(MAINNET_CHAIN_ID, height, 4).is_err()); + } + + #[test] + fn tx_type4_is_open_at_pqc_activation_height() { + assert!(is_pqc_type4_open(PQC_TYPE4_OPEN_HEIGHT)); + assert!(check_gated_tx(MAINNET_CHAIN_ID, PQC_TYPE4_OPEN_HEIGHT, 4).is_ok()); + } + + #[test] + fn tx_type4_is_open_in_dev_window() { + assert!(check_gated_tx(MAINNET_CHAIN_ID, 0, 4).is_ok()); + } } diff --git a/scripts/mining-amd/BENCHMARK-AMD.bat b/scripts/mining-amd/BENCHMARK-AMD.bat new file mode 100644 index 00000000..25df8f8b --- /dev/null +++ b/scripts/mining-amd/BENCHMARK-AMD.bat @@ -0,0 +1,39 @@ +@echo off +setlocal EnableDelayedExpansion +title AMD mining autotune benchmark + +set "REPO_ROOT=%~dp0..\.." + +echo. +echo Runs a short GPU benchmark and recommends gpu_profile. +echo Sets benchmark_seconds=45 in target configs, then starts poworker. +echo. +echo After benchmark completes, set benchmark_seconds=0 and gpu_profile as recommended. +echo. + +for %%D in (debug release) do ( + set "CFG=%REPO_ROOT%\target\%%D\poworker.config.ini" + if exist "!CFG!" ( + powershell -NoProfile -Command ^ + "$p='!CFG!'; $t=Get-Content $p -Raw; if($t -notmatch '\[efficiency\]'){ $t += \"`n[efficiency]`nmode = profit`nbenchmark_seconds = 45`n\" } else { $t = $t -replace '(?m)^benchmark_seconds\s*=\s*\d+','benchmark_seconds = 45' }; Set-Content $p $t" + echo Updated !CFG! + ) +) + +echo. +echo Starting poworker benchmark (45s total)... +echo. + +if exist "%REPO_ROOT%\target\release\poworker.exe" ( + cd /d "%REPO_ROOT%\target\release" + poworker.exe +) else if exist "%REPO_ROOT%\target\debug\poworker.exe" ( + cd /d "%REPO_ROOT%\target\debug" + poworker.exe +) else ( + echo Build first: scripts\mining-amd\BUILD-AMD-MINER.bat +) + +echo. +pause +exit /b 0 \ No newline at end of file diff --git a/scripts/mining-amd/BUILD-AMD-MINER.bat b/scripts/mining-amd/BUILD-AMD-MINER.bat new file mode 100644 index 00000000..f55c67c3 --- /dev/null +++ b/scripts/mining-amd/BUILD-AMD-MINER.bat @@ -0,0 +1,49 @@ +@echo off +setlocal EnableDelayedExpansion +title Build Hacash AMD miners (OpenCL) + +set "REPO_ROOT=%~dp0..\.." +cd /d "%REPO_ROOT%" + +echo. +echo Building poworker + diaworker + list_opencl with OpenCL (AMD/NVIDIA)... +echo Repo: %REPO_ROOT% +echo. + +call "%~dp0FIND-OPENCL-LIB.bat" +if errorlevel 1 ( + echo. + echo OpenCL.lib not found. Try automatic install? [Y/N] + set /p DO_INSTALL=" " + if /i "!DO_INSTALL!"=="Y" ( + call "%~dp0INSTALL-OPENCL-SDK.bat" + if errorlevel 1 ( + pause + exit /b 1 + ) + ) else ( + echo BUILD ABORTED — run INSTALL-OPENCL-SDK.bat or install AMD drivers. + pause + exit /b 1 + ) +) + +cargo build --release --features ocl +if errorlevel 1 ( + echo. + echo BUILD FAILED + echo If you see LNK1181 / OpenCL.lib: run FIND-OPENCL-LIB.bat + pause + exit /b 1 +) + +echo. +echo OK: %REPO_ROOT%\target\release\poworker.exe +echo %REPO_ROOT%\target\release\diaworker.exe +echo %REPO_ROOT%\target\release\list_opencl.exe +echo. +echo Next: CONFIGURE-MINING.bat or INSTALL-CONFIGS.bat +echo LIST-OPENCL-DEVICES.bat +echo. +pause +exit /b 0 \ No newline at end of file diff --git a/scripts/mining-amd/BUILD-MINER-PANEL.bat b/scripts/mining-amd/BUILD-MINER-PANEL.bat new file mode 100644 index 00000000..47354e30 --- /dev/null +++ b/scripts/mining-amd/BUILD-MINER-PANEL.bat @@ -0,0 +1,31 @@ +@echo off +setlocal +title Build HAC Miner Panel (GUI) + +set "REPO_ROOT=%~dp0..\.." +cd /d "%REPO_ROOT%" + +call "%~dp0FIND-OPENCL-LIB.bat" >nul 2>&1 + +echo. +echo Building miner-panel.exe (GUI setup + dashboard)... +echo. + +cargo build --release -p miner-panel +if errorlevel 1 ( + echo BUILD FAILED + pause + exit /b 1 +) + +set "OUT=%REPO_ROOT%\target\release" +copy /Y "%OUT%\miner-panel.exe" "%OUT%\" >nul 2>&1 + +echo. +echo OK: %OUT%\miner-panel.exe +echo. +echo Copy to the same folder as poworker.exe and hacash.exe, then double-click. +echo Or run from: %OUT% +echo. +pause +exit /b 0 \ No newline at end of file diff --git a/scripts/mining-amd/CONFIGURE-MINING.bat b/scripts/mining-amd/CONFIGURE-MINING.bat new file mode 100644 index 00000000..cc4bc9ad --- /dev/null +++ b/scripts/mining-amd/CONFIGURE-MINING.bat @@ -0,0 +1,8 @@ +@echo off +setlocal +title Configure AMD mining (type your CPU + GPU) + +cd /d "%~dp0" +chcp 65001 >nul +powershell -NoProfile -ExecutionPolicy Bypass -File "%~dp0GENERATE-MINING-CONFIG.ps1" +exit /b %ERRORLEVEL% \ No newline at end of file diff --git a/scripts/mining-amd/FIND-OPENCL-LIB.bat b/scripts/mining-amd/FIND-OPENCL-LIB.bat new file mode 100644 index 00000000..11cfa0ab --- /dev/null +++ b/scripts/mining-amd/FIND-OPENCL-LIB.bat @@ -0,0 +1,55 @@ +@echo off +setlocal EnableDelayedExpansion +title Find OpenCL.lib for MSVC linker + +echo. +echo Searching for OpenCL.lib (required to link poworker/diaworker)... +echo. + +set "FOUND=" +set "FOUND_DIR=" + +for %%P in ( + "%ProgramFiles(x86)%\OCL_SDK_Light\lib\x86_64" + "%ProgramFiles%\OCL_SDK_Light\lib\x86_64" + "%AMDAPPSDKROOT%\lib\x86_64" + "%CUDA_PATH%\lib\x64" + "%INTELOCLSDKROOT%\lib\x64" + "%ProgramFiles(x86)%\AMD APP SDK\2.9\lib\x86_64" + "%ProgramFiles%\Khronos\OpenCL-SDK\lib" +) do ( + if exist "%%~P\OpenCL.lib" ( + set "FOUND=1" + set "FOUND_DIR=%%~P" + goto :found + ) +) + +:: Recursive search in Program Files (slow, only if not found above) +for /f "delims=" %%F in ('where /r "%ProgramFiles%" OpenCL.lib 2^>nul') do ( + set "FOUND=1" + for %%D in ("%%~dpF.") do set "FOUND_DIR=%%~fD" + goto :found +) + +:found +if defined FOUND ( + echo Found: !FOUND_DIR!\OpenCL.lib + set "LIB=!FOUND_DIR!;%LIB%" + echo Added to LIB for this session. + echo. + exit /b 0 +) + +echo NOT FOUND: OpenCL.lib +echo. +echo Install one of: +echo 1. AMD Adrenalin GPU drivers (recommended for RX cards) +echo 2. Khronos OpenCL SDK: https://github.com/KhronosGroup/OpenCL-SDK/releases +echo 3. CUDA Toolkit (includes OpenCL.lib) if you have NVIDIA tools +echo. +echo After install, re-run this script or BUILD-AMD-MINER.bat +echo. +echo Note: OpenCL.dll may exist in System32 but .lib is needed at BUILD time. +echo. +exit /b 1 \ No newline at end of file diff --git a/scripts/mining-amd/GENERATE-MINING-CONFIG.ps1 b/scripts/mining-amd/GENERATE-MINING-CONFIG.ps1 new file mode 100644 index 00000000..66df3adb --- /dev/null +++ b/scripts/mining-amd/GENERATE-MINING-CONFIG.ps1 @@ -0,0 +1,336 @@ +# Generates poworker.config.ini + diaworker.config.ini from CPU + GPU input. +# Usage: interactive (no args) or -Cpu "9950x" -Gpu "7900xtx" + +param( + [string]$Cpu = "", + [string]$Gpu = "", + [switch]$NoPause +) + +$ErrorActionPreference = "Stop" +$ScriptDir = Split-Path -Parent $MyInvocation.MyCommand.Path +$RepoRoot = Resolve-Path (Join-Path $ScriptDir "..\..") +$PresetsDir = Join-Path $ScriptDir "presets" + +$CpuCatalog = @( + @{ Id = "1"; Slug = "ryzen5"; Label = "Ryzen 5 (5600X, 7600X - 6 cores)"; Supervene = 4; Aliases = @("ryzen5","r5","5600","5600x","7600","7600x","ryzen 5") } + @{ Id = "2"; Slug = "ryzen7"; Label = "Ryzen 7 (5800X, 7700X - 8 cores)"; Supervene = 6; Aliases = @("ryzen7","r7","5800","5800x","7700","7700x","7800x3d","7800","ryzen 7") } + @{ Id = "3"; Slug = "ryzen9"; Label = "Ryzen 9 (5900X, 7900X - 12 cores)"; Supervene = 8; Aliases = @("ryzen9","r9","5900","5900x","7900","7900x","ryzen 9") } + @{ Id = "4"; Slug = "ryzen9-9950x"; Label = "Ryzen 9 9950X (16 cores / Zen 5)"; Supervene = 10; Aliases = @("9950","9950x","ryzen9-9950x","ryzen 9 9950x") } + @{ Id = "5"; Slug = "tr-7960x"; Label = "Threadripper 7960X (24 cores)"; Supervene = 14; Aliases = @("7960","7960x","tr7960","tr-7960","threadripper-7960","threadripper 7960") } + @{ Id = "6"; Slug = "tr-7970x"; Label = "Threadripper 7970X (32 cores)"; Supervene = 18; Aliases = @("7970","7970x","tr7970","tr-7970","threadripper-7970") } + @{ Id = "7"; Slug = "tr-7980x"; Label = "Threadripper 7980X / 9980WX (64+ cores)"; Supervene = 22; Aliases = @("7980","7980x","9980","9980wx","tr7980","tr-7980","threadripper-7980","threadripper 7980") } + @{ Id = "8"; Slug = "cpu-only"; Label = "CPU only (no AMD GPU)"; Supervene = 0; Aliases = @("cpu","cpu-only","no-gpu","nogpu","none","without-gpu","horis-gpu") } +) + +$GpuCatalog = @( + @{ Id = "1"; Slug = "rx6600"; Label = "RX 6600 / 6600 XT (8 GB)"; Profile = "amd_balanced"; WorkGroups = 1024; UnitSize = 128; VramGb = 8; Aliases = @("6600","6600xt","rx6600","rx 6600") } + @{ Id = "2"; Slug = "rx7600"; Label = "RX 7600 (8 GB)"; Profile = "amd_balanced"; WorkGroups = 1024; UnitSize = 128; VramGb = 8; Aliases = @("7600","rx7600","rx 7600") } + @{ Id = "3"; Slug = "rx6700xt"; Label = "RX 6700 XT (12 GB)"; Profile = "amd_performance"; WorkGroups = 2048; UnitSize = 96; VramGb = 12; Aliases = @("6700","6700xt","rx6700","rx6700xt","rx 6700") } + @{ Id = "4"; Slug = "rx6800xt"; Label = "RX 6800 / 6800 XT (16 GB)"; Profile = "amd_performance"; WorkGroups = 2048; UnitSize = 96; VramGb = 16; Aliases = @("6800","6800xt","rx6800","rx6800xt","rx 6800") } + @{ Id = "5"; Slug = "rx6900xt"; Label = "RX 6900 XT (16 GB)"; Profile = "amd_performance"; WorkGroups = 2048; UnitSize = 96; VramGb = 16; Aliases = @("6900","6900xt","rx6900","rx6900xt","rx 6900") } + @{ Id = "6"; Slug = "rx7900xt"; Label = "RX 7900 XT / GRE (16-20 GB)"; Profile = "amd_performance"; WorkGroups = 2048; UnitSize = 96; VramGb = 20; Aliases = @("7900xt","7900gre","7900 gre","rx7900xt","rx 7900xt","7900 xt") } + @{ Id = "7"; Slug = "rx7900xtx"; Label = "RX 7900 XTX (24 GB)"; Profile = "amd_max"; WorkGroups = 4096; UnitSize = 128; VramGb = 24; Aliases = @("7900xtx","rx7900xtx","rx 7900 xtx","7900 xtx") } + @{ Id = "8"; Slug = "rx9070xt"; Label = "RX 9070 XT (16 GB, RDNA4)"; Profile = "amd_performance"; WorkGroups = 2048; UnitSize = 96; VramGb = 16; Aliases = @("9070","9070xt","rx9070","rx9070xt","rx 9070") } + @{ Id = "9"; Slug = "none"; Label = "(no GPU - CPU only)"; Profile = ""; WorkGroups = 0; UnitSize = 0; VramGb = 0; Aliases = @("none","no-gpu","nogpu","cpu","skip") } +) + +function Normalize([string]$s) { + if (-not $s) { return "" } + $s = $s.ToLower().Trim() + $s = $s -replace '\s+', '' + $s = $s -replace '^rx', '' + $s = $s -replace 'threadripper', 'tr' + $s = $s -replace 'ryzen', 'ryzen' + return $s +} + +function Resolve-Entry([string]$Query, $Catalog) { + $norm = Normalize $Query + if (-not $norm) { return $null } + + foreach ($item in $Catalog) { + if ($norm -eq $item.Id) { return $item } + } + + $best = $null + $bestScore = 0 + foreach ($item in $Catalog) { + foreach ($alias in $item.Aliases) { + $a = Normalize $alias + if ($norm -eq $a) { return $item } + if ($norm.Contains($a) -or $a.Contains($norm)) { + $score = [Math]::Min($norm.Length, $a.Length) + if ($score -gt $bestScore) { $bestScore = $score; $best = $item } + } + } + $slugNorm = Normalize $item.Slug + if ($norm -eq $slugNorm -or $norm.Contains($slugNorm)) { + $score = $slugNorm.Length + if ($score -gt $bestScore) { $bestScore = $score; $best = $item } + } + } + return $best +} + +function Get-PresetSlug($CpuEntry, $GpuEntry) { + if ($CpuEntry.Slug -eq "cpu-only" -or $GpuEntry.Slug -eq "none") { + switch ($CpuEntry.Slug) { + "ryzen5" { return "cpu-only-ryzen5" } + "ryzen7" { return "cpu-only-ryzen7" } + "ryzen9" { return "cpu-only-ryzen9" } + "ryzen9-9950x" { return "cpu-only-ryzen9" } + "tr-7960x" { return "cpu-only-ryzen9" } + "tr-7970x" { return "cpu-only-ryzen9" } + "tr-7980x" { return "cpu-only-ryzen9" } + "cpu-only" { return "cpu-only-ryzen7" } + default { return "cpu-only-ryzen7" } + } + } + + $cpuPart = $CpuEntry.Slug + $gpuPart = switch ($GpuEntry.Slug) { + "rx6600" { "rx6600" } + "rx7600" { "rx7600" } + "rx6700xt" { "rx6700xt" } + "rx6800xt" { "rx6800xt" } + "rx6900xt" { "rx6800xt" } + "rx7900xt" { "rx7900xt" } + "rx7900xtx" { "rx7900xtx" } + "rx9070xt" { "rx9070xt" } + default { $GpuEntry.Slug } + } + return "$cpuPart-$gpuPart" +} + +function Get-CpuOnlySupervene($CpuEntry) { + switch ($CpuEntry.Slug) { + "ryzen5" { return 10 } + "ryzen7" { return 14 } + "ryzen9" { return 20 } + "ryzen9-9950x" { return 20 } + "tr-7960x" { return 24 } + "tr-7970x" { return 28 } + "tr-7980x" { return 30 } + default { return 14 } + } +} + +function New-PoworkerContent($CpuEntry, $GpuEntry, [string]$ComboLabel) { + $cpuOnly = ($CpuEntry.Slug -eq "cpu-only") -or ($GpuEntry.Slug -eq "none") + $sv = if ($cpuOnly) { Get-CpuOnlySupervene $CpuEntry } else { $CpuEntry.Supervene } + + $lines = @( + "; ============================================================================" + "; HAC block miner - auto-generated for: $ComboLabel" + "; Generated by CONFIGURE-MINING.bat / GENERATE-MINING-CONFIG.ps1" + "; Set platform_id / device_ids after LIST-OPENCL-DEVICES.bat" + "; ============================================================================" + "" + "connect = 127.0.0.1:8080" + "supervene = $sv" + "nonce_max = 4294967295" + "notice_wait = 45" + "" + "[efficiency]" + "mode = profit" + "power_cost_kwh = 0.15" + "gpu_watts = 0" + "cpu_watts_per_thread = 8" + "hac_price = 0" + "dynamic_supervene = true" + "supervene_min = 2" + "supervene_max = $sv" + "oom_fallback = true" + "max_temp_c = 0" + "throttle_work_groups = 1024" + "benchmark_seconds = 0" + "" + "[gpu]" + ) + + if ($cpuOnly) { + $lines += @( + "use_opencl = false" + "cpu_assist = false" + "platform_id = 0" + "device_ids = 0" + "opencl_dir = ../../x16rs/opencl/" + "debug = 0" + ) + } else { + $lines += @( + "use_opencl = true" + "cpu_assist = true" + "gpu_profile = $($GpuEntry.Profile)" + "platform_id = 0" + "device_ids = 0" + "opencl_dir = ../../x16rs/opencl/" + "work_groups = $($GpuEntry.WorkGroups)" + "local_size = 256" + "unit_size = $($GpuEntry.UnitSize)" + "debug = 0" + ) + if ($GpuEntry.Slug -eq "rx9070xt") { + $lines += "; Tip: if stable and no OOM, try gpu_profile = amd_max" + } + } + return ($lines -join "`r`n") + "`r`n" +} + +function New-DiaworkerContent($CpuEntry, $GpuEntry, [string]$ComboLabel) { + $cpuOnly = ($CpuEntry.Slug -eq "cpu-only") -or ($GpuEntry.Slug -eq "none") + $svPow = if ($cpuOnly) { Get-CpuOnlySupervene $CpuEntry } else { $CpuEntry.Supervene } + $sv = [Math]::Max(2, $svPow - 2) + + $lines = @( + "; ============================================================================" + "; HACD diamond miner - auto-generated for: $ComboLabel" + "; Requires [diamondminer] enable = true in hacash.config.ini" + "; ============================================================================" + "" + "connect = 127.0.0.1:8080" + "supervene = $sv" + "" + "[gpu]" + ) + + if ($cpuOnly) { + $lines += @( + "use_opencl = false" + "cpu_assist = false" + "platform_id = 0" + "device_ids = 0" + "opencl_dir = ../../x16rs/opencl/" + "debug = 0" + ) + } else { + $lines += @( + "use_opencl = true" + "cpu_assist = true" + "gpu_profile = $($GpuEntry.Profile)" + "platform_id = 0" + "device_ids = 0" + "opencl_dir = ../../x16rs/opencl/" + "work_groups = $($GpuEntry.WorkGroups)" + "local_size = 256" + "unit_size = $($GpuEntry.UnitSize)" + "debug = 0" + ) + } + return ($lines -join "`r`n") + "`r`n" +} + +function Install-Configs([string]$PowContent, [string]$DiaContent) { + $utf8 = New-Object System.Text.UTF8Encoding $false + $targets = @( + (Join-Path $RepoRoot "target\debug") + (Join-Path $RepoRoot "target\release") + ) + $written = @() + foreach ($dir in $targets) { + if (-not (Test-Path $dir)) { + if ($dir -like "*\debug") { New-Item -ItemType Directory -Path $dir -Force | Out-Null } + else { continue } + } + $powPath = Join-Path $dir "poworker.config.ini" + $diaPath = Join-Path $dir "diaworker.config.ini" + [System.IO.File]::WriteAllText($powPath, $PowContent, $utf8) + [System.IO.File]::WriteAllText($diaPath, $DiaContent, $utf8) + $written += $powPath + $written += $diaPath + } + return $written +} + +function Show-Catalog($Catalog, [string]$Title) { + Write-Host "" + Write-Host " $Title" -ForegroundColor Cyan + foreach ($item in $Catalog) { + Write-Host (" {0,2} {1}" -f $item.Id, $item.Label) + } + Write-Host "" +} + +# --- Interactive or CLI --- +if (-not $Cpu) { + Clear-Host + Write-Host "" + Write-Host " ============================================================" -ForegroundColor Yellow + Write-Host " HAC Mining - type your CPU + GPU" -ForegroundColor Yellow + Write-Host " ============================================================" -ForegroundColor Yellow + Write-Host "" + Write-Host " Write your CPU and GPU (e.g. 9950x + 7900xtx)" + Write-Host " Or pick a number from the lists below." + Write-Host "" + + Show-Catalog $CpuCatalog "CPU options:" + $Cpu = Read-Host " CPU (name or number)" + Write-Host "" + Show-Catalog $GpuCatalog "GPU options:" + $Gpu = Read-Host " GPU (name or number)" +} + +$cpuEntry = Resolve-Entry $Cpu $CpuCatalog +$gpuEntry = Resolve-Entry $Gpu $GpuCatalog + +if (-not $cpuEntry) { + Write-Host "" + Write-Host " Could not recognize CPU: '$Cpu'" -ForegroundColor Red + Write-Host " Examples: 9950x, ryzen7, 7960x, cpu-only" + if (-not $NoPause) { Read-Host " Press Enter to exit" } + exit 1 +} + +if (-not $gpuEntry -and $cpuEntry.Slug -ne "cpu-only") { + Write-Host "" + Write-Host " Could not recognize GPU: '$Gpu'" -ForegroundColor Red + Write-Host " Examples: 7900xtx, 9070xt, 6700xt, none" + if (-not $NoPause) { Read-Host " Press Enter to exit" } + exit 1 +} + +if ($cpuEntry.Slug -eq "cpu-only") { $gpuEntry = $GpuCatalog | Where-Object { $_.Slug -eq "none" } | Select-Object -First 1 } +if ($gpuEntry.Slug -eq "none" -and $cpuEntry.Slug -ne "cpu-only") { + Write-Host "" + Write-Host " GPU set to none - using CPU-only mode." -ForegroundColor Yellow +} + +$comboLabel = "$($cpuEntry.Label) + $($gpuEntry.Label)" +$presetSlug = Get-PresetSlug $cpuEntry $gpuEntry +$presetPow = Join-Path $PresetsDir "poworker\$presetSlug.ini" +$presetDia = Join-Path $PresetsDir "diaworker\$presetSlug.ini" + +Write-Host "" +Write-Host " Matched:" -ForegroundColor Green +Write-Host " CPU: $($cpuEntry.Label)" +Write-Host " GPU: $($gpuEntry.Label)" +if (-not ($cpuEntry.Slug -eq "cpu-only" -or $gpuEntry.Slug -eq "none")) { + Write-Host " supervene: $($cpuEntry.Supervene) gpu_profile: $($gpuEntry.Profile)" +} +Write-Host " preset: $presetSlug" +Write-Host "" + +if ((Test-Path $presetPow) -and (Test-Path $presetDia)) { + Write-Host " Using tuned preset file: presets\$presetSlug.ini" -ForegroundColor Green + $powContent = [System.IO.File]::ReadAllText($presetPow) + $diaContent = [System.IO.File]::ReadAllText($presetDia) +} else { + Write-Host " No exact preset on disk - generating from rules." -ForegroundColor Yellow + $powContent = New-PoworkerContent $cpuEntry $gpuEntry $comboLabel + $diaContent = New-DiaworkerContent $cpuEntry $gpuEntry $comboLabel +} + +$paths = Install-Configs $powContent $diaContent +Write-Host " Installed:" -ForegroundColor Green +foreach ($p in $paths) { Write-Host " $p" } + +Write-Host "" +Write-Host " Next:" +Write-Host " 1. LIST-OPENCL-DEVICES.bat" +Write-Host " 2. Edit platform_id / device_ids if needed" +Write-Host " 3. START-AMD-HAC-MINING.bat" +Write-Host "" + +if (-not $NoPause) { Read-Host " Press Enter to close" } +exit 0 \ No newline at end of file diff --git a/scripts/mining-amd/INSTALL-CONFIGS.bat b/scripts/mining-amd/INSTALL-CONFIGS.bat new file mode 100644 index 00000000..2a41d012 --- /dev/null +++ b/scripts/mining-amd/INSTALL-CONFIGS.bat @@ -0,0 +1,27 @@ +@echo off +setlocal EnableDelayedExpansion + +set "REPO_ROOT=%~dp0..\.." +set "DEBUG_DIR=%REPO_ROOT%\target\debug" +set "RELEASE_DIR=%REPO_ROOT%\target\release" +set "SCRIPT_DIR=%~dp0" + +if not exist "%DEBUG_DIR%" mkdir "%DEBUG_DIR%" + +for %%D in (debug release) do ( + set "OUT=%REPO_ROOT%\target\%%D" + if exist "!OUT!" ( + copy /Y "%SCRIPT_DIR%poworker.amd.ini.example" "!OUT!\poworker.config.ini" >nul + copy /Y "%SCRIPT_DIR%diaworker.amd.ini.example" "!OUT!\diaworker.config.ini" >nul + echo Installed configs in !OUT! + ) +) + +echo. +echo Easiest: CONFIGURE-MINING.bat — type your CPU and GPU (e.g. 9950x + 7900xtx) +echo Or: PICK-PRESET.bat — pick from numbered list (PRESETS-INDEX.txt) +echo. +echo Then: LIST-OPENCL-DEVICES.bat — set platform_id / device_ids +echo. +pause +exit /b 0 \ No newline at end of file diff --git a/scripts/mining-amd/INSTALL-OPENCL-SDK.bat b/scripts/mining-amd/INSTALL-OPENCL-SDK.bat new file mode 100644 index 00000000..203c2fca --- /dev/null +++ b/scripts/mining-amd/INSTALL-OPENCL-SDK.bat @@ -0,0 +1,73 @@ +@echo off +setlocal EnableDelayedExpansion +title Install OpenCL.lib (Khronos ICD via vcpkg) + +set "REPO_ROOT=%~dp0..\.." +set "VCPKG_ROOT=%REPO_ROOT%\.tools\vcpkg" +set "OPENCL_LIB=%VCPKG_ROOT%\installed\x64-windows\lib\OpenCL.lib" +set "CARGO_CONFIG=%REPO_ROOT%\.cargo\config.toml" + +echo. +echo Installing OpenCL ICD loader (OpenCL.lib) for MSVC build... +echo Repo: %REPO_ROOT% +echo. + +if exist "%OPENCL_LIB%" ( + echo Already installed: %OPENCL_LIB% + goto :write_config +) + +where git >nul 2>&1 +if errorlevel 1 ( + echo ERROR: git not found. Install Git for Windows first. + goto :fail +) + +if not exist "%VCPKG_ROOT%" ( + echo Cloning vcpkg into .tools\vcpkg ... + git clone --depth 1 https://github.com/microsoft/vcpkg.git "%VCPKG_ROOT%" + if errorlevel 1 goto :fail +) + +if not exist "%VCPKG_ROOT%\vcpkg.exe" ( + echo Bootstrapping vcpkg... + call "%VCPKG_ROOT%\bootstrap-vcpkg.bat" -disableMetrics + if errorlevel 1 goto :fail +) + +echo Installing opencl:x64-windows (may take a few minutes)... +"%VCPKG_ROOT%\vcpkg.exe" install opencl:x64-windows +if errorlevel 1 goto :fail + +if not exist "%OPENCL_LIB%" ( + echo ERROR: vcpkg finished but OpenCL.lib not found at: + echo %OPENCL_LIB% + goto :fail +) + +echo OK: %OPENCL_LIB% + +:write_config +if not exist "%REPO_ROOT%\.cargo" mkdir "%REPO_ROOT%\.cargo" + +powershell -NoProfile -Command ^ + "$dir=(Resolve-Path (Split-Path '%OPENCL_LIB%')).Path;" ^ + "$esc=$dir -replace '\\','\\\\';" ^ + "$lines=@('[target.x86_64-pc-windows-msvc]','rustflags = [\"-Clink-arg=/LIBPATH:{0}\"]' -f $esc);" ^ + "$utf8=New-Object System.Text.UTF8Encoding $false;" ^ + "[System.IO.File]::WriteAllText('%CARGO_CONFIG%', ($lines -join [Environment]::NewLine) + [Environment]::NewLine, $utf8)" + +echo Wrote %CARGO_CONFIG% +echo. +echo Now run: scripts\mining-amd\BUILD-AMD-MINER.bat +echo. +exit /b 0 + +:fail +echo. +echo Manual alternative: +echo 1. Install Khronos OpenCL-SDK to: +echo C:\Program Files (x86)\OCL_SDK_Light\lib\x86_64\OpenCL.lib +echo 2. Or run FIND-OPENCL-LIB.bat if .lib exists elsewhere +echo. +exit /b 1 \ No newline at end of file diff --git a/scripts/mining-amd/LIST-OPENCL-DEVICES.bat b/scripts/mining-amd/LIST-OPENCL-DEVICES.bat new file mode 100644 index 00000000..6a18081b --- /dev/null +++ b/scripts/mining-amd/LIST-OPENCL-DEVICES.bat @@ -0,0 +1,21 @@ +@echo off +setlocal +set "REPO_ROOT=%~dp0..\.." + +if exist "%REPO_ROOT%\target\release\list_opencl.exe" ( + "%REPO_ROOT%\target\release\list_opencl.exe" + goto :done +) +if exist "%REPO_ROOT%\target\debug\list_opencl.exe" ( + "%REPO_ROOT%\target\debug\list_opencl.exe" + goto :done +) + +echo list_opencl.exe not found. Run BUILD-AMD-MINER.bat first. +pause +exit /b 1 + +:done +echo. +pause +exit /b 0 \ No newline at end of file diff --git a/scripts/mining-amd/PACK-RELEASE.bat b/scripts/mining-amd/PACK-RELEASE.bat new file mode 100644 index 00000000..22f3aad1 --- /dev/null +++ b/scripts/mining-amd/PACK-RELEASE.bat @@ -0,0 +1,18 @@ +@echo off +setlocal +title Pack Windows miner release ZIP + +set "REPO_ROOT=%~dp0..\.." +cd /d "%REPO_ROOT%" + +if not exist "target\release\miner-panel.exe" ( + echo Build first: + echo BUILD-AMD-MINER.bat + echo BUILD-MINER-PANEL.bat + pause + exit /b 1 +) + +powershell -NoProfile -ExecutionPolicy Bypass -File "%REPO_ROOT%\scripts\pack-release.ps1" -Version dev +pause +exit /b 0 \ No newline at end of file diff --git a/scripts/mining-amd/PICK-PRESET.bat b/scripts/mining-amd/PICK-PRESET.bat new file mode 100644 index 00000000..89c9b493 --- /dev/null +++ b/scripts/mining-amd/PICK-PRESET.bat @@ -0,0 +1,140 @@ +@echo off +setlocal EnableDelayedExpansion +title Pick AMD mining preset (CPU + GPU) + +set "REPO_ROOT=%~dp0..\.." +set "SCRIPT_DIR=%~dp0" +set "PRESETS=%SCRIPT_DIR%presets" + +:menu +cls +echo. +echo ============================================================ +echo HAC / HACD AMD Mining — CPU + GPU Presets +echo ============================================================ +echo. +echo Type your hardware instead? Run CONFIGURE-MINING.bat +echo (e.g. CPU: 9950x GPU: 7900xtx) +echo. +echo Or pick a preset number below. +echo Full list: scripts\mining-amd\PRESETS-INDEX.txt +echo. +echo --- Ryzen 5 (entry) --- +echo 01 Ryzen 5 + RX 6600 / 6600 XT (8 GB) +echo 02 Ryzen 5 + RX 7600 (8 GB) +echo. +echo --- Ryzen 7 (mid) --- +echo 03 Ryzen 7 + RX 6700 XT (12 GB) +echo 04 Ryzen 7 + RX 6800 / 6800 XT (16 GB) +echo 05 Ryzen 7 + RX 7900 XT (20 GB) +echo 06 Ryzen 7 + RX 7900 XTX (24 GB) +echo 07 Ryzen 7 + RX 9070 XT (16 GB) +echo. +echo --- Ryzen 9 (12-core) --- +echo 08 Ryzen 9 + RX 6800 / 6800 XT (16 GB) +echo 09 Ryzen 9 + RX 7900 XT (20 GB) +echo 10 Ryzen 9 + RX 7900 XTX (24 GB) +echo 11 Ryzen 9 + RX 9070 XT (16 GB) +echo. +echo --- Ryzen 9 9950X (Zen 5) --- +echo 12 9950X + RX 7900 XT (20 GB) +echo 13 9950X + RX 7900 XTX (24 GB) ^<-- ideal +echo 14 9950X + RX 9070 XT (16 GB) +echo. +echo --- Threadripper --- +echo 15 TR 7960X + RX 7900 XTX +echo 16 TR 7960X + RX 9070 XT +echo 17 TR 7970X + RX 7900 XTX +echo 18 TR 7970X + RX 9070 XT +echo 19 TR 7980X + RX 7900 XTX +echo 20 TR 7980X + RX 9070 XT +echo. +echo --- CPU only (no AMD GPU) --- +echo 21 Ryzen 5 CPU only +echo 22 Ryzen 7 CPU only +echo 23 Ryzen 9 CPU only +echo. +echo 0 Exit +echo. +set /p CHOICE=" Your number: " + +if "%CHOICE%"=="0" exit /b 0 +if "%CHOICE%"=="1" set "SLUG=ryzen5-rx6600" & goto install +if "%CHOICE%"=="01" set "SLUG=ryzen5-rx6600" & goto install +if "%CHOICE%"=="2" set "SLUG=ryzen5-rx7600" & goto install +if "%CHOICE%"=="02" set "SLUG=ryzen5-rx7600" & goto install +if "%CHOICE%"=="3" set "SLUG=ryzen7-rx6700xt" & goto install +if "%CHOICE%"=="03" set "SLUG=ryzen7-rx6700xt" & goto install +if "%CHOICE%"=="4" set "SLUG=ryzen7-rx6800xt" & goto install +if "%CHOICE%"=="04" set "SLUG=ryzen7-rx6800xt" & goto install +if "%CHOICE%"=="5" set "SLUG=ryzen7-rx7900xt" & goto install +if "%CHOICE%"=="05" set "SLUG=ryzen7-rx7900xt" & goto install +if "%CHOICE%"=="6" set "SLUG=ryzen7-rx7900xtx" & goto install +if "%CHOICE%"=="06" set "SLUG=ryzen7-rx7900xtx" & goto install +if "%CHOICE%"=="7" set "SLUG=ryzen7-rx9070xt" & goto install +if "%CHOICE%"=="07" set "SLUG=ryzen7-rx9070xt" & goto install +if "%CHOICE%"=="8" set "SLUG=ryzen9-rx6800xt" & goto install +if "%CHOICE%"=="08" set "SLUG=ryzen9-rx6800xt" & goto install +if "%CHOICE%"=="9" set "SLUG=ryzen9-rx7900xt" & goto install +if "%CHOICE%"=="09" set "SLUG=ryzen9-rx7900xt" & goto install +if "%CHOICE%"=="10" set "SLUG=ryzen9-rx7900xtx" & goto install +if "%CHOICE%"=="11" set "SLUG=ryzen9-rx9070xt" & goto install +if "%CHOICE%"=="12" set "SLUG=ryzen9-9950x-rx7900xt" & goto install +if "%CHOICE%"=="13" set "SLUG=ryzen9-9950x-rx7900xtx" & goto install +if "%CHOICE%"=="14" set "SLUG=ryzen9-9950x-rx9070xt" & goto install +if "%CHOICE%"=="15" set "SLUG=tr-7960x-rx7900xtx" & goto install +if "%CHOICE%"=="16" set "SLUG=tr-7960x-rx9070xt" & goto install +if "%CHOICE%"=="17" set "SLUG=tr-7970x-rx7900xtx" & goto install +if "%CHOICE%"=="18" set "SLUG=tr-7970x-rx9070xt" & goto install +if "%CHOICE%"=="19" set "SLUG=tr-7980x-rx7900xtx" & goto install +if "%CHOICE%"=="20" set "SLUG=tr-7980x-rx9070xt" & goto install +if "%CHOICE%"=="21" set "SLUG=cpu-only-ryzen5" & goto install +if "%CHOICE%"=="22" set "SLUG=cpu-only-ryzen7" & goto install +if "%CHOICE%"=="23" set "SLUG=cpu-only-ryzen9" & goto install + +echo. +echo Invalid choice. Try again. +timeout /t 2 >nul +goto menu + +:install +set "POW_SRC=%PRESETS%\poworker\%SLUG%.ini" +set "DIA_SRC=%PRESETS%\diaworker\%SLUG%.ini" + +if not exist "%POW_SRC%" ( + echo Preset not found: %POW_SRC% + pause + goto menu +) + +echo. +echo Installing preset: %SLUG% +echo. + +for %%D in (debug release) do ( + set "OUT=%REPO_ROOT%\target\%%D" + if exist "!OUT!" ( + copy /Y "%POW_SRC%" "!OUT!\poworker.config.ini" >nul + if exist "%DIA_SRC%" copy /Y "%DIA_SRC%" "!OUT!\diaworker.config.ini" >nul + echo -^> !OUT!\poworker.config.ini + echo -^> !OUT!\diaworker.config.ini + ) +) + +if not exist "%REPO_ROOT%\target\debug" ( + set "OUT=%REPO_ROOT%\target\debug" + mkdir "!OUT!" 2>nul + copy /Y "%POW_SRC%" "!OUT!\poworker.config.ini" >nul + if exist "%DIA_SRC%" copy /Y "%DIA_SRC%" "!OUT!\diaworker.config.ini" >nul + echo -^> !OUT!\poworker.config.ini +) + +echo. +echo Next steps: +echo 1. LIST-OPENCL-DEVICES.bat — check platform_id / device_ids +echo 2. Edit poworker.config.ini if GPU index is not 0 +echo 3. START-AMD-HAC-MINING.bat or START-AMD-HACD-MINING.bat +echo. +echo Close to main menu, or run another preset. +pause +goto menu \ No newline at end of file diff --git a/scripts/mining-amd/PRESETS-INDEX.txt b/scripts/mining-amd/PRESETS-INDEX.txt new file mode 100644 index 00000000..d99c671b --- /dev/null +++ b/scripts/mining-amd/PRESETS-INDEX.txt @@ -0,0 +1,126 @@ +HAC / HACD AMD mining presets — find your CPU + GPU combo +Ετοιμες ρυθμισεις mining — βρες τον συνδυασμο CPU + GPU σου +================================================================ + +Για αρχαριους: τρεξε CONFIGURE-MINING.bat και γραψε CPU + GPU (π.χ. 9950x, 7900xtx) +For beginners: run CONFIGURE-MINING.bat and type your CPU + GPU names. +Or numbered list: PICK-PRESET.bat + +Manual copy: presets\poworker\ and presets\diaworker\ +After install: LIST-OPENCL-DEVICES.bat → set platform_id / device_ids + +[01] ryzen5-rx6600 + CPU: Ryzen 5 (5600X, 7600X, 6 cores / 12 threads) + GPU: RX 6600 / 6600 XT (8 GB VRAM) + supervene=4 gpu_profile=amd_balanced + +[02] ryzen5-rx7600 + CPU: Ryzen 5 (5600X, 7600X, 6 cores / 12 threads) + GPU: RX 7600 (8 GB VRAM) + supervene=4 gpu_profile=amd_balanced + +[03] ryzen7-rx6700xt + CPU: Ryzen 7 (5800X, 7700X, 8 cores / 16 threads) + GPU: RX 6700 XT (12 GB VRAM) + supervene=6 gpu_profile=amd_performance + +[04] ryzen7-rx6800xt + CPU: Ryzen 7 (5800X, 7700X, 8 cores / 16 threads) + GPU: RX 6800 / 6800 XT (16 GB VRAM) + supervene=6 gpu_profile=amd_performance + +[05] ryzen7-rx7900xt + CPU: Ryzen 7 (5800X, 7700X, 8 cores / 16 threads) + GPU: RX 7900 XT (20 GB VRAM) + supervene=6 gpu_profile=amd_performance + +[06] ryzen7-rx7900xtx + CPU: Ryzen 7 (5800X, 7700X, 8 cores / 16 threads) + GPU: RX 7900 XTX (24 GB VRAM) + supervene=6 gpu_profile=amd_max + +[07] ryzen7-rx9070xt + CPU: Ryzen 7 (5800X, 7700X, 8 cores / 16 threads) + GPU: RX 9070 XT (16 GB VRAM, RDNA4) + supervene=6 gpu_profile=amd_performance + +[08] ryzen9-rx6800xt + CPU: Ryzen 9 (5900X, 7900X, 12 cores / 24 threads) + GPU: RX 6800 / 6800 XT (16 GB VRAM) + supervene=8 gpu_profile=amd_performance + +[09] ryzen9-rx7900xt + CPU: Ryzen 9 (5900X, 7900X, 12 cores / 24 threads) + GPU: RX 7900 XT (20 GB VRAM) + supervene=8 gpu_profile=amd_performance + +[10] ryzen9-rx7900xtx + CPU: Ryzen 9 (5900X, 7900X, 12 cores / 24 threads) + GPU: RX 7900 XTX (24 GB VRAM) + supervene=8 gpu_profile=amd_max + +[11] ryzen9-rx9070xt + CPU: Ryzen 9 (5900X, 7900X, 12 cores / 24 threads) + GPU: RX 9070 XT (16 GB VRAM, RDNA4) + supervene=8 gpu_profile=amd_performance + +[12] ryzen9-9950x-rx7900xt + CPU: Ryzen 9 9950X (16 cores / 32 threads) + GPU: RX 7900 XT (20 GB VRAM) + supervene=10 gpu_profile=amd_performance + +[13] ryzen9-9950x-rx7900xtx + CPU: Ryzen 9 9950X (16 cores / 32 threads) + GPU: RX 7900 XTX (24 GB VRAM) + supervene=10 gpu_profile=amd_max + +[14] ryzen9-9950x-rx9070xt + CPU: Ryzen 9 9950X (16 cores / 32 threads) + GPU: RX 9070 XT (16 GB VRAM, RDNA4) + supervene=10 gpu_profile=amd_performance + +[15] tr-7960x-rx7900xtx + CPU: Threadripper 7960X (24 cores / 48 threads) + GPU: RX 7900 XTX (24 GB VRAM) + supervene=14 gpu_profile=amd_max + +[16] tr-7960x-rx9070xt + CPU: Threadripper 7960X (24 cores / 48 threads) + GPU: RX 9070 XT (16 GB VRAM, RDNA4) + supervene=14 gpu_profile=amd_performance + +[17] tr-7970x-rx7900xtx + CPU: Threadripper 7970X (32 cores / 64 threads) + GPU: RX 7900 XTX (24 GB VRAM) + supervene=18 gpu_profile=amd_max + +[18] tr-7970x-rx9070xt + CPU: Threadripper 7970X (32 cores / 64 threads) + GPU: RX 9070 XT (16 GB VRAM, RDNA4) + supervene=18 gpu_profile=amd_performance + +[19] tr-7980x-rx7900xtx + CPU: Threadripper 7980X / 9980WX (64+ cores) + GPU: RX 7900 XTX (24 GB VRAM) + supervene=22 gpu_profile=amd_max + +[20] tr-7980x-rx9070xt + CPU: Threadripper 7980X / 9980WX (64+ cores) + GPU: RX 9070 XT (16 GB VRAM, RDNA4) + supervene=22 gpu_profile=amd_performance + +[21] cpu-only-ryzen5 + CPU: Ryzen 5 CPU only (no AMD GPU / OpenCL off) + GPU: (none) + supervene=10 gpu_profile=cpu-only + +[22] cpu-only-ryzen7 + CPU: Ryzen 7 CPU only (no AMD GPU / OpenCL off) + GPU: (none) + supervene=14 gpu_profile=cpu-only + +[23] cpu-only-ryzen9 + CPU: Ryzen 9 / 9950X CPU only (no AMD GPU) + GPU: (none) + supervene=20 gpu_profile=cpu-only + diff --git a/scripts/mining-amd/SETUP.bat b/scripts/mining-amd/SETUP.bat new file mode 100644 index 00000000..702cf6a0 --- /dev/null +++ b/scripts/mining-amd/SETUP.bat @@ -0,0 +1,10 @@ +@echo off +:: Wrapper: run the main setup from repo root (works for dev and release zip). +set "ROOT=%~dp0..\.." +if exist "%ROOT%SETUP.bat" ( + call "%ROOT%SETUP.bat" + exit /b %ERRORLEVEL% +) +echo SETUP.bat not found at repo root. +pause +exit /b 1 \ No newline at end of file diff --git a/scripts/mining-amd/START-AMD-HAC-MINING.bat b/scripts/mining-amd/START-AMD-HAC-MINING.bat new file mode 100644 index 00000000..a117ef7f --- /dev/null +++ b/scripts/mining-amd/START-AMD-HAC-MINING.bat @@ -0,0 +1,28 @@ +@echo off +setlocal +title HAC miner (AMD OpenCL + Ryzen CPU) + +set "REPO_ROOT=%~dp0..\.." +set "RUN_DIR=%REPO_ROOT%\target\release" +if not exist "%RUN_DIR%\poworker.exe" set "RUN_DIR=%REPO_ROOT%\target\debug" + +if not exist "%RUN_DIR%\poworker.exe" ( + echo poworker.exe not found. Run BUILD-AMD-MINER.bat first. + pause + exit /b 1 +) + +if not exist "%RUN_DIR%\poworker.config.ini" ( + echo Missing poworker.config.ini in %RUN_DIR% + echo Run INSTALL-CONFIGS.bat first. + pause + exit /b 1 +) + +echo Starting HAC block miner from %RUN_DIR% +echo Requires fullnode RPC on connect= in poworker.config.ini +echo. +cd /d "%RUN_DIR%" +poworker.exe +pause +exit /b 0 \ No newline at end of file diff --git a/scripts/mining-amd/START-AMD-HACD-MINING.bat b/scripts/mining-amd/START-AMD-HACD-MINING.bat new file mode 100644 index 00000000..a74b5362 --- /dev/null +++ b/scripts/mining-amd/START-AMD-HACD-MINING.bat @@ -0,0 +1,28 @@ +@echo off +setlocal +title HACD diamond miner (AMD OpenCL + Ryzen CPU) + +set "REPO_ROOT=%~dp0..\.." +set "RUN_DIR=%REPO_ROOT%\target\release" +if not exist "%RUN_DIR%\diaworker.exe" set "RUN_DIR=%REPO_ROOT%\target\debug" + +if not exist "%RUN_DIR%\diaworker.exe" ( + echo diaworker.exe not found. Run BUILD-AMD-MINER.bat first. + pause + exit /b 1 +) + +if not exist "%RUN_DIR%\diaworker.config.ini" ( + echo Missing diaworker.config.ini in %RUN_DIR% + echo Run INSTALL-CONFIGS.bat first. + pause + exit /b 1 +) + +echo Starting HACD diamond miner from %RUN_DIR% +echo Requires fullnode with [diamondminer] enable = true +echo. +cd /d "%RUN_DIR%" +diaworker.exe +pause +exit /b 0 \ No newline at end of file diff --git a/scripts/mining-amd/START-MINER-PANEL.bat b/scripts/mining-amd/START-MINER-PANEL.bat new file mode 100644 index 00000000..edeea056 --- /dev/null +++ b/scripts/mining-amd/START-MINER-PANEL.bat @@ -0,0 +1,16 @@ +@echo off +setlocal +title HAC Miner Panel (GUI) + +set "REPO_ROOT=%~dp0..\.." +set "BIN=%REPO_ROOT%\target\release" + +if not exist "%BIN%\miner-panel.exe" ( + echo miner-panel.exe not found. Run BUILD-MINER-PANEL.bat first. + pause + exit /b 1 +) + +cd /d "%BIN%" +start "" "%BIN%\miner-panel.exe" +exit /b 0 \ No newline at end of file diff --git a/scripts/mining-amd/TUNE-AMD-EFFICIENCY.bat b/scripts/mining-amd/TUNE-AMD-EFFICIENCY.bat new file mode 100644 index 00000000..58b9c5a6 --- /dev/null +++ b/scripts/mining-amd/TUNE-AMD-EFFICIENCY.bat @@ -0,0 +1,37 @@ +@echo off +setlocal EnableDelayedExpansion +title Tune AMD miner efficiency + +set "REPO_ROOT=%~dp0..\.." +set "SCRIPT_DIR=%~dp0" +set /a CORES=%NUMBER_OF_PROCESSORS% +set /a CPU_THREADS=%CORES% +if %CPU_THREADS% gtr 8 set /a CPU_THREADS=8 +if %CPU_THREADS% lss 2 set /a CPU_THREADS=2 + +echo. +echo Ryzen logical cores: %CORES% +echo Suggested supervene (CPU assist): %CPU_THREADS% +echo GPU profile: amd_performance (edit in config to amd_max if VRAM allows) +echo. + +for %%D in (debug release) do ( + set "CFG=%REPO_ROOT%\target\%%D\poworker.config.ini" + if exist "!CFG!" ( + echo Updating !CFG! + powershell -NoProfile -Command ^ + "$p='!CFG!'; $t=Get-Content $p -Raw; $t=$t -replace '(?m)^supervene\s*=\s*\d+','supervene = %CPU_THREADS%'; if($t -notmatch 'cpu_assist'){$t=$t -replace '(\[gpu\])','$1`ncpu_assist = true'}; if($t -notmatch 'gpu_profile'){$t=$t -replace '(\[gpu\])','$1`ngpu_profile = amd_performance'}; Set-Content $p $t" + ) + set "CFG=%REPO_ROOT%\target\%%D\diaworker.config.ini" + if exist "!CFG!" ( + powershell -NoProfile -Command ^ + "$p='!CFG!'; $t=Get-Content $p -Raw; $t=$t -replace '(?m)^supervene\s*=\s*\d+','supervene = %CPU_THREADS%'; Set-Content $p $t" + ) +) + +echo. +echo Done. Rebuild if code changed: BUILD-AMD-MINER.bat +echo Delete old *.bin in x16rs/opencl if kernels were updated. +echo. +pause +exit /b 0 \ No newline at end of file diff --git a/scripts/mining-amd/diaworker.amd.ini.example b/scripts/mining-amd/diaworker.amd.ini.example new file mode 100644 index 00000000..52d75cce --- /dev/null +++ b/scripts/mining-amd/diaworker.amd.ini.example @@ -0,0 +1,24 @@ +; HACD diamond miner — AMD GPU + Ryzen hybrid +; Requires [diamondminer] enable = true in hacash.config.ini + +connect = 127.0.0.1:8080 +supervene = 4 + +[efficiency] +mode = profit +power_cost_kwh = 0.15 +dynamic_supervene = true +supervene_min = 2 +supervene_max = 0 + +[gpu] +use_opencl = true +cpu_assist = true +gpu_profile = amd_performance +platform_id = 0 +device_ids = 0 +opencl_dir = ../../x16rs/opencl/ +work_groups = 2048 +local_size = 256 +unit_size = 96 +debug = 0 \ No newline at end of file diff --git a/scripts/mining-amd/poworker.amd.ini.example b/scripts/mining-amd/poworker.amd.ini.example new file mode 100644 index 00000000..45331820 --- /dev/null +++ b/scripts/mining-amd/poworker.amd.ini.example @@ -0,0 +1,39 @@ +; HAC block miner — AMD GPU + Ryzen hybrid (efficiency tuned) +; Copy via INSTALL-CONFIGS.bat or CONFIGURE-MINING.bat + +connect = 127.0.0.1:8080 +supervene = 6 +nonce_max = 4294967295 +notice_wait = 45 + +[efficiency] +; mode: eco | profit | max (profit = best HAC per kWh, default) +mode = profit +power_cost_kwh = 0.15 +gpu_watts = 0 +cpu_watts_per_thread = 8 +hac_price = 0 +dynamic_supervene = true +supervene_min = 2 +supervene_max = 0 +oom_fallback = true +max_temp_c = 0 +throttle_work_groups = 1024 +thermal_file = +idle_start_hour = 255 +idle_end_hour = 255 +pause_if_unprofitable = false +benchmark_seconds = 0 + +[gpu] +use_opencl = true +cpu_assist = true +; Presets: amd_eco | amd_profit | amd_balanced | amd_performance | amd_max +gpu_profile = amd_profit +platform_id = 0 +device_ids = 0 +opencl_dir = ../../x16rs/opencl/ +work_groups = 1536 +local_size = 256 +unit_size = 96 +debug = 0 \ No newline at end of file diff --git a/scripts/pack-release.ps1 b/scripts/pack-release.ps1 new file mode 100644 index 00000000..f9eafd0f --- /dev/null +++ b/scripts/pack-release.ps1 @@ -0,0 +1,107 @@ +# Packages Windows miner release ZIPs from target\release. +# Produces TWO downloads: +# - hacash-miner-only-* (workers + panel; you already have fullnode) +# - hacash-miner-full-* (fullnode + workers + panel; clean PC) +param( + [string]$Version = "dev", + [string]$OutDir = "dist" +) + +$ErrorActionPreference = "Stop" +$Root = Split-Path $PSScriptRoot -Parent +$Release = Join-Path $Root "target\release" +$opencl = Join-Path $Root "x16rs\opencl" + +if (-not (Test-Path $Release)) { + throw "Missing folder: $Release — run cargo build first." +} +if (-not (Test-Path (Join-Path $opencl "x16rs_main.cl"))) { + throw "Missing OpenCL kernels: $opencl" +} + +$minerOnlyExes = @( + "poworker.exe", + "diaworker.exe", + "list_opencl.exe", + "miner-panel.exe" +) +$fullExes = @("hacash.exe") + $minerOnlyExes + +foreach ($e in $fullExes) { + if (-not (Test-Path (Join-Path $Release $e))) { + throw "Missing binary: $(Join-Path $Release $e)" + } +} + +function Copy-OpenClKernels { + param([string]$Stage) + $oclDest = Join-Path $Stage "x16rs\opencl" + New-Item -ItemType Directory -Force -Path $oclDest | Out-Null + Get-ChildItem $opencl -Filter "*.cl" | Copy-Item -Destination $oclDest +} + +function Copy-Logo { + param([string]$Stage) + $logo = Join-Path $Root "miner-panel\assets\hhh.png" + if (Test-Path $logo) { + Copy-Item $logo (Join-Path $Stage "hhh.png") + } +} + +function Pack-Flavor { + param( + [string]$PackageName, + [string[]]$Exes, + [string[]]$Extras, + [string]$Version + ) + + $Stage = Join-Path $OutDir $PackageName + if (Test-Path $Stage) { Remove-Item $Stage -Recurse -Force } + New-Item -ItemType Directory -Force -Path $Stage | Out-Null + + foreach ($e in $Exes) { + Copy-Item (Join-Path $Release $e) (Join-Path $Stage $e) + } + Copy-OpenClKernels $Stage + Copy-Logo $Stage + + foreach ($f in $Extras) { + $src = Join-Path $Root $f + if (Test-Path $src) { + Copy-Item $src (Join-Path $Stage $f) + } + } + + Set-Content -Path (Join-Path $Stage "VERSION.txt") -Value $Version -NoNewline + + New-Item -ItemType Directory -Force -Path $OutDir | Out-Null + $zipName = if ($Version -match "^v") { + "$PackageName-$Version.zip" + } else { + "$PackageName.zip" + } + $zipPath = Join-Path $OutDir $zipName + if (Test-Path $zipPath) { Remove-Item $zipPath -Force } + Compress-Archive -Path $Stage -DestinationPath $zipPath -CompressionLevel Optimal + return $zipPath +} + +$common = @("START-MINER-PANEL.bat", "LIST-OPENCL.bat") + +$zipMiner = Pack-Flavor ` + -PackageName "hacash-miner-only-windows-x64" ` + -Exes $minerOnlyExes ` + -Extras ($common + @("SETUP-MINER.bat", "README-MINER-ONLY.txt")) ` + -Version $Version + +$zipFull = Pack-Flavor ` + -PackageName "hacash-miner-full-windows-x64" ` + -Exes $fullExes ` + -Extras ($common + @("SETUP.bat", "README-RELEASE.txt")) ` + -Version $Version + +Write-Host "" +Write-Host " Packaged (miner only): $zipMiner" +Write-Host " Packaged (full stack): $zipFull" +Write-Host "" \ No newline at end of file diff --git a/sdk/Cargo.toml b/sdk/Cargo.toml index b8e9ef50..06fb53b9 100644 --- a/sdk/Cargo.toml +++ b/sdk/Cargo.toml @@ -14,4 +14,12 @@ field = {path = "../field"} basis = {path = "../basis"} protocol = {path = "../protocol"} hex = "0.4.3" +serde_json = "1.0" +argon2 = "0.5.3" +aes-gcm = "0.10.3" +getrandom = { version = "0.3", features = ["wasm_js"] } wasm-bindgen = "=0.2.100" + +[target.'cfg(target_arch = "wasm32")'.dependencies] +getrandom02 = { package = "getrandom", version = "0.2", features = ["js"] } +getrandom04 = { package = "getrandom", version = "0.4", features = ["wasm_js"] } \ No newline at end of file diff --git a/sdk/js/hacashsdk.mjs b/sdk/js/hacashsdk.mjs index 9d255fb3..daac20c4 100644 --- a/sdk/js/hacashsdk.mjs +++ b/sdk/js/hacashsdk.mjs @@ -135,17 +135,85 @@ function createFriendlyApi(rawApi, env) { const param = new rawApi.SignTxParam(); const prikey = pickField(src, ["prikey"]); const body = pickField(src, ["body"]); + const hybrid_keystore = pickField(src, ["hybrid_keystore"]); + const keystore_pass = pickField(src, ["keystore_pass"]); if (prikey !== undefined && prikey !== null) { param.prikey = String(prikey); } if (body !== undefined && body !== null) { param.body = String(body); } + if (hybrid_keystore !== undefined && hybrid_keystore !== null) { + param.hybrid_keystore = String(hybrid_keystore); + } + if (keystore_pass !== undefined && keystore_pass !== null) { + param.keystore_pass = String(keystore_pass); + } + return param; + }; + + const create_sign_tx_v4_param = (input) => { + if (isInstanceOf(input, rawApi.SignTxV4Param)) { + return input; + } + const src = ensureObjectParam("create_sign_tx_v4_param", input); + const param = new rawApi.SignTxV4Param(); + const body = pickField(src, ["body"]); + const hybrid_keystore = pickField(src, ["hybrid_keystore"]); + const keystore_pass = pickField(src, ["keystore_pass"]); + if (body !== undefined && body !== null) { + param.body = String(body); + } + if (hybrid_keystore !== undefined && hybrid_keystore !== null) { + param.hybrid_keystore = String(hybrid_keystore); + } + if (keystore_pass !== undefined && keystore_pass !== null) { + param.keystore_pass = String(keystore_pass); + } + return param; + }; + + const create_coin_transfer_v4_param = (input) => { + if (isInstanceOf(input, rawApi.CoinTransferV4Param)) { + return input; + } + const src = ensureObjectParam("create_coin_transfer_v4_param", input); + const param = new rawApi.CoinTransferV4Param(); + const main_keystore = pickField(src, ["main_keystore"]); + const keystore_pass = pickField(src, ["keystore_pass"]); + const fee = pickField(src, ["fee"]); + const to_address = pickField(src, ["to_address"]); + const hacash = pickField(src, ["hacash"]); + const timestamp = pickField(src, ["timestamp"]); + const gas_max = pickField(src, ["gas_max"]); + if (main_keystore !== undefined && main_keystore !== null) { + param.main_keystore = String(main_keystore); + } + if (keystore_pass !== undefined && keystore_pass !== null) { + param.keystore_pass = String(keystore_pass); + } + if (fee !== undefined && fee !== null) { + param.fee = String(fee); + } + if (to_address !== undefined && to_address !== null) { + param.to_address = String(to_address); + } + if (hacash !== undefined && hacash !== null) { + param.hacash = String(hacash); + } + if (timestamp !== undefined && timestamp !== null) { + param.timestamp = toU64BigInt("timestamp", timestamp); + } + if (gas_max !== undefined && gas_max !== null) { + param.gas_max = Number(gas_max); + } return param; }; const create_coin_transfer = (input) => rawApi.create_coin_transfer(create_coin_transfer_param(input)); + const create_coin_transfer_v4 = (input) => rawApi.create_coin_transfer_v4(create_coin_transfer_v4_param(input)); const sign_transaction = (input) => rawApi.sign_transaction(create_sign_tx_param(input)); + const sign_transaction_v4 = (input) => rawApi.sign_transaction_v4(create_sign_tx_v4_param(input)); const sdk = { env, @@ -155,19 +223,36 @@ function createFriendlyApi(rawApi, env) { coin_transfer_param_class: rawApi.CoinTransferParam, coin_transfer_result_class: rawApi.CoinTransferResult, sign_tx_param_class: rawApi.SignTxParam, + sign_tx_v4_param_class: rawApi.SignTxV4Param, sign_tx_result_class: rawApi.SignTxResult, verify_address_result_class: rawApi.VerifyAddressResult, + coin_transfer_v4_param_class: rawApi.CoinTransferV4Param, + hybrid_account_info_class: rawApi.HybridAccountInfo, + hybrid_account_with_keystore_class: rawApi.HybridAccountWithKeystore, + hybrid_keystore_export_class: rawApi.HybridKeystoreExport, to_u64_bigint: (field, value) => toU64BigInt(field, value), create_coin_transfer_param, + create_coin_transfer_v4_param, create_sign_tx_param, + create_sign_tx_v4_param, create_account: rawApi.create_account, + create_pqc_account: rawApi.create_pqc_account, + create_hybrid_account: rawApi.create_hybrid_account, + create_hybrid_from_privakey: rawApi.create_hybrid_from_privakey, + create_pqc_account_keystore: rawApi.create_pqc_account_keystore, + create_hybrid_account_keystore: rawApi.create_hybrid_account_keystore, + export_hybrid_keystore: rawApi.export_hybrid_keystore, + unlock_hybrid_keystore: rawApi.unlock_hybrid_keystore, + address_version_label: rawApi.address_version_label, hac_to_unit: rawApi.hac_to_unit, hac_to_mei: rawApi.hac_to_mei, verify_address: rawApi.verify_address, create_coin_transfer, + create_coin_transfer_v4, sign_transaction, + sign_transaction_v4, }; return sdk; diff --git a/sdk/src/account.rs b/sdk/src/account.rs index 7f07d9c6..51b28399 100644 --- a/sdk/src/account.rs +++ b/sdk/src/account.rs @@ -1,56 +1,66 @@ - #[wasm_bindgen(getter_with_clone, inspectable)] pub struct Account { - pub prikey: String, - pub pubkey: String, - pub address: String, + pub prikey: String, + pub pubkey: String, + pub address: String, pub address_hex: String, } - /* stuff is private key or password */ #[wasm_bindgen] pub fn create_account(pass: &str) -> Ret { - SysAccount::create_by(pass).map(|acc|{ - Account{ - prikey: hex::encode(&acc.secret_key().serialize()), - pubkey: hex::encode(&acc.public_key().serialize_compressed()), - address_hex: hex::encode(acc.address()), - address: acc.readable().to_owned(), - } + SysAccount::create_by(pass).map(|acc| Account { + prikey: hex::encode(&acc.secret_key().serialize()), + pubkey: hex::encode(&acc.public_key().serialize_compressed()), + address_hex: hex::encode(acc.address()), + address: acc.readable().to_owned(), }) } - - - -/* - verify address -*/ #[wasm_bindgen(getter_with_clone, inspectable)] pub struct VerifyAddressResult { pub ok: bool, - pub error: String, + pub error: String, + pub version: u8, + pub version_label: String, } #[wasm_bindgen] pub fn verify_address(pass: &str) -> VerifyAddressResult { - let re = |e| VerifyAddressResult{ ok: false, error: e }; + let re = |e: String| VerifyAddressResult { + ok: false, + error: e, + version: 255, + version_label: String::new(), + }; let addr = match Address::from_readable(pass) { Ok(a) => a, - Err(e) => return re(e.to_string()) + Err(e) => return re(e), }; if let Err(e) = addr.check_version() { - return re(e.to_string()) + return re(e); } - // ok - VerifyAddressResult{ ok: true, error: "".into() } -} - + let version = addr.version(); + let version_label = match version { + Address::PRIVAKEY => "privakey (v0)", + Address::PQCKEY => "pqckey (v6)", + Address::HYBRID => "hybrid (v7)", + Address::CONTRACT => "contract (v1)", + Address::SCRIPTMH => "scriptmh (v5)", + v => return re(format!("unknown address version {v}")), + } + .to_owned(); + VerifyAddressResult { + ok: true, + error: String::new(), + version, + version_label, + } +} \ No newline at end of file diff --git a/sdk/src/coin.rs b/sdk/src/coin.rs index 011ae21d..a44fecd4 100644 --- a/sdk/src/coin.rs +++ b/sdk/src/coin.rs @@ -160,3 +160,60 @@ pub fn create_coin_transfer(param: CoinTransferParam) -> Ret timestamp: ts, }) } + +#[derive(Default)] +#[wasm_bindgen(getter_with_clone, inspectable)] +pub struct CoinTransferV4Param { + pub main_keystore: String, + pub keystore_pass: String, + pub fee: String, + pub to_address: String, + pub timestamp: u64, + pub hacash: String, + pub gas_max: u8, +} + +#[wasm_bindgen] +impl CoinTransferV4Param { + #[wasm_bindgen(constructor)] + pub fn new() -> Self { + Self::default() + } +} + +#[wasm_bindgen] +pub fn create_coin_transfer_v4(param: CoinTransferV4Param) -> Ret { + use basis::interface::{Transaction, TransactionRead}; + use protocol::action::HacToTrs; + use protocol::transaction::TransactionType4; + + let main = q_hybrid_acc!(param.main_keystore, param.keystore_pass); + let mainaddr = Address::from(*main.address()); + if !mainaddr.is_pqckey() && !mainaddr.is_hybrid() { + return errf!("type 4 transfer main address must be pqckey or hybrid"); + } + let fee = q_amt!(param.fee); + let toaddr = q_adr!(param.to_address); + let ts = if param.timestamp == 0 { + curtimes() + } else { + param.timestamp + }; + + let mut tx = TransactionType4::new_by(mainaddr, fee, ts); + tx.gas_max = Uint1::from(param.gas_max); + + if param.hacash.is_empty() { + return errf!("hacash amount required for type 4 transfer"); + } + let hac = Amount::from(¶m.hacash).map_err(|e| format!("hacash invalid: {e}"))?; + tx.push_action(Box::new(HacToTrs::create_by(toaddr, hac)))?; + tx.fill_hybrid_sign(&main)?; + + Ok(CoinTransferResult { + hash: tx.hash().to_hex(), + hash_with_fee: tx.hash_with_fee().to_hex(), + body: tx.serialize().to_hex(), + timestamp: ts, + }) +} diff --git a/sdk/src/hybrid.rs b/sdk/src/hybrid.rs new file mode 100644 index 00000000..3cea9ecc --- /dev/null +++ b/sdk/src/hybrid.rs @@ -0,0 +1,133 @@ +use sys::{HybridAccount, HybridAccountKind}; + +#[derive(Clone)] +#[wasm_bindgen(getter_with_clone, inspectable)] +pub struct HybridAccountInfo { + pub kind: String, + pub address: String, + pub address_hex: String, + pub address_version: u8, + pub mldsa_pubkey: String, + pub secp_pubkey: String, + pub alg_id: u8, +} + +fn hybrid_info_from(acc: &HybridAccount) -> HybridAccountInfo { + let kind = match acc.kind() { + HybridAccountKind::PqcOnly => "pqckey", + HybridAccountKind::Hybrid => "hybrid", + }; + let secp_pubkey = acc + .secp_account() + .map(|a| hex::encode(a.public_key().serialize_compressed())) + .unwrap_or_default(); + HybridAccountInfo { + kind: kind.to_owned(), + address: acc.readable().to_owned(), + address_hex: hex::encode(acc.address()), + address_version: acc.address()[0], + mldsa_pubkey: hex::encode(acc.mldsa_public_key_bytes()), + secp_pubkey, + alg_id: acc.sign_alg_id(), + } +} + +pub fn sdk_random_fill(buf: &mut [u8]) -> Rerr { + random_fill(buf) +} + +#[wasm_bindgen] +pub fn create_pqc_account() -> Ret { + let acc = HybridAccount::create_pqc_randomly(&sdk_random_fill)?; + Ok(hybrid_info_from(&acc)) +} + +#[wasm_bindgen] +pub fn create_hybrid_account() -> Ret { + let acc = HybridAccount::create_hybrid_randomly(&sdk_random_fill)?; + Ok(hybrid_info_from(&acc)) +} + +#[wasm_bindgen] +pub fn create_hybrid_from_privakey(prikey_hex: &str) -> Ret { + let secp = q_acc!(prikey_hex); + let acc = HybridAccount::create_hybrid_from_secp(secp)?; + Ok(hybrid_info_from(&acc)) +} + +#[wasm_bindgen(getter_with_clone, inspectable)] +pub struct HybridKeystoreExport { + pub json: String, + pub address: String, + pub kind: String, +} + +#[wasm_bindgen(getter_with_clone, inspectable)] +pub struct HybridAccountWithKeystore { + pub info: HybridAccountInfo, + pub keystore: String, +} + +#[wasm_bindgen] +pub fn create_pqc_account_keystore(password: &str) -> Ret { + let acc = HybridAccount::create_pqc_randomly(&sdk_random_fill)?; + let blob = acc.export_key_blob()?; + let ks = keystore_export_blob(&blob, acc.readable(), password)?; + Ok(HybridAccountWithKeystore { + info: hybrid_info_from(&acc), + keystore: ks.json, + }) +} + +#[wasm_bindgen] +pub fn create_hybrid_account_keystore(password: &str, prikey_hex: &str) -> Ret { + let acc = if prikey_hex.len() == 64 { + let secp = q_acc!(prikey_hex); + HybridAccount::create_hybrid_from_secp(secp)? + } else { + HybridAccount::create_hybrid_randomly(&sdk_random_fill)? + }; + let blob = acc.export_key_blob()?; + let ks = keystore_export_blob(&blob, acc.readable(), password)?; + Ok(HybridAccountWithKeystore { + info: hybrid_info_from(&acc), + keystore: ks.json, + }) +} + +#[wasm_bindgen] +pub fn export_hybrid_keystore(json: &str, password: &str, new_password: &str) -> Ret { + let acc = hybrid_account_from_keystore(json, password)?; + let blob = acc.export_key_blob()?; + let ks = keystore_export_blob(&blob, acc.readable(), new_password)?; + let info = hybrid_info_from(&acc); + Ok(HybridKeystoreExport { + json: ks.json, + address: info.address, + kind: info.kind, + }) +} + +pub fn hybrid_account_from_keystore(json: &str, password: &str) -> Ret { + let blob = keystore_unlock_blob(json, password)?; + HybridAccount::from_key_blob(&blob) +} + +#[wasm_bindgen] +pub fn unlock_hybrid_keystore(json: &str, password: &str) -> Ret { + let acc = hybrid_account_from_keystore(json, password)?; + Ok(hybrid_info_from(&acc)) +} + +#[wasm_bindgen] +pub fn address_version_label(address: &str) -> Ret { + let addr = q_adr!(address); + Ok(match addr.version() { + Address::PRIVAKEY => "privakey (v0)".to_owned(), + Address::PQCKEY => "pqckey (v6)".to_owned(), + Address::HYBRID => "hybrid (v7)".to_owned(), + Address::CONTRACT => "contract (v1)".to_owned(), + Address::SCRIPTMH => "scriptmh (v5)".to_owned(), + v => format!("unknown (v{v})"), + }) +} \ No newline at end of file diff --git a/sdk/src/keystore.rs b/sdk/src/keystore.rs new file mode 100644 index 00000000..5c533fba --- /dev/null +++ b/sdk/src/keystore.rs @@ -0,0 +1,251 @@ +use sys::HybridKeyBlob; + +pub const KEYSTORE_VERSION: u32 = 3; + +/// JSON keystore v3 (browser-wallet friendly). +/// +/// ```json +/// { +/// "version": 3, +/// "kind": "pqckey" | "hybrid", +/// "address": "base58check", +/// "mldsa_pk": "hex", +/// "secp_pubkey": "hex|null", +/// "kdf": "argon2id", +/// "kdf_salt": "hex", +/// "kdf_m_cost_kb": 19456, +/// "kdf_t_cost": 2, +/// "kdf_p_cost": 1, +/// "cipher": "aes-256-gcm", +/// "cipher_nonce": "hex", +/// "ciphertext": "hex" +/// } +/// ``` +#[derive(Debug, Clone)] +pub struct HybridKeystoreV3 { + pub json: String, +} + +pub fn keystore_export_blob(blob: &HybridKeyBlob, address: &str, pass: &str) -> Ret { + if pass.len() < 8 { + return errf!("keystore password must be at least 8 characters"); + } + let kind = match blob.kind { + 1 => "pqckey", + 3 => "hybrid", + _ => return errf!("unsupported hybrid key kind {}", blob.kind), + }; + let mut plain = Vec::with_capacity(1 + blob.mldsa_sk.len() + 33); + plain.push(blob.kind); + plain.extend_from_slice(&blob.mldsa_sk); + if let Some(sk) = blob.secp_sk { + plain.extend_from_slice(&sk); + } + let salt = random_bytes(16)?; + let key = derive_key_argon2id(pass, &salt)?; + let nonce = random_bytes(12)?; + let ciphertext = aes_gcm_encrypt(&key, &nonce, &plain)?; + let secp_pubkey = blob.secp_sk.map(|sk| { + SysAccount::create_by_secret_key_value(sk) + .map(|a| hex::encode(a.public_key().serialize_compressed())) + .unwrap_or_default() + }); + let json = serde_json::json!({ + "version": KEYSTORE_VERSION, + "kind": kind, + "address": address, + "mldsa_pk": hex::encode(&blob.mldsa_pk), + "secp_pubkey": secp_pubkey, + "kdf": "argon2id", + "kdf_salt": hex::encode(&salt), + "kdf_m_cost_kb": 19456u32, + "kdf_t_cost": 2u32, + "kdf_p_cost": 1u32, + "cipher": "aes-256-gcm", + "cipher_nonce": hex::encode(&nonce), + "ciphertext": hex::encode(&ciphertext), + }); + Ok(HybridKeystoreV3 { + json: json.to_string(), + }) +} + +pub fn keystore_unlock_blob(json: &str, pass: &str) -> Ret { + let v: serde_json::Value = + serde_json::from_str(json).map_err(|e: serde_json::Error| e.to_string())?; + if v["version"].as_u64() != Some(KEYSTORE_VERSION as u64) { + return errf!("keystore version must be {}", KEYSTORE_VERSION); + } + let kind = match v["kind"].as_str() { + Some("pqckey") => 1u8, + Some("hybrid") => 3u8, + _ => return errf!("keystore kind invalid"), + }; + let salt = hex_field(&v, "kdf_salt")?; + let m_cost = v["kdf_m_cost_kb"].as_u64().unwrap_or(19456) as u32; + let t_cost = v["kdf_t_cost"].as_u64().unwrap_or(2) as u32; + let p_cost = v["kdf_p_cost"].as_u64().unwrap_or(1) as u32; + let nonce = hex_field(&v, "cipher_nonce")?; + let ciphertext = hex_field(&v, "ciphertext")?; + let key = derive_key_argon2id_params(pass, &salt, m_cost, t_cost, p_cost)?; + let plain = aes_gcm_decrypt(&key, &nonce, &ciphertext)?; + if plain.is_empty() { + return errf!("keystore plaintext empty"); + } + let blob_kind = plain[0]; + if blob_kind != kind { + return errf!("keystore kind mismatch"); + } + let sk_len = mldsa65_secret_key_size(); + if plain.len() < 1 + sk_len { + return errf!("keystore plaintext too short"); + } + let mldsa_sk = plain[1..1 + sk_len].to_vec(); + let secp_sk = if kind == 3 { + if plain.len() != 1 + sk_len + 32 { + return errf!("hybrid keystore plaintext size invalid"); + } + let mut sk = [0u8; 32]; + sk.copy_from_slice(&plain[1 + sk_len..1 + sk_len + 32]); + Some(sk) + } else { + None + }; + let mldsa_pk = hex_field(&v, "mldsa_pk")?; + Ok(HybridKeyBlob { + kind, + mldsa_sk, + secp_sk, + mldsa_pk, + }) +} + +fn hex_field(v: &serde_json::Value, key: &str) -> Ret> { + let s = v + .get(key) + .and_then(|x| x.as_str()) + .ok_or_else(|| format!("keystore field {} missing", key))?; + hex::decode(s).map_err(|e: hex::FromHexError| e.to_string()) +} + +fn derive_key_argon2id(pass: &str, salt: &[u8]) -> Ret<[u8; 32]> { + derive_key_argon2id_params(pass, salt, 19456, 2, 1) +} + +fn derive_key_argon2id_params( + pass: &str, + salt: &[u8], + m_cost_kb: u32, + t_cost: u32, + p_cost: u32, +) -> Ret<[u8; 32]> { + use argon2::{Algorithm, Argon2, Params, Version}; + let params = Params::new(m_cost_kb, t_cost, p_cost, Some(32)) + .map_err(|e: argon2::Error| e.to_string())?; + let argon = Argon2::new(Algorithm::Argon2id, Version::V0x13, params); + let mut key = [0u8; 32]; + argon + .hash_password_into(pass.as_bytes(), salt, &mut key) + .map_err(|e: argon2::Error| e.to_string())?; + Ok(key) +} + +fn aes_gcm_encrypt(key: &[u8; 32], nonce: &[u8], plain: &[u8]) -> Ret> { + use aes_gcm::aead::{Aead, KeyInit}; + use aes_gcm::{Aes256Gcm, Nonce}; + if nonce.len() != 12 { + return errf!("aes-gcm nonce must be 12 bytes"); + } + let cipher = Aes256Gcm::new_from_slice(key).map_err(|e| e.to_string())?; + let nonce = Nonce::from_slice(nonce); + cipher + .encrypt(nonce, plain) + .map_err(|e: aes_gcm::Error| e.to_string()) +} + +fn aes_gcm_decrypt(key: &[u8; 32], nonce: &[u8], ciphertext: &[u8]) -> Ret> { + use aes_gcm::aead::{Aead, KeyInit}; + use aes_gcm::{Aes256Gcm, Nonce}; + if nonce.len() != 12 { + return errf!("aes-gcm nonce must be 12 bytes"); + } + let cipher = Aes256Gcm::new_from_slice(key).map_err(|e| e.to_string())?; + let nonce = Nonce::from_slice(nonce); + cipher + .decrypt(nonce, ciphertext) + .map_err(|e: aes_gcm::Error| "keystore decrypt failed: bad password or corrupted data".to_string()) +} + +pub fn random_bytes(n: usize) -> Ret> { + let mut buf = vec![0u8; n]; + random_fill(&mut buf)?; + Ok(buf) +} + +pub fn random_fill(buf: &mut [u8]) -> Rerr { + getrandom::fill(buf).map_err(|e: getrandom::Error| e.to_string()) +} + +#[cfg(test)] +mod keystore_tests { + use super::*; + use sys::HybridAccount; + + #[test] + fn keystore_v3_roundtrip_pqc() { + let acc = HybridAccount::create_pqc_randomly(&|b| { + for (i, x) in b.iter_mut().enumerate() { + *x = i as u8; + } + Ok(()) + }) + .unwrap(); + let blob = acc.export_key_blob().unwrap(); + let ks = keystore_export_blob(&blob, acc.readable(), "test-password-123").unwrap(); + let got = keystore_unlock_blob(&ks.json, "test-password-123").unwrap(); + let acc2 = HybridAccount::from_key_blob(&got).unwrap(); + assert_eq!(acc.address(), acc2.address()); + } + + #[test] + fn keystore_v3_roundtrip_hybrid() { + let acc = HybridAccount::create_hybrid_randomly(&|b| { + for (i, x) in b.iter_mut().enumerate() { + *x = (i as u8).wrapping_add(9); + } + Ok(()) + }) + .unwrap(); + let blob = acc.export_key_blob().unwrap(); + let ks = keystore_export_blob(&blob, acc.readable(), "hybrid-pass-12345").unwrap(); + assert!(ks.json.contains("\"kind\":\"hybrid\"")); + let got = keystore_unlock_blob(&ks.json, "hybrid-pass-12345").unwrap(); + let acc2 = HybridAccount::from_key_blob(&got).unwrap(); + assert_eq!(acc.address(), acc2.address()); + assert!(acc2.is_hybrid()); + } + + #[test] + #[ignore = "demo: cargo test -p sdk demo_print_hybrid_keystore -- --ignored --exact --nocapture"] + fn demo_print_hybrid_keystore() { + let acc = HybridAccount::create_hybrid_randomly(&|b| { + for (i, x) in b.iter_mut().enumerate() { + *x = (i as u8).wrapping_add(7); + } + Ok(()) + }) + .unwrap(); + let blob = acc.export_key_blob().unwrap(); + let ks = keystore_export_blob(&blob, acc.readable(), "hybrid-pass-12345").unwrap(); + println!("HYBRID_ADDRESS={}", acc.readable()); + println!("KEYSTORE_JSON={}", ks.json); + } + + #[test] + fn keystore_v3_rejects_bad_password() { + let acc = HybridAccount::create_pqc_randomly(&|_| Ok(())).unwrap(); + let blob = acc.export_key_blob().unwrap(); + let ks = keystore_export_blob(&blob, acc.readable(), "correct-password").unwrap(); + assert!(keystore_unlock_blob(&ks.json, "wrong-password").is_err()); + } +} \ No newline at end of file diff --git a/sdk/src/lib.rs b/sdk/src/lib.rs index 1281f7e3..413201b9 100644 --- a/sdk/src/lib.rs +++ b/sdk/src/lib.rs @@ -15,6 +15,8 @@ use wasm_bindgen::prelude::*; include! {"param.rs"} include! {"util.rs"} +include! {"keystore.rs"} +include! {"hybrid.rs"} include! {"account.rs"} include! {"coin.rs"} include! {"sign.rs"} diff --git a/sdk/src/param.rs b/sdk/src/param.rs index 4b7276cc..76123ec5 100644 --- a/sdk/src/param.rs +++ b/sdk/src/param.rs @@ -30,6 +30,15 @@ macro_rules! q_adr { }) } +macro_rules! q_hybrid_acc { + ($keystore: expr, $pass: expr) => {{ + match hybrid_account_from_keystore(&$keystore, &$pass) { + Err(e) => return errf!("hybrid keystore unlock failed: {}", e), + Ok(a) => a, + } + }}; +} + diff --git a/sdk/src/sign.rs b/sdk/src/sign.rs index af7127e5..25b786f4 100644 --- a/sdk/src/sign.rs +++ b/sdk/src/sign.rs @@ -1,69 +1,120 @@ - #[derive(Default)] #[wasm_bindgen(getter_with_clone, inspectable)] pub struct SignTxParam { pub prikey: String, - pub body: String, // hex + pub body: String, + pub hybrid_keystore: String, + pub keystore_pass: String, } - - #[wasm_bindgen] impl SignTxParam { - #[wasm_bindgen(constructor)] pub fn new() -> Self { Self::default() } - } +#[derive(Default)] +#[wasm_bindgen(getter_with_clone, inspectable)] +pub struct SignTxV4Param { + pub body: String, + pub hybrid_keystore: String, + pub keystore_pass: String, +} +#[wasm_bindgen] +impl SignTxV4Param { + #[wasm_bindgen(constructor)] + pub fn new() -> Self { + Self::default() + } +} #[wasm_bindgen(getter_with_clone, inspectable)] pub struct SignTxResult { - pub hash: String, + pub hash: String, pub hash_with_fee: String, - pub body: String, // tx body with signature - pub signature: String, - pub timestamp: u64, // tx timestamp + pub body: String, + pub signature: String, + pub timestamp: u64, + pub tx_type: u8, + pub sign_alg: u8, } +fn parse_tx_body(body_hex: &str) -> Ret<(Box, usize)> { + let Ok(body) = hex::decode(body_hex) else { + return errf!("tx body hex decode failed"); + }; + protocol::transaction::transaction_create(&body).map_err(|e| e.to_string()) +} - - +fn finish_sign_result( + trs: &dyn basis::interface::TransactionRead, + body_hex: String, + signature_hex: String, + sign_alg: u8, +) -> SignTxResult { + SignTxResult { + hash: trs.hash().to_hex(), + hash_with_fee: trs.hash_with_fee().to_hex(), + body: body_hex, + signature: signature_hex, + timestamp: trs.timestamp().uint(), + tx_type: trs.ty(), + sign_alg, + } +} /* - sign one tx + sign one tx (legacy Types 1–3, or Type 4 when hybrid_keystore is set) */ #[wasm_bindgen] pub fn sign_transaction(param: SignTxParam) -> Ret { - - use protocol::transaction; - + use protocol::transaction::TransactionType4; + + let (mut trs, _) = parse_tx_body(¶m.body)?; + if trs.ty() == TransactionType4::TYPE { + if param.hybrid_keystore.is_empty() { + return errf!("type 4 transaction requires hybrid_keystore"); + } + return sign_transaction_v4(SignTxV4Param { + body: param.body, + hybrid_keystore: param.hybrid_keystore, + keystore_pass: param.keystore_pass, + }); + } let acc = q_acc!(param.prikey); - // let accadr = Address::from(acc.address().clone()); - let Ok(body) = hex::decode(¶m.body) else { - return errf!("tx body hex decode failed") - }; - let (mut trs, _) = match transaction::transaction_create(&body) { - Ok(v) => v, - Err(e) => return errf!("tx parse failed: {}", e), - }; - let Ok(signature) = trs.fill_sign(&acc) else { - return errf!("sign failed") - }; - // ok finish - Ok(SignTxResult { - hash: trs.hash().to_hex(), - hash_with_fee: trs.hash_with_fee().to_hex(), - body: trs.serialize().to_hex(), - signature: signature.signature.to_hex(), - timestamp: trs.timestamp().uint(), - }) + let signature = trs.fill_sign(&acc)?; + let body_hex = trs.serialize().to_hex(); + Ok(finish_sign_result( + trs.as_read(), + body_hex, + signature.signature.to_hex(), + 0, + )) } +#[wasm_bindgen] +pub fn sign_transaction_v4(param: SignTxV4Param) -> Ret { + use basis::interface::Transaction; + use protocol::transaction::TransactionType4; - - + if param.hybrid_keystore.is_empty() { + return errf!("hybrid_keystore required"); + } + let (mut trs, _) = parse_tx_body(¶m.body)?; + if trs.ty() != TransactionType4::TYPE { + return errf!("sign_transaction_v4 requires transaction type 4"); + } + let hybrid = q_hybrid_acc!(param.hybrid_keystore, param.keystore_pass); + let signobj = trs.fill_hybrid_sign(&hybrid)?; + let body_hex = trs.serialize().to_hex(); + Ok(finish_sign_result( + trs.as_read(), + body_hex, + signobj.body.to_hex(), + signobj.alg_id(), + )) +} \ No newline at end of file diff --git a/src/bin/list_opencl.rs b/src/bin/list_opencl.rs new file mode 100644 index 00000000..5c99b98d --- /dev/null +++ b/src/bin/list_opencl.rs @@ -0,0 +1,11 @@ +fn main() { + #[cfg(feature = "ocl")] + { + app::opencl_list::list_opencl_devices(); + } + #[cfg(not(feature = "ocl"))] + { + eprintln!("Rebuild with OpenCL support: cargo build --release --features ocl"); + std::process::exit(1); + } +} \ No newline at end of file diff --git a/sys/Cargo.toml b/sys/Cargo.toml index d16389a1..fbb2fda3 100644 --- a/sys/Cargo.toml +++ b/sys/Cargo.toml @@ -14,5 +14,6 @@ sha3 = "0.10.1" blake2 = "0.10.6" ripemd = "0.1.1" libsecp256k1 = { version = "0.7.2", features = ["hmac", "static-context"], default-features = false } -# getrandom = { version = "0.3.2", features = ["wasm_js"] } +ml-dsa = { version = "0.1.1", features = ["getrandom", "rand_core", "alloc"] } +getrandom = { version = "0.3", features = ["wasm_js"] } async-broadcast = "0.7.2" diff --git a/sys/src/lib.rs b/sys/src/lib.rs index eb77bb22..30270143 100644 --- a/sys/src/lib.rs +++ b/sys/src/lib.rs @@ -22,5 +22,6 @@ include! {"hash.rs"} include! {"ini.rs"} include! {"time.rs"} include! {"account.rs"} +include! {"pqc.rs"} include! {"config.rs"} include! {"exiter.rs"} diff --git a/sys/src/pqc.rs b/sys/src/pqc.rs new file mode 100644 index 00000000..4d7a162d --- /dev/null +++ b/sys/src/pqc.rs @@ -0,0 +1,450 @@ +use ml_dsa::{ + ExpandedSigningKey, ExpandedSigningKeyBytes, Generate, KeyExport, KeyInit, Keypair, + MlDsa65, Signature as MlDsaSignature, SignatureEncoding, SigningKey, VerifyingKey, +}; + +const SECP_PK_SIZE: usize = 33; +const SECP_SIG_SIZE: usize = 64; + +/// ML-DSA-65 domain-separation context for Hacash Type4 transaction signatures. +pub const MLDSA_TX_DOMAIN_CTX: &[u8] = b"HACASH_TX4"; + +pub type MldsaPublicKey = VerifyingKey; +pub type MldsaSecretKey = ExpandedSigningKey; + +const MLDSA65_PK_BYTES: usize = 1952; +const MLDSA65_SIG_BYTES: usize = 3309; +const MLDSA65_SK_BYTES: usize = 4032; + +#[derive(Clone, PartialEq)] +pub enum HybridAccountKind { + PqcOnly, + Hybrid, +} + +#[derive(Clone, PartialEq)] +pub struct HybridAccount { + kind: HybridAccountKind, + secp: Option, + mldsa_sk: MldsaSecretKey, + mldsa_pk: MldsaPublicKey, + mldsa_pk_bytes: Vec, + address: [u8; ADDRESS_SIZE], + address_readable: String, +} + +impl HybridAccount { + pub fn kind(&self) -> &HybridAccountKind { + &self.kind + } + + pub fn is_pqc_only(&self) -> bool { + matches!(self.kind, HybridAccountKind::PqcOnly) + } + + pub fn is_hybrid(&self) -> bool { + matches!(self.kind, HybridAccountKind::Hybrid) + } + + pub fn secp_account(&self) -> Option<&Account> { + self.secp.as_ref() + } + + pub fn mldsa_public_key(&self) -> &MldsaPublicKey { + &self.mldsa_pk + } + + pub fn mldsa_public_key_bytes(&self) -> &[u8] { + &self.mldsa_pk_bytes + } + + pub fn address(&self) -> &[u8; ADDRESS_SIZE] { + &self.address + } + + pub fn readable(&self) -> &str { + &self.address_readable + } + + pub fn check_addr(&self, addr: &[u8]) -> Rerr { + if self.address == *addr { + return Ok(()); + } + errf!( + "HybridAccount check failed: expected {} but got {}", + self.address_readable, + Account::to_base58check(addr) + ) + } + + pub fn create_pqc_randomly(randomfill: &dyn Fn(&mut [u8]) -> Rerr) -> Ret { + let _ = randomfill; + let signing = SigningKey::::generate(); + let mldsa_sk = ExpandedSigningKey::from_seed(signing.as_seed()); + let mldsa_pk = signing.verifying_key().clone(); + let mldsa_pk_bytes = pk_to_vec(&mldsa_pk); + let address = get_pqckey_address(&mldsa_pk_bytes); + let addrshow = Account::to_readable(&address); + Ok(HybridAccount { + kind: HybridAccountKind::PqcOnly, + secp: None, + mldsa_sk, + mldsa_pk, + mldsa_pk_bytes, + address, + address_readable: addrshow, + }) + } + + pub fn create_hybrid_randomly(randomfill: &dyn Fn(&mut [u8]) -> Rerr) -> Ret { + let secp = Account::create_randomly(randomfill)?; + let signing = SigningKey::::generate(); + let mldsa_sk = ExpandedSigningKey::from_seed(signing.as_seed()); + let mldsa_pk = signing.verifying_key().clone(); + Self::from_secp_and_mldsa(secp, mldsa_sk, mldsa_pk) + } + + pub fn create_hybrid_from_secp(secp: Account) -> Ret { + let signing = SigningKey::::generate(); + let mldsa_sk = ExpandedSigningKey::from_seed(signing.as_seed()); + let mldsa_pk = signing.verifying_key().clone(); + Self::from_secp_and_mldsa(secp, mldsa_sk, mldsa_pk) + } + + fn from_secp_and_mldsa( + secp: Account, + mldsa_sk: MldsaSecretKey, + mldsa_pk: MldsaPublicKey, + ) -> Ret { + let secp_pk = secp.public_key().serialize_compressed(); + let mldsa_pk_bytes = pk_to_vec(&mldsa_pk); + let address = get_hybrid_address(&secp_pk, &mldsa_pk_bytes); + let addrshow = Account::to_readable(&address); + Ok(HybridAccount { + kind: HybridAccountKind::Hybrid, + secp: Some(secp), + mldsa_sk, + mldsa_pk, + mldsa_pk_bytes, + address, + address_readable: addrshow, + }) + } + + pub fn get_pqckey_address(mldsa_pk: &[u8]) -> [u8; ADDRESS_SIZE] { + get_pqckey_address(mldsa_pk) + } + + pub fn get_hybrid_address(secp_pk: &[u8; SECP_PK_SIZE], mldsa_pk: &[u8]) -> [u8; ADDRESS_SIZE] { + get_hybrid_address(secp_pk, mldsa_pk) + } + + pub fn sign_hash(&self, hash: &[u8; 32]) -> Ret> { + let sig = self + .mldsa_sk + .sign_deterministic(hash, MLDSA_TX_DOMAIN_CTX) + .map_err(|e| e.to_string())?; + let sig_bytes = sig.to_bytes(); + match self.kind { + HybridAccountKind::PqcOnly => { + let mut body = Vec::with_capacity(public_key_bytes() + signature_bytes()); + body.extend_from_slice(&self.mldsa_pk_bytes); + body.extend_from_slice(sig_bytes.as_ref()); + Ok(body) + } + HybridAccountKind::Hybrid => { + let secp = self + .secp + .as_ref() + .ok_or_else(|| "hybrid account missing secp key".to_string())?; + let secp_pk = secp.public_key().serialize_compressed(); + let secp_sig = secp.do_sign(hash); + let mut body = Vec::with_capacity( + SECP_PK_SIZE + SECP_SIG_SIZE + public_key_bytes() + signature_bytes(), + ); + body.extend_from_slice(&secp_pk); + body.extend_from_slice(&secp_sig); + body.extend_from_slice(&self.mldsa_pk_bytes); + body.extend_from_slice(sig_bytes.as_ref()); + Ok(body) + } + } + } + + pub fn sign_alg_id(&self) -> u8 { + match self.kind { + HybridAccountKind::PqcOnly => 1, + HybridAccountKind::Hybrid => 3, + } + } + + pub fn export_key_blob(&self) -> Ret { + let kind = self.sign_alg_id(); + let mldsa_sk = expanded_sk_to_vec(&self.mldsa_sk); + let secp_sk = match &self.secp { + Some(acc) => Some(acc.secret_key().serialize()), + None => None, + }; + Ok(HybridKeyBlob { + kind, + mldsa_sk, + secp_sk, + mldsa_pk: self.mldsa_pk_bytes.clone(), + }) + } + + pub fn from_key_blob(blob: &HybridKeyBlob) -> Ret { + if blob.mldsa_sk.len() != secret_key_bytes() { + return errf!( + "mldsa secret key size {} expected {}", + blob.mldsa_sk.len(), + secret_key_bytes() + ); + } + if blob.mldsa_pk.len() != public_key_bytes() { + return errf!( + "mldsa public key size {} expected {}", + blob.mldsa_pk.len(), + public_key_bytes() + ); + } + let mldsa_sk = expanded_sk_from_bytes(&blob.mldsa_sk)?; + let mldsa_pk = VerifyingKey::::new_from_slice(&blob.mldsa_pk) + .map_err(|e| e.to_string())?; + let mldsa_pk_bytes = pk_to_vec(&mldsa_pk); + if pk_to_vec(&mldsa_sk.verifying_key()) != mldsa_pk_bytes { + return errf!("hybrid key blob mldsa pk/sk mismatch"); + } + match blob.kind { + 1 => { + let address = get_pqckey_address(&mldsa_pk_bytes); + Ok(HybridAccount { + kind: HybridAccountKind::PqcOnly, + secp: None, + mldsa_sk, + mldsa_pk, + mldsa_pk_bytes, + address, + address_readable: Account::to_readable(&address), + }) + } + 3 => { + let secp_sk = blob + .secp_sk + .ok_or_else(|| "hybrid key blob missing secp secret".to_string())?; + let secp = Account::create_by_secret_key_value(secp_sk)?; + Self::from_secp_and_mldsa(secp, mldsa_sk, mldsa_pk) + } + _ => errf!("hybrid key blob kind {} not supported", blob.kind), + } + } +} + +#[derive(Clone, PartialEq, Eq, Debug)] +pub struct HybridKeyBlob { + pub kind: u8, + pub mldsa_sk: Vec, + pub secp_sk: Option<[u8; 32]>, + pub mldsa_pk: Vec, +} + +pub fn mldsa65_secret_key_size() -> usize { + secret_key_bytes() +} + +fn pk_to_vec(pk: &MldsaPublicKey) -> Vec { + pk.to_bytes().into_iter().collect() +} + +fn secret_key_bytes() -> usize { + MLDSA65_SK_BYTES +} + +fn public_key_bytes() -> usize { + MLDSA65_PK_BYTES +} + +fn signature_bytes() -> usize { + MLDSA65_SIG_BYTES +} + +fn expanded_sk_to_vec(sk: &MldsaSecretKey) -> Vec { + #[allow(deprecated)] + { + sk.to_expanded().into_iter().collect() + } +} + +fn expanded_sk_from_bytes(bytes: &[u8]) -> Ret { + if bytes.len() != secret_key_bytes() { + return errf!( + "mldsa expanded secret key size {} expected {}", + bytes.len(), + secret_key_bytes() + ); + } + let mut enc: ExpandedSigningKeyBytes = Default::default(); + for (dst, src) in enc.iter_mut().zip(bytes.iter()) { + *dst = *src; + } + #[allow(deprecated)] + { + Ok(ExpandedSigningKey::from_expanded(&enc)) + } +} + +pub fn get_pqckey_address(mldsa_pk: &[u8]) -> [u8; ADDRESS_SIZE] { + let dt = sha2(mldsa_pk); + let dt = ripemd160(dt); + let version = 6u8; + let mut addr = [version; ADDRESS_SIZE]; + addr[1..].copy_from_slice(&dt[..]); + addr +} + +pub fn get_hybrid_address(secp_pk: &[u8; SECP_PK_SIZE], mldsa_pk: &[u8]) -> [u8; ADDRESS_SIZE] { + let mut stuff = Vec::with_capacity(1 + SECP_PK_SIZE + 1 + mldsa_pk.len()); + stuff.push(0x01); + stuff.extend_from_slice(secp_pk); + stuff.push(0x02); + stuff.extend_from_slice(mldsa_pk); + let dt = sha2(stuff); + let dt = ripemd160(dt); + let version = 7u8; + let mut addr = [version; ADDRESS_SIZE]; + addr[1..].copy_from_slice(&dt[..]); + addr +} + +pub fn legacy_secp_sign_body(acc: &Account, hash: &[u8; 32]) -> Vec { + let mut body = Vec::with_capacity(SECP_PK_SIZE + SECP_SIG_SIZE); + body.extend_from_slice(&acc.public_key().serialize_compressed()); + body.extend_from_slice(&acc.do_sign(hash)); + body +} + +pub type MldsaVerifyObserver = fn(u64); + +static MLDSA_VERIFY_OBSERVER: std::sync::OnceLock = std::sync::OnceLock::new(); + +pub fn set_mldsa_verify_observer(observer: MldsaVerifyObserver) { + let _ = MLDSA_VERIFY_OBSERVER.set(observer); +} + +#[inline] +fn observe_mldsa_verify_us(us: u64) { + if let Some(obs) = MLDSA_VERIFY_OBSERVER.get() { + obs(us); + } +} + +pub fn verify_mldsa65_detached(msg: &[u8; 32], pk_bytes: &[u8], sig_bytes: &[u8]) -> bool { + let start = std::time::Instant::now(); + let Ok(pk) = VerifyingKey::::new_from_slice(pk_bytes) else { + return false; + }; + let Ok(sig) = MlDsaSignature::::try_from(sig_bytes) else { + return false; + }; + let ok = pk.verify_with_context(msg, MLDSA_TX_DOMAIN_CTX, &sig); + let us = start.elapsed().as_micros().min(u64::MAX as u128) as u64; + observe_mldsa_verify_us(us); + ok +} + +pub fn address_from_hybrid_sign_body(alg: u8, body: &[u8]) -> Ret<[u8; ADDRESS_SIZE]> { + match alg { + 0 => { + if body.len() != SECP_PK_SIZE + SECP_SIG_SIZE { + return errf!("legacy secp hybrid sign body length invalid"); + } + let mut pk = [0u8; SECP_PK_SIZE]; + pk.copy_from_slice(&body[..SECP_PK_SIZE]); + Ok(Account::get_address_by_public_key(pk)) + } + 1 => { + if body.len() != public_key_bytes() + signature_bytes() { + return errf!("mldsa65 hybrid sign body length invalid"); + } + let pk = &body[..public_key_bytes()]; + Ok(get_pqckey_address(pk)) + } + 3 => { + let expect = SECP_PK_SIZE + SECP_SIG_SIZE + public_key_bytes() + signature_bytes(); + if body.len() != expect { + return errf!("hybrid sign body length invalid"); + } + let mut secp_pk = [0u8; SECP_PK_SIZE]; + secp_pk.copy_from_slice(&body[..SECP_PK_SIZE]); + let mldsa_off = SECP_PK_SIZE + SECP_SIG_SIZE; + let mldsa_pk = &body[mldsa_off..mldsa_off + public_key_bytes()]; + Ok(get_hybrid_address(&secp_pk, mldsa_pk)) + } + _ => errf!("hybrid sign alg {} not supported", alg), + } +} + +#[cfg(test)] +mod pqc_tests { + use super::*; + + #[test] + fn mldsa65_sizes_match_wire_constants() { + assert_eq!(public_key_bytes(), 1952); + assert_eq!(signature_bytes(), 3309); + assert_eq!(secret_key_bytes(), 4032); + } + + #[test] + fn pqc_roundtrip_sign_verify() { + let acc = HybridAccount::create_pqc_randomly(&|buf| { + for (i, b) in buf.iter_mut().enumerate() { + *b = (i as u8).wrapping_add(3); + } + Ok(()) + }) + .unwrap(); + let msg = sha2(b"test-message"); + let body = acc.sign_hash(&msg).unwrap(); + let pk = &body[..public_key_bytes()]; + let sig = &body[public_key_bytes()..]; + assert!(verify_mldsa65_detached(&msg, pk, sig)); + assert_eq!(acc.address(), &get_pqckey_address(pk)); + } + + #[test] + fn hybrid_roundtrip_sign_verify() { + let acc = HybridAccount::create_hybrid_randomly(&|buf| { + for b in buf.iter_mut() { + *b = 7; + } + Ok(()) + }) + .unwrap(); + let msg = sha2(b"hybrid-test"); + let body = acc.sign_hash(&msg).unwrap(); + let secp_pk: [u8; SECP_PK_SIZE] = body[..SECP_PK_SIZE].try_into().unwrap(); + let secp_sig = &body[SECP_PK_SIZE..SECP_PK_SIZE + SECP_SIG_SIZE]; + let mldsa_off = SECP_PK_SIZE + SECP_SIG_SIZE; + let mldsa_pk = &body[mldsa_off..mldsa_off + public_key_bytes()]; + let mldsa_sig = &body[mldsa_off + public_key_bytes()..]; + assert!(Account::verify_signature(&msg, &secp_pk, secp_sig.try_into().unwrap())); + assert!(verify_mldsa65_detached(&msg, mldsa_pk, mldsa_sig)); + assert_eq!( + acc.address(), + &get_hybrid_address(&secp_pk, mldsa_pk) + ); + } + + #[test] + fn key_blob_roundtrip() { + let acc = HybridAccount::create_pqc_randomly(&|_| Ok(())).unwrap(); + let blob = acc.export_key_blob().unwrap(); + let acc2 = HybridAccount::from_key_blob(&blob).unwrap(); + assert_eq!(acc.address(), acc2.address()); + let msg = sha2(b"blob-test"); + let body1 = acc.sign_hash(&msg).unwrap(); + let body2 = acc2.sign_hash(&msg).unwrap(); + assert_eq!(body1, body2); + } +} \ No newline at end of file diff --git a/tests/pqc_hybrid_integration.rs b/tests/pqc_hybrid_integration.rs new file mode 100644 index 00000000..951d494a --- /dev/null +++ b/tests/pqc_hybrid_integration.rs @@ -0,0 +1,108 @@ +use basis::interface::{StateOperat, Transaction, TransactionRead, TxExec}; +use field::*; +use protocol::action::HacToTrs; +use protocol::transaction::TransactionType4; +use protocol::upgrade::{check_gated_tx, DEV_OPEN_MAX_HEIGHT, MAINNET_CHAIN_ID, PQC_TYPE4_OPEN_HEIGHT}; +use sys::*; + +use testkit::sim::integration::ensure_standard_protocol_setup_for_tests; + +fn init_setup() { + ensure_standard_protocol_setup_for_tests(|_, stuff| sys::calculate_hash(stuff), false); +} + +fn random_fill(buf: &mut [u8]) -> Rerr { + for (i, b) in buf.iter_mut().enumerate() { + *b = (i as u8).wrapping_add(11); + } + Ok(()) +} + +fn fund_main(ctx: &mut protocol::context::ContextInst<'_>, main: &Address) { + let mut st = protocol::state::CoreState::wrap(ctx.state()); + let mut bls = st.balance(main).unwrap_or_default(); + bls.hacash = Amount::unit238(10_000_000_000_000); + st.balance_set(main, &bls); +} + +#[test] +fn pqc_type4_wire_roundtrip_and_verify() { + init_setup(); + let acc = HybridAccount::create_pqc_randomly(&random_fill).unwrap(); + let main = Address::from(*acc.address()); + let to = Address::from_readable(Account::create_by("recipient-1").unwrap().readable()).unwrap(); + let mut tx = TransactionType4::new_by(main, Amount::unit238(1000), 1_730_000_000); + tx.push_action(Box::new(HacToTrs::create_by(to, Amount::unit238(10)))) + .unwrap(); + tx.fill_hybrid_sign(&acc).unwrap(); + tx.verify_signature().unwrap(); + + let wire = tx.serialize(); + let (decoded, _) = TransactionType4::create(&wire).unwrap(); + decoded.verify_signature().unwrap(); + assert_eq!(decoded.size(), tx.size()); + assert!(decoded.size() < TransactionType4::MAX_WIRE_SIZE); +} + +#[test] +fn hybrid_type4_dual_alg_verify() { + init_setup(); + let acc = HybridAccount::create_hybrid_randomly(&random_fill).unwrap(); + let main = Address::from(*acc.address()); + let mut tx = TransactionType4::new_by(main, Amount::unit238(500), 1_730_000_001); + tx.fill_hybrid_sign(&acc).unwrap(); + tx.verify_signature().unwrap(); + assert!(acc.is_hybrid()); + assert!(main.is_hybrid()); +} + +#[test] +fn type4_rejects_before_activation_height_on_mainnet() { + let height = DEV_OPEN_MAX_HEIGHT.saturating_add(1); + assert!(check_gated_tx(MAINNET_CHAIN_ID, height, 4).is_err()); + assert!(check_gated_tx(MAINNET_CHAIN_ID, PQC_TYPE4_OPEN_HEIGHT, 4).is_ok()); + assert!(check_gated_tx(MAINNET_CHAIN_ID, 0, 4).is_ok()); +} + +#[test] +fn type4_execute_simple_transfer_in_dev_window() { + init_setup(); + let acc = HybridAccount::create_pqc_randomly(&random_fill).unwrap(); + let main = Address::from(*acc.address()); + let to = Address::from_readable(Account::create_by("recipient-2").unwrap().readable()).unwrap(); + + let mut tx = TransactionType4::new_by(main, Amount::unit238(1000), 1_730_000_002); + tx.push_action(Box::new(HacToTrs::create_by(to, Amount::unit238(50)))) + .unwrap(); + tx.fill_hybrid_sign(&acc).unwrap(); + + let mut env = basis::component::Env::default(); + env.block.height = 100; + env.chain.id = MAINNET_CHAIN_ID; + env.tx = protocol::transaction::create_tx_info(&tx); + + let state: Box = + Box::new(testkit::sim::state::FlatMemState::default()); + let logs: Box = Box::new(testkit::sim::logs::MemLogs::new()); + let mut ctx = testkit::sim::context::make_ctx_with_logs(env, state, logs, &tx); + fund_main(&mut ctx, &main); + + tx.execute(&mut ctx).unwrap(); + + let st = protocol::state::CoreState::wrap(ctx.state()); + let to_bal = st.balance(&to).unwrap_or_default().hacash; + assert_eq!(to_bal, Amount::unit238(50)); +} + +#[test] +fn type4_rejects_wrong_alg_for_pqckey_main() { + init_setup(); + let pqc = HybridAccount::create_pqc_randomly(&random_fill).unwrap(); + let hybrid = HybridAccount::create_hybrid_randomly(&random_fill).unwrap(); + let main = Address::from(*pqc.address()); + let mut tx = TransactionType4::new_by(main, Amount::unit238(100), 1); + let sign = tx + .create_hybrid_sign_by(&hybrid, &tx.hash_with_fee()) + .unwrap_err(); + assert!(sign.contains("PQCKEY") || sign.contains("ML-DSA"), "{}", sign); +} \ No newline at end of file diff --git a/tests/pqc_mempool_metrics_integration.rs b/tests/pqc_mempool_metrics_integration.rs new file mode 100644 index 00000000..23276571 --- /dev/null +++ b/tests/pqc_mempool_metrics_integration.rs @@ -0,0 +1,63 @@ +use basis::interface::Transaction; +use field::*; +use protocol::action::HacToTrs; +use protocol::transaction::TransactionType4; +use protocol::upgrade::{check_gated_tx, DEV_OPEN_MAX_HEIGHT, MAINNET_CHAIN_ID, PQC_TYPE4_OPEN_HEIGHT}; +use sys::*; + +use testkit::sim::integration::ensure_standard_protocol_setup_for_tests; + +fn init_setup() { + ensure_standard_protocol_setup_for_tests(|_, stuff| sys::calculate_hash(stuff), false); +} + +fn random_fill(buf: &mut [u8]) -> Rerr { + for (i, b) in buf.iter_mut().enumerate() { + *b = (i as u8).wrapping_add(3); + } + Ok(()) +} + +#[test] +fn type4_mempool_gate_respects_dev_and_activation_window() { + assert!(check_gated_tx(MAINNET_CHAIN_ID, 0, 4).is_ok()); + assert!(check_gated_tx(MAINNET_CHAIN_ID, DEV_OPEN_MAX_HEIGHT, 4).is_ok()); + let mid = DEV_OPEN_MAX_HEIGHT.saturating_add(1); + assert!(check_gated_tx(MAINNET_CHAIN_ID, mid, 4).is_err()); + assert!(check_gated_tx(MAINNET_CHAIN_ID, PQC_TYPE4_OPEN_HEIGHT, 4).is_ok()); +} + +#[test] +fn type4_wire_size_within_mempool_cap() { + init_setup(); + let acc = HybridAccount::create_hybrid_randomly(&random_fill).unwrap(); + let main = Address::from(*acc.address()); + let to = Address::from_readable(Account::create_by("recv").unwrap().readable()).unwrap(); + let mut tx = TransactionType4::new_by(main, Amount::unit238(500), 1_730_100_000); + tx.push_action(Box::new(HacToTrs::create_by(to, Amount::unit238(1)))) + .unwrap(); + tx.fill_hybrid_sign(&acc).unwrap(); + let wire = tx.serialize(); + assert!(wire.len() >= 5_000, "type4 wire should be ~5-6KB, got {}", wire.len()); + assert!(wire.len() <= 16 * 1024); + let cap = protocol::transaction::effective_max_tx_wire_size(16 * 1024, 4); + assert!(wire.len() <= cap); +} + +#[test] +fn mldsa_verify_observer_records_timing() { + use std::sync::atomic::{AtomicU64, Ordering}; + + static SEEN_US: AtomicU64 = AtomicU64::new(0); + sys::set_mldsa_verify_observer(|us| { + SEEN_US.fetch_add(us, Ordering::Relaxed); + }); + + let acc = HybridAccount::create_pqc_randomly(&random_fill).unwrap(); + let msg = sha2(b"metrics-observer-test"); + let body = acc.sign_hash(&msg).unwrap(); + let pk = &body[..1952]; + let sig = &body[1952..]; + assert!(verify_mldsa65_detached(&msg, pk, sig)); + assert!(SEEN_US.load(Ordering::Relaxed) > 0); +} \ No newline at end of file diff --git a/x16rs/opencl/aes_helper.cl b/x16rs/opencl/aes_helper.cl index 6f4088cd..9bbeba61 100644 --- a/x16rs/opencl/aes_helper.cl +++ b/x16rs/opencl/aes_helper.cl @@ -388,7 +388,11 @@ static const sph_u32 AES3_C[256] = { AESx(0x7BCBB0B0), AESx(0xA8FC5454), AESx(0x6DD6BBBB), AESx(0x2C3A1616) }; -#define BYTE(x, y) (amd_bfe((x), (y) << 3U, 8U)) +#ifdef NO_AMD_OPS +#define BYTE(x, y) (((x) >> ((y) << 3U)) & 0xFFU) +#else +#define BYTE(x, y) (amd_bfe((x), (y) << 3U, 8U)) +#endif uint4 AES_Round(const __local uint *AES0, const __local uint *AES1, const __local uint *AES2, const __local uint *AES3, const uint4 X, uint4 key) { diff --git a/x16rs/opencl/util.cl b/x16rs/opencl/util.cl index 31902640..310cba79 100644 --- a/x16rs/opencl/util.cl +++ b/x16rs/opencl/util.cl @@ -1,6 +1,14 @@ #ifndef X16RX_UTIL_CL #define X16RX_UTIL_CL +#ifdef NVIDIA_GPU + #define X16RS_PRAGMA_UNROLL_8 _Pragma("clang unroll(8)") + #define X16RS_PRAGMA_UNROLL_4 _Pragma("clang unroll(4)") +#else + #define X16RS_PRAGMA_UNROLL_8 + #define X16RS_PRAGMA_UNROLL_4 +#endif + #define ALIGN8 __attribute__((aligned(8))) #define ALIGN __attribute__((aligned(16))) #define ALIGN32 __attribute__((aligned(32))) diff --git a/x16rs/opencl/x16rs.cl b/x16rs/opencl/x16rs.cl index 4792d024..23ed207a 100644 --- a/x16rs/opencl/x16rs.cl +++ b/x16rs/opencl/x16rs.cl @@ -38,7 +38,9 @@ typedef int sph_s32; #define SPH_SMALL_FOOTPRINT_HAMSI 0 #define SPH_HAMSI_SHORT 1 #define SPH_HAMSI_EXPAND_BIG 1 +#ifndef NO_AMD_OPS #define NO_AMD_OPS 1 +#endif #define SPH_COMPACT_BLAKE_64 0 #define SPH_SIMD_NOCOPY 0 #define SPH_SMALL_FOOTPRINT_JH 1 @@ -148,6 +150,7 @@ typedef union ALIGN { (offset)[(local_id)] = 0; \ } \ barrier(CLK_LOCAL_MEM_FENCE); \ + X16RS_PRAGMA_UNROLL_8 \ for (unsigned int h = 0; h < (unit_size); h++) { \ unsigned char mod = (local_hashes)[(index) + h].h4[7] % 16; \ atomic_inc(&(histogram)[mod]); \ @@ -160,12 +163,14 @@ typedef union ALIGN { } \ } \ barrier(CLK_LOCAL_MEM_FENCE); \ + X16RS_PRAGMA_UNROLL_8 \ for (unsigned int h = 0; h < (unit_size); h++) { \ unsigned int mod = (local_hashes)[(index) + h].h4[7] % 16; \ unsigned int pos = (starting_index)[mod] + atomic_inc(&(offset)[mod]); \ (local_order)[pos] = (index) + h; \ } \ barrier(CLK_LOCAL_MEM_FENCE); \ + X16RS_PRAGMA_UNROLL_4 \ for (unsigned int h = 0; h < (unit_size); h++) { \ const unsigned int* hash_pos = &(local_order)[((local_size) * h) + (local_id)]; \ switch ((local_hashes)[hash_pos[0]].h4[7] % 16) { \ diff --git a/x16rs/opencl/x16rs_diamond.cl b/x16rs/opencl/x16rs_diamond.cl index 156aef95..69d0a52a 100644 --- a/x16rs/opencl/x16rs_diamond.cl +++ b/x16rs/opencl/x16rs_diamond.cl @@ -77,8 +77,13 @@ __kernel void x16rs_diamond( block_diamond_t base_stuff = input_stuff[0]; const ulong global_offset = nonce_start + (get_global_id(0) * unit_size); + X16RS_PRAGMA_UNROLL_8 for (unsigned int i = 0; i < unit_size; i++) { +#ifdef NVIDIA_GPU + const ulong nonce = global_offset + i; +#else volatile const ulong nonce = global_offset + i; +#endif if(false) { // Insert Nonce write_nonce_to_bytes(79, base_stuff.h1, nonce); diff --git a/x16rs/opencl/x16rs_main.cl b/x16rs/opencl/x16rs_main.cl index 41b442ae..3a80daa0 100644 --- a/x16rs/opencl/x16rs_main.cl +++ b/x16rs/opencl/x16rs_main.cl @@ -43,9 +43,14 @@ __kernel void x16rs_main( block_t base_stuff = input_stuff_89[0]; const unsigned int global_offset = nonce_start + (get_global_id(0) * unit_size); + X16RS_PRAGMA_UNROLL_8 for (unsigned int i = 0; i < unit_size; i++) { // Insert Nonce +#ifdef NVIDIA_GPU + const unsigned int nonce = global_offset + i; +#else volatile const unsigned int nonce = global_offset + i; +#endif write_nonce_to_bytes(79, base_stuff.h1, nonce); // Hash Block sha3_256_hash(base_stuff.h8, local_hashes[index + i].h8); @@ -64,6 +69,7 @@ __kernel void x16rs_main( ); unsigned int best_hash = 0; + X16RS_PRAGMA_UNROLL_8 for (unsigned int i = 1; i < unit_size; i++) { if (diff_big_hash(&local_hashes[best_hash], &local_hashes[index + i]) == 1) { best_hash = index + i;